diff --git a/MANIFEST b/MANIFEST index e77ce8f..0854c5c 100644 --- a/MANIFEST +++ b/MANIFEST @@ -70,5 +70,6 @@ t/Net-ACME2-order-lifecycle.t t/Net-ACME2-PromiseUtil.t t/Net-ACME2-RetryAfter.t t/Net-ACME2-revoke.t +t/Net-ACME2-update-fields.t t/Net-ACME2.t t/Net-ACME2_pre_rename.t diff --git a/lib/Net/ACME2/Authorization.pm b/lib/Net/ACME2/Authorization.pm index 87aa057..dc527b9 100644 --- a/lib/Net/ACME2/Authorization.pm +++ b/lib/Net/ACME2/Authorization.pm @@ -144,7 +144,7 @@ sub challenges { sub update { my ($self, $new_hr) = @_; - for my $name ( 'status', 'challenges' ) { + for my $name ( 'status', 'challenges', 'expires' ) { $self->{"_$name"} = $new_hr->{$name}; } diff --git a/lib/Net/ACME2/Order.pm b/lib/Net/ACME2/Order.pm index 2a2042e..13d5aa6 100644 --- a/lib/Net/ACME2/Order.pm +++ b/lib/Net/ACME2/Order.pm @@ -29,6 +29,7 @@ use constant _ACCESSORS => ( 'certificate', 'finalize', 'retry_after', + 'error', ); =head1 ACCESSORS @@ -57,6 +58,12 @@ The C value from the most recent poll response, or C if the server did not send one. Only populated after C. +=item * B + +The error object (as a hash reference) from the ACME server when the +order's status is C. This is an RFC 7807 problem document. +C when the order has no error. Updated by C. + =back =head2 I->retry_after_seconds() @@ -110,7 +117,7 @@ sub identifiers { sub update { my ($self, $new_hr) = @_; - for my $name ( 'status', 'certificate' ) { + for my $name ( 'status', 'certificate', 'expires', 'error' ) { $self->{"_$name"} = $new_hr->{$name}; } diff --git a/t/Net-ACME2-update-fields.t b/t/Net-ACME2-update-fields.t new file mode 100644 index 0000000..0fc3546 --- /dev/null +++ b/t/Net-ACME2-update-fields.t @@ -0,0 +1,172 @@ +#!/usr/bin/env perl + +use strict; +use warnings; + +use Test::More; +use Test::Deep; +use Test::FailWarnings; + +use Digest::MD5; +use HTTP::Status; +use URI; +use JSON; + +use Crypt::Format (); +use MIME::Base64 (); + +use FindBin; +use lib "$FindBin::Bin/lib"; +use Test::ACME2_Server; + +#---------------------------------------------------------------------- + +{ + package MyCA; + + use parent qw( Net::ACME2 ); + + use constant { + HOST => 'acme.someca.net', + DIRECTORY_PATH => '/acme-directory', + }; +} + +my $_P256_KEY = < sub { + my $SERVER_OBJ = Test::ACME2_Server->new( + ca_class => 'MyCA', + ); + + my $acme = MyCA->new( key => $_P256_KEY ); + $acme->create_account( termsOfServiceAgreed => 1 ); + + my $order = $acme->create_order( + identifiers => [ + { type => 'dns', value => 'example.com' }, + ], + ); + + is( $order->error(), undef, 'no error on new order' ); + + # Simulate the server marking the order invalid with an error + $SERVER_OBJ->{'_order_invalid'} = 1; + $SERVER_OBJ->{'_order_error'} = { + type => 'urn:ietf:params:acme:error:unauthorized', + detail => 'CAA record forbids issuance', + status => 403, + }; + + my $status = $acme->poll_order($order); + is( $status, 'invalid', 'poll_order returns invalid' ); + is( $order->status(), 'invalid', 'order status updated' ); + + my $error = $order->error(); + ok( $error, 'error field is populated' ); + is( ref $error, 'HASH', 'error is a hashref' ); + is( $error->{'type'}, 'urn:ietf:params:acme:error:unauthorized', 'error type' ); + is( $error->{'detail'}, 'CAA record forbids issuance', 'error detail' ); + is( $error->{'status'}, 403, 'error status' ); +}; + +subtest 'Order error cleared when order becomes valid' => sub { + my $SERVER_OBJ = Test::ACME2_Server->new( + ca_class => 'MyCA', + ); + + my $acme = MyCA->new( key => $_P256_KEY ); + $acme->create_account( termsOfServiceAgreed => 1 ); + + my $order = $acme->create_order( + identifiers => [ + { type => 'dns', value => 'example.com' }, + ], + ); + + # First, mark order invalid with error + $SERVER_OBJ->{'_order_invalid'} = 1; + $SERVER_OBJ->{'_order_error'} = { + type => 'urn:ietf:params:acme:error:unauthorized', + detail => 'temporary issue', + }; + + $acme->poll_order($order); + ok( $order->error(), 'error set after invalid poll' ); + + # Now simulate recovery (server no longer reports error) + $SERVER_OBJ->{'_order_invalid'} = 0; + $SERVER_OBJ->{'_order_finalized'} = 1; + delete $SERVER_OBJ->{'_order_error'}; + + $acme->poll_order($order); + is( $order->status(), 'valid', 'order recovered to valid' ); + is( $order->error(), undef, 'error cleared after valid poll' ); +}; + +subtest 'Order expires updated on poll' => sub { + my $SERVER_OBJ = Test::ACME2_Server->new( + ca_class => 'MyCA', + ); + + my $acme = MyCA->new( key => $_P256_KEY ); + $acme->create_account( termsOfServiceAgreed => 1 ); + + my $order = $acme->create_order( + identifiers => [ + { type => 'dns', value => 'example.com' }, + ], + ); + + # Initially no expires (our mock doesn't set one by default) + my $initial_expires = $order->expires(); + + # Set expires on the server + $SERVER_OBJ->{'_order_expires'} = '2099-12-31T23:59:59Z'; + + $acme->poll_order($order); + is( $order->expires(), '2099-12-31T23:59:59Z', 'expires updated from poll response' ); + + # Change expires again + $SERVER_OBJ->{'_order_expires'} = '2100-06-15T12:00:00Z'; + + $acme->poll_order($order); + is( $order->expires(), '2100-06-15T12:00:00Z', 'expires updated again on subsequent poll' ); +}; + +subtest 'Authorization expires updated on poll' => sub { + my $SERVER_OBJ = Test::ACME2_Server->new( + ca_class => 'MyCA', + ); + + my $acme = MyCA->new( key => $_P256_KEY ); + $acme->create_account( termsOfServiceAgreed => 1 ); + + my $order = $acme->create_order( + identifiers => [ + { type => 'dns', value => 'example.com' }, + ], + ); + + my @authz_urls = $order->authorizations(); + my $authz = $acme->get_authorization( $authz_urls[0] ); + + # Initially no expires + my $initial_expires = $authz->expires(); + + # Set expires on the server + $SERVER_OBJ->{'_authz_expires'} = '2099-12-31T23:59:59Z'; + + $acme->poll_authorization($authz); + is( $authz->expires(), '2099-12-31T23:59:59Z', 'authz expires updated from poll response' ); +}; + +done_testing(); diff --git a/t/lib/Test/ACME2_Server.pm b/t/lib/Test/ACME2_Server.pm index 1b3b72b..aeb50d3 100644 --- a/t/lib/Test/ACME2_Server.pm +++ b/t/lib/Test/ACME2_Server.pm @@ -324,7 +324,9 @@ sub new { 'POST:/order/1' => sub { my $h = $self->{'ca_class'}->HOST(); - my $status = $self->{'_order_finalized'} ? 'valid' : 'pending'; + my $status = $self->{'_order_invalid'} ? 'invalid' + : $self->{'_order_finalized'} ? 'valid' + : 'pending'; my $order = $self->{'_orders'}{1}; $order->{'status'} = $status; @@ -333,6 +335,17 @@ sub new { $order->{'certificate'} = "https://$h/cert/1"; } + if ($self->{'_order_error'}) { + $order->{'error'} = $self->{'_order_error'}; + } + else { + delete $order->{'error'}; + } + + if ($self->{'_order_expires'}) { + $order->{'expires'} = $self->{'_order_expires'}; + } + my %extra_headers; if ($self->{'_retry_after_order'}) { $extra_headers{'retry-after'} = $self->{'_retry_after_order'}; @@ -477,9 +490,15 @@ sub _authz_content { : $self->{'_challenge_accepted'} ? 'valid' : 'pending'; + my %extra; + if ($self->{'_authz_expires'}) { + $extra{'expires'} = $self->{'_authz_expires'}; + } + return { status => $status, identifier => { type => 'dns', value => 'example.com' }, + %extra, challenges => [ { type => 'http-01',