diff --git a/MANIFEST b/MANIFEST index 13324dc..e77ce8f 100644 --- a/MANIFEST +++ b/MANIFEST @@ -58,6 +58,7 @@ t/Net-ACME2-Challenge-dns_account_01.t t/Net-ACME2-Challenge-http_01-Handler.t t/Net-ACME2-Challenge-tls_alpn_01.t t/Net-ACME2-Challenge.t +t/Net-ACME2-Curl.t t/Net-ACME2-EAB.t t/Net-ACME2-Error.t t/Net-ACME2-get-certificate-guard.t diff --git a/lib/Net/ACME2/Curl.pm b/lib/Net/ACME2/Curl.pm index 39d62fe..1edc44c 100644 --- a/lib/Net/ACME2/Curl.pm +++ b/lib/Net/ACME2/Curl.pm @@ -46,6 +46,7 @@ use Net::Curl::Easy (); use Net::ACME2 (); use Net::ACME2::HTTP::Convert (); +use Net::ACME2::X (); use constant _HTTP_TINY_INTERNAL_EXCEPTION_REASON => 'Internal Exception'; @@ -159,8 +160,7 @@ sub _imitate_http_tiny { $reason = $1; } else { - $reason = q<>; - warn "Unparsable first header line: [$line]\n"; + die Net::ACME2::X->create('Generic', "Unparsable first header line: [$line]"); } } } diff --git a/t/Net-ACME2-Curl.t b/t/Net-ACME2-Curl.t new file mode 100644 index 0000000..f0cfb3b --- /dev/null +++ b/t/Net-ACME2-Curl.t @@ -0,0 +1,98 @@ +#!/usr/bin/env perl + +use strict; +use warnings; + +use Test::More; +use Test::Exception; + +# _imitate_http_tiny is not exported, so we call it via full package name. +# Net::ACME2::Curl requires Net::Curl::Easy (optional XS dep) at compile +# time, so skip the entire test when it is not installed. + +use Net::ACME2::X; + +BEGIN { + eval { require Net::ACME2::Curl; 1 } + or plan skip_all => 'Net::Curl::Easy not available'; +} + +# Minimal mock that stands in for a Net::Curl::Easy instance. +{ + package # hide from PAUSE + MockEasy; + + sub new { + my ( $class, %opts ) = @_; + return bless \%opts, $class; + } + + sub getinfo { + my ( $self, $key ) = @_; + + # Only two keys are used: RESPONSE_CODE and EFFECTIVE_URL. + # We distinguish them by value rather than importing constants. + return $self->{response_code} if $key == 0x00200002; # CURLINFO_RESPONSE_CODE + return $self->{effective_url} if $key == 0x00100001; # CURLINFO_EFFECTIVE_URL + die "MockEasy::getinfo: unknown key $key"; + } +} + +# --- happy path: valid status line ---------------------------------------- + +{ + my $easy = MockEasy->new( + response_code => 200, + effective_url => 'https://example.com/acme', + ); + + my $head = "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\n"; + my $body = '{"status":"valid"}'; + + my $resp = Net::ACME2::Curl::_imitate_http_tiny( $easy, $head, $body ); + + is $resp->{status}, 200, 'status from valid response'; + is $resp->{reason}, 'OK', 'reason parsed from status line'; + is $resp->{content}, $body, 'body passed through'; + is $resp->{headers}{'content-type'}, 'application/json', 'header parsed'; +} + +# --- unparseable status line: must throw, not silently continue ------------ + +sub test_unparseable_status_line_throws { + my $easy = MockEasy->new( + response_code => 200, + effective_url => 'https://example.com/acme', + ); + + # A garbage first line that does not match "PROTO STATUS REASON". + my $head = "GARBAGE\r\ncontent-type: text/plain\r\n"; + my $body = 'irrelevant'; + + # Before the fix this would only warn and return a response with an + # empty reason — silently corrupting the parsed result. After the fix + # it must die with a structured exception. + throws_ok { + Net::ACME2::Curl::_imitate_http_tiny( $easy, $head, $body ); + } + qr/[Uu]nparsable.*header/i, + 'unparseable status line throws instead of warning'; +} + +test_unparseable_status_line_throws(); + +# --- empty header block: reason stays undef / does not crash --------------- + +{ + my $easy = MockEasy->new( + response_code => 0, + effective_url => 'https://example.com/acme', + ); + + # Completely empty header — split produces no lines. + my $resp = Net::ACME2::Curl::_imitate_http_tiny( $easy, q<>, 'body' ); + + is $resp->{reason}, undef, 'empty header yields undef reason (no crash)'; +} + +done_testing();