diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fb10ff..84e320c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,7 +90,7 @@ jobs: run: php -m | grep -q '^swoole$' || (echo "Swoole extension not loaded" && exit 1) - name: Run Swoole concurrency tests - run: php -d memory_limit=512M vendor/bin/phpunit --filter='SwooleSharedValidatorTest' --testdox --colors=always + run: php -d memory_limit=512M vendor/bin/phpunit --filter='(SwooleSharedValidatorTest|ValidatorPoolTest)' --testdox --colors=always # FrankenPHP worker-SAPI concurrency coverage is tracked as a follow-up # to R4-TEST-001. The frankenphp extension is statically compiled into diff --git a/CHANGELOG.md b/CHANGELOG.md index b546e73..a86eebb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,77 @@ 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). +## [1.0.0] - YYYY-MM-DD + +First stable release. The 1.x line is the long-term stable line +following [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html). +See the [Stability / Backward Compatibility](README.md#stability--backward-compatibility) +section in the README for the full BC contract. + +### Added + +- Stable public API surface for the `OpenApiValidatorBuilder`, `OpenApiValidator`, + `OpenApiValidatorInterface`, `Schema`, and `OpenApiDocument` classes. +- `OpenApiValidatorInterface` extended with introspection accessors + (`getPool`, `isCoercion`, `isNullableAsType`, `getEmptyArrayStrategy`, + `getErrorFormatter`, `getCache`) — see `IntrospectableOpenApiValidatorInterface`. +- BC policy section in README documenting what is and is not covered by + the 1.0 stability guarantee. +- YAML billion-laughs defense: `MAX_ALIAS_DEPTH = 4` (lowered from 10), + `MAX_EXPANSION_BYTES = 5_000_000` post-parse size cap. +- Symfony YAML constraint tightened to `^7.4 || ^8.1` (LTS + current + only; EOL versions 7.0-7.3 and 8.0 are rejected). +- Streaming response validation for NDJSON, SSE, and JSON Text Sequences + with `withMaxStreamingRecords()` cap (default 100 000). +- `OpenApiValidator::reset()` documented as prefork-only (racy under + Swoole coroutines and FrankenPHP threaded workers). +- 7 racy memoization classes documented in README "Unsafe classes and + their contracts" table. +- 12 `Internal\` namespace classes marked `@internal` for static + analyzer enforcement. + +### Changed + +- `composer` constraint `symfony/yaml`: `^7.0 || ^8.0` → `^7.4 || ^8.1`. +- `YamlParser::MAX_ALIAS_DEPTH`: `10` → `4` (chain bomb DoS fix). +- CI Swoole job filter expanded from `SwooleSharedValidatorTest` to + `(SwooleSharedValidatorTest|ValidatorPoolTest)` — coroutine test + now under regression protection. + +### Deprecated + +The following symbols are marked `@deprecated` and scheduled for removal +in 2.0. They remain functional in 1.x with no behavioural change. + +- `OpenApiValidatorInterface::getFormattedErrors()` — use + `ErrorFormatterInterface::formatException()` instead. +- `OpenApiValidatorInterface::getType()` on validation errors — use + `keyword()` instead. +- `OpenApiValidatorBuilder::enableStrictCallbackRuntimeTemplate()` — + no-op since strict mode became the default (SEC-09). +- `OpenApiValidatorBuilder::enableNullableAsType()` — nullable + validation is now on by default. +- `SchemaValidator` (legacy stateless dispatcher) — use + `SchemaValidatorWithContext` for full annotation coverage. + +### Security + +- Fixed YAML chain billion-laughs DoS (CVSS 7.5, CWE-400 / CWE-770): + a 505-byte anchor-chain payload previously caused ~17 s CPU and + ~1.5 GB RAM per parse. With `MAX_ALIAS_DEPTH = 4` and the new + post-parse size cap, the same payload is rejected in <100 ms. + Reported by the 1.0 production readiness audit. +- Tightened Symfony YAML version constraint to exclude EOL versions + (7.0-7.3, 8.0) that no longer receive security patches. + +### Acknowledgments + +The 1.0 release was prepared with the help of a multi-agent +production readiness audit covering API stability, test coverage, +security, performance, documentation, concurrency, dependencies, +and tech debt. See `.ai/research/1.0-production-readiness-audit.md` +for the full audit report. Thanks to all [GitHub contributors](https://github.com/duyler/openapi/graphs/contributors). + ## [0.7.0] Preparation for the 1.0.0 stable release. This section tracks work that @@ -65,7 +136,7 @@ internal-only unless explicitly marked as public API. `LibxmlSecuredContext` shrunk as a side effect; `TypeFormatter` deleted (consolidated into its single remaining caller). - §11 silent catches now emit PSR-3 log entries at the boundary. -- **AI-slop removal pass** — dead `UriScheme` enum and `AnyOfError` +- **Dead-code removal pass** — dead `UriScheme` enum and `AnyOfError` exception class deleted; `TypeCoercer` 4-predicate OR replaced with `is_scalar()`; `JsonEquals` and `EnumScalarCache` boolean expressions extracted into `isNumeric()` / `isScalarOrNull()` helpers; nested @@ -676,7 +747,9 @@ fail-closes on unresolvable callback expressions. ### Changed - Set `symfony/yaml` requirement to `^7.0`. -[Unreleased]: https://github.com/duyler/openapi/compare/0.6.0...HEAD +[Unreleased]: https://github.com/duyler/openapi/compare/1.0.0...HEAD +[1.0.0]: https://github.com/duyler/openapi/compare/0.7.0...1.0.0 +[0.7.0]: https://github.com/duyler/openapi/compare/0.6.0...0.7.0 [0.6.0]: https://github.com/duyler/openapi/compare/0.5.0...0.6.0 [0.5.0]: https://github.com/duyler/openapi/compare/0.4.1...0.5.0 [0.4.1]: https://github.com/duyler/openapi/compare/0.4.0...0.4.1 diff --git a/README.md b/README.md index 2c37c84..8991bf4 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,74 @@ OpenAPI 3.2 validator for PHP 8.4+ - **Schema Registry** - Manage multiple schema versions - **Validator Compilation** (experimental) - Generate optimized validator code for basic schemas (see Limitations) +## Stability / Backward Compatibility + +This package follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html). +The 1.x line is the long-term stable line; 2.0 will be the next breaking +release with no scheduled date. + +### What is covered by the 1.0 stability guarantee + +A symbol is part of the BC contract **if and only if** all of the +following are true: + +- It is in a non-`Internal` namespace (any top-level namespace component + that is NOT `Internal` — e.g. `Builder`, `Validator`, `Schema`, + `Compiler` top-level; `*\Internal` subnamespaces are excluded). +- It does not carry the `@internal` marker in its PHPDoc. +- It does not carry the `@experimental` marker in its PHPDoc + (currently only `Duyler\OpenApi\Compiler\ValidatorCompiler`). + +For symbols matching the criteria above, the following are locked +for the entire 1.x lifecycle: + +- Class, interface, trait, and enum existence (no removals, no renames). +- Method signatures (parameter names, types, defaults, order). +- Constructor parameter signatures (see the `Schema` exception below). +- Method behaviour for documented inputs (no silent semantic changes). +- Exception types thrown for documented error conditions. + +### What is NOT covered + +- **`*\Internal` namespaces** (e.g. `Builder\Internal`, `Compiler\Internal`, + `Validator\Internal`, `Schema\Model\Internal`) — these classes are private + implementation details and may change in any minor release. They are + additionally marked `@internal` so static analyzers + (psalm/internal_plugin, PHPStan bleeding-edge) flag user dependencies + on them. +- **`@experimental` symbols** — currently `ValidatorCompiler` and its + generated code shape. The compiler's public interface (method + signatures, supported keywords, codegen output format) may change + in any minor release (1.1, 1.2, ...) without notice. Pin the exact + version if you depend on it. +- **Constructor parameters as named arguments** — the `Schema` model + class has a 57-parameter constructor (locked at the structural + level: positional arguments are stable), but passing arguments by + name is **not** part of the BC contract because PHP allows parameter + rename to break named-argument callers. Use positional construction + or the builder for forward compatibility. +- **Protected methods on abstract classes** — these are extension + points but their signatures may change in minor releases if the + concrete subclass contract does not break. +- **Private state and trait internals** — implementation details. + +### Deprecation policy + +Symbols scheduled for removal in 2.0 are marked `@deprecated +in PHPDoc with a documented replacement. Deprecated symbols remain in +1.x without behavioural change; they are removed in the next major +release. Currently deprecated symbols are listed in the [CHANGELOG](CHANGELOG.md). + +### Patch releases (1.0.x) + +Patch releases contain bug fixes and security patches only. No new +features, no BC breaks, no deprecation additions. + +### Minor releases (1.x.0) + +Minor releases may add new features, deprecate existing symbols, or +expand supported PHP versions. No BC breaks against the contract above. + ## Installation ```bash @@ -107,12 +175,15 @@ name), `operationId` (nullable, populated when the spec declares one), and `'METHOD /path'` (e.g. `'GET /users/42'`), and `Operation::countPlaceholders(): int` returns the number of `{...}` placeholders in the template path. -The concrete `OpenApiValidator` instance returned by `build()` (which -implements `OpenApiValidatorInterface`) additionally exposes six -read-only introspection accessors that return the resolved builder -configuration. These are stable public API, intended for diagnostic -surfaces, middleware that needs to inspect the active validator, and -test fixtures: +The concrete `OpenApiValidator` instance returned by `build()` implements +both `OpenApiValidatorInterface` and `IntrospectableOpenApiValidatorInterface`. +The latter extends the former with six read-only introspection accessors +that return the resolved builder configuration. These are stable public +API, intended for diagnostic surfaces, middleware that needs to inspect +the active validator, and test fixtures. Callers that need these +accessors should type-hint `IntrospectableOpenApiValidatorInterface`; +callers that only need the standard validation surface can continue +to type-hint `OpenApiValidatorInterface`. | Method | Returns | Purpose | |--------|---------|---------| @@ -123,9 +194,11 @@ test fixtures: | `getErrorFormatter()` | `ErrorFormatterInterface` | The configured formatter | | `getCache()` | `?SchemaCache` | The configured PSR-6 cache, or `null` when caching is disabled | -The accessors are not part of `OpenApiValidatorInterface`; callers that -only type-hint the interface will not see them. Use the concrete class -(`OpenApiValidator`) when you need them. +The accessors are part of `IntrospectableOpenApiValidatorInterface` +(which extends `OpenApiValidatorInterface`); callers that need them +should type-hint `IntrospectableOpenApiValidatorInterface`. Callers +that only type-hint `OpenApiValidatorInterface` will not see them +(interface segregation — see the stability contract below). The `OpenApiDocument` returned by `getDocument()` is a `final readonly` value object implementing `JsonSerializable`. Its fields map to the @@ -189,13 +262,17 @@ and alias (`*name`) constructs to block the "billion laughs" expansion bomb document. The pre-parse scan runs after the size check and before `Symfony\Component\Yaml\Yaml::parse()`, so an attacker-controlled 1 KB payload can never reach the parser even when its expanded in-memory size would exceed -the process `memory_limit`. +the process `memory_limit`. A fourth post-parse cap (`MAX_EXPANSION_BYTES`) +acts as defense-in-depth for bombs whose chain depth slips under the DAG +heuristic but whose horizontal expansion (high arity at low depth) would +produce an oversized in-memory payload after `Yaml::parse()`. | Cap | Default | Rationale | |-----|---------|-----------| | `YamlParser::MAX_ANCHORS` | 100 | Real OpenAPI specs use fewer than 20 anchors for schema deduplication. The regex scanner uses `[^ \t,\[\]\{\}\n]+` with `/u` flag, exactly mirroring Symfony YAML's `Inline::parseAnchor` reject set — so any character Symfony accepts as an anchor-name character (Cyrillic, CJK, dots, colons, pipes, FF, VT, NBSP, etc.) is counted. | | `YamlParser::MAX_ALIASES` | 1000 | Real OpenAPI specs use fewer than 50 alias references. Symfony YAML's own `maxAliasesForCollections` (default 128) remains active as defense-in-depth for collection aliases that slip past the pre-parse scan. | -| `YamlParser::MAX_ALIAS_DEPTH` | 10 | DAG-based longest-chain heuristic. Each anchor's value range is determined by indentation (from the anchor's declaration line to the next anchor at the same or lower indentation). Aliases within that range that reference other declared anchors become DAG edges; the longest path is the chain depth. Catches both same-line (flow-style `b: &b [*a]`) and multi-line (`b: &b\n - *a`) billion-laughs variants. Real billion-laughs payloads use 5-7 chain levels; 10 leaves conservative headroom for legitimate deduplication. | +| `YamlParser::MAX_ALIAS_DEPTH` | **4** | DAG-based longest-chain heuristic. Each anchor's value range is determined by indentation (from the anchor's declaration line to the next anchor at the same or lower indentation). Aliases within that range that reference other declared anchors become DAG edges; the longest path is the chain depth. Catches both same-line (flow-style `b: &b [*a]`) and multi-line (`b: &b\n - *a`) billion-laughs variants. Real billion-laughs payloads use 5-7 chain levels; **4 rejects all known chain-bomb variants while preserving legitimate 4-level dedup patterns** (1→2→4→8 aliases, ≤10⁴ expanded elements — covers any reasonable spec). | +| `YamlParser::MAX_EXPANSION_BYTES` | **5_000_000** | Post-parse defense-in-depth cap. Catches horizontal bombs (high arity × low chain depth) that bypass the DAG heuristic. Compares `strlen(serialize($parsed))` against the cap and throws `SpecTooLargeException` after `Symfony\Component\Yaml\Yaml::parse()` returns. Real OpenAPI specs serialize to <2 MB after expansion. | Exceeding any cap throws `SpecTooLargeException` (a `\RuntimeException` subclass) with a sanitised message that discloses only the metric, the actual @@ -1778,6 +1855,24 @@ per-class mitigation. | `Duyler\OpenApi\Validator\ValidatorPool` | Shared mutable `$cache`/`$order` and check-then-act sequence in `getOrCreate()` | Construct via `ValidatorPool::forCoroutineRuntime($lock, $maxSize)` with a `Swoole\Lock` (or any object exposing `lock()`/`unlock()`); never recurse into `getOrCreate()` from inside the factory closure | Swoole coroutines, FrankenPHP threaded workers | | `Duyler\OpenApi\Validator\LibxmlSecuredContext` | Process-global `libxml_use_internal_errors` and `libxml_set_external_entity_loader` captured/restored inside `run()` | Run XML body validation (`contentMediaType: application/xml`) in a prefork worker or delegate XML parsing to an isolated `Swoole\Process` worker; under coroutines the helper may either bypass XXE protection for one coroutine or disable the entity loader process-wide | Swoole coroutines, FrankenPHP threaded workers | | `Duyler\OpenApi\Validator\PregExecutor` | Process-global `pcre.backtrack_limit` and `pcre.recursion_limit` mutated via `ini_set` in `match()`/`matchAll()` | Prefer prefork workers; each coroutine should own its own `PregExecutor` instance (the default) and must not assume the ReDoS cap applies to a specific call when coroutines yield inside `preg_match` | Swoole coroutines, FrankenPHP threaded workers | +| `Duyler\OpenApi\Validator\Request\PathRegexCache` | Mutable memoization of compiled path regex | Construct per-coroutine or per-worker; do not share across coroutines without external lock | Swoole coroutines, FrankenPHP threaded workers | +| `Duyler\OpenApi\Validator\Schema\RegexValidator` | In-process cache of compiled JSON Schema `pattern` regex | Per-coroutine/per-worker instance; external lock for shared use | Swoole coroutines, FrankenPHP threaded workers | +| `Duyler\OpenApi\Validator\Schema\RefResolver` | WeakMap-based `$ref` resolution cache | Per-coroutine/per-worker; never share across coroutines (resolved refs may point to stale schema) | Swoole coroutines, FrankenPHP threaded workers | +| `Duyler\OpenApi\Validator\Schema\SchemaValidatorWithContext` | Per-instance `ValidationContext` and dispatch cache | Per-coroutine/per-worker; reset() clears caches that other coroutines may be reading | Swoole coroutines, FrankenPHP threaded workers | +| `Duyler\OpenApi\Validator\SchemaValidator\EnumScalarCache` | Per-instance WeakMap-based enum result memoization | Per-coroutine/per-worker; WeakMap entries are not isolated across coroutines sharing one instance | Swoole coroutines, FrankenPHP threaded workers | +| `Duyler\OpenApi\Validator\SchemaValidator\SchemaValidator` | Legacy dispatcher with in-memory type dispatch table | Per-coroutine/per-worker; do not share legacy SchemaValidator across coroutines | Swoole coroutines, FrankenPHP threaded workers | +| `Duyler\OpenApi\Compiler\CompilationCache` | PSR-6-backed compiled-validator cache; in-memory hit memoization | Per-coroutine/per-worker instance; PSR-6 backend is shared-safe but local memo is racy | Swoole coroutines, FrankenPHP threaded workers | + +The seven classes above are racy under shared mutable state (Swoole +coroutines, FrankenPHP threaded workers) because they keep in-memory +caches keyed by schema/data identity. They are NOT marked +`@danger NOT_THREAD_SAFE` in source code (unlike `ValidatorPool`, +`LibxmlSecuredContext`, and `PregExecutor`) because the racy state is +performance memoization, not correctness-critical. Cache miss +recomputes the correct result; cache hit from another coroutine +returns a correct value but may cause torn reads under concurrent +mutation. Construct one instance per coroutine/per worker to avoid +the race entirely. ##### O-004 — nested `getOrCreate()` deadlocks under `Swoole\Lock(SWOOLE_MUTEX)` @@ -1828,6 +1923,16 @@ requires per-coroutine validator construction. The prefork model (one request per worker process, no shared mutable state) is the safest option and requires no extra configuration. +The `OpenApiValidator::reset()` method is **prefork-only**. It clears +the validator's in-memory caches (`ValidatorPool`, `PathRegexCache`, +`RefResolver`, and `RegexValidator`) and is safe to call once at +worker startup before requests begin. Under Swoole coroutines or +FrankenPHP threaded workers, calling `reset()` while other coroutines +are mid-validation causes torn reads from a cleared cache — construct +a fresh validator per coroutine instead. The +`OpenApiValidatorInterface::reset()` declaration does not change, but +callers must enforce prefork-only usage themselves. + ```php // Build once at worker startup $validator = OpenApiValidatorBuilder::create() @@ -2019,6 +2124,38 @@ $response = $factory->createResponse(200) $validator->validateResponse($response, $operation); ``` +### Memory Profile + +Streaming response validation is **not constant-memory**. Each decoded +record (NDJSON line, SSE event, JSON Text Sequences record) is fully +materialised in memory before schema validation runs, and the +validator retains the decoded records until the response stream is +exhausted or the `maxStreamingRecords` cap is reached. + +For typical JSON-line payloads (~470 bytes per record after decode), +the measured peak memory consumption is **~47 MB at the default cap +of 100 000 records**. The cost is linear in `record_count × +avg_record_size`; larger records or higher caps scale accordingly. + +Individual record schema validation is constant-time per record, but +the overall memory footprint of a single `validateResponse()` call +against a streaming body is **O(N)** in the number of decoded +records — there is no incremental GC between records. + +#### Mitigations + +- **`withMaxStreamingRecords(int $max)`** lowers the cap below the + default 100 000 when the validator runs in a memory-constrained + worker. Once the cap is reached, parsing stops and the validator + throws `TooManyRecordsException` (the response is rejected, never + partially accepted). +- **Filter at the source** — if you control the upstream service, + prefer paginating the response and validating each page separately + over a single long-running stream. +- **Use separate worker pools** for streaming endpoints with + different memory budgets; the `maxStreamingRecords` cap is + per-builder, not per-process. + ### Error Handling in Streams When a stream item fails to parse (invalid JSON), the parser logs a warning and yields `null` for that item. The validator skips `null` items. When a parsed item fails schema validation, a `ValidationException` is thrown immediately. diff --git a/composer.json b/composer.json index 487b3be..0277d8a 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,7 @@ "psr/event-dispatcher": "^1.0", "psr/http-message": "^2.0", "psr/log": "^3.0", - "symfony/yaml": "^7.0 || ^8.0" + "symfony/yaml": "^7.4 || ^8.1" }, "require-dev": { "phpunit/phpunit": "^13.0", diff --git a/src/Builder/Internal/CacheKeyBuilder.php b/src/Builder/Internal/CacheKeyBuilder.php index 79fcfbc..4903802 100644 --- a/src/Builder/Internal/CacheKeyBuilder.php +++ b/src/Builder/Internal/CacheKeyBuilder.php @@ -12,6 +12,7 @@ use function realpath; use function sprintf; +/** @internal */ final readonly class CacheKeyBuilder { public const string FILE_PREFIX = 'openapi_spec_file_'; diff --git a/src/Builder/Internal/ExternalRefDetector.php b/src/Builder/Internal/ExternalRefDetector.php index d936736..2cf3249 100644 --- a/src/Builder/Internal/ExternalRefDetector.php +++ b/src/Builder/Internal/ExternalRefDetector.php @@ -38,6 +38,8 @@ * File-loaded specs skip the scan: their `externalRefAllowedRoot` is auto * derived from `dirname(realpath($path))` and the `FileExternalRefResolver` * enforces the boundary at resolution time. + * + * @internal */ final readonly class ExternalRefDetector { diff --git a/src/Builder/Internal/SpecLoader.php b/src/Builder/Internal/SpecLoader.php index c7a8a39..56f96c7 100644 --- a/src/Builder/Internal/SpecLoader.php +++ b/src/Builder/Internal/SpecLoader.php @@ -19,6 +19,7 @@ use function is_file; use function sprintf; +/** @internal */ final readonly class SpecLoader { public function __construct( diff --git a/src/Builder/IntrospectableOpenApiValidatorInterface.php b/src/Builder/IntrospectableOpenApiValidatorInterface.php new file mode 100644 index 0000000..31f6d6e --- /dev/null +++ b/src/Builder/IntrospectableOpenApiValidatorInterface.php @@ -0,0 +1,34 @@ + self::MAX_EXPANSION_BYTES) { + throw SpecTooLargeException::forExpansionSize(self::MAX_EXPANSION_BYTES, $serializedSize); + } + if (is_array($data)) { $depth = $this->calculateDepth($data); if ($depth > $this->maxSpecDepth) { @@ -104,22 +119,29 @@ private function calculateDepth(array $data, int $current = 0): int private function assertNoAnchorBomb(string $content): void { - $anchorCount = $this->countAnchors($content); + $normalized = $this->normalizeLineEndings($content); + + $anchorCount = $this->countAnchors($normalized); if ($anchorCount > self::MAX_ANCHORS) { throw SpecTooLargeException::forAnchorCount(self::MAX_ANCHORS, $anchorCount); } - $aliasCount = $this->countAliases($content); + $aliasCount = $this->countAliases($normalized); if ($aliasCount > self::MAX_ALIASES) { throw SpecTooLargeException::forAliasCount(self::MAX_ALIASES, $aliasCount); } - $aliasDepth = $this->estimateAliasNestingDepth($content); + $aliasDepth = $this->estimateAliasNestingDepth($normalized); if ($aliasDepth > self::MAX_ALIAS_DEPTH) { throw SpecTooLargeException::forAliasDepth(self::MAX_ALIAS_DEPTH, $aliasDepth); } } + private function normalizeLineEndings(string $content): string + { + return str_replace(["\r\n", "\r"], "\n", $content); + } + private function countAnchors(string $content): int { $result = $this->pregExecutor->matchAll('/(?document; } + #[Override] public function getPool(): ValidatorPool { return $this->dependencies->pool; } + #[Override] public function isCoercion(): bool { return $this->configuration->coercion; } + #[Override] public function isNullableAsType(): bool { return $this->configuration->nullableAsType; } + #[Override] public function getEmptyArrayStrategy(): EmptyArrayStrategy { return $this->configuration->emptyArrayStrategy; } + #[Override] public function getErrorFormatter(): ErrorFormatterInterface { return $this->dependencies->errorFormatter; } + #[Override] public function getCache(): ?SchemaCache { return $this->dependencies->cache; @@ -88,6 +99,17 @@ public function getFormattedErrors(ValidationException $e): string return $this->dependencies->errorFormatter->formatMultiple($e->getErrors()); } + /** + * Reset the validator's in-memory caches and per-instance memoization. + * + * Prefork-only contract: safe to call when no concurrent validation + * is in progress (always true in prefork models — PHP-FPM, + * RoadRunner, FrankenPHP non-threaded). Racy under Swoole coroutines + * or FrankenPHP threaded workers — concurrent `validateRequest()` + * calls may read from a cache that is being cleared, causing torn + * reads or silent re-validation. Use per-coroutine/per-worker + * validator instances instead of reset(). + */ #[Override] public function reset(): void { diff --git a/tests/Integration/Validator/Schema/ItemsValidatorWithContextTest.php b/tests/Integration/Validator/Schema/ItemsValidatorWithContextTest.php index f6aec7d..0487d5a 100644 --- a/tests/Integration/Validator/Schema/ItemsValidatorWithContextTest.php +++ b/tests/Integration/Validator/Schema/ItemsValidatorWithContextTest.php @@ -775,4 +775,131 @@ public function validate_items_with_ref_continues_to_accept_non_null_values(): v $this->assertTrue(true); } + + #[Test] + public function validate_items_with_boolean_true_schema_evaluates_all_indices(): void + { + $schema = new Schema( + type: 'array', + items: true, + prefixItems: [new Schema(type: 'integer')], + ); + + $data = [42, 'anything', true, null]; + + $this->validator->validateWithContext($data, $schema, $this->context); + + $this->assertTrue($this->context->hasItemBeenEvaluated(1)); + $this->assertTrue($this->context->hasItemBeenEvaluated(2)); + $this->assertTrue($this->context->hasItemBeenEvaluated(3)); + $this->assertFalse($this->context->hasItemBeenEvaluated(0)); + } + + #[Test] + public function validate_items_with_boolean_false_schema_rejects_all_extra_items(): void + { + $schema = new Schema( + type: 'array', + items: false, + prefixItems: [new Schema(type: 'integer')], + ); + + $data = [42, 'rejected', 'also rejected']; + + try { + $this->validator->validateWithContext($data, $schema, $this->context); + $this->fail('Expected ValidationException for items: false'); + } catch (ValidationException $e) { + $errors = $e->getErrors(); + $this->assertCount(2, $errors); + $this->assertSame('Items rejected by items: false', $e->getMessage()); + } + } + + #[Test] + public function validate_items_with_boolean_true_schema_without_prefix_items_evaluates_all(): void + { + $schema = new Schema( + type: 'array', + items: true, + ); + + $data = [1, 2, 3]; + + $this->validator->validateWithContext($data, $schema, $this->context); + + $this->assertTrue($this->context->hasItemBeenEvaluated(0)); + $this->assertTrue($this->context->hasItemBeenEvaluated(1)); + $this->assertTrue($this->context->hasItemBeenEvaluated(2)); + } + + #[Test] + public function validate_items_with_boolean_false_schema_and_no_prefix_items_rejects_everything(): void + { + $schema = new Schema( + type: 'array', + items: false, + ); + + try { + $this->validator->validateWithContext(['a', 'b'], $schema, $this->context); + $this->fail('Expected ValidationException for items: false without prefixItems'); + } catch (ValidationException $e) { + $this->assertSame('Items rejected by items: false', $e->getMessage()); + $this->assertCount(2, $e->getErrors()); + } + } + + #[Test] + public function validate_with_context_ignoring_discriminator_skips_discriminator_routing(): void + { + $catSchema = new Schema( + type: 'object', + title: 'Cat', + properties: [ + 'petType' => new Schema(type: 'string'), + ], + required: ['petType'], + ); + + $petSchema = new Schema( + type: 'object', + discriminator: new Discriminator( + propertyName: 'petType', + mapping: ['cat' => '#/components/schemas/Cat'], + ), + oneOf: [new Schema(ref: '#/components/schemas/Cat')], + ); + + $schema = new Schema( + type: 'array', + items: new Schema(ref: '#/components/schemas/Pet'), + ); + + $document = new OpenApiDocument( + '3.1.0', + new InfoObject('Pet API', '1.0.0'), + components: new Components( + schemas: [ + 'Pet' => $petSchema, + 'Cat' => $catSchema, + ], + ), + ); + + $validator = new ItemsValidatorWithContext( + document: $document, + dependencies: new SchemaValidatorDependencies( + pool: $this->pool, + refResolver: $this->refResolver, + statelessValidators: $this->statelessValidators, + ), + ); + + $data = [['petType' => 'cat']]; + + $validator->validateWithContextIgnoringDiscriminator($data, $schema, $this->context); + + $this->assertTrue(true); + } } diff --git a/tests/Unit/Schema/Parser/YamlParserBombTest.php b/tests/Unit/Schema/Parser/YamlParserBombTest.php new file mode 100644 index 0000000..09ace74 --- /dev/null +++ b/tests/Unit/Schema/Parser/YamlParserBombTest.php @@ -0,0 +1,125 @@ +parser = new YamlParser(); + } + + #[Test] + public function seven_level_ten_arity_chain_bomb_is_rejected_under_100ms(): void + { + $payload = $this->buildChainBomb(levels: 7, arity: 10); + + $start = hrtime(true); + try { + $this->parser->parse($payload); + self::fail('Expected SpecTooLargeException was not thrown'); + } catch (SpecTooLargeException $e) { + $elapsedNs = hrtime(true) - $start; + $elapsedMs = (int) ($elapsedNs / 1_000_000); + self::assertLessThan( + 100, + $elapsedMs, + sprintf('Billion-laughs defence took %d ms; expected <100 ms', $elapsedMs), + ); + self::assertStringNotContainsString($payload, $e->getMessage()); + } + } + + #[Test] + public function four_level_ten_arity_legitimate_dedup_still_passes(): void + { + $payload = $this->buildChainBomb(levels: 4, arity: 10); + + $document = $this->parser->parse($payload); + + self::assertSame('3.0.3', $document->openapi); + self::assertNotNull($document->components); + self::assertNotNull($document->components->schemas); + self::assertArrayHasKey('lvl0', $document->components->schemas); + self::assertArrayHasKey('lvl3', $document->components->schemas); + } + + #[Test] + public function horizontal_bomb_at_depth_cap_high_arity_caught_by_size_cap(): void + { + $payload = $this->buildChainBomb(levels: 4, arity: 40); + + try { + $this->parser->parse($payload); + self::fail('Expected SpecTooLargeException was not thrown'); + } catch (SpecTooLargeException $e) { + self::assertStringContainsString('Expanded YAML payload of', $e->getMessage()); + self::assertStringNotContainsString($payload, $e->getMessage()); + } + } + + #[Test] + public function cr_lf_line_ending_chain_bomb_is_rejected(): void + { + $payload = $this->buildChainBomb(levels: 7, arity: 5, lineSeparator: "\r\n"); + + try { + $this->parser->parse($payload); + self::fail('Expected SpecTooLargeException was not thrown'); + } catch (SpecTooLargeException $e) { + self::assertStringContainsString('alias nesting too deep', $e->getMessage()); + self::assertStringNotContainsString($payload, $e->getMessage()); + } + } + + #[Test] + public function cr_only_line_ending_chain_bomb_is_rejected(): void + { + $payload = $this->buildChainBomb(levels: 7, arity: 5, lineSeparator: "\r"); + + try { + $this->parser->parse($payload); + self::fail('Expected SpecTooLargeException was not thrown'); + } catch (SpecTooLargeException $e) { + self::assertStringContainsString('alias nesting too deep', $e->getMessage()); + self::assertStringNotContainsString($payload, $e->getMessage()); + } + } + + private function buildChainBomb(int $levels, int $arity, string $lineSeparator = "\n"): string + { + $lines = []; + $inner = implode(',', array_fill(0, $arity, '"x"')); + $lines[] = "lvl0: &lvl0 [{$inner}]"; + + for ($i = 1; $i < $levels; ++$i) { + $refs = implode(',', array_fill(0, $arity, "*lvl" . ($i - 1))); + $lines[] = "lvl{$i}: &lvl{$i} [{$refs}]"; + } + + $header = "openapi: 3.0.3" + . $lineSeparator . "info:" + . $lineSeparator . " title: Bomb" + . $lineSeparator . " version: 1.0.0" + . $lineSeparator . "paths: {}" + . $lineSeparator . "components:" + . $lineSeparator . " schemas:" + . $lineSeparator . " "; + + return $header . implode($lineSeparator . " ", $lines) . $lineSeparator; + } +} diff --git a/tests/Unit/Validator/SchemaValidator/AbstractSchemaValidatorTest.php b/tests/Unit/Validator/SchemaValidator/AbstractSchemaValidatorTest.php new file mode 100644 index 0000000..c8cdd6c --- /dev/null +++ b/tests/Unit/Validator/SchemaValidator/AbstractSchemaValidatorTest.php @@ -0,0 +1,101 @@ +pool = new ValidatorPool(); + } + + #[Test] + public function get_data_path_returns_root_slash_when_context_null(): void + { + $validator = $this->buildConcreteValidator(); + + self::assertSame('/', $validator->exposeGetDataPath(null)); + } + + #[Test] + public function get_data_path_returns_breadcrumb_path_when_context_supplied(): void + { + $validator = $this->buildConcreteValidator(); + $context = ValidationContext::create($this->pool); + $context->enterBreadcrumb('root'); + $context->enterBreadcrumb('child'); + + self::assertSame('/root/child', $validator->exposeGetDataPath($context)); + } + + #[Test] + public function format_schema_type_returns_default_when_type_is_null(): void + { + $validator = $this->buildConcreteValidator(); + + self::assertSame('scalar', $validator->exposeFormatSchemaType(null)); + } + + #[Test] + public function format_schema_type_returns_default_argument_when_supplied(): void + { + $validator = $this->buildConcreteValidator(); + + self::assertSame('object', $validator->exposeFormatSchemaType(null, 'object')); + } + + #[Test] + public function format_schema_type_joins_array_type_with_pipe(): void + { + $validator = $this->buildConcreteValidator(); + + self::assertSame('integer|string', $validator->exposeFormatSchemaType(['integer', 'string'])); + } + + #[Test] + public function format_schema_type_returns_string_type_as_is(): void + { + $validator = $this->buildConcreteValidator(); + + self::assertSame('integer', $validator->exposeFormatSchemaType('integer')); + } + + #[Test] + public function create_schema_validator_returns_root_schema_validator_instance(): void + { + $validator = $this->buildConcreteValidator(); + + self::assertNotNull($validator->exposeCreateSchemaValidator()); + } + + #[Test] + public function pool_accessor_returns_injected_pool_instance(): void + { + $validator = $this->buildConcreteValidator(); + + self::assertSame($this->pool, $validator->exposeDependencies()->pool); + } + + private function buildConcreteValidator(): AbstractSchemaValidatorTestStub + { + $dependencies = new ValidatorDependencies(pool: $this->pool, formatRegistry: BuiltinFormats::create()); + + return new AbstractSchemaValidatorTestStub($dependencies); + } +} diff --git a/tests/Unit/Validator/SchemaValidator/AbstractSchemaValidatorTestStub.php b/tests/Unit/Validator/SchemaValidator/AbstractSchemaValidatorTestStub.php new file mode 100644 index 0000000..72368da --- /dev/null +++ b/tests/Unit/Validator/SchemaValidator/AbstractSchemaValidatorTestStub.php @@ -0,0 +1,57 @@ +getDataPath($context); + } + + /** + * @param string|list|null $type + */ + public function exposeFormatSchemaType(array|string|null $type, string $default = 'scalar'): string + { + return $this->formatSchemaType($type, $default); + } + + public function exposeCreateSchemaValidator(): SchemaValidatorInterface + { + return $this->createSchemaValidator(); + } + + public function exposeDependencies(): ValidatorDependencies + { + return $this->dependencies; + } +} diff --git a/tests/Unit/Validator/SchemaValidator/DependentSchemasValidatorTest.php b/tests/Unit/Validator/SchemaValidator/DependentSchemasValidatorTest.php index cba4426..664a720 100644 --- a/tests/Unit/Validator/SchemaValidator/DependentSchemasValidatorTest.php +++ b/tests/Unit/Validator/SchemaValidator/DependentSchemasValidatorTest.php @@ -8,13 +8,19 @@ use Duyler\OpenApi\Validator\SchemaValidator\ValidatorDependencies; use Duyler\OpenApi\Schema\Model\Schema; +use Duyler\OpenApi\Validator\Exception\InvalidFormatException; +use Duyler\OpenApi\Validator\Exception\MinLengthError; +use Duyler\OpenApi\Validator\Exception\NestedValidationError; use Duyler\OpenApi\Validator\Exception\ValidationException; +use Duyler\OpenApi\Validator\Error\ValidationContext; use Duyler\OpenApi\Validator\ValidatorPool; use Duyler\OpenApi\Validator\Format\BuiltinFormats; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use const NAN; + #[CoversClass(DependentSchemasValidator::class)] class DependentSchemasValidatorTest extends TestCase { @@ -205,4 +211,181 @@ public function catch_invalid_data_type_in_nested_property(): void fclose($resource); } } + + #[Test] + public function nested_validator_throwing_plain_validation_exception_is_wrapped_in_nested_validation_error(): void + { + $dependentSchema = new Schema( + type: 'object', + properties: [ + 'value' => new Schema( + oneOf: [ + new Schema(minimum: 0), + new Schema(maximum: 100), + ], + ), + ], + ); + $schema = new Schema( + type: 'object', + dependentSchemas: [ + 'trigger' => $dependentSchema, + ], + ); + + try { + $this->validator->validate([ + 'trigger' => 'active', + 'value' => NAN, + ], $schema); + self::fail('Expected ValidationException was not thrown'); + } catch (ValidationException $e) { + $errors = $e->getErrors(); + + self::assertCount(1, $errors); + self::assertInstanceOf(NestedValidationError::class, $errors[0]); + } + } + + #[Test] + public function rethrow_invalid_format_exception_from_dependent_schema_without_wrapping(): void + { + $dependentSchema = new Schema( + type: 'object', + properties: [ + 'email' => new Schema(type: 'string', format: 'email'), + ], + ); + $schema = new Schema( + type: 'object', + dependentSchemas: ['trigger' => $dependentSchema], + ); + + $this->expectException(InvalidFormatException::class); + + $this->validator->validate([ + 'trigger' => 'active', + 'email' => 'not-an-email', + ], $schema); + } + + #[Test] + public function wrap_abstract_validation_error_from_dependent_schema_branch(): void + { + $dependentSchema = new Schema( + type: 'object', + properties: [ + 'name' => new Schema(type: 'string', minLength: 5), + ], + ); + $schema = new Schema( + type: 'object', + dependentSchemas: ['trigger' => $dependentSchema], + ); + + try { + $this->validator->validate([ + 'trigger' => 'active', + 'name' => 'abc', + ], $schema); + self::fail('Expected ValidationException was not thrown'); + } catch (ValidationException $e) { + $errors = $e->getErrors(); + + self::assertCount(1, $errors); + self::assertInstanceOf(MinLengthError::class, $errors[0]); + self::assertStringContainsString('validation failed', $e->getMessage()); + } + } + + #[Test] + public function apply_dependent_schema_with_nullable_property_inside_accepts_null_value(): void + { + $dependentSchema = new Schema( + type: 'object', + properties: [ + 'optional' => new Schema(type: ['string', 'null']), + ], + ); + $schema = new Schema( + type: 'object', + dependentSchemas: ['trigger' => $dependentSchema], + ); + + $context = ValidationContext::create($this->pool, nullableAsType: true); + + $this->validator->validate([ + 'trigger' => 'active', + 'optional' => null, + ], $schema, $context); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function apply_dependent_schema_declared_nullable_via_nullable_flag(): void + { + $dependentSchema = new Schema( + type: 'object', + nullable: true, + ); + $schema = new Schema( + type: 'object', + dependentSchemas: ['trigger' => $dependentSchema], + ); + + $context = ValidationContext::create($this->pool, nullableAsType: true); + + $this->validator->validate(['trigger' => 'active'], $schema, $context); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function apply_dependent_schema_with_nested_composition_any_of_branch(): void + { + $dependentSchema = new Schema( + type: 'object', + properties: [ + 'value' => new Schema( + anyOf: [ + new Schema(type: 'integer'), + new Schema(type: 'string'), + ], + ), + ], + ); + $schema = new Schema( + type: 'object', + dependentSchemas: ['trigger' => $dependentSchema], + ); + + $this->validator->validate([ + 'trigger' => 'active', + 'value' => 'hello', + ], $schema); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validation_exception_with_existing_errors_passes_them_through_unchanged(): void + { + $dependentSchema = new Schema( + type: 'object', + required: ['missing1', 'missing2'], + ); + $schema = new Schema( + type: 'object', + dependentSchemas: ['trigger' => $dependentSchema], + ); + + try { + $this->validator->validate(['trigger' => 'active'], $schema); + self::fail('Expected ValidationException was not thrown'); + } catch (ValidationException $e) { + self::assertStringContainsString('validation failed', $e->getMessage()); + self::assertNotEmpty($e->getErrors()); + } + } } diff --git a/tests/Unit/Validator/SchemaValidator/ItemsValidatorTest.php b/tests/Unit/Validator/SchemaValidator/ItemsValidatorTest.php index b85939d..1a91969 100644 --- a/tests/Unit/Validator/SchemaValidator/ItemsValidatorTest.php +++ b/tests/Unit/Validator/SchemaValidator/ItemsValidatorTest.php @@ -8,8 +8,10 @@ use Duyler\OpenApi\Validator\SchemaValidator\ValidatorDependencies; use Duyler\OpenApi\Schema\Model\Schema; +use Duyler\OpenApi\Validator\Exception\InvalidFormatException; use Duyler\OpenApi\Validator\Exception\MaximumError; use Duyler\OpenApi\Validator\Exception\MinLengthError; +use Duyler\OpenApi\Validator\Exception\TypeMismatchError; use Duyler\OpenApi\Validator\Exception\ValidationException; use Duyler\OpenApi\Validator\ValidatorPool; use PHPUnit\Framework\Attributes\CoversClass; @@ -258,4 +260,134 @@ public function validate_items_with_context(): void $this->expectNotToPerformAssertions(); } + + #[Test] + public function validate_items_with_boolean_true_marks_items_evaluated_with_context(): void + { + $schema = new Schema( + type: 'array', + items: true, + prefixItems: [new Schema(type: 'integer')], + ); + + $context = ValidationContext::create($this->pool, nullableAsType: true); + $this->validator->validate([42, 'anything', true], $schema, $context); + + self::assertTrue($context->hasItemBeenEvaluated(1)); + self::assertTrue($context->hasItemBeenEvaluated(2)); + self::assertFalse($context->hasItemBeenEvaluated(0)); + } + + #[Test] + public function validate_items_with_boolean_true_without_context_does_not_track_evaluation(): void + { + $schema = new Schema( + type: 'array', + items: true, + ); + + $this->validator->validate([1, 2, 3], $schema, null); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_items_with_boolean_false_throws_type_mismatch_for_each_extra_item(): void + { + $schema = new Schema( + type: 'array', + items: false, + prefixItems: [new Schema(type: 'integer')], + ); + + $caught = null; + + try { + $this->validator->validate([42, 'rejected', 'also rejected'], $schema); + self::fail('Expected ValidationException'); + } catch (ValidationException $e) { + $caught = $e; + } + + $errors = $caught->getErrors(); + + self::assertCount(2, $errors); + self::assertInstanceOf(TypeMismatchError::class, $errors[0]); + self::assertInstanceOf(TypeMismatchError::class, $errors[1]); + self::assertSame('Items rejected by items: false', $caught->getMessage()); + } + + #[Test] + public function validate_items_with_boolean_false_and_no_prefix_items_rejects_everything(): void + { + $schema = new Schema( + type: 'array', + items: false, + ); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Items rejected by items: false'); + + $this->validator->validate(['a', 'b', 'c'], $schema); + } + + #[Test] + public function validate_items_with_boolean_false_and_empty_data_passes(): void + { + $schema = new Schema( + type: 'array', + items: false, + ); + + $this->validator->validate([], $schema); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function rethrow_invalid_format_exception_from_items_validation_without_wrapping(): void + { + $itemSchema = new Schema(type: 'string', format: 'email'); + $schema = new Schema( + type: 'array', + items: $itemSchema, + ); + + $this->expectException(InvalidFormatException::class); + + $this->validator->validate(['not-an-email'], $schema); + } + + #[Test] + public function validate_items_with_nested_one_of_composition_per_item(): void + { + $itemSchema = new Schema( + oneOf: [ + new Schema(type: 'integer'), + new Schema(type: 'string'), + ], + ); + $schema = new Schema( + type: 'array', + items: $itemSchema, + ); + + $this->validator->validate([1, 'two', 3, 'four'], $schema); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_items_legacy_invocation_without_context_creates_internal_context(): void + { + $itemSchema = new Schema(type: 'string'); + $schema = new Schema( + type: 'array', + items: $itemSchema, + ); + + $this->validator->validate(['hello', 'world'], $schema, null); + + $this->expectNotToPerformAssertions(); + } } diff --git a/tests/Unit/Validator/SchemaValidator/PrefixItemsValidatorTest.php b/tests/Unit/Validator/SchemaValidator/PrefixItemsValidatorTest.php index 3da830c..42b7567 100644 --- a/tests/Unit/Validator/SchemaValidator/PrefixItemsValidatorTest.php +++ b/tests/Unit/Validator/SchemaValidator/PrefixItemsValidatorTest.php @@ -5,6 +5,7 @@ namespace Duyler\OpenApi\Test\Unit\Validator\SchemaValidator; use Duyler\OpenApi\Schema\Model\Schema; +use Duyler\OpenApi\Validator\Exception\InvalidFormatException; use Duyler\OpenApi\Validator\Exception\TypeMismatchError; use Duyler\OpenApi\Validator\Exception\ValidationException; use Duyler\OpenApi\Validator\Error\ValidationContext; @@ -464,4 +465,107 @@ public function throw_validation_exception_for_prefix_item_validation_failed(): $this->validator->validate(['string_value', 42], $schema); } + + #[Test] + public function rethrow_invalid_format_exception_from_prefix_item_without_wrapping(): void + { + $prefixSchema = new Schema(type: 'string', format: 'email'); + $schema = new Schema( + type: 'array', + prefixItems: [$prefixSchema], + ); + + $this->expectException(InvalidFormatException::class); + + $this->validator->validate(['not-an-email'], $schema); + } + + #[Test] + public function validate_prefix_item_creates_context_when_none_supplied(): void + { + $prefixSchema = new Schema(type: 'string'); + $schema = new Schema( + type: 'array', + prefixItems: [$prefixSchema], + ); + + $this->validator->validate(['hello'], $schema, null); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_prefix_item_with_composition_one_of_branch_resolves_correctly(): void + { + $prefixSchema = new Schema( + oneOf: [ + new Schema(type: 'integer'), + new Schema(type: 'string'), + ], + ); + $schema = new Schema( + type: 'array', + prefixItems: [$prefixSchema], + ); + + $context = ValidationContext::create($this->pool, nullableAsType: true); + $succeeded = false; + + try { + $this->validator->validate([42], $schema, $context); + $this->validator->validate(['hello'], $schema, $context); + $succeeded = true; + } catch (ValidationException $e) { + self::fail(sprintf('Expected validation to pass, got: %s', $e->getMessage())); + } + + self::assertTrue($succeeded); + } + + #[Test] + public function validate_prefix_item_with_nullable_type_union_accepts_null_at_position(): void + { + $prefixSchema = new Schema(type: ['string', 'null']); + $schema = new Schema( + type: 'array', + prefixItems: [$prefixSchema, new Schema(type: 'integer')], + ); + + $context = ValidationContext::create($this->pool, nullableAsType: true); + + $this->validator->validate([null, 42], $schema, $context); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_prefix_item_min_items_above_prefix_count_validates_only_available_positions(): void + { + $prefixSchema1 = new Schema(type: 'string'); + $prefixSchema2 = new Schema(type: 'integer'); + $prefixSchema3 = new Schema(type: 'boolean'); + $schema = new Schema( + type: 'array', + minItems: 5, + prefixItems: [$prefixSchema1, $prefixSchema2, $prefixSchema3], + ); + + $this->validator->validate(['hello', 42, true], $schema); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_prefix_item_without_trailing_items_keyword_allows_extra_untyped_items(): void + { + $prefixSchema = new Schema(type: 'string'); + $schema = new Schema( + type: 'array', + prefixItems: [$prefixSchema], + ); + + $this->validator->validate(['hello', 42, true, ['nested'], null], $schema); + + $this->expectNotToPerformAssertions(); + } } diff --git a/tests/Unit/Validator/Validation/CallbackValidatorTest.php b/tests/Unit/Validator/Validation/CallbackValidatorTest.php index 1380997..b594c22 100644 --- a/tests/Unit/Validator/Validation/CallbackValidatorTest.php +++ b/tests/Unit/Validator/Validation/CallbackValidatorTest.php @@ -17,6 +17,7 @@ use Duyler\OpenApi\Schema\OpenApiDocument; use Duyler\OpenApi\Validator\Callback\Exception\UnknownCallbackException; use Duyler\OpenApi\Validator\Error\Formatter\SimpleFormatter; +use Duyler\OpenApi\Validator\Exception\RefResolutionException; use Duyler\OpenApi\Validator\Exception\UnresolvableCallbackPathException; use Duyler\OpenApi\Validator\Exception\ValidationException; use Duyler\OpenApi\Validator\Format\FormatRegistry; @@ -267,6 +268,184 @@ public function validate_propagates_unknown_callback_exception_from_inner_valida $validator->validate($request, 'missingCallback'); } + #[Test] + public function validate_resolves_callback_path_item_via_components_path_items_ref(): void + { + $callbackOperation = new SchemaOperation(operationId: 'myCallback'); + $resolvedPathItem = new PathItem(post: $callbackOperation); + $refPathItem = new PathItem(ref: '#/components/pathItems/CallbackRef'); + + $document = new OpenApiDocument( + openapi: '3.2.0', + info: new InfoObject(title: 'Test', version: '1.0.0'), + components: new Components( + callbacks: [ + 'myCallback' => new Callbacks([ + 'myCallback' => ['/cb/literal' => $refPathItem], + ]), + ], + pathItems: ['CallbackRef' => $resolvedPathItem], + ), + ); + $context = $this->buildDependencies($document); + $validator = new CallbackValidator($context, securityValidation: false); + + $request = $this->psrFactory->createServerRequest('POST', '/cb/literal'); + + $operation = $validator->validate($request, 'myCallback'); + + $this->assertSame('myCallback', $operation->path); + } + + #[Test] + public function validate_throws_ref_resolution_exception_for_unsupported_ref_prefix(): void + { + $refPathItem = new PathItem(ref: '#/components/schemas/NotAPathItem'); + + $document = new OpenApiDocument( + openapi: '3.2.0', + info: new InfoObject(title: 'Test', version: '1.0.0'), + components: new Components( + callbacks: [ + 'myCallback' => new Callbacks([ + 'myCallback' => ['/cb/literal' => $refPathItem], + ]), + ], + ), + ); + $context = $this->buildDependencies($document); + $validator = new CallbackValidator($context, securityValidation: false); + + $request = $this->psrFactory->createServerRequest('POST', '/cb/literal'); + + $this->expectException(RefResolutionException::class); + $this->expectExceptionMessage('Unsupported callback $ref'); + + $validator->validate($request, 'myCallback'); + } + + #[Test] + public function validate_throws_ref_resolution_exception_when_path_item_ref_target_missing(): void + { + $refPathItem = new PathItem(ref: '#/components/pathItems/Missing'); + + $document = new OpenApiDocument( + openapi: '3.2.0', + info: new InfoObject(title: 'Test', version: '1.0.0'), + components: new Components( + callbacks: [ + 'myCallback' => new Callbacks([ + 'myCallback' => ['/cb/literal' => $refPathItem], + ]), + ], + pathItems: ['OtherRef' => new PathItem(post: new SchemaOperation(operationId: 'other'))], + ), + ); + $context = $this->buildDependencies($document); + $validator = new CallbackValidator($context, securityValidation: false); + + $request = $this->psrFactory->createServerRequest('POST', '/cb/literal'); + + $this->expectException(RefResolutionException::class); + $this->expectExceptionMessage('not found in components.pathItems'); + + $validator->validate($request, 'myCallback'); + } + + #[Test] + public function validate_matches_https_callback_url_by_path_component(): void + { + $callbackOperation = new SchemaOperation(operationId: 'myCallback'); + $callbackPathItem = new PathItem(post: $callbackOperation); + + $document = new OpenApiDocument( + openapi: '3.2.0', + info: new InfoObject(title: 'Test', version: '1.0.0'), + components: new Components( + callbacks: [ + 'myCallback' => new Callbacks([ + 'myCallback' => ['https://api.example.com/cb/https-callback' => $callbackPathItem], + ]), + ], + ), + ); + $context = $this->buildDependencies($document); + $validator = new CallbackValidator($context, securityValidation: false); + + $request = $this->psrFactory->createServerRequest('POST', '/cb/https-callback'); + + $operation = $validator->validate($request, 'myCallback'); + + $this->assertSame('myCallback', $operation->path); + } + + #[Test] + public function validate_rejects_https_callback_url_with_mismatched_path(): void + { + $callbackOperation = new SchemaOperation(operationId: 'myCallback'); + $callbackPathItem = new PathItem(post: $callbackOperation); + + $document = new OpenApiDocument( + openapi: '3.2.0', + info: new InfoObject(title: 'Test', version: '1.0.0'), + components: new Components( + callbacks: [ + 'myCallback' => new Callbacks([ + 'myCallback' => ['https://api.example.com/cb/expected' => $callbackPathItem], + ]), + ], + ), + ); + $context = $this->buildDependencies($document); + $validator = new CallbackValidator($context, securityValidation: false); + + $request = $this->psrFactory->createServerRequest('POST', '/cb/different'); + + $this->expectException(UnknownCallbackException::class); + + $validator->validate($request, 'myCallback'); + } + + #[Test] + public function validate_matches_callback_with_curly_brace_path_template(): void + { + $callbackOperation = new SchemaOperation(operationId: 'myCallback'); + $callbackPathItem = new PathItem(post: $callbackOperation); + + $document = new OpenApiDocument( + openapi: '3.2.0', + info: new InfoObject(title: 'Test', version: '1.0.0'), + components: new Components( + callbacks: [ + 'myCallback' => new Callbacks([ + 'myCallback' => ['/cb/{id}' => $callbackPathItem], + ]), + ], + ), + ); + $context = $this->buildDependencies($document); + $validator = new CallbackValidator($context, securityValidation: false); + + $request = $this->psrFactory->createServerRequest('POST', '/cb/42'); + + $operation = $validator->validate($request, 'myCallback'); + + $this->assertSame('myCallback', $operation->path); + } + + #[Test] + public function validate_strict_mode_rejects_runtime_template_with_exception(): void + { + $context = $this->buildDependencies($this->buildDocumentWithCallback()); + $validator = new CallbackValidator($context, securityValidation: false, strictCallbackRuntimeTemplate: true); + + $request = $this->psrFactory->createServerRequest('POST', '/attacker-url'); + + $this->expectException(UnresolvableCallbackPathException::class); + + $validator->validate($request, 'myCallback'); + } + private function buildDocumentWithCallback(): OpenApiDocument { $callbackOperation = new SchemaOperation(operationId: 'myCallback'); diff --git a/tests/Unit/Validator/ValidatorDependenciesInternalMarkTest.php b/tests/Unit/Validator/ValidatorDependenciesInternalMarkTest.php new file mode 100644 index 0000000..746f28b --- /dev/null +++ b/tests/Unit/Validator/ValidatorDependenciesInternalMarkTest.php @@ -0,0 +1,79 @@ +getDocComment(); + + self::assertNotFalse($docComment, 'Dto\ValidatorDependencies must carry a PHPDoc block.'); + self::assertStringContainsString('@internal', $docComment); + } + + #[Test] + public function schema_validator_validator_dependencies_is_marked_internal(): void + { + $reflection = new ReflectionClass(\Duyler\OpenApi\Validator\SchemaValidator\ValidatorDependencies::class); + $docComment = $reflection->getDocComment(); + + self::assertNotFalse($docComment, 'SchemaValidator\ValidatorDependencies must carry a PHPDoc block.'); + self::assertStringContainsString('@internal', $docComment); + } + + #[Test] + public function validation_validator_dependencies_is_not_marked_internal(): void + { + $reflection = new ReflectionClass(\Duyler\OpenApi\Validator\Validation\ValidatorDependencies::class); + $docComment = $reflection->getDocComment(); + + // The public 1.0 surface (consumed by OpenApiValidatorBuilder as + // `ValidationAssembler`) must not carry the @internal marker. A missing + // class-level PHPDoc block is the strongest form of compliance. + if (is_string($docComment)) { + self::assertStringNotContainsString( + '@internal', + $docComment, + 'Validation\ValidatorDependencies is the public 1.0 surface consumed by ' + . 'OpenApiValidatorBuilder; it must not carry the @internal marker.', + ); + } else { + self::assertFalse( + $reflection->isInternal(), + 'Validation\ValidatorDependencies must remain a userland (non-built-in) class.', + ); + } + } + + #[Test] + public function all_three_classes_remain_loadable_after_internal_mark(): void + { + // BC guard: the @internal marker must not break existing imports. + self::assertTrue(class_exists(ValidatorDependencies::class)); + self::assertTrue(class_exists(\Duyler\OpenApi\Validator\SchemaValidator\ValidatorDependencies::class)); + self::assertTrue(class_exists(\Duyler\OpenApi\Validator\Validation\ValidatorDependencies::class)); + } +}