diff --git a/AGENTS.md b/AGENTS.md index 8d1458e..4e1b4b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,10 +122,19 @@ byte-budget throw, the scheme-detection regex anchor whose removal only widens an already fail-closed rejection (a colon in a later path segment), and the canonical-delimiter index in `ParameterCodec::parseDelimitedQuery()` (every wire form of a delimiter is folded to the chosen one before the -split, so any element of the list produces the same partition), and the +split, so any element of the list produces the same partition), the `array_values()` calls over a Path Item's and an Operation's `parameters` in `DocumentCompiler::parameters()` (a JSON array decodes to a list, so -the re-index has nothing to change and only the pointer index would move). +the re-index has nothing to change and only the pointer index would move), +and — since the message-reading trait joined the coverage map — the whole +chunking arithmetic of `MessageReading::bodyContents()`: a larger `$remaining` +or a wider `read()` window still lands on the same `> $maxBytes` check, and +`break` against `continue` on an at-eof empty chunk differ only in re-testing +the `while` condition that is already false. The media-type selection helpers +in the same trait escape for the reasons above: the rank sentinel is below +every specificity, the key/definition type guard is reachable only through a +hand-built `Operation`, and the strict `>` is untestable because no two +declarations of equal specificity can match one media type. ## When you finish diff --git a/CHANGELOG.md b/CHANGELOG.md index 04d91dd..ca76228 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +- **Fixed.** A YAML document under a kilobyte could exhaust memory and die with + a fatal error rather than an `InvalidContract`. Anchors and aliases expand + inside the parser, before any budget measures anything, and the byte budget + measures the file; the reference resolver's own budget counts only the nodes + it descends into, and it rightly does not descend into data — which is where + an alias is just as welcome. Nine levels of anchors in an `enum` turned 772 + bytes into 387 million nodes. Documents are now measured by what they expand + into, at every entry point and shared across a multi-file graph. +- **Added.** `Limits::$documentNodes` (default 5 000 000, constant + `Limits::DEFAULT_DOCUMENT_NODES`) — the budget above. It sits above what any + document within `documentBytes` can hold, so it refuses amplification without + refusing size. Counting stops at the budget, so an oversized document costs + the budget rather than its own size. +- **Changed.** The message-reading trait joined the mutation gate's coverage + map: it was in no `#[Covers]`, and so produced no mutants at all — the body + reading loop, its byte-budget comparison included, was outside the gate. The + package's own `AGENTS.md` names that as the symptom. + ## 0.9.0 — 2026-09-06 The three decisions a 1.0 tag would have frozen, settled while they are still diff --git a/README.md b/README.md index 0904e77..2a99e17 100644 --- a/README.md +++ b/README.md @@ -94,9 +94,12 @@ absolute paths, URI schemes, percent-encoded paths, traversal, and symlink escapes are rejected before any read, and resolution errors report paths relative to the document root. `fromArray()` and `fromJson()` have no trusted filesystem root and accept same-document references only. -Documents are bounded: byte size, JSON depth, `$ref` depth, a shared node -budget, and — for multi-file documents — file-count and byte budgets shared -across the whole reference graph. +Documents are bounded: byte size, JSON depth, `$ref` depth, the number of +nodes a document expands into, a reference-resolution budget, and — for +multi-file documents — file-count, byte and node budgets shared across the +whole reference graph. The node budget is the one that bounds YAML: anchors +and aliases produce nodes out of no bytes at all, so a file well inside the +byte budget can still expand into hundreds of millions of nodes. #### Budgets @@ -110,6 +113,7 @@ $contract = Contract::fromFile('openapi.yaml', new Limits( documentBytes: 40 * 1024 * 1024, // default 10 MiB messageBodyBytes: 8 * 1024 * 1024, // default 1 MiB documentFiles: 256, // default 64 + documentNodes: 20_000_000, // default 5 000 000 )); ``` diff --git a/README.ru.md b/README.ru.md index add4020..04078ed 100644 --- a/README.ru.md +++ b/README.ru.md @@ -95,8 +95,11 @@ traversal и symlink escape отклоняются до какого-либо ч резолюции показывают пути относительно document root. У `fromArray()` и `fromJson()` нет доверенного filesystem root — они принимают только same-document ссылки. Документы ограничены бюджетами: размер в байтах, JSON -depth, глубина `$ref`, общий node budget, а для многофайловых документов — -общие на весь граф бюджеты числа файлов и байтов. +depth, глубина `$ref`, число узлов, в которое документ разворачивается, бюджет +резолюции ссылок, а для многофайловых документов — общие на весь граф бюджеты +числа файлов, байтов и узлов. Именно узловой бюджет ограничивает YAML: якоря и +алиасы делают узлы из ничего, поэтому файл, спокойно проходящий по байтам, +может развернуться в сотни миллионов узлов. #### Бюджеты @@ -110,6 +113,7 @@ $contract = Contract::fromFile('openapi.yaml', new Limits( documentBytes: 40 * 1024 * 1024, // по умолчанию 10 MiB messageBodyBytes: 8 * 1024 * 1024, // по умолчанию 1 MiB documentFiles: 256, // по умолчанию 64 + documentNodes: 20_000_000, // по умолчанию 5 000 000 )); ``` diff --git a/llms.txt b/llms.txt index 3d08b9f..2be7fff 100644 --- a/llms.txt +++ b/llms.txt @@ -80,8 +80,10 @@ Rules: an unencoded one) — it is a hand-off to consumers that render query values, which cannot derive it from the schema; - budgets are `Limits`, not constants: `new Limits(documentBytes:, - messageBodyBytes:, documentFiles:)` passed to any factory; a budget below 1 - throws `\InvalidArgumentException`. `*.body.too_large` means the validator + messageBodyBytes:, documentFiles:, documentNodes:)` passed to any factory; a + budget below 1 throws `\InvalidArgumentException`. `documentNodes` bounds + what a document expands into, which for YAML anchors is unrelated to what it + weighs. `*.body.too_large` means the validator declined to read that body, NOT that the message is invalid — raise the budget rather than treating it as a failed verdict; - `Operation` is a read model: its constructor is `@internal` (nothing public @@ -112,9 +114,10 @@ API reference: symfony/yaml; multi-file relative `$ref`s under the entry directory) build the immutable compiled contract. - `Limits` (readonly): `documentBytes` (default 10 MiB), - `messageBodyBytes` (default 1 MiB), `documentFiles` (default 64), with - `Limits::DEFAULT_DOCUMENT_BYTES` / `DEFAULT_MESSAGE_BODY_BYTES` / - `DEFAULT_DOCUMENT_FILES` as the constants behind them. + `messageBodyBytes` (default 1 MiB), `documentFiles` (default 64), + `documentNodes` (default 5 000 000), with `Limits::DEFAULT_DOCUMENT_BYTES` / + `DEFAULT_MESSAGE_BODY_BYTES` / `DEFAULT_DOCUMENT_FILES` / + `DEFAULT_DOCUMENT_NODES` as the constants behind them. - `Contract::operations(): list`; `Contract::operation(string $key): Operation` throws `UnknownOperation`. - `Contract::securitySchemes(): array` — diff --git a/src/Contract.php b/src/Contract.php index 0c3dad2..263fe69 100644 --- a/src/Contract.php +++ b/src/Contract.php @@ -7,6 +7,7 @@ use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use Rasuvaeff\OpenApiContract\Internal\Compilation\DocumentCompiler; +use Rasuvaeff\OpenApiContract\Internal\Compilation\DocumentNodes; use Rasuvaeff\OpenApiContract\Internal\Reference\DocumentGraph; use Rasuvaeff\OpenApiContract\Internal\Schema\SchemaDialect; use Rasuvaeff\OpenApiContract\Internal\Schema\SchemaValidator; @@ -67,9 +68,13 @@ private function __construct( /** @param array $document */ public static function fromArray(array $document, ?Limits $limits = null): self { + $limits ??= new Limits(); + if (DocumentNodes::within($document, $limits->documentNodes) === null) { + throw new InvalidContract(sprintf('OpenAPI document expands to more than %d nodes', $limits->documentNodes)); + } $compiled = (new DocumentCompiler())->compile($document); - return new self($compiled->dialect, $compiled->operations, $compiled->securitySchemes, $limits ?? new Limits()); + return new self($compiled->dialect, $compiled->operations, $compiled->securitySchemes, $limits); } public static function fromJson(string $json, string $source = 'openapi.json', ?Limits $limits = null): self @@ -100,7 +105,7 @@ public static function fromJson(string $json, string $source = 'openapi.json', ? public static function fromFile(string $path, ?Limits $limits = null): self { $limits ??= new Limits(); - $graph = DocumentGraph::open($path, $limits->documentFiles, $limits->documentBytes); + $graph = DocumentGraph::open($path, $limits->documentFiles, $limits->documentBytes, $limits->documentNodes); $compiled = (new DocumentCompiler())->compile($graph->entryDocument(), $graph); return new self($compiled->dialect, $compiled->operations, $compiled->securitySchemes, $limits); diff --git a/src/Internal/Compilation/DocumentNodes.php b/src/Internal/Compilation/DocumentNodes.php new file mode 100644 index 0000000..cb819e7 --- /dev/null +++ b/src/Internal/Compilation/DocumentNodes.php @@ -0,0 +1,52 @@ + $document + * + * @return int|null the node count, or `null` when the document exceeds + * the budget — counting stops there, so an oversized document + * costs the budget and not its own size + */ + public static function within(array $document, int $budget): ?int + { + $count = 0; + $stack = [$document]; + while ($stack !== []) { + /** @var array $node */ + $node = array_pop($stack); + /** @var mixed $value */ + foreach ($node as $value) { + if (++$count > $budget) { + return null; + } + if (is_array($value)) { + $stack[] = $value; + } + } + } + + return $count; + } +} diff --git a/src/Internal/Reference/DocumentGraph.php b/src/Internal/Reference/DocumentGraph.php index 9ab3fd8..0cef7e8 100644 --- a/src/Internal/Reference/DocumentGraph.php +++ b/src/Internal/Reference/DocumentGraph.php @@ -4,6 +4,7 @@ namespace Rasuvaeff\OpenApiContract\Internal\Reference; +use Rasuvaeff\OpenApiContract\Internal\Compilation\DocumentNodes; use Rasuvaeff\OpenApiContract\InvalidContract; use Rasuvaeff\OpenApiContract\Limits; @@ -22,9 +23,9 @@ final class DocumentGraph /** @var array> */ private array $documents = []; - private function __construct(private readonly string $root, private readonly string $entryPath, private readonly int $maximumFiles, private int $remainingBytes) {} + private function __construct(private readonly string $root, private readonly string $entryPath, private readonly int $maximumFiles, private int $remainingBytes, private int $remainingNodes) {} - public static function open(string $path, int $maximumFiles = Limits::DEFAULT_DOCUMENT_FILES, int $maximumBytes = Limits::DEFAULT_DOCUMENT_BYTES): self + public static function open(string $path, int $maximumFiles = Limits::DEFAULT_DOCUMENT_FILES, int $maximumBytes = Limits::DEFAULT_DOCUMENT_BYTES, int $maximumNodes = Limits::DEFAULT_DOCUMENT_NODES): self { if ($maximumFiles < 1) { throw new \InvalidArgumentException('Maximum file count must be positive'); @@ -32,12 +33,15 @@ public static function open(string $path, int $maximumFiles = Limits::DEFAULT_DO if ($maximumBytes < 1) { throw new \InvalidArgumentException('Maximum byte budget must be positive'); } + if ($maximumNodes < 1) { + throw new \InvalidArgumentException('Maximum node budget must be positive'); + } $canonical = realpath($path); if ($canonical === false || !is_file($canonical)) { throw new InvalidContract(sprintf('OpenAPI document "%s" is not readable', $path)); } - $graph = new self(\dirname($canonical), $canonical, $maximumFiles, $maximumBytes); + $graph = new self(\dirname($canonical), $canonical, $maximumFiles, $maximumBytes, $maximumNodes); $graph->document($canonical); return $graph; @@ -76,8 +80,16 @@ public function document(string $canonicalPath): array throw new InvalidContract(sprintf('OpenAPI document "%s" exceeds the shared byte budget', $display)); } $this->remainingBytes -= strlen($contents); + $parsed = $this->parse($canonicalPath, $display, $contents); + // Bytes are what the file weighs; nodes are what it expands into, and + // YAML anchors make the two unrelated. + $nodes = DocumentNodes::within($parsed, $this->remainingNodes); + if ($nodes === null) { + throw new InvalidContract(sprintf('OpenAPI document "%s" exceeds the shared node budget', $display)); + } + $this->remainingNodes -= $nodes; - return $this->documents[$canonicalPath] = $this->parse($canonicalPath, $display, $contents); + return $this->documents[$canonicalPath] = $parsed; } /** diff --git a/src/Limits.php b/src/Limits.php index ee7c98a..32529f5 100644 --- a/src/Limits.php +++ b/src/Limits.php @@ -14,6 +14,12 @@ * middleware is a denial of service; a caller whose traffic is legitimately * larger raises the budget here instead of losing the verdict. * + * `documentNodes` bounds what a document expands into rather than what it + * weighs: YAML anchors produce nodes out of no bytes, so the byte budget alone + * does not bound the memory a document costs. The default sits above what any + * document within `documentBytes` can hold, so it refuses amplification + * without refusing size. + * * @api */ final readonly class Limits @@ -21,11 +27,13 @@ public const int DEFAULT_DOCUMENT_BYTES = 10 * 1024 * 1024; public const int DEFAULT_MESSAGE_BODY_BYTES = 1024 * 1024; public const int DEFAULT_DOCUMENT_FILES = 64; + public const int DEFAULT_DOCUMENT_NODES = 5_000_000; public function __construct( public int $documentBytes = self::DEFAULT_DOCUMENT_BYTES, public int $messageBodyBytes = self::DEFAULT_MESSAGE_BODY_BYTES, public int $documentFiles = self::DEFAULT_DOCUMENT_FILES, + public int $documentNodes = self::DEFAULT_DOCUMENT_NODES, ) { if ($documentBytes < 1) { throw new \InvalidArgumentException('Document byte budget must be positive'); @@ -36,5 +44,8 @@ public function __construct( if ($documentFiles < 1) { throw new \InvalidArgumentException('Document file budget must be positive'); } + if ($documentNodes < 1) { + throw new \InvalidArgumentException('Document node budget must be positive'); + } } } diff --git a/tests/ContractTest.php b/tests/ContractTest.php index 7e04d7c..8d65652 100644 --- a/tests/ContractTest.php +++ b/tests/ContractTest.php @@ -12,6 +12,7 @@ use Rasuvaeff\OpenApiContract\ContractViolation; use Rasuvaeff\OpenApiContract\Internal\Compilation\CompiledDocument; use Rasuvaeff\OpenApiContract\Internal\Compilation\DocumentCompiler; +use Rasuvaeff\OpenApiContract\Internal\Compilation\DocumentNodes; use Rasuvaeff\OpenApiContract\Internal\Exception\UnsupportedDialect; use Rasuvaeff\OpenApiContract\InvalidContract; use Rasuvaeff\OpenApiContract\Limits; @@ -31,6 +32,7 @@ #[Covers(Contract::class)] #[Covers(CompiledDocument::class)] #[Covers(DocumentCompiler::class)] +#[Covers(DocumentNodes::class)] #[Covers(InvalidContract::class)] #[Covers(UnknownOperation::class)] #[Covers(UnsupportedSerialization::class)] @@ -471,6 +473,38 @@ public function reportsTheAmbiguousPathsWithTheUppercaseMethod(): void } } + /** + * The byte budget measures the document; this one measures what it + * expands into, which for YAML aliases is unrelated. + */ + public function refusesADocumentOverTheConfiguredNodeBudget(): void + { + $document = ['openapi' => '3.1.0', 'paths' => ['/h' => ['get' => [ + 'parameters' => [['name' => 'q', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => range(1, 40)]]], + 'responses' => ['200' => []], + ]]]]; + + try { + Contract::fromArray($document, new Limits(documentNodes: 20)); + Assert::true(actual: false, message: 'Expected the node budget to refuse the document'); + } catch (InvalidContract $exception) { + Assert::same($exception->getMessage(), 'OpenAPI document expands to more than 20 nodes'); + } + + Assert::same(Contract::fromArray($document, new Limits(documentNodes: 200))->operations()[0]->path, '/h'); + } + + public function countsEveryNodeOfADocumentOnce(): void + { + Assert::same(DocumentNodes::within(['a' => 1, 'b' => ['c' => 2]], 100), 3); + Assert::same(DocumentNodes::within([], 100), 0); + Assert::same(DocumentNodes::within(['a' => 1, 'b' => 2], 2), 2); + Assert::null(DocumentNodes::within(['a' => 1, 'b' => 2], 1)); + // The count stops at the budget, so an oversized document costs the + // budget rather than its own size. + Assert::null(DocumentNodes::within(['a' => range(1, 10_000)], 3)); + } + public function refusesADocumentOverTheConfiguredByteBudget(): void { $json = '{"openapi":"3.1.0","paths":{"/h":{"get":{"responses":{"200":{}}}}}}'; diff --git a/tests/LimitsTest.php b/tests/LimitsTest.php index 397bb6f..545b4ed 100644 --- a/tests/LimitsTest.php +++ b/tests/LimitsTest.php @@ -21,22 +21,24 @@ public function defaultsToTheDocumentedBudgets(): void Assert::same($limits->documentBytes, 10 * 1024 * 1024); Assert::same($limits->messageBodyBytes, 1024 * 1024); Assert::same($limits->documentFiles, 64); + Assert::same($limits->documentNodes, 5_000_000); } public function carriesTheBudgetsItWasGiven(): void { - $limits = new Limits(documentBytes: 11, messageBodyBytes: 12, documentFiles: 13); + $limits = new Limits(documentBytes: 11, messageBodyBytes: 12, documentFiles: 13, documentNodes: 14); Assert::same($limits->documentBytes, 11); Assert::same($limits->messageBodyBytes, 12); Assert::same($limits->documentFiles, 13); + Assert::same($limits->documentNodes, 14); } #[DataProvider('emptyBudgetProvider')] - public function refusesABudgetThatAdmitsNothing(int $documentBytes, int $messageBodyBytes, int $documentFiles, string $message): void + public function refusesABudgetThatAdmitsNothing(int $documentBytes, int $messageBodyBytes, int $documentFiles, int $documentNodes, string $message): void { try { - new Limits(documentBytes: $documentBytes, messageBodyBytes: $messageBodyBytes, documentFiles: $documentFiles); + new Limits(documentBytes: $documentBytes, messageBodyBytes: $messageBodyBytes, documentFiles: $documentFiles, documentNodes: $documentNodes); Assert::true(actual: false, message: 'Expected an empty budget to be refused'); } catch (\InvalidArgumentException $exception) { Assert::same($exception->getMessage(), $message); @@ -45,11 +47,13 @@ public function refusesABudgetThatAdmitsNothing(int $documentBytes, int $message public static function emptyBudgetProvider(): iterable { - yield 'zero document bytes' => [0, 1, 1, 'Document byte budget must be positive']; - yield 'negative document bytes' => [-1, 1, 1, 'Document byte budget must be positive']; - yield 'zero message body bytes' => [1, 0, 1, 'Message body byte budget must be positive']; - yield 'negative message body bytes' => [1, -1, 1, 'Message body byte budget must be positive']; - yield 'zero document files' => [1, 1, 0, 'Document file budget must be positive']; - yield 'negative document files' => [1, 1, -1, 'Document file budget must be positive']; + yield 'zero document bytes' => [0, 1, 1, 1, 'Document byte budget must be positive']; + yield 'negative document bytes' => [-1, 1, 1, 1, 'Document byte budget must be positive']; + yield 'zero message body bytes' => [1, 0, 1, 1, 'Message body byte budget must be positive']; + yield 'negative message body bytes' => [1, -1, 1, 1, 'Message body byte budget must be positive']; + yield 'zero document files' => [1, 1, 0, 1, 'Document file budget must be positive']; + yield 'negative document files' => [1, 1, -1, 1, 'Document file budget must be positive']; + yield 'zero document nodes' => [1, 1, 1, 0, 'Document node budget must be positive']; + yield 'negative document nodes' => [1, 1, 1, -1, 'Document node budget must be positive']; } } diff --git a/tests/MultiFileContractTest.php b/tests/MultiFileContractTest.php index 59932bf..5ac7fd0 100644 --- a/tests/MultiFileContractTest.php +++ b/tests/MultiFileContractTest.php @@ -245,6 +245,43 @@ public function reportsRootRelativePathsWithoutLeakingTheHostLocation(): void } } + /** + * A YAML file that weighs a few hundred bytes and expands into millions of + * nodes: the byte budget cannot see it, and the resolver's own budget + * never counts it, because the anchor is used where the resolver rightly + * stops — inside `enum`, which is data. + */ + public function refusesAYamlDocumentThatExpandsPastTheNodeBudget(): void + { + $root = $this->workspace(); + + try { + $yaml = "openapi: 3.1.0\n"; + $yaml .= "x-anchors:\n a0: &a0 [\"a\",\"b\",\"c\",\"d\",\"e\",\"f\",\"g\",\"h\",\"i\"]\n"; + for ($level = 1; $level <= 5; $level++) { + $previous = '*a' . ($level - 1); + $yaml .= sprintf(" a%d: &a%d [%s]\n", $level, $level, implode(',', array_fill(0, 9, $previous))); + } + $yaml .= "paths:\n /h:\n get:\n parameters:\n - name: q\n in: query\n schema:\n type: string\n enum: *a5\n"; + $yaml .= " responses:\n '200':\n description: ok\n"; + $this->write($root, 'bomb.yaml', $yaml); + + Assert::true(strlen($yaml) < 1024); + + try { + // 9^6 nodes out of under a kilobyte: the budget is what the + // document expands into, and counting stops at it, so the + // refusal costs the budget rather than the expansion. + Contract::fromFile($root . '/bomb.yaml', new Limits(documentNodes: 100_000)); + Assert::true(actual: false, message: 'Expected the node budget to refuse the expanded document'); + } catch (InvalidContract $exception) { + Assert::same($exception->getMessage(), 'OpenAPI document "bomb.yaml" exceeds the shared node budget'); + } + } finally { + $this->remove($root); + } + } + public function passesTheCallersBudgetsIntoTheDocumentGraph(): void { $root = $this->workspace(); @@ -262,6 +299,13 @@ public function passesTheCallersBudgetsIntoTheDocumentGraph(): void Assert::same($exception->getMessage(), 'OpenAPI document graph exceeds the budget of 1 files'); } + try { + Contract::fromFile($root . '/entry.json', new Limits(documentNodes: 2)); + Assert::true(actual: false, message: 'Expected the configured node budget to refuse the graph'); + } catch (InvalidContract $exception) { + Assert::string($exception->getMessage())->contains('exceeds the shared node budget'); + } + try { Contract::fromFile($root . '/entry.json', new Limits(documentBytes: 1)); Assert::true(actual: false, message: 'Expected the configured byte budget to refuse the graph'); diff --git a/tests/RequestValidationTest.php b/tests/RequestValidationTest.php index c976d54..6e7f13c 100644 --- a/tests/RequestValidationTest.php +++ b/tests/RequestValidationTest.php @@ -14,6 +14,7 @@ use Rasuvaeff\OpenApiContract\Internal\Validation\FormUrlencodedBodyDecoder; use Rasuvaeff\OpenApiContract\Internal\Validation\MessageBodyTooLarge; use Rasuvaeff\OpenApiContract\Internal\Validation\MessageBodyUnreadable; +use Rasuvaeff\OpenApiContract\Internal\Validation\MessageReading; use Rasuvaeff\OpenApiContract\Internal\Validation\MultipartBodyDecoder; use Rasuvaeff\OpenApiContract\Internal\Validation\OpaqueBodyVerdict; use Rasuvaeff\OpenApiContract\Internal\Validation\RequestValidator; @@ -37,6 +38,7 @@ #[Test] #[Covers(Contract::class)] #[Covers(RequestValidator::class)] +#[Covers(MessageReading::class)] #[Covers(Operation::class)] #[Covers(MatchedOperation::class)] #[Covers(ValidationResult::class)] diff --git a/tests/ResponseValidationTest.php b/tests/ResponseValidationTest.php index a882766..4e0724f 100644 --- a/tests/ResponseValidationTest.php +++ b/tests/ResponseValidationTest.php @@ -12,6 +12,7 @@ use Rasuvaeff\OpenApiContract\Internal\Response\SelectedResponse; use Rasuvaeff\OpenApiContract\Internal\Validation\MessageBodyTooLarge; use Rasuvaeff\OpenApiContract\Internal\Validation\MessageBodyUnreadable; +use Rasuvaeff\OpenApiContract\Internal\Validation\MessageReading; use Rasuvaeff\OpenApiContract\Internal\Validation\OpaqueBodyVerdict; use Rasuvaeff\OpenApiContract\Internal\Validation\ResponseValidator; use Rasuvaeff\OpenApiContract\Internal\Validation\SchemaValueDecoder; @@ -29,6 +30,7 @@ #[Test] #[Covers(Contract::class)] #[Covers(ResponseValidator::class)] +#[Covers(MessageReading::class)] #[Covers(SelectedResponse::class)] #[Covers(MessageBodyTooLarge::class)] #[Covers(MessageBodyUnreadable::class)]