diff --git a/README.md b/README.md index e830ff3..8412c2d 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,25 @@ Response::ok(['ok' => true], $cacheControl, ContentType::applicationJson()) ->withHeader(name: 'X-Trace-Id', value: 'abc-123'); ``` +Every header that reaches a response, whether through a `Headerable`, `withHeader()` or `withAddedHeader()`, is +checked against RFC 9110. A name that is not a token raises `HeaderNameIsInvalid`, and a value carrying a control +character other than horizontal tab (CR, LF, and NUL included) raises `HeaderValueIsInvalid`. Both extend +`InvalidArgumentException`, as PSR-7 requires, so a header can never smuggle a line break into the response. + +The connection-specific headers `Connection`, `Keep-Alive`, `Proxy-Connection`, `Transfer-Encoding` and `Upgrade`, and +`TE` with any value other than `trailers`, raise `HeaderIsConnectionSpecific` on the same paths, with names compared +case-insensitively and the `TE` value trimmed and compared case-insensitively. They describe the connection rather than +the message, HTTP/2 (RFC 9113 section 8.2.2) and HTTP/3 (RFC 9114 section 4.2) treat a message carrying one as +malformed, and the server that writes the response to the wire owns them. + +A response starts on protocol version `1.1`, the PSR-7 convention. `withProtocolVersion()` accepts `1.0`, `1.1`, `2` +and `3`, stores `2.0` and `3.0` as `2` and `3`, and raises `ProtocolVersionIsUnsupported` for anything else, since an +emitter writes the version into the status line. To answer in the protocol the request arrived on, mirror its version: + +```php +$response = Response::ok(body: ['ok' => true])->withProtocolVersion($request->getProtocolVersion()); +``` + `withStatus($code, $reasonPhrase)` honors the supplied reason phrase: when a non-empty string is passed, `getReasonPhrase()` returns it instead of the enum-derived phrase. diff --git a/src/Exceptions/HeaderIsConnectionSpecific.php b/src/Exceptions/HeaderIsConnectionSpecific.php new file mode 100644 index 0000000..054ea16 --- /dev/null +++ b/src/Exceptions/HeaderIsConnectionSpecific.php @@ -0,0 +1,40 @@ +Connection, Keep-Alive, Proxy-Connection, + * Transfer-Encoding and Upgrade describe the connection that carries a + * message, not the message, and HTTP/2 (RFC 9113 section 8.2.2) and HTTP/3 (RFC 9114 section 4.2) + * treat a message carrying one as malformed. TE is admitted only with the value + * trailers. + */ +final class HeaderIsConnectionSpecific extends InvalidArgumentException implements HttpException +{ + private const string REASON_TEMPLATE = 'Header <%s> is connection-specific and must not be set on a response. ' + . 'RFC 9113 section 8.2.2 and RFC 9114 section 4.2 forbid it, and TE admits only the value trailers.'; + + private function __construct(string $name) + { + $template = HeaderIsConnectionSpecific::REASON_TEMPLATE; + + parent::__construct(message: sprintf($template, $name)); + } + + /** + * Creates a HeaderIsConnectionSpecific signaling that the given header must not be set on a response. + * + * @param string $name The offending header name. + * @return HeaderIsConnectionSpecific The composed exception describing the connection-specific header. + */ + public static function for(string $name): HeaderIsConnectionSpecific + { + return new HeaderIsConnectionSpecific(name: $name); + } +} diff --git a/src/Exceptions/ProtocolVersionIsUnsupported.php b/src/Exceptions/ProtocolVersionIsUnsupported.php new file mode 100644 index 0000000..9d813ee --- /dev/null +++ b/src/Exceptions/ProtocolVersionIsUnsupported.php @@ -0,0 +1,39 @@ +1.0, + * 1.1, 2 and 3. The forms 2.0 and + * 3.0 are accepted and stored as 2 and 3. Anything else is + * rejected, because an emitter writes the version into the status line. + */ +final class ProtocolVersionIsUnsupported extends InvalidArgumentException implements HttpException +{ + private const string REASON_TEMPLATE = 'Protocol version <%s> is not supported. Supported versions are 1.0, ' + . '1.1, 2 and 3.'; + + private function __construct(string $version) + { + $template = ProtocolVersionIsUnsupported::REASON_TEMPLATE; + + parent::__construct(message: sprintf($template, $version)); + } + + /** + * Creates a ProtocolVersionIsUnsupported signaling that the given version is not a supported HTTP version. + * + * @param string $version The offending protocol version. + * @return ProtocolVersionIsUnsupported The composed exception describing the unsupported version. + */ + public static function for(string $version): ProtocolVersionIsUnsupported + { + return new ProtocolVersionIsUnsupported(version: $version); + } +} diff --git a/src/Headers.php b/src/Headers.php index f00462a..52f76db 100644 --- a/src/Headers.php +++ b/src/Headers.php @@ -7,6 +7,8 @@ use Psr\Http\Message\MessageInterface; use TinyBlocks\Http\Exceptions\HeaderNameIsInvalid; use TinyBlocks\Http\Exceptions\HeaderValueIsInvalid; +use TinyBlocks\Http\Internal\HeaderName; +use TinyBlocks\Http\Internal\HeaderValue; /** * Case-insensitive collection of HTTP headers represented as a name-to-value map. @@ -73,17 +75,13 @@ public static function empty(): Headers */ public static function fromArray(array $entries): Headers { - foreach ($entries as $name => $value) { - if (!preg_match('/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/', $name)) { - throw HeaderNameIsInvalid::for(name: $name); - } + $fields = []; - if (preg_match('/[\x00-\x08\x0A-\x1F\x7F]/', $value) === 1) { - throw HeaderValueIsInvalid::for(value: $value); - } + foreach ($entries as $name => $value) { + $fields[HeaderName::from(value: $name)->toString()] = HeaderValue::from(value: $value)->toString(); } - return new Headers(entries: $entries); + return new Headers(entries: $fields); } /** diff --git a/src/Internal/HeaderName.php b/src/Internal/HeaderName.php new file mode 100644 index 0000000..13dcaa6 --- /dev/null +++ b/src/Internal/HeaderName.php @@ -0,0 +1,30 @@ +value; + } +} diff --git a/src/Internal/HeaderValue.php b/src/Internal/HeaderValue.php new file mode 100644 index 0000000..fda4666 --- /dev/null +++ b/src/Internal/HeaderValue.php @@ -0,0 +1,30 @@ +value; + } +} diff --git a/src/Internal/Server/Response/InternalResponse.php b/src/Internal/Server/Response/InternalResponse.php index f7efc8d..041a32a 100644 --- a/src/Internal/Server/Response/InternalResponse.php +++ b/src/Internal/Server/Response/InternalResponse.php @@ -28,7 +28,7 @@ public static function createWithBody(mixed $body, Code $code, Headerable ...$he body: StreamFactory::fromBody(body: $body)->write(), code: $code, headers: ResponseHeaders::fromWithDefaultContentType(...$headers), - protocolVersion: ProtocolVersion::default(), + protocolVersion: ProtocolVersion::HTTP_1_1, customReasonPhrase: null ); } @@ -39,7 +39,7 @@ public static function createWithoutBody(Code $code, Headerable ...$headers): Re body: StreamFactory::fromEmptyBody()->write(), code: $code, headers: ResponseHeaders::fromWithoutDefaultContentType(...$headers), - protocolVersion: ProtocolVersion::default(), + protocolVersion: ProtocolVersion::HTTP_1_1, customReasonPhrase: null ); } @@ -136,12 +136,12 @@ public function withAddedHeader(string $name, mixed $value): MessageInterface public function getProtocolVersion(): string { - return $this->protocolVersion->version; + return $this->protocolVersion->value; } public function withProtocolVersion(string $version): MessageInterface { - $protocolVersion = ProtocolVersion::from(version: $version); + $protocolVersion = ProtocolVersion::fromVersion(version: $version); return new InternalResponse( body: $this->body, diff --git a/src/Internal/Server/Response/ProtocolVersion.php b/src/Internal/Server/Response/ProtocolVersion.php index aa3d186..c732233 100644 --- a/src/Internal/Server/Response/ProtocolVersion.php +++ b/src/Internal/Server/Response/ProtocolVersion.php @@ -4,21 +4,23 @@ namespace TinyBlocks\Http\Internal\Server\Response; -final readonly class ProtocolVersion -{ - private const string DEFAULT_PROTOCOL_VERSION = '1.1'; +use TinyBlocks\Http\Exceptions\ProtocolVersionIsUnsupported; - private function __construct(public string $version) - { - } +enum ProtocolVersion: string +{ + case HTTP_1_0 = '1.0'; + case HTTP_1_1 = '1.1'; + case HTTP_2 = '2'; + case HTTP_3 = '3'; - public static function from(string $version): ProtocolVersion + public static function fromVersion(string $version): ProtocolVersion { - return $version === '' ? ProtocolVersion::default() : new ProtocolVersion(version: $version); - } + $canonical = match ($version) { + '2.0' => self::HTTP_2->value, + '3.0' => self::HTTP_3->value, + default => $version + }; - public static function default(): ProtocolVersion - { - return new ProtocolVersion(version: ProtocolVersion::DEFAULT_PROTOCOL_VERSION); + return (ProtocolVersion::tryFrom($canonical) ?? throw ProtocolVersionIsUnsupported::for(version: $version)); } } diff --git a/src/Internal/Server/Response/ResponseField.php b/src/Internal/Server/Response/ResponseField.php new file mode 100644 index 0000000..7d95b6d --- /dev/null +++ b/src/Internal/Server/Response/ResponseField.php @@ -0,0 +1,57 @@ +toString(); + $loweredName = strtolower($fieldName); + $values = array_map( + static fn(string $fieldValue): string => HeaderValue::from(value: $fieldValue)->toString(), + is_array($value) ? $value : [$value] + ); + + $isOtherThanTrailers = static fn(string $fieldValue): bool => strtolower(trim($fieldValue)) !== self::TRAILERS; + + if ( + in_array($loweredName, self::CONNECTION_SPECIFIC, true) + || ($loweredName === self::TE && array_any($values, $isOtherThanTrailers)) + ) { + throw HeaderIsConnectionSpecific::for(name: $fieldName); + } + + return new ResponseField(name: $fieldName, values: $values); + } + + public function name(): string + { + return $this->name; + } + + public function values(): array + { + return $this->values; + } +} diff --git a/src/Internal/Server/Response/ResponseHeaders.php b/src/Internal/Server/Response/ResponseHeaders.php index 162366c..59586e4 100644 --- a/src/Internal/Server/Response/ResponseHeaders.php +++ b/src/Internal/Server/Response/ResponseHeaders.php @@ -19,8 +19,9 @@ private function __construct(private array $headers) private static function mergeInto(Headerable $header, array $merged): array { foreach ($header->toArray() as $name => $value) { - $values = is_array($value) ? $value : [$value]; - $merged[$name] = isset($merged[$name]) ? array_merge($merged[$name], $values) : $values; + $field = ResponseField::from(name: $name, value: $value); + $key = $field->name(); + $merged[$key] = isset($merged[$key]) ? array_merge($merged[$key], $field->values()) : $field->values(); } return $merged; @@ -84,11 +85,12 @@ public function hasHeader(string $name): bool public function withAdded(string $name, string|array $value): ResponseHeaders { $headers = $this->headers; - $existingKey = $this->findKey(name: $name); - $appended = is_array($value) ? $value : [$value]; + $field = ResponseField::from(name: $name, value: $value); + $appended = $field->values(); + $existingKey = $this->findKey(name: $field->name()); if (is_null($existingKey)) { - $headers[$name] = $appended; + $headers[$field->name()] = $appended; return new ResponseHeaders(headers: $headers); } @@ -121,9 +123,9 @@ public function removeByName(string $name): ResponseHeaders public function withReplaced(string $name, string|array $value): ResponseHeaders { $headers = $this->headers; - $existingKey = $this->findKey(name: $name); - $targetKey = ($existingKey ?? $name); - $headers[$targetKey] = is_array($value) ? $value : [$value]; + $field = ResponseField::from(name: $name, value: $value); + $targetKey = ($this->findKey(name: $field->name()) ?? $field->name()); + $headers[$targetKey] = $field->values(); return new ResponseHeaders(headers: $headers); } diff --git a/tests/Unit/HeadersTest.php b/tests/Unit/HeadersTest.php index 0af0c5c..66fcf2a 100644 --- a/tests/Unit/HeadersTest.php +++ b/tests/Unit/HeadersTest.php @@ -437,7 +437,9 @@ public static function invalidHeaderNameProvider(): array 'Name with CR' => ["foo\r"], 'Name with CRLF' => ["foo\r\nbar"], 'Name with tab' => ["foo\tbar"], - 'Name with null byte' => ["foo\x00bar"] + 'Name with null byte' => ["foo\x00bar"], + 'Name ending in LF' => ["foo\n"], + 'Name ending in CRLF' => ["foo\r\n"] ]; } @@ -447,7 +449,8 @@ public static function invalidHeaderValueProvider(): array 'Value with LF' => ["foo\nbar"], 'Value with CR' => ["foo\rbar"], 'CRLF injection' => ["foo\r\nbar: injected"], - 'Value with null byte' => ["foo\x00bar"] + 'Value with null byte' => ["foo\x00bar"], + 'Value ending in LF' => ["foo\n"] ]; } } diff --git a/tests/Unit/RawHeader.php b/tests/Unit/RawHeader.php new file mode 100644 index 0000000..1ba5e6c --- /dev/null +++ b/tests/Unit/RawHeader.php @@ -0,0 +1,19 @@ +name => $this->value]; + } +} diff --git a/tests/Unit/Server/HeadersTest.php b/tests/Unit/Server/HeadersTest.php index f67b2a2..6253d30 100644 --- a/tests/Unit/Server/HeadersTest.php +++ b/tests/Unit/Server/HeadersTest.php @@ -4,10 +4,15 @@ namespace Test\TinyBlocks\Http\Unit\Server; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Test\TinyBlocks\Http\Unit\RawHeader; use TinyBlocks\Http\CacheControl; use TinyBlocks\Http\Charset; use TinyBlocks\Http\ContentType; +use TinyBlocks\Http\Exceptions\HeaderIsConnectionSpecific; +use TinyBlocks\Http\Exceptions\HeaderNameIsInvalid; +use TinyBlocks\Http\Exceptions\HeaderValueIsInvalid; use TinyBlocks\Http\ResponseCacheDirectives; use TinyBlocks\Http\Server\Response; use TinyBlocks\Http\UserAgent; @@ -48,6 +53,18 @@ public function testNoContentWhenInvokedThenCarriesNoContentType(): void self::assertSame([], $response->getHeaders()); } + public function testWithHeaderWhenTeCarriesOnlyTrailersThenKeepsIt(): void + { + /** @Given an HTTP response */ + $response = Response::noContent(); + + /** @When TE is set to trailers, in another case and padded, the only value HTTP/2 and HTTP/3 admit */ + $actual = $response->withHeader('te', ' Trailers '); + + /** @Then the value is kept as given */ + self::assertSame([' Trailers '], $actual->getHeader('TE')); + } + public function testGetHeaderWhenHeaderMissingThenReturnsEmptyArray(): void { /** @Given an HTTP response with no custom headers */ @@ -114,6 +131,44 @@ public function testNoContentWhenContentTypeIsJsonThenHeaderReflectsIt(): void self::assertSame('application/json', $actual->getHeaderLine('Content-Type')); } + #[DataProvider('invalidHeaderNameProvider')] + public function testWithHeaderWhenNameIsInvalidThenHeaderNameIsInvalid(string $name): void + { + /** @Given an HTTP response */ + $response = Response::noContent(); + + /** @Then the name is rejected */ + $this->expectException(HeaderNameIsInvalid::class); + + /** @When a header is set under a name that is not an RFC 9110 token */ + $response->withHeader($name, 'value'); + } + + public function testWithHeaderWhenValueCarriesHorizontalTabThenKeepsIt(): void + { + /** @Given an HTTP response */ + $response = Response::noContent(); + + /** @When a header is set to a value carrying a horizontal tab, which RFC 9110 allows inside a value */ + $actual = $response->withHeader('X-Trace', "first\tsecond"); + + /** @Then the value is kept as given */ + self::assertSame(["first\tsecond"], $actual->getHeader('X-Trace')); + } + + #[DataProvider('invalidHeaderValueProvider')] + public function testWithHeaderWhenValueIsInvalidThenHeaderValueIsInvalid(string $value): void + { + /** @Given an HTTP response */ + $response = Response::noContent(); + + /** @Then the value is rejected */ + $this->expectException(HeaderValueIsInvalid::class); + + /** @When a header is set to a value carrying a forbidden control character */ + $response->withHeader('X-Trace', $value); + } + public function testOkWhenNoContentTypeGivenThenCarriesDefaultContentType(): void { /** @When a response with a body is created without a media type */ @@ -161,6 +216,19 @@ public function testNoContentWhenContentTypeIsPlainTextThenHeaderReflectsIt(): v self::assertSame('text/plain', $actual->getHeaderLine('Content-Type')); } + #[DataProvider('invalidHeaderNameProvider')] + public function testWithAddedHeaderWhenNameIsInvalidThenHeaderNameIsInvalid(string $name): void + { + /** @Given an HTTP response */ + $response = Response::noContent(); + + /** @Then the name is rejected */ + $this->expectException(HeaderNameIsInvalid::class); + + /** @When a value is added under a name that is not an RFC 9110 token */ + $response->withAddedHeader($name, 'value'); + } + public function testWithHeaderWhenChainedWithDistinctKeysThenBothArePresent(): void { /** @Given an HTTP response */ @@ -211,6 +279,19 @@ public function testNoContentWhenContentTypeIsOctetStreamThenHeaderReflectsIt(): self::assertSame('application/octet-stream', $actual->getHeaderLine('Content-Type')); } + #[DataProvider('invalidHeaderValueProvider')] + public function testWithAddedHeaderWhenValueIsInvalidThenHeaderValueIsInvalid(string $value): void + { + /** @Given an HTTP response already carrying the header */ + $response = Response::noContent()->withHeader('X-Trace', 'first'); + + /** @Then the value is rejected */ + $this->expectException(HeaderValueIsInvalid::class); + + /** @When a value carrying a forbidden control character is added to it */ + $response->withAddedHeader('X-Trace', $value); + } + public function testWithAddedHeaderWhenCaseMismatchedThenMatchesExistingHeader(): void { /** @Given an HTTP response with a custom header */ @@ -237,6 +318,19 @@ public function testWithAddedHeaderWhenHeaderAbsentThenCreatesItWithGivenValue() self::assertSame(['X-Trace' => ['only-value']], $actual->getHeaders()); } + #[DataProvider('invalidHeaderNameProvider')] + public function testNoContentWhenHeaderableNameIsInvalidThenHeaderNameIsInvalid(string $name): void + { + /** @Given a Headerable emitting a name that is not an RFC 9110 token */ + $header = new RawHeader(name: $name, value: 'value'); + + /** @Then the name is rejected */ + $this->expectException(HeaderNameIsInvalid::class); + + /** @When a response is created with that header */ + Response::noContent($header); + } + public function testNoContentWhenContentTypeIsFormUrlEncodedThenHeaderReflectsIt(): void { /** @Given the Content-Type header set to application/x-www-form-urlencoded */ @@ -249,6 +343,31 @@ public function testNoContentWhenContentTypeIsFormUrlEncodedThenHeaderReflectsIt self::assertSame('application/x-www-form-urlencoded', $actual->getHeaderLine('Content-Type')); } + #[DataProvider('invalidHeaderValueProvider')] + public function testNoContentWhenHeaderableValueIsInvalidThenHeaderValueIsInvalid(string $value): void + { + /** @Given a Headerable emitting a value carrying a forbidden control character */ + $header = new RawHeader(name: 'X-Trace', value: $value); + + /** @Then the value is rejected */ + $this->expectException(HeaderValueIsInvalid::class); + + /** @When a response is created with that header */ + Response::noContent($header); + } + + public function testWithHeaderWhenListCarriesInvalidValueThenHeaderValueIsInvalid(): void + { + /** @Given an HTTP response */ + $response = Response::noContent(); + + /** @Then the list is rejected */ + $this->expectException(HeaderValueIsInvalid::class); + + /** @When a header is set to a list whose second value ends in a line feed */ + $response->withHeader('X-Trace', ['first', "second\n"]); + } + public function testNoContentWhenMultipleHeaderablesGivenThenCacheControlIsPresent(): void { /** @Given a Cache-Control header */ @@ -321,4 +440,97 @@ public function testNoContentWhenCacheControlWithEveryDirectiveGivenThenHeaderRe /** @And nothing sits beside it, because a bodiless response invents no media type of its own */ self::assertSame($cacheControl->toArray(), $actual->getHeaders()); } + + #[DataProvider('connectionSpecificHeaderProvider')] + public function testWithHeaderWhenHeaderIsConnectionSpecificThenHeaderIsConnectionSpecific( + string $name, + string $value + ): void { + /** @Given an HTTP response */ + $response = Response::noContent(); + + /** @Then the header is rejected */ + $this->expectException(HeaderIsConnectionSpecific::class); + + /** @When a connection-specific header is set */ + $response->withHeader($name, $value); + } + + #[DataProvider('connectionSpecificHeaderProvider')] + public function testNoContentWhenHeaderableIsConnectionSpecificThenHeaderIsConnectionSpecific( + string $name, + string $value + ): void { + /** @Given a Headerable emitting a connection-specific header */ + $header = new RawHeader(name: $name, value: $value); + + /** @Then the header is rejected */ + $this->expectException(HeaderIsConnectionSpecific::class); + + /** @When a response is created with that header */ + Response::noContent($header); + } + + public function testWithHeaderWhenTeListMixesTrailersWithACodingThenHeaderIsConnectionSpecific(): void + { + /** @Given an HTTP response */ + $response = Response::noContent(); + + /** @Then the header is rejected, naming it and the rules that forbid it */ + $this->expectException(HeaderIsConnectionSpecific::class); + $this->expectExceptionMessage('Header is connection-specific and must not be set on a response.'); + + /** @When TE is set to a list holding trailers and a transfer coding */ + $response->withHeader('TE', ['trailers', 'gzip']); + } + + #[DataProvider('connectionSpecificHeaderProvider')] + public function testWithAddedHeaderWhenHeaderIsConnectionSpecificThenHeaderIsConnectionSpecific( + string $name, + string $value + ): void { + /** @Given an HTTP response */ + $response = Response::noContent(); + + /** @Then the header is rejected */ + $this->expectException(HeaderIsConnectionSpecific::class); + + /** @When a value is added under a connection-specific header */ + $response->withAddedHeader($name, $value); + } + + public static function invalidHeaderNameProvider(): array + { + return [ + 'Empty name' => [''], + 'Name with space' => ['X Trace'], + 'Name with colon' => ['X-Trace:'], + 'Name ending in LF' => ["X-Trace\n"], + 'Name ending in CRLF' => ["X-Trace\r\n"], + 'Name with null byte' => ["X-Trace\x00"] + ]; + } + + public static function invalidHeaderValueProvider(): array + { + return [ + 'Value with CR' => ["first\rsecond"], + 'CRLF injection' => ["first\r\nX-Injected: yes"], + 'Value ending in LF' => ["first\n"], + 'Value with null byte' => ["first\x00"] + ]; + } + + public static function connectionSpecificHeaderProvider(): array + { + return [ + 'Connection' => ['name' => 'Connection', 'value' => 'close'], + 'Keep-Alive' => ['name' => 'Keep-Alive', 'value' => 'timeout=5'], + 'Proxy-Connection in upper' => ['name' => 'PROXY-CONNECTION', 'value' => 'keep-alive'], + 'Transfer-Encoding' => ['name' => 'Transfer-Encoding', 'value' => 'chunked'], + 'Upgrade in lower case' => ['name' => 'upgrade', 'value' => 'h2c'], + 'TE with a coding' => ['name' => 'TE', 'value' => 'gzip'], + 'TE with trailers and more' => ['name' => 'te', 'value' => 'trailers, deflate'] + ]; + } } diff --git a/tests/Unit/Server/ProtocolVersionTest.php b/tests/Unit/Server/ProtocolVersionTest.php index 5635075..47aa71b 100644 --- a/tests/Unit/Server/ProtocolVersionTest.php +++ b/tests/Unit/Server/ProtocolVersionTest.php @@ -4,7 +4,9 @@ namespace Test\TinyBlocks\Http\Unit\Server; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use TinyBlocks\Http\Exceptions\ProtocolVersionIsUnsupported; use TinyBlocks\Http\Server\Response; final class ProtocolVersionTest extends TestCase @@ -23,4 +25,60 @@ public function testWithProtocolVersionWhenInvokedThenReturnsResponseWithUpdated /** @Then the response uses the updated protocol version 3 */ self::assertSame('3', $actual->getProtocolVersion()); } + + #[DataProvider('supportedVersionProvider')] + public function testWithProtocolVersionWhenVersionIsSupportedThenCarriesItsCanonicalForm( + string $version, + string $expected + ): void { + /** @Given an HTTP response */ + $response = Response::noContent(); + + /** @When the protocol version is set to a version the response supports */ + $actual = $response->withProtocolVersion($version); + + /** @Then the response carries the canonical form of that version */ + self::assertSame($expected, $actual->getProtocolVersion()); + } + + #[DataProvider('unsupportedVersionProvider')] + public function testWithProtocolVersionWhenVersionIsUnsupportedThenProtocolVersionIsUnsupported( + string $version + ): void { + /** @Given an HTTP response */ + $response = Response::noContent(); + + /** @Then the version is rejected, naming the versions that are supported */ + $this->expectException(ProtocolVersionIsUnsupported::class); + $this->expectExceptionMessage('is not supported. Supported versions are 1.0, 1.1, 2 and 3.'); + + /** @When the protocol version is set to a version outside 1.0, 1.1, 2 and 3 */ + $response->withProtocolVersion($version); + } + + public static function supportedVersionProvider(): array + { + return [ + 'HTTP/1.0' => ['version' => '1.0', 'expected' => '1.0'], + 'HTTP/1.1' => ['version' => '1.1', 'expected' => '1.1'], + 'HTTP/2' => ['version' => '2', 'expected' => '2'], + 'HTTP/2 as 2.0' => ['version' => '2.0', 'expected' => '2'], + 'HTTP/3' => ['version' => '3', 'expected' => '3'], + 'HTTP/3 as 3.0' => ['version' => '3.0', 'expected' => '3'] + ]; + } + + public static function unsupportedVersionProvider(): array + { + return [ + 'Empty version' => ['version' => ''], + 'HTTP/0.9' => ['version' => '0.9'], + 'Unknown minor' => ['version' => '1.2'], + 'Unknown HTTP/2 minor' => ['version' => '2.1'], + 'Unknown major' => ['version' => '4'], + 'With the prefix' => ['version' => 'HTTP/1.1'], + 'With trailing space' => ['version' => '1.1 '], + 'Ending in LF' => ['version' => "1.1\n"] + ]; + } }