From 4227fd25229d472b479fd38ac65a89795ad0d69e Mon Sep 17 00:00:00 2001 From: Andreas Granig Date: Mon, 15 Jun 2015 12:10:16 +0200 Subject: [PATCH] MT#13201 Enhance voucher API. - Use billing_data ACL grants to modify vouchers - Use encryption in UI for voucher code Change-Id: I7711a43db8596d5f733d6c52d2f6608f434b2463 --- lib/NGCP/Panel/Controller/API/Vouchers.pm | 8 ++- lib/NGCP/Panel/Controller/API/VouchersItem.pm | 16 +++++- lib/NGCP/Panel/Controller/Administrator.pm | 1 + lib/NGCP/Panel/Controller/Reseller.pm | 1 + lib/NGCP/Panel/Controller/Voucher.pm | 54 ++++++++++++++++--- lib/NGCP/Panel/Form/Administrator/Admin.pm | 2 +- lib/NGCP/Panel/Form/Administrator/Reseller.pm | 4 +- lib/NGCP/Panel/Form/Voucher/Reseller.pm | 10 ++++ lib/NGCP/Panel/Form/Voucher/ResellerAPI.pm | 12 ++++- lib/NGCP/Panel/Role/API/Vouchers.pm | 49 +++-------------- lib/NGCP/Panel/Utils/Datatables.pm | 1 + lib/NGCP/Panel/Utils/DateTime.pm | 9 ++++ lib/NGCP/Panel/Utils/Voucher.pm | 44 +++++++++++++++ share/templates/voucher/list.tt | 2 + t/api-vouchers.t | 17 +++++- 15 files changed, 174 insertions(+), 56 deletions(-) create mode 100644 lib/NGCP/Panel/Utils/Voucher.pm diff --git a/lib/NGCP/Panel/Controller/API/Vouchers.pm b/lib/NGCP/Panel/Controller/API/Vouchers.pm index 5074e085cd..0b3ebb713c 100644 --- a/lib/NGCP/Panel/Controller/API/Vouchers.pm +++ b/lib/NGCP/Panel/Controller/API/Vouchers.pm @@ -138,6 +138,12 @@ sub OPTIONS :Allow { sub POST :Allow { my ($self, $c) = @_; + unless($c->user->billing_data) { + $c->log->error("user does not have billing data rights"); + $self->error($c, HTTP_FORBIDDEN, "Unsufficient rights to create voucher"); + return; + } + my $guard = $c->model('DB')->txn_scope_guard; { my $resource = $self->get_valid_post_data( @@ -158,7 +164,7 @@ sub POST :Allow { } my $item; - my $code = $self->encrypt_code($c, $resource->{code}); + my $code = NGCP::Panel::Utils::Voucher::encrypt_code($c, $resource->{code}); $item = $c->model('DB')->resultset('vouchers')->find({ reseller_id => $resource->{reseller_id}, code => $code, diff --git a/lib/NGCP/Panel/Controller/API/VouchersItem.pm b/lib/NGCP/Panel/Controller/API/VouchersItem.pm index 8ba8728c92..eccd2d5aad 100644 --- a/lib/NGCP/Panel/Controller/API/VouchersItem.pm +++ b/lib/NGCP/Panel/Controller/API/VouchersItem.pm @@ -84,6 +84,11 @@ sub OPTIONS :Allow { sub PATCH :Allow { my ($self, $c, $id) = @_; + unless($c->user->billing_data) { + $c->log->error("user does not have billing data rights"); + $self->error($c, HTTP_FORBIDDEN, "Unsufficient rights to edit voucher"); + return; + } my $guard = $c->model('DB')->txn_scope_guard; { my $preference = $self->require_preference($c); @@ -127,6 +132,11 @@ sub PATCH :Allow { sub PUT :Allow { my ($self, $c, $id) = @_; + unless($c->user->billing_data) { + $c->log->error("user does not have billing data rights"); + $self->error($c, HTTP_FORBIDDEN, "Unsufficient rights to edit voucher"); + return; + } my $guard = $c->model('DB')->txn_scope_guard; { my $preference = $self->require_preference($c); @@ -167,7 +177,11 @@ sub PUT :Allow { sub DELETE :Allow { my ($self, $c, $id) = @_; - + unless($c->user->billing_data) { + $c->log->error("user does not have billing data rights"); + $self->error($c, HTTP_FORBIDDEN, "Unsufficient rights to delete voucher"); + return; + } my $guard = $c->model('DB')->txn_scope_guard; { my $item = $self->item_by_id($c, $id); diff --git a/lib/NGCP/Panel/Controller/Administrator.pm b/lib/NGCP/Panel/Controller/Administrator.pm index 1c66c06d17..12b8169987 100644 --- a/lib/NGCP/Panel/Controller/Administrator.pm +++ b/lib/NGCP/Panel/Controller/Administrator.pm @@ -38,6 +38,7 @@ sub list_admin :PathPart('administrator') :Chained('/') :CaptureArgs(0) { { name => "read_only", title => $c->loc("Read Only") }, { name => "show_passwords", title => $c->loc("Show Passwords") }, { name => "call_data", title => $c->loc("Show CDRs") }, + { name => "billing_data", title => $c->loc("Show Billing Info") }, ); if($c->user->is_superuser) { @{ $cols } = (@{ $cols }, { name => "lawful_intercept", title => $c->loc("Lawful Intercept") }); diff --git a/lib/NGCP/Panel/Controller/Reseller.pm b/lib/NGCP/Panel/Controller/Reseller.pm index 7c759699f7..fc4bd0233d 100644 --- a/lib/NGCP/Panel/Controller/Reseller.pm +++ b/lib/NGCP/Panel/Controller/Reseller.pm @@ -146,6 +146,7 @@ sub base :Chained('list_reseller') :PathPart('') :CaptureArgs(1) { { name => "read_only", title => $c->loc('Read-Only') }, { name => "show_passwords", title => $c->loc('Show Passwords') }, { name => "call_data", title => $c->loc('Show CDRs') }, + { name => "billing_data", title => $c->loc('Show Billing Info') }, ]); $c->stash->{customer_dt_columns} = NGCP::Panel::Utils::Datatables::set_columns($c, [ { name => "id", search => 1, title => $c->loc('#') }, diff --git a/lib/NGCP/Panel/Controller/Voucher.pm b/lib/NGCP/Panel/Controller/Voucher.pm index 4023c4329e..f5b926f563 100644 --- a/lib/NGCP/Panel/Controller/Voucher.pm +++ b/lib/NGCP/Panel/Controller/Voucher.pm @@ -12,6 +12,8 @@ use NGCP::Panel::Utils::Message; use NGCP::Panel::Utils::Navigation; use NGCP::Panel::Utils::Datatables; use NGCP::Panel::Utils::DateTime; +use NGCP::Panel::Utils::Voucher; +use Scalar::Util qw/blessed/; sub auto :Does(ACL) :ACLDetachTo('/denied_page') :AllowedRole(admin) :AllowedRole(reseller) { my ($self, $c) = @_; @@ -34,9 +36,10 @@ sub voucher_list :Chained('/') :PathPart('voucher') :CaptureArgs(0) { }); } $c->stash(voucher_rs => $voucher_rs); + $c->stash->{voucher_dt_columns} = NGCP::Panel::Utils::Datatables::set_columns($c, [ { name => "id", "search" => 1, "title" => $c->loc("#") }, - { name => "code", "search" => 1, "title" => $c->loc("Code") }, + $c->user->billing_data ? { name => "code", "search" => 1, "title" => $c->loc("Code") } : (), { name => "amount", "search" => 1, "title" => $c->loc("Amount") }, { name => "reseller.name", "search" => 1, "title" => $c->loc("Reseller") }, { name => "valid_until", "search" => 1, "title" => $c->loc("Valid Until") }, @@ -55,7 +58,21 @@ sub ajax :Chained('voucher_list') :PathPart('ajax') :Args(0) { my ($self, $c) = @_; my $resultset = $c->stash->{voucher_rs}; - NGCP::Panel::Utils::Datatables::process($c, $resultset, $c->stash->{voucher_dt_columns}); + NGCP::Panel::Utils::Datatables::process($c, $resultset, $c->stash->{voucher_dt_columns}, sub { + my $row = shift; + my $v = { $row->get_inflated_columns }; + if($c->user->billing_data) { + $v->{code} = NGCP::Panel::Utils::Voucher::decrypt_code($c, $row->code); + } else { + $v->{code} = ""; + } + foreach my $k(keys %{ $v }) { + if(blessed($v->{$k}) && $v->{$k}->isa('DateTime')) { + $v->{$k} = NGCP::Panel::Utils::DateTime::to_string($v->{$k}); + } + } + return %{ $v }; + }); $c->detach( $c->view("JSON") ); } @@ -90,6 +107,9 @@ sub base :Chained('voucher_list') :PathPart('') :CaptureArgs(1) { sub delete :Chained('base') :PathPart('delete') { my ($self, $c) = @_; + $c->detach('/denied_page') + unless($c->user->billing_data); + try { $c->stash->{voucher_result}->delete; NGCP::Panel::Utils::Message->info( @@ -111,16 +131,24 @@ sub delete :Chained('base') :PathPart('delete') { sub edit :Chained('base') :PathPart('edit') { my ($self, $c) = @_; + $c->detach('/denied_page') + unless($c->user->billing_data); + my $posted = ($c->request->method eq 'POST'); my $form; my $params = $c->stash->{voucher}; $params->{valid_until} =~ s/^(\d{4}\-\d{2}\-\d{2}).*$/$1/; $params->{reseller}{id} = delete $params->{reseller_id}; + if($c->user->billing_data) { + $params->{code} = NGCP::Panel::Utils::Voucher::decrypt_code($c, $params->{code}); + } else { + delete $params->{code}; + } $params = $params->merge($c->session->{created_objects}); if($c->user->is_superuser) { - $form = NGCP::Panel::Form::Voucher::Admin->new; + $form = NGCP::Panel::Form::Voucher::Admin->new(ctx => $c); } else { - $form = NGCP::Panel::Form::Voucher::Reseller->new; + $form = NGCP::Panel::Form::Voucher::Reseller->new(ctx => $c); } $form->process( posted => $posted, @@ -151,6 +179,11 @@ sub edit :Chained('base') :PathPart('edit') { ->add(days => 1)->subtract(seconds => 1); } + if($c->user->billing_data) { + $form->values->{code} = NGCP::Panel::Utils::Voucher::encrypt_code($c, $form->values->{code}); + } else { + delete $form->values->{code}; + } my $schema = $c->model('DB'); $schema->txn_do(sub { $c->stash->{voucher_result}->update($form->values); @@ -178,15 +211,18 @@ sub edit :Chained('base') :PathPart('edit') { sub create :Chained('voucher_list') :PathPart('create') :Args(0) { my ($self, $c) = @_; + $c->detach('/denied_page') + unless($c->user->billing_data); + my $posted = ($c->request->method eq 'POST'); my $form; my $params = {}; $params->{reseller}{id} = delete $params->{reseller_id}; $params = $params->merge($c->session->{created_objects}); if($c->user->is_superuser) { - $form = NGCP::Panel::Form::Voucher::Admin->new; + $form = NGCP::Panel::Form::Voucher::Admin->new(ctx => $c); } else { - $form = NGCP::Panel::Form::Voucher::Reseller->new; + $form = NGCP::Panel::Form::Voucher::Reseller->new(ctx => $c); } $form->process( posted => $posted, @@ -217,6 +253,7 @@ sub create :Chained('voucher_list') :PathPart('create') :Args(0) { $form->values->{valid_until} = NGCP::Panel::Utils::DateTime::from_string($form->values->{valid_until}) ->add(days => 1)->subtract(seconds => 1); } + $form->values->{code} = NGCP::Panel::Utils::Voucher::encrypt_code($c, $form->values->{code}); my $voucher = $c->model('DB')->resultset('vouchers')->create($form->values); $c->session->{created_objects}->{voucher} = { id => $voucher->id }; delete $c->session->{created_objects}->{reseller}; @@ -240,6 +277,9 @@ sub create :Chained('voucher_list') :PathPart('create') :Args(0) { sub voucher_upload :Chained('voucher_list') :PathPart('upload') :Args(0) { my ($self, $c) = @_; + + $c->detach('/denied_page') + unless($c->user->billing_data); my $form = NGCP::Panel::Form::Voucher::Upload->new; my $upload = $c->req->upload('upload_vouchers'); @@ -281,6 +321,7 @@ sub voucher_upload :Chained('voucher_list') :PathPart('upload') :Args(0) { while(my $row = $csv->getline_hr($upload->fh)) { ++$linenum; + use Data::Printer; p $row; if($csv->is_missing(1)) { push @fails, $linenum; next; @@ -290,6 +331,7 @@ sub voucher_upload :Chained('voucher_list') :PathPart('upload') :Args(0) { ->add(days => 1)->subtract(seconds => 1); } $row->{customer_id} = undef if(defined $row->{customer_id} && $row->{customer_id} eq ""); + $row->{code} = NGCP::Panel::Utils::Voucher::encrypt_code($c, $row->{code}); push @vouchers, $row; } unless ($csv->eof()) { diff --git a/lib/NGCP/Panel/Form/Administrator/Admin.pm b/lib/NGCP/Panel/Form/Administrator/Admin.pm index cdf3ab211a..8af14c82fc 100644 --- a/lib/NGCP/Panel/Form/Administrator/Admin.pm +++ b/lib/NGCP/Panel/Form/Administrator/Admin.pm @@ -16,7 +16,7 @@ has_block 'fields' => ( tag => 'div', class => [qw(modal-body)], render_list => [qw( - reseller login md5pass is_superuser is_master is_active read_only show_passwords call_data lawful_intercept + reseller login md5pass is_superuser is_master is_active read_only show_passwords call_data billing_data lawful_intercept )], ); diff --git a/lib/NGCP/Panel/Form/Administrator/Reseller.pm b/lib/NGCP/Panel/Form/Administrator/Reseller.pm index addfefe8b5..848891e199 100644 --- a/lib/NGCP/Panel/Form/Administrator/Reseller.pm +++ b/lib/NGCP/Panel/Form/Administrator/Reseller.pm @@ -12,7 +12,7 @@ sub build_form_element_class {[qw(form-horizontal)]} has_field 'login' => (type => 'Text', required => 1, minlength => 5); has_field 'md5pass' => (type => 'Password', required => 1, label => 'Password'); -for (qw(is_active show_passwords call_data)) { +for (qw(is_active show_passwords call_data billing_data)) { has_field $_ => (type => 'Boolean', default => 1); } for (qw(is_master read_only)) { @@ -23,7 +23,7 @@ has_block 'fields' => ( tag => 'div', class => [qw(modal-body)], render_list => [qw( - login md5pass is_master is_active read_only show_passwords call_data + login md5pass is_master is_active read_only show_passwords call_data billing_data )], ); has_block 'actions' => (tag => 'div', class => [qw(modal-footer)], render_list => [qw(save)],); diff --git a/lib/NGCP/Panel/Form/Voucher/Reseller.pm b/lib/NGCP/Panel/Form/Voucher/Reseller.pm index 6bf55a5926..ec2bb2104e 100644 --- a/lib/NGCP/Panel/Form/Voucher/Reseller.pm +++ b/lib/NGCP/Panel/Form/Voucher/Reseller.pm @@ -81,5 +81,15 @@ sub validate_valid_until { } } +sub update_fields { + my $self = shift; + my $c = $self->ctx; + return unless $c; + + unless($c->user->billing_data) { + $self->field('code')->inactive(1); + } +} + 1 # vim: set tabstop=4 expandtab: diff --git a/lib/NGCP/Panel/Form/Voucher/ResellerAPI.pm b/lib/NGCP/Panel/Form/Voucher/ResellerAPI.pm index edc317e4b6..8ff1784e2c 100644 --- a/lib/NGCP/Panel/Form/Voucher/ResellerAPI.pm +++ b/lib/NGCP/Panel/Form/Voucher/ResellerAPI.pm @@ -55,7 +55,7 @@ has_field 'customer' => ( sub validate_valid_until { my ($self, $field) = @_; - unless($field->value =~ /^(\d{4})\-\d{2}\-\d{2}(T| )\d{2}:\d{2}:\d{2}$/) { + unless($field->value =~ /^(\d{4})\-\d{2}\-\d{2}(T| )\d{2}:\d{2}:\d{2}(\+\d{2}:\d{2})?$/) { my $err_msg = 'Invalid date format, must be YYYY-MM-DD'; $field->add_error($err_msg); } @@ -65,5 +65,15 @@ sub validate_valid_until { } } +sub update_fields { + my $self = shift; + my $c = $self->ctx; + return unless $c; + + unless($c->user->billing_data) { + $self->field('code')->inactive(1); + } +} + 1 # vim: set tabstop=4 expandtab: diff --git a/lib/NGCP/Panel/Role/API/Vouchers.pm b/lib/NGCP/Panel/Role/API/Vouchers.pm index de5bfe4297..90fa5f6c4d 100644 --- a/lib/NGCP/Panel/Role/API/Vouchers.pm +++ b/lib/NGCP/Panel/Role/API/Vouchers.pm @@ -1,8 +1,6 @@ package NGCP::Panel::Role::API::Vouchers; use Moose::Role; use Sipwise::Base; -use Crypt::Rijndael; -use MIME::Base64; with 'NGCP::Panel::Role::API' => { -alias =>{ item_rs => '_item_rs', }, -excludes => [ 'item_rs' ], @@ -15,6 +13,7 @@ use Data::HAL::Link qw(); use HTTP::Status qw(:constants); use NGCP::Panel::Form::Voucher::AdminAPI; use NGCP::Panel::Form::Voucher::ResellerAPI; +use NGCP::Panel::Utils::Voucher; sub item_rs { my ($self, $c) = @_; @@ -66,7 +65,11 @@ sub hal_from_item { ); $resource{valid_until} = $item->valid_until->ymd('-') . ' ' . $item->valid_until->hms(':'); - $resource{code} = $self->decrypt_code($c, $item->code); + if($c->user->billing_data) { + $resource{code} = NGCP::Panel::Utils::Voucher::decrypt_code($c, $item->code); + } else { + delete $resource{code}; + } $resource{id} = int($item->id); $hal->resource({%resource}); return $hal; @@ -78,44 +81,6 @@ sub item_by_id { return $item_rs->find($id); } -sub encrypt_code { - my ($self, $c, $plain) = @_; - - my $key = $c->config->{vouchers}->{key}; - my $iv = $c->config->{vouchers}->{iv}; - - # pkcs#5 padding to 16 bytes blocksize - my $pad = 16 - (length $plain) % 16; - $plain .= pack('C', $pad) x $pad; - - my $cipher = Crypt::Rijndael->new( - $key, - Crypt::Rijndael::MODE_CBC() - ); - $cipher->set_iv($iv); - my $crypted = $cipher->encrypt($plain); - my $b64 = encode_base64($crypted, ''); - return $b64; -} - -sub decrypt_code { - my ($self, $c, $code) = @_; - - my $key = $c->config->{vouchers}->{key}; - my $iv = $c->config->{vouchers}->{iv}; - - my $cipher = Crypt::Rijndael->new( - $key, - Crypt::Rijndael::MODE_CBC() - ); - $cipher->set_iv($iv); - my $crypted = decode_base64($code); - my $plain = $cipher->decrypt($crypted) . ""; - # remove padding - $plain =~ s/[\x01-\x1e]*$//; - return $plain; -} - sub update_item { my ($self, $c, $item, $old_resource, $resource, $form) = @_; @@ -130,7 +95,7 @@ sub update_item { $resource->{reseller_id} = $c->user->reseller_id; } - my $code = $self->encrypt_code($c, $resource->{code}); + my $code = NGCP::Panel::Utils::Voucher::encrypt_code($c, $resource->{code}); my $dup_item = $c->model('DB')->resultset('vouchers')->find({ reseller_id => $resource->{reseller_id}, code => $code, diff --git a/lib/NGCP/Panel/Utils/Datatables.pm b/lib/NGCP/Panel/Utils/Datatables.pm index d5d3149212..397184fbb1 100644 --- a/lib/NGCP/Panel/Utils/Datatables.pm +++ b/lib/NGCP/Panel/Utils/Datatables.pm @@ -175,6 +175,7 @@ sub process { for my $row ($rs->all) { push @{ $aaData }, _prune_row($cols, $row->get_inflated_columns); if (defined $row_func) { + my $r = $row_func->($row); $aaData->[-1]->put($row_func->($row)); } } diff --git a/lib/NGCP/Panel/Utils/DateTime.pm b/lib/NGCP/Panel/Utils/DateTime.pm index f6399d8d3f..677c5df4b6 100644 --- a/lib/NGCP/Panel/Utils/DateTime.pm +++ b/lib/NGCP/Panel/Utils/DateTime.pm @@ -56,6 +56,15 @@ sub sec_to_hms return "$h:$m:$s"; } +sub to_string +{ + my ($dt) = @_; + return unless defined ($dt); + my $s = $dt->ymd('-') . ' ' . $dt->hms(':'); + $s .= '.'.$dt->millisecond if $dt->millisecond > 0.0; + return $s; +} + 1; diff --git a/lib/NGCP/Panel/Utils/Voucher.pm b/lib/NGCP/Panel/Utils/Voucher.pm new file mode 100644 index 0000000000..ff63fd9b62 --- /dev/null +++ b/lib/NGCP/Panel/Utils/Voucher.pm @@ -0,0 +1,44 @@ +package NGCP::Panel::Utils::Voucher; +use Sipwise::Base; +use Crypt::Rijndael; +use MIME::Base64; + +sub encrypt_code { + my ($c, $plain) = @_; + + my $key = $c->config->{vouchers}->{key}; + my $iv = $c->config->{vouchers}->{iv}; + + # pkcs#5 padding to 16 bytes blocksize + my $pad = 16 - (length $plain) % 16; + $plain .= pack('C', $pad) x $pad; + + my $cipher = Crypt::Rijndael->new( + $key, + Crypt::Rijndael::MODE_CBC() + ); + $cipher->set_iv($iv); + my $crypted = $cipher->encrypt($plain); + my $b64 = encode_base64($crypted, ''); + return $b64; +} + +sub decrypt_code { + my ($c, $code) = @_; + + my $key = $c->config->{vouchers}->{key}; + my $iv = $c->config->{vouchers}->{iv}; + + my $cipher = Crypt::Rijndael->new( + $key, + Crypt::Rijndael::MODE_CBC() + ); + $cipher->set_iv($iv); + my $crypted = decode_base64($code); + my $plain = $cipher->decrypt($crypted) . ""; + # remove padding + $plain =~ s/[\x01-\x1e]*$//; + return $plain; +} + +1; diff --git a/share/templates/voucher/list.tt b/share/templates/voucher/list.tt index 461aa7ee39..6998a9c9dd 100644 --- a/share/templates/voucher/list.tt +++ b/share/templates/voucher/list.tt @@ -13,6 +13,7 @@ helper.ajax_uri = c.uri_for( c.controller.action_for('ajax') ); UNLESS c.user.read_only; + IF c.user.billing_data; helper.dt_buttons = [ { name = c.loc('Edit'), uri = "/voucher/'+full[\"id\"]+'/edit", class = 'btn-small btn-primary', icon = 'icon-edit' }, { name = c.loc('Delete'), uri = "/voucher/'+full[\"id\"]+'/delete", class = 'btn-small btn-secondary', icon = 'icon-trash' }, @@ -21,6 +22,7 @@ { name = c.loc('Create Billing Voucher'), uri = c.uri_for('/voucher/create'), icon = 'icon-star' }, { name = c.loc('Upload Vouchers as CSV'), uri = c.uri_for('/voucher/upload'), icon = 'icon-star' }, ]; + END; END; PROCESS 'helpers/datatables.tt'; diff --git a/t/api-vouchers.t b/t/api-vouchers.t index af84d256e1..85092c2be9 100644 --- a/t/api-vouchers.t +++ b/t/api-vouchers.t @@ -88,7 +88,6 @@ sub test_voucher { reseller_id => $default_reseller_id, valid_until => '2037-01-01 12:00:00', }; - p $voucher; $req = HTTP::Request->new('POST', $uri.'/api/vouchers/'); $req->header('Content-Type' => 'application/json'); $req->content(JSON::to_json($voucher)); @@ -118,7 +117,21 @@ sub test_voucher { $voucher_id = delete $put_voucher->{id}; is_deeply($voucher, $put_voucher, "check PUTed voucher against POSTed voucher"); - p $put_voucher; + $req = HTTP::Request->new('PATCH', $voucher_uri); + $req->header('Content-Type' => 'application/json-patch+json'); + $req->header('Prefer' => 'return=representation'); + $req->content(JSON::to_json([{op=>"replace", path=>"/code", value=>$put_voucher->{code}}])); + $res = $ua->request($req); + is($res->code, 200, _get_request_test_message("PATCH test voucher")); + $req = HTTP::Request->new('GET', $voucher_uri); + $res = $ua->request($req); + is($res->code, 200, _get_request_test_message("fetch PATCH test voucher")); + my $patch_voucher = JSON::from_json($res->decoded_content); + delete $patch_voucher->{_links}; + $voucher_id = delete $patch_voucher->{id}; + is_deeply($voucher, $patch_voucher, "check PATCHed voucher against POSTed voucher"); + + $req = HTTP::Request->new('POST', $uri.'/api/vouchers/'); $req->header('Content-Type' => 'application/json'); $req->content(JSON::to_json($put_voucher));