diff --git a/MANIFEST b/MANIFEST index e77ce8f..9584ea4 100644 --- a/MANIFEST +++ b/MANIFEST @@ -66,6 +66,7 @@ t/Net-ACME2-get_orders.t t/Net-ACME2-HTTP.t t/Net-ACME2-JWTMaker.t t/Net-ACME2-key-change.t +t/Net-ACME2-link-alternates.t t/Net-ACME2-order-lifecycle.t t/Net-ACME2-PromiseUtil.t t/Net-ACME2-RetryAfter.t diff --git a/lib/Net/ACME2.pm b/lib/Net/ACME2.pm index 3073cda..c54fb86 100644 --- a/lib/Net/ACME2.pm +++ b/lib/Net/ACME2.pm @@ -923,9 +923,8 @@ sub _parse_link_alternates { my @alt_urls; for my $link (@links) { - if ($link =~ m{<([^>]+)>\s*;\s*rel="alternate"}) { - push @alt_urls, $1; - } + next unless $link =~ m{;\s*rel="alternate"}i; + push @alt_urls, $1 if $link =~ m{<([^>]+)>}; } return @alt_urls; diff --git a/t/Net-ACME2-link-alternates.t b/t/Net-ACME2-link-alternates.t new file mode 100644 index 0000000..d57c0aa --- /dev/null +++ b/t/Net-ACME2-link-alternates.t @@ -0,0 +1,184 @@ +#!/usr/bin/env perl + +use strict; +use warnings; + +use Test::More; +use Test::FailWarnings; + +# _parse_link_alternates is a private function in Net::ACME2. +# We test it directly to verify RFC 8288 compliance: +# - relation types are case-insensitive (section 2.1.1) +# - link parameters may appear in any order (section 3) + +use Net::ACME2 (); + +{ + package MockResponse; + + sub new { + my ($class, %opts) = @_; + return bless \%opts, $class; + } + + sub header { + my ($self, $name) = @_; + return $self->{ lc $name }; + } +} + +# --- No Link header --- +{ + my $resp = MockResponse->new(); + my @urls = Net::ACME2::_parse_link_alternates($resp); + is( scalar @urls, 0, 'no Link header returns empty list' ); +} + +# --- Single alternate link --- +{ + my $resp = MockResponse->new( + link => ';rel="alternate"', + ); + my @urls = Net::ACME2::_parse_link_alternates($resp); + is_deeply( + \@urls, + ['https://ca.example/cert/alt1'], + 'single alternate link', + ); +} + +# --- Multiple alternate links (arrayref) --- +{ + my $resp = MockResponse->new( + link => [ + ';rel="alternate"', + ';rel="alternate"', + ], + ); + my @urls = Net::ACME2::_parse_link_alternates($resp); + is_deeply( + \@urls, + ['https://ca.example/cert/alt1', 'https://ca.example/cert/alt2'], + 'multiple alternate links as arrayref', + ); +} + +# --- Non-alternate link is ignored --- +{ + my $resp = MockResponse->new( + link => ';rel="index"', + ); + my @urls = Net::ACME2::_parse_link_alternates($resp); + is( scalar @urls, 0, 'non-alternate link ignored' ); +} + +# --- Mixed alternate and non-alternate --- +{ + my $resp = MockResponse->new( + link => [ + ';rel="index"', + ';rel="alternate"', + ], + ); + my @urls = Net::ACME2::_parse_link_alternates($resp); + is_deeply( + \@urls, + ['https://ca.example/cert/alt1'], + 'mixed link types: only alternate extracted', + ); +} + +# --- RFC 8288 section 2.1.1: relation types are case-insensitive --- +{ + my $resp = MockResponse->new( + link => ';rel="Alternate"', + ); + my @urls = Net::ACME2::_parse_link_alternates($resp); + is_deeply( + \@urls, + ['https://ca.example/cert/alt1'], + 'mixed-case "Alternate" matches (RFC 8288 case-insensitive)', + ); +} + +{ + my $resp = MockResponse->new( + link => ';rel="ALTERNATE"', + ); + my @urls = Net::ACME2::_parse_link_alternates($resp); + is_deeply( + \@urls, + ['https://ca.example/cert/alt1'], + 'uppercase "ALTERNATE" matches (RFC 8288 case-insensitive)', + ); +} + +# --- Whitespace variations --- +{ + my $resp = MockResponse->new( + link => ' ; rel="alternate"', + ); + my @urls = Net::ACME2::_parse_link_alternates($resp); + is_deeply( + \@urls, + ['https://ca.example/cert/alt1'], + 'extra whitespace around semicolon', + ); +} + +# --- RFC 8288 section 3: parameters may appear in any order --- +# The rel parameter does not need to be the first parameter after the URI. +{ + my $resp = MockResponse->new( + link => '; title="cross-signed"; rel="alternate"', + ); + my @urls = Net::ACME2::_parse_link_alternates($resp); + is_deeply( + \@urls, + ['https://ca.example/cert/alt1'], + 'rel after other params (title before rel)', + ); +} + +{ + my $resp = MockResponse->new( + link => '; type="application/pem-certificate-chain"; rel="alternate"', + ); + my @urls = Net::ACME2::_parse_link_alternates($resp); + is_deeply( + \@urls, + ['https://ca.example/cert/alt1'], + 'rel after type param', + ); +} + +{ + my $resp = MockResponse->new( + link => '; rel="alternate"; title="ISRG Root X2"', + ); + my @urls = Net::ACME2::_parse_link_alternates($resp); + is_deeply( + \@urls, + ['https://ca.example/cert/alt1'], + 'rel before other params (rel then title)', + ); +} + +# --- rel in any position with mixed links --- +{ + my $resp = MockResponse->new( + link => [ + '; rel="index"', + '; title="cross-signed"; rel="alternate"', + '; rel="alternate"; title="ISRG Root X2"', + ], + ); + my @urls = Net::ACME2::_parse_link_alternates($resp); + is_deeply( + \@urls, + ['https://ca.example/cert/alt1', 'https://ca.example/cert/alt2'], + 'mixed links with rel in various positions', + ); +} + +done_testing();