From 81da76df48eb302d7be05eab8947bca76f92be06 Mon Sep 17 00:00:00 2001 From: yuriizee Date: Fri, 16 Oct 2026 17:21:40 +0300 Subject: [PATCH] Release 2.6.0: PHPStan and mutation testing in CI, bounded contextual serializers, linear collection validation - PHPStan level 5 in CI, covering the Laravel traits through their users - Infection in CI with a covered-MSI gate of 84% (MSI 83% -> 86%); tests that pin the ciphertext formats, the blind index, cache defaults and file-cache, XML and unknown-key edge cases - Contexts not declared in #[Hidden(except:)] share one serializer; at most 64 contexts are cached per class - Expand index-independent wildcard rules per item so collection validation runs in linear time - Exact Money::toDecimal() beyond 2^53 - CommaSeparatedCast(maxItems:) and SensitiveVarDumperCaster, both opt-in - Document the cache deploy order and cache-directory permissions --- .gitattributes | 2 + .github/workflows/tests.yml | 39 ++++++ CHANGELOG.md | 31 +++- composer.json | 9 +- docs/attributes/sensitive.md | 9 +- docs/casts/comma-separated.md | 11 ++ docs/features/cache.md | 21 +++ infection.json5 | 16 +++ phpstan.neon.dist | 30 ++++ src/BaseData.php | 8 +- src/Casts/CommaSeparatedCast.php | 23 ++- src/Casts/EncryptedCast.php | 2 +- src/Contracts/EncryptionKeyring.php | 2 +- src/Support/ClassMeta.php | 43 +++++- src/Support/ClassMetaFactory.php | 8 +- src/Support/SchemaGenerator.php | 5 +- src/Support/SensitiveVarDumperCaster.php | 37 +++++ src/Support/SerializerCompiler.php | 39 +++++- src/ValueObjects/Money.php | 8 +- tests/EncryptionFormatTest.php | 163 ++++++++++++++++++++++ tests/Fixtures/ArrayObjectDefaultData.php | 15 ++ tests/Fixtures/DistinctCollectionData.php | 19 +++ tests/Fixtures/DistinctItemData.php | 16 +++ tests/Fixtures/EnumDefaultData.php | 16 +++ tests/Fixtures/NullableFlattenData.php | 17 +++ tests/Fixtures/OptionalIgnoredData.php | 18 +++ tests/Fixtures/SensitivePrivateData.php | 25 ++++ tests/FromResultTest.php | 14 ++ tests/HydrationTest.php | 11 ++ tests/MetadataRegistryFileCacheTest.php | 134 ++++++++++++++++++ tests/MetadataStateDefaultsTest.php | 120 ++++++++++++++++ tests/MoneyCastTest.php | 16 +++ tests/PrimitiveCastsTest.php | 34 +++++ tests/SensitiveTest.php | 14 ++ tests/SensitiveVarDumperCasterTest.php | 65 +++++++++ tests/SerializerCompilerTest.php | 90 ++++++++++++ tests/WildcardRuleExpansionTest.php | 64 +++++++++ tests/XmlStreamTest.php | 32 +++++ 38 files changed, 1199 insertions(+), 27 deletions(-) create mode 100644 infection.json5 create mode 100644 phpstan.neon.dist create mode 100644 src/Support/SensitiveVarDumperCaster.php create mode 100644 tests/EncryptionFormatTest.php create mode 100644 tests/Fixtures/ArrayObjectDefaultData.php create mode 100644 tests/Fixtures/DistinctCollectionData.php create mode 100644 tests/Fixtures/DistinctItemData.php create mode 100644 tests/Fixtures/EnumDefaultData.php create mode 100644 tests/Fixtures/NullableFlattenData.php create mode 100644 tests/Fixtures/OptionalIgnoredData.php create mode 100644 tests/Fixtures/SensitivePrivateData.php create mode 100644 tests/MetadataRegistryFileCacheTest.php create mode 100644 tests/MetadataStateDefaultsTest.php create mode 100644 tests/SensitiveVarDumperCasterTest.php create mode 100644 tests/SerializerCompilerTest.php create mode 100644 tests/WildcardRuleExpansionTest.php diff --git a/.gitattributes b/.gitattributes index 077318a..fc0d718 100644 --- a/.gitattributes +++ b/.gitattributes @@ -25,3 +25,5 @@ package.json export-ignore package-lock.json export-ignore phpunit.xml.dist export-ignore pint.json export-ignore +phpstan.neon.dist export-ignore +infection.json5 export-ignore diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 31023e1..55eb4b3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -57,6 +57,45 @@ jobs: - name: Run tests run: vendor/bin/phpunit --no-coverage + static-analysis: + name: PHPStan + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + + - name: Set up PHP 8.4 + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 + with: + php-version: "8.4" + coverage: none + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Run PHPStan + run: vendor/bin/phpstan analyse --no-progress --memory-limit=1G + + mutation: + name: Mutation testing + runs-on: ubuntu-latest + needs: test + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + + - name: Set up PHP 8.4 with pcov + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 + with: + php-version: "8.4" + coverage: pcov + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Run Infection + run: php -d memory_limit=1G vendor/bin/infection --threads=max --only-covering-test-cases --no-progress + coverage: name: Coverage runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f02ba7..346fce4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,34 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. ## [Unreleased] +## [2.6.0] — 2026-10-16 + +### Added +- `CommaSeparatedCast(maxItems: ...)` caps the item count of untrusted input; + the string is never split past the limit. +- `Support\SensitiveVarDumperCaster::register()` redacts `#[Sensitive]` + fields in Symfony VarDumper (`dump()`, `dd()`). Opt-in. + +### Fixed +- `Money::toDecimal()` is exact for amounts above 2^53 (it went through a + float before) and about 25% faster. +- `fromValidated()` on large collections under `#[InferRules]` now validates + in linear time: 8 000 items take 0.2 s instead of 1.3 s. Wildcard rules + are expanded per item only when the result is identical; rules with `*` + in their parameters and `distinct` keep the wildcard. +- `toArray($context)` with ever-new context names no longer compiles and + keeps a serializer per name: contexts not declared in `#[Hidden(except:)]` + share one serializer, and at most 64 of them are cached per class. + +### Changed +- PHPStan (level 5) runs in CI and covers the Laravel traits through the + classes that use them. `EncryptionKeyring::keys()` is documented as + `array`, since numeric key IDs become integer keys. +- New tests pin the ciphertext formats, the blind-index construction, the + metadata cache defaults and the file-cache edge cases (mutation testing). +- Documentation: deploy order for the metadata cache (warm, then reload + PHP) and how to protect the cache directory. + ## [2.5.0] — 2026-10-16 ### Added @@ -605,7 +633,8 @@ Initial release. `toResponse()`). - CI pipeline: tests across Laravel 10–13 and a scheduled `composer audit`. -[Unreleased]: https://github.com/std-out/simple-data-objects/compare/v2.5.0...HEAD +[Unreleased]: https://github.com/std-out/simple-data-objects/compare/v2.6.0...HEAD +[2.6.0]: https://github.com/std-out/simple-data-objects/compare/v2.5.0...v2.6.0 [2.5.0]: https://github.com/std-out/simple-data-objects/compare/v2.4.0...v2.5.0 [2.4.0]: https://github.com/std-out/simple-data-objects/compare/v2.3.0...v2.4.0 [2.3.0]: https://github.com/std-out/simple-data-objects/compare/v2.2.1...v2.3.0 diff --git a/composer.json b/composer.json index 9710280..637e6b8 100644 --- a/composer.json +++ b/composer.json @@ -21,8 +21,10 @@ "illuminate/database": "^12.0|^13.0", "illuminate/http": "^12.0|^13.0", "illuminate/pagination": "^12.0|^13.0", + "infection/infection": "^0.35", "laravel/pint": "^1.0", "orchestra/testbench": "^11.0", + "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^11.0" }, "suggest": { @@ -48,11 +50,16 @@ }, "config": { "sort-packages": true, - "optimize-autoloader": true + "optimize-autoloader": true, + "allow-plugins": { + "infection/extension-installer": true + } }, "scripts": { "lint": "vendor/bin/pint", "lint:check": "vendor/bin/pint --test", + "analyse": "vendor/bin/phpstan analyse --memory-limit=1G", + "mutation": "php -d memory_limit=1G vendor/bin/infection --threads=max --only-covering-test-cases", "test": "vendor/bin/phpunit --no-coverage", "test:coverage": "vendor/bin/phpunit --coverage-text --coverage-clover=build/coverage.xml", "coverage:check": "php bin/check-coverage.php build/coverage.xml 100" diff --git a/docs/attributes/sensitive.md b/docs/attributes/sensitive.md index 4474819..df460cb 100644 --- a/docs/attributes/sensitive.md +++ b/docs/attributes/sensitive.md @@ -53,7 +53,14 @@ To queue a job that needs the card, pass an identifier (or a token from your pay ## Limits -- `var_export()` and Symfony VarDumper (`dump()`, `dd()`) read properties directly and ignore `__debugInfo()`, so they still show the value. +- Symfony VarDumper (`dump()`, `dd()`) reads properties directly and ignores `__debugInfo()`. Register the caster once at boot — for example in a service provider's `boot()` — to redact `#[Sensitive]` fields there too: + + ```php + \StdOut\SimpleDataObjects\Support\SensitiveVarDumperCaster::register(); + ``` + + It requires `symfony/var-dumper` (installed with Laravel) and changes nothing until you call it. +- `var_export()` has no hook and always shows the value. - A field without a cast, enum or nested type goes to the constructor as-is; a wrong type raises PHP's `TypeError`, which names the type, never the value. - Every entry point (`from*()`, `tryFrom()`, `validate()`, `with()`, `collection()`), the compiled hydrators, pipes, casts and the Eloquent casts mark their input `#[\SensitiveParameter]`, so with `zend.exception_ignore_args=Off` stack traces show `Object(SensitiveParameterValue)` instead of the input. Your own casts and pipes need the same attribute on their `$value` / `$data` parameter. Keep `zend.exception_ignore_args=On` in production anyway: it also covers application frames. diff --git a/docs/casts/comma-separated.md b/docs/casts/comma-separated.md index e8a71a9..53d9506 100644 --- a/docs/casts/comma-separated.md +++ b/docs/casts/comma-separated.md @@ -34,6 +34,17 @@ $filter->toArray()['tags']; // 'a,b,c' - Items are trimmed by default (`trim: true`); pass `trim: false` to keep raw whitespace. - Serialization (`toArray()`) joins the array back with the same separator, so `from()`/`toArray()` round-trip. +## Limiting the number of items + +Every item costs far more memory than its separator: a 10 MB string of commas expands to about 500 MB of array, and running out of memory is a fatal error that `tryFrom()` cannot catch. For untrusted input, cap the count: + +```php +#[Cast(new CommaSeparatedCast(maxItems: 100))] +public readonly array $tags, +``` + +Input with more items — a string or an array — throws `InvalidArgumentException`, and the string is never split past the limit. Without `maxItems` there is no limit. + ## Null Handling Returns `null` for `null` input on both hydration and serialization. diff --git a/docs/features/cache.md b/docs/features/cache.md index 3495193..df15edc 100644 --- a/docs/features/cache.md +++ b/docs/features/cache.md @@ -63,6 +63,27 @@ It scans the sources for **concrete** `BaseData` subclasses (abstract bases are Classes whose metadata cannot be exported (see [Limitations](#limitations)) are reported as `skipped` and keep using the in-memory cache. A broken DTO definition (e.g. conflicting attributes) fails the command immediately — deploy-time is exactly when you want to find out. +### Deploying a change + +The cache is keyed by class name only, not by the class's source. A worker that keeps running the old cache file keeps applying the old attributes — a removed `#[Hidden]` or a changed `#[Rules]` does not take effect. Every deploy that changes a DTO needs, in this order: + +1. **Warm:** `sdo-warm` (or `sdo:warm`). It always rebuilds every class, so stale files are replaced. +2. **Reload PHP:** `systemctl reload php-fpm`, `php artisan octane:reload`, or restart the queue workers. Long-running workers keep their in-memory cache, and with `opcache.validate_timestamps=0` opcache keeps serving the old file even after it changed on disk. + +Skipping step 2 is safe only with `opcache.validate_timestamps=1` and short-lived FPM workers. + +### Protecting the cache directory + +Cache files are PHP code that every worker `require`s, so whoever can write to the directory can run code in your application. Keep the runtime user from writing there: + +1. Run `sdo-warm` as the deploy user, not as the web server user. +2. Make the directory and files read-only for the runtime user, for example `chown -R deploy: storage/framework/data-objects && chmod 0755 … && chmod 0644 …/*.meta.php`. +3. Watch the directory with your file-integrity monitoring (PCI DSS 11.5.2): it changes only on deploy. + +A read-only directory is safe: a class whose file is missing is built in memory and the failed write is ignored. + +The library does not sign cache files. A signature checked before `require` would not help: the file could be swapped between the check and the `require`, and reading it twice would cancel the opcache benefit. File permissions are the control that works. + ### Going further: opcache.preload The warmed cache files are plain PHP, so they can be compiled into opcache **before the first request ever arrives** via [preloading](https://www.php.net/manual/en/opcache.preloading.php). Add to your preload script: diff --git a/infection.json5 b/infection.json5 new file mode 100644 index 0000000..40806e1 --- /dev/null +++ b/infection.json5 @@ -0,0 +1,16 @@ +{ + "$schema": "vendor/infection/infection/resources/schema.json", + "source": { + "directories": ["src"] + }, + "logs": { + "text": "build/infection.log", + "summary": "build/infection-summary.log" + }, + "tmpDir": "build/infection", + "mutators": { + "@default": true + }, + "timeout": 20, + "minCoveredMsi": 84 +} diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..ee8e5e8 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,30 @@ +parameters: + level: 5 + paths: + - src + - tests/Fixtures + ignoreErrors: + - + identifier: new.static + path: src/BaseData.php + - + identifier: method.notFound + message: '#BaseData::__construct\(\)#' + path: src/BaseData.php + - + identifier: return.type + path: src/Laravel/Console/MakeDataCommand.php + - + identifier: return.type + message: '#should return StdOut\\SimpleDataObjects\\(TypedDataCollection|Laravel\\PaginatedDataCollection)<.+> but returns StdOut\\SimpleDataObjects\\(TypedDataCollection|Laravel\\PaginatedDataCollection)\.$#' + - + identifier: property.uninitializedReadonly + path: tests/Fixtures/* + - + identifier: property.onlyWritten + path: tests/Fixtures/SensitiveCardData.php + - + identifier: argument.type + paths: + - tests/Fixtures/MissingTargetDiscriminatorData.php + - tests/Fixtures/NonStringTargetDiscriminatorData.php diff --git a/src/BaseData.php b/src/BaseData.php index 4fe21b9..8333e21 100644 --- a/src/BaseData.php +++ b/src/BaseData.php @@ -264,7 +264,7 @@ public static function fromValidated(#[\SensitiveParameter] mixed $data): static if ($meta->validationRules !== []) { [$input, $rules] = $meta->validationInput($array); - static::validatorFactory()->make($input, $rules)->validate(); + self::validatorFactory()->make($input, $rules)->validate(); } return static::from($array); @@ -309,7 +309,7 @@ public static function fromValidatedResult(#[\SensitiveParameter] mixed $data): } [$input, $rules] = $meta->validationInput($data); - $validator = static::validatorFactory()->make($input, $rules); + $validator = self::validatorFactory()->make($input, $rules); try { if (! $validator->fails()) { @@ -348,7 +348,7 @@ public static function validate(#[\SensitiveParameter] mixed $data): void $array = is_array($data) ? $data : InputNormalizer::normalize(static::class, $data); [$input, $rules] = $meta->validationInput($array); - static::validatorFactory() + self::validatorFactory() ->make($input, $rules) ->validate(); } @@ -429,7 +429,7 @@ public function toArray(?string $context = null): array return (SerializerCompiler::$serializers[static::class] ?? SerializerCompiler::compile(static::class))($this); } - return (SerializerCompiler::$contextualSerializers[static::class][$context] ?? SerializerCompiler::compile(static::class, $context))($this); + return (SerializerCompiler::$contextualSerializers[static::class][$context] ?? SerializerCompiler::compile(static::class, $context))($this, $context); } public function toJson(int $flags = 0, ?string $context = null): string diff --git a/src/Casts/CommaSeparatedCast.php b/src/Casts/CommaSeparatedCast.php index 2390175..0e6a1c2 100644 --- a/src/Casts/CommaSeparatedCast.php +++ b/src/Casts/CommaSeparatedCast.php @@ -4,6 +4,7 @@ namespace StdOut\SimpleDataObjects\Casts; +use InvalidArgumentException; use StdOut\SimpleDataObjects\Contracts\CastsValue; use StdOut\SimpleDataObjects\Contracts\ProvidesJsonSchema; @@ -12,11 +13,16 @@ final class CommaSeparatedCast implements CastsValue, ProvidesJsonSchema public function __construct( private readonly string $separator = ',', private readonly bool $trim = true, - ) {} + private readonly ?int $maxItems = null, + ) { + if ($maxItems !== null && $maxItems < 1) { + throw new InvalidArgumentException('CommaSeparatedCast $maxItems must be at least 1.'); + } + } public static function __set_state(array $state): self { - return new self($state['separator'], $state['trim']); + return new self($state['separator'], $state['trim'], $state['maxItems'] ?? null); } public function get(#[\SensitiveParameter] mixed $value): ?array @@ -26,7 +32,7 @@ public function get(#[\SensitiveParameter] mixed $value): ?array } if (is_array($value)) { - return $value; + return $this->maxItems === null || count($value) <= $this->maxItems ? $value : $this->tooMany(); } $str = (string) $value; @@ -35,7 +41,11 @@ public function get(#[\SensitiveParameter] mixed $value): ?array return []; } - $parts = explode($this->separator, $str); + if ($this->maxItems === null) { + $parts = explode($this->separator, $str); + } elseif (count($parts = explode($this->separator, $str, $this->maxItems + 1)) > $this->maxItems) { + $this->tooMany(); + } return $this->trim ? array_map('trim', $parts) : $parts; } @@ -49,6 +59,11 @@ public function set(#[\SensitiveParameter] mixed $value): ?string return implode($this->separator, $value); } + private function tooMany(): never + { + throw new InvalidArgumentException("CommaSeparatedCast accepts at most {$this->maxItems} items."); + } + public function jsonSchema(): array { return ['type' => 'string']; diff --git a/src/Casts/EncryptedCast.php b/src/Casts/EncryptedCast.php index 53ef40d..22202dd 100644 --- a/src/Casts/EncryptedCast.php +++ b/src/Casts/EncryptedCast.php @@ -178,7 +178,7 @@ public function jsonSchema(): array private function seal(#[\SensitiveParameter] string $plaintext): string { - $keyId = (string) $this->currentKeyId; + $keyId = $this->currentKeyId; $nonce = random_bytes(SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES); $ciphertext = sodium_crypto_aead_xchacha20poly1305_ietf_encrypt( sodium_pad($plaintext, self::PAD_BLOCK), diff --git a/src/Contracts/EncryptionKeyring.php b/src/Contracts/EncryptionKeyring.php index 2816e71..28c372d 100644 --- a/src/Contracts/EncryptionKeyring.php +++ b/src/Contracts/EncryptionKeyring.php @@ -8,6 +8,6 @@ interface EncryptionKeyring { public function currentKeyId(): string; - /** @return array */ + /** @return array */ public function keys(): array; } diff --git a/src/Support/ClassMeta.php b/src/Support/ClassMeta.php index 1bf0475..b255474 100644 --- a/src/Support/ClassMeta.php +++ b/src/Support/ClassMeta.php @@ -4,6 +4,7 @@ namespace StdOut\SimpleDataObjects\Support; +use Illuminate\Support\Arr; use StdOut\SimpleDataObjects\Contracts\DataPipe; final class ClassMeta @@ -125,8 +126,48 @@ public function validationInput(#[\SensitiveParameter] array $data): array } $rules = $this->validationRules; + $data = $this->prepareValidation($data, '', $rules); - return [$this->prepareValidation($data, '', $rules), $rules]; + return [$data, self::expandCollectionWildcards($data, $rules)]; + } + + /** + * @param array $data + * @param array> $rules + * @return array> + */ + private static function expandCollectionWildcards(#[\SensitiveParameter] array $data, array $rules): array + { + $expanded = []; + + foreach ($rules as $key => $keyRules) { + $star = strpos($key, '.*'); + $suffix = $star === false ? '' : substr($key, $star + 2); + $items = $star === false ? null : Arr::get($data, substr($key, 0, $star)); + + if (! is_array($items) + || ($suffix !== '' && $suffix[0] !== '.') + || str_contains($suffix, '*') + || ! array_all($items, static fn (mixed $item): bool => is_array($item)) + || ! array_all($keyRules, self::isIndexIndependent(...))) { + $expanded[$key] = $keyRules; + + continue; + } + + $prefix = substr($key, 0, $star + 1); + + foreach (array_keys($items) as $index) { + $expanded[$prefix.$index.$suffix] = $keyRules; + } + } + + return $expanded; + } + + private static function isIndexIndependent(mixed $rule): bool + { + return ! is_string($rule) || (! str_contains($rule, '*') && ! str_starts_with($rule, 'distinct')); } /** diff --git a/src/Support/ClassMetaFactory.php b/src/Support/ClassMetaFactory.php index 9dcefbe..8c7145b 100644 --- a/src/Support/ClassMetaFactory.php +++ b/src/Support/ClassMetaFactory.php @@ -93,7 +93,7 @@ private static function doBuild(string $class): ClassMeta $pipes, hasConstructor: false, discriminatorField: $discriminator?->field, - discriminatorMap: $discriminator?->map ?? [], + discriminatorMap: $discriminator->map ?? [], discriminatorFallback: $discriminator?->fallback, rejectUnknownKeys: $rejectUnknownKeys, wrapIn: $wrapIn, @@ -127,7 +127,7 @@ private static function doBuild(string $class): ClassMeta $allParams, $pipes, discriminatorField: $discriminator?->field, - discriminatorMap: $discriminator?->map ?? [], + discriminatorMap: $discriminator->map ?? [], discriminatorFallback: $discriminator?->fallback, rejectUnknownKeys: $rejectUnknownKeys, wrapIn: $wrapIn, @@ -351,9 +351,9 @@ private static function buildParam(ReflectionParameter|ReflectionProperty $param $explicitRules !== null && ! $explicitRules->merge => $explicitRules->rules, $inferRules => [ ...RuleInferrer::forParameter($parameter, $allowsNull, $nestedDataClass, $enumClass, $dataCollectionClass, $isOptional), - ...$explicitRules?->rules ?? [], + ...$explicitRules->rules ?? [], ], - default => $explicitRules?->rules ?? [], + default => $explicitRules->rules ?? [], }; return new ParameterMeta( diff --git a/src/Support/SchemaGenerator.php b/src/Support/SchemaGenerator.php index 60d98d5..df99e25 100644 --- a/src/Support/SchemaGenerator.php +++ b/src/Support/SchemaGenerator.php @@ -46,7 +46,10 @@ private static function classSchema(string $class, array &$defs): array return $schema; } - /** @return array{0: array, 1: list} */ + /** + * @param array $defs + * @return array{0: array, 1: list} + */ private static function properties(ClassMeta $meta, array &$defs): array { $properties = []; diff --git a/src/Support/SensitiveVarDumperCaster.php b/src/Support/SensitiveVarDumperCaster.php new file mode 100644 index 0000000..48a56cd --- /dev/null +++ b/src/Support/SensitiveVarDumperCaster.php @@ -0,0 +1,37 @@ + [self::class, 'cast']]); + } + + /** + * @param array $properties + * @return array + */ + public static function cast(BaseData $data, array $properties): array + { + $sensitive = MetadataRegistry::get($data::class)->sensitiveFields; + + if ($sensitive === []) { + return $properties; + } + + foreach ($properties as $key => $value) { + if ($value !== null && isset($sensitive[substr($key, (int) strrpos("\0".$key, "\0"))])) { + $properties[$key] = Redactor::REDACTED; + } + } + + return $properties; + } +} diff --git a/src/Support/SerializerCompiler.php b/src/Support/SerializerCompiler.php index 48c0af1..72be014 100644 --- a/src/Support/SerializerCompiler.php +++ b/src/Support/SerializerCompiler.php @@ -32,10 +32,15 @@ final class SerializerCompiler * * @internal Read directly by BaseData::toArray() for speed — do not mutate. * - * @var array> + * @var array> */ public static array $contextualSerializers = []; + private const int MAX_CACHED_CONTEXTS = 64; + + /** @var array */ + private static array $undeclaredContextSerializers = []; + /** @param class-string $class */ public static function compile(string $class, ?string $context = null): Closure { @@ -56,17 +61,36 @@ public static function compile(string $class, ?string $context = null): Closure } $p = $meta->parameters; + $declared = self::declaresContext($meta, $context); - /** @var Closure(object): array $fn */ - $fn = Closure::bind(eval('return '.self::generate($class, $meta, $context).';'), null, $class); + /** @var Closure(object, string): array $fn */ + $fn = $declared + ? Closure::bind(eval('return '.self::generate($class, $meta, $context, true).';'), null, $class) + : self::$undeclaredContextSerializers[$class] ??= Closure::bind(eval('return '.self::generate($class, $meta, null, true).';'), null, $class); - return self::$contextualSerializers[$class][$context] = $fn; + if ($declared || count(self::$contextualSerializers[$class] ?? []) < self::MAX_CACHED_CONTEXTS) { + self::$contextualSerializers[$class][$context] = $fn; + } + + return $fn; } public static function flush(): void { self::$serializers = []; self::$contextualSerializers = []; + self::$undeclaredContextSerializers = []; + } + + private static function declaresContext(ClassMeta $meta, string $context): bool + { + foreach ($meta->parameters as $param) { + if (in_array($context, $param->hiddenExcept, true)) { + return true; + } + } + + return false; } /** @@ -76,9 +100,10 @@ public static function flush(): void * * @internal also used by MetadataRegistry to persist compiled code */ - public static function generate(string $class, ClassMeta $meta, ?string $context = null): string + public static function generate(string $class, ClassMeta $meta, ?string $context = null, bool $contextual = false): string { - $contextExport = var_export($context, true); + $contextExport = $contextual ? '$context' : var_export($context, true); + $signature = $contextual ? "\\{$class} \$o, string \$context" : "\\{$class} \$o"; $body = ''; foreach ($meta->parameters as $i => $param) { @@ -114,7 +139,7 @@ public static function generate(string $class, ClassMeta $meta, ?string $context } return <<amount / (10 ** $decimals), $decimals, '.', ''); + if ($decimals < 1) { + return number_format($this->amount / (10 ** $decimals), $decimals, '.', ''); + } + + $digits = str_pad(ltrim((string) $this->amount, '-'), $decimals + 1, '0', STR_PAD_LEFT); + + return ($this->amount < 0 ? '-' : '').substr($digits, 0, -$decimals).'.'.substr($digits, -$decimals); } public function equals(self $other): bool diff --git a/tests/EncryptionFormatTest.php b/tests/EncryptionFormatTest.php new file mode 100644 index 0000000..7e5929e --- /dev/null +++ b/tests/EncryptionFormatTest.php @@ -0,0 +1,163 @@ + self::seal('4111111111111111', KeyringCardData::class.'::pan'), + 'holder' => self::seal('Alice', KeyringCardData::class.'::holder'), + 'note' => self::seal('vip', 'cards.note'), + ]); + + $this->assertSame(['4111111111111111', 'Alice', 'vip'], [$card->pan, $card->holder, $card->note]); + } + + public function test_dto_writes_values_that_follow_the_documented_keyring_construction(): void + { + $row = (new KeyringCardData('4111111111111111', 'Alice', 'vip'))->toArray(); + + $this->assertStringStartsWith('v2:2026:', $row['pan']); + $this->assertSame('4111111111111111', self::open($row['pan'], KeyringCardData::class.'::pan')); + $this->assertSame('Alice', self::open($row['holder'], KeyringCardData::class.'::holder')); + $this->assertSame('vip', self::open($row['note'], 'cards.note')); + $this->assertSame(24 + 32 + 16, strlen(sodium_base642bin(substr($row['pan'], 8), self::BASE64))); + } + + public function test_explicit_context_is_used_even_when_the_cast_is_unbound(): void + { + $cast = new EncryptedCast(keyring: TestKeyring::class, context: 'cards.note'); + + $this->assertSame('vip', self::open($cast->set('vip'), 'cards.note')); + } + + public function test_legacy_format_matches_the_documented_construction(): void + { + $key = sodium_crypto_generichash('old-key', '', 32); + $nonce = random_bytes(24); + $legacy = sodium_bin2base64($nonce.sodium_crypto_secretbox('secret', $nonce, $key), self::BASE64); + $written = sodium_base642bin((new EncryptedCast('old-key'))->set('secret'), self::BASE64); + + $this->assertSame('secret', (new EncryptedCast('old-key'))->get($legacy)); + $this->assertSame('secret', sodium_crypto_secretbox_open(substr($written, 24), substr($written, 0, 24), $key)); + } + + public function test_legacy_value_of_exactly_one_nonce_is_too_short(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Encrypted value is too short.'); + + (new EncryptedCast('old-key'))->get(sodium_bin2base64(str_repeat('x', 24), self::BASE64)); + } + + public function test_payload_containing_a_colon_is_not_valid_base64(): void + { + ConfigurableKeyring::$current = 'k1'; + ConfigurableKeyring::$keys = ['k1' => str_repeat("\x03", 32)]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Encrypted value is not valid base64.'); + + (new EncryptedCast(keyring: ConfigurableKeyring::class))->get('v2:k1:abc:def'); + } + + public function test_needs_reencryption_compares_the_whole_key_id(): void + { + ConfigurableKeyring::$current = 'k1'; + ConfigurableKeyring::$keys = ['k1' => str_repeat("\x03", 32), 'k10' => str_repeat("\x04", 32)]; + + $this->assertTrue((new EncryptedCast(keyring: ConfigurableKeyring::class))->needsReencryption('v2:k10:AAAA')); + } + + public function test_blind_index_matches_the_documented_construction(): void + { + $key = str_repeat("\x05", 32); + $expected = sodium_bin2base64( + hash_hmac('sha256', pack('N', 9).'cards.pan'.'4111111111111111', self::subkey($key, 'SDOindex'), true), + self::BASE64, + ); + + $this->assertSame($expected, (new BlindIndex($key, context: 'cards.pan'))->hash('4111111111111111')); + } + + public function test_error_messages_name_the_expected_contract(): void + { + $messages = []; + + foreach ([ + static fn () => new EncryptedCast(keyring: \stdClass::class), + static fn () => new EncryptedCast(provider: \stdClass::class), + static fn () => (new EncryptedCast('k'))->set([]), + ] as $call) { + try { + $call(); + } catch (InvalidArgumentException $e) { + $messages[] = $e->getMessage(); + } + } + + $this->assertSame([ + "Keyring 'stdClass' must implement ".EncryptionKeyring::class.'.', + "Key provider 'stdClass' must implement ".EncryptionKeyProvider::class.'.', + 'EncryptedCast can only encrypt scalar or Stringable values, got array.', + ], $messages); + } + + public function test_tampering_is_reported_as_an_authentication_failure(): void + { + $value = self::seal('Alice', KeyringCardData::class.'::pan'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Decryption failed: authentication tag mismatch.'); + + KeyringCardData::from(['pan' => $value, 'holder' => $value]); + } +} diff --git a/tests/Fixtures/ArrayObjectDefaultData.php b/tests/Fixtures/ArrayObjectDefaultData.php new file mode 100644 index 0000000..e229fd7 --- /dev/null +++ b/tests/Fixtures/ArrayObjectDefaultData.php @@ -0,0 +1,15 @@ +pin; + } +} diff --git a/tests/FromResultTest.php b/tests/FromResultTest.php index 213e50b..bee2d6f 100644 --- a/tests/FromResultTest.php +++ b/tests/FromResultTest.php @@ -483,4 +483,18 @@ public function test_from_validated_result_never_throws_when_a_rule_cannot_evalu $this->assertFalse($result->ok()); $this->assertSame('Input could not be validated.', $result->errors()['$input']); } + + public function test_extra_keys_are_ignored_for_a_class_without_reject_unknown_keys(): void + { + $result = UserData::fromResult(['name' => 'Alice', 'email' => 'a@example.com', 'extra' => 1]); + + $this->assertTrue($result->ok()); + $this->assertSame([], $result->errors()); + } + + public function test_try_from_rejects_unknown_keys_on_a_strict_class(): void + { + $this->assertNull(StrictUserData::tryFrom(['name' => 'Alice', 'user_email' => 'a@example.com', 'extra' => 1])); + $this->assertNotNull(StrictUserData::tryFrom(['name' => 'Alice', 'user_email' => 'a@example.com'])); + } } diff --git a/tests/HydrationTest.php b/tests/HydrationTest.php index 098c51d..a0a271f 100644 --- a/tests/HydrationTest.php +++ b/tests/HydrationTest.php @@ -7,6 +7,7 @@ use JsonSerializable; use PHPUnit\Framework\TestCase; use StdOut\SimpleDataObjects\Exceptions\DataHydrationException; +use StdOut\SimpleDataObjects\Support\InputNormalizer; use StdOut\SimpleDataObjects\Tests\Fixtures\MultiUnionData; use StdOut\SimpleDataObjects\Tests\Fixtures\NullableData; use StdOut\SimpleDataObjects\Tests\Fixtures\OrderData; @@ -106,6 +107,16 @@ public function test_from_invalid_json_string_throws(): void UserData::from('not json at all'); } + public function test_json_input_is_decoded_with_a_depth_limit_of_32(): void + { + $nested = static fn (int $depth): string => '{"a":'.str_repeat('[', $depth - 1).str_repeat(']', $depth - 1).'}'; + + $this->assertCount(1, InputNormalizer::normalize(UserData::class, $nested(31))); + + $this->expectException(DataHydrationException::class); + InputNormalizer::normalize(UserData::class, $nested(32)); + } + public function test_from_scalar_json_string_throws(): void { $this->expectException(DataHydrationException::class); diff --git a/tests/MetadataRegistryFileCacheTest.php b/tests/MetadataRegistryFileCacheTest.php new file mode 100644 index 0000000..9966c4f --- /dev/null +++ b/tests/MetadataRegistryFileCacheTest.php @@ -0,0 +1,134 @@ +dir = sys_get_temp_dir().'/sdo_registry_'.bin2hex(random_bytes(6)); + mkdir($this->dir); + MetadataRegistry::setStoragePath($this->dir); + MetadataRegistry::flush(); + } + + protected function tearDown(): void + { + MetadataRegistry::clearCache(); + MetadataRegistry::setStoragePath(''); + MetadataRegistry::flush(); + exec('rm -rf '.escapeshellarg($this->dir)); + } + + private function cacheFile(string $class): string + { + return $this->dir.'/'.hash('sha256', $class).'.meta.php'; + } + + public function test_clear_cache_and_flush_drop_compiled_closures(): void + { + foreach ([MetadataRegistry::clearCache(...), MetadataRegistry::flush(...)] as $reset) { + UserData::from(['name' => 'a', 'email' => 'a@b.c'])->toArray(); + + $reset(); + + $this->assertSame([], HydratorCompiler::$hydrators); + $this->assertSame([], SerializerCompiler::$serializers); + } + } + + public function test_clear_cache_removes_cache_and_leftover_temp_files_only(): void + { + $own = ['a.meta.php', 'a.meta.php.tmp.0123456789abcdef']; + $foreign = ['a.meta.php.bak', 'upload.tmp.1', 'notes.php']; + + foreach ([...$own, ...$foreign] as $name) { + touch($this->dir.'/'.$name); + } + + MetadataRegistry::clearCache(); + + $this->assertSame($foreign, array_values(array_filter([...$own, ...$foreign], fn (string $name): bool => is_file($this->dir.'/'.$name)))); + } + + public function test_a_warm_cache_file_is_reused_rather_than_rebuilt(): void + { + foreach ([UserData::class, NoConstructorData::class, HybridData::class] as $class) { + MetadataRegistry::get($class); + file_put_contents($this->cacheFile($class), "\n// marker\n", FILE_APPEND); + MetadataRegistry::flush(); + + MetadataRegistry::get($class); + + $this->assertStringEndsWith("// marker\n", (string) file_get_contents($this->cacheFile($class))); + $this->assertArrayHasKey($class, HydratorCompiler::$hydrators); + } + + $this->assertSame('7', NoConstructorData::from(['id' => '7', 'required' => 'r'])->id); + $this->assertSame('x', HybridData::from(['id' => 'n', 'extraId' => 'x', 'extra_label' => 'l'])->extraId); + $this->assertSame('a', UserData::from(['name' => 'a', 'email' => 'a@b.c'])->name); + } + + public function test_a_legacy_meta_only_cache_file_is_reused(): void + { + MetadataRegistry::get(UserData::class); + $file = $this->cacheFile(UserData::class); + $legacy = "assertSame('a', UserData::from(['name' => 'a', 'email' => 'a@b.c'])->name); + $this->assertSame($legacy, file_get_contents($file)); + } + + public function test_missing_storage_directory_is_created_with_0755(): void + { + $nested = $this->dir.'/a/b'; + MetadataRegistry::setStoragePath($nested); + $umask = umask(0022); + + try { + MetadataRegistry::get(UserData::class); + } finally { + umask($umask); + } + + $this->assertSame(0755, fileperms($nested) & 0777); + $this->assertTrue(MetadataRegistry::isPersisted(UserData::class)); + } + + public function test_storage_path_that_cannot_be_created_is_skipped(): void + { + touch($this->dir.'/file'); + MetadataRegistry::setStoragePath($this->dir.'/file/sub'); + + $this->assertSame('a', UserData::from(['name' => 'a', 'email' => 'a@b.c'])->name); + $this->assertFalse(MetadataRegistry::isPersisted(UserData::class)); + } + + public function test_only_exportable_default_values_are_persisted(): void + { + MetadataRegistry::get(EnumDefaultData::class); + MetadataRegistry::get(ObjectDefaultData::class); + MetadataRegistry::get(ArrayObjectDefaultData::class); + + $this->assertTrue(MetadataRegistry::isPersisted(EnumDefaultData::class)); + $this->assertFalse(MetadataRegistry::isPersisted(ObjectDefaultData::class)); + $this->assertFalse(MetadataRegistry::isPersisted(ArrayObjectDefaultData::class)); + } +} diff --git a/tests/MetadataStateDefaultsTest.php b/tests/MetadataStateDefaultsTest.php new file mode 100644 index 0000000..065ecfe --- /dev/null +++ b/tests/MetadataStateDefaultsTest.php @@ -0,0 +1,120 @@ + */ + private static function legacyParameterState(): array + { + return [ + 'phpName' => 'name', + 'inputName' => 'full_name', + 'allowsNull' => false, + 'hasDefault' => false, + 'defaultValue' => null, + 'nestedDataClass' => null, + 'enumClass' => null, + 'dataCollectionClass' => null, + 'isHidden' => false, + 'ignoreIfNull' => false, + 'flatten' => false, + 'rules' => [], + 'caster' => null, + ]; + } + + public function test_class_meta_from_an_old_cache_gets_safe_defaults(): void + { + $meta = ClassMeta::__set_state(['parameters' => []]); + + $this->assertSame([], $meta->pipes); + $this->assertTrue($meta->hasConstructor); + $this->assertNull($meta->discriminatorField); + $this->assertSame([], $meta->discriminatorMap); + $this->assertNull($meta->discriminatorFallback); + $this->assertFalse($meta->rejectUnknownKeys); + $this->assertNull($meta->wrapIn); + $this->assertSame([], $meta->computed); + $this->assertFalse($meta->inferRules); + } + + public function test_class_meta_state_values_override_the_defaults(): void + { + $meta = ClassMeta::__set_state([ + 'parameters' => [], + 'pipes' => ['P'], + 'hasConstructor' => false, + 'rejectUnknownKeys' => true, + 'computed' => ['total' => 'total'], + 'inferRules' => true, + ]); + + $this->assertSame(['P'], $meta->pipes); + $this->assertFalse($meta->hasConstructor); + $this->assertTrue($meta->rejectUnknownKeys); + $this->assertSame(['total' => 'total'], $meta->computed); + $this->assertTrue($meta->inferRules); + } + + public function test_class_meta_constructor_defaults(): void + { + $meta = new ClassMeta([]); + + $this->assertFalse($meta->rejectUnknownKeys); + $this->assertFalse($meta->inferRules); + $this->assertTrue($meta->hasConstructor); + } + + public function test_parameter_meta_from_an_old_cache_reuses_its_single_input_name(): void + { + $param = ParameterMeta::__set_state(self::legacyParameterState()); + + $this->assertSame(['full_name'], $param->inputNames); + $this->assertSame('full_name', $param->outputName); + $this->assertSame([], $param->pipes); + $this->assertTrue($param->viaConstructor); + $this->assertNull($param->whenLoadedRelation); + $this->assertSame([], $param->nestedRules); + $this->assertFalse($param->isOptional); + $this->assertSame([], $param->hiddenExcept); + $this->assertNull($param->phpType); + $this->assertNull($param->xmlSource); + $this->assertFalse($param->isSensitive); + $this->assertTrue($param->isPlain); + } + + public function test_parameter_meta_output_name_falls_back_to_the_first_input_name(): void + { + $state = ['inputNames' => ['first', 'second']] + self::legacyParameterState(); + unset($state['inputName']); + + $this->assertSame('first', ParameterMeta::__set_state($state)->outputName); + $this->assertSame('out', ParameterMeta::__set_state(['outputName' => 'out'] + $state)->outputName); + } + + public function test_parameter_meta_state_values_override_the_defaults(): void + { + $param = ParameterMeta::__set_state(['pipes' => ['P'], 'viaConstructor' => false, 'isOptional' => true] + self::legacyParameterState()); + + $this->assertSame(['P'], $param->pipes); + $this->assertFalse($param->viaConstructor); + $this->assertTrue($param->isOptional); + $this->assertFalse($param->isPlain); + } + + public function test_parameter_meta_constructor_defaults(): void + { + $param = new ParameterMeta('a', ['a'], 'a', false, false, null, null, null, null, false, false, false, [], null); + + $this->assertTrue($param->viaConstructor); + $this->assertFalse($param->isOptional); + $this->assertFalse($param->isSensitive); + } +} diff --git a/tests/MoneyCastTest.php b/tests/MoneyCastTest.php index 7a728dd..b70b500 100644 --- a/tests/MoneyCastTest.php +++ b/tests/MoneyCastTest.php @@ -33,6 +33,22 @@ public function test_money_to_decimal_accepts_custom_decimals(): void $this->assertSame('1.099', (new Money(1099, 'BHD'))->toDecimal(3)); } + public function test_to_decimal_is_exact_beyond_float_precision(): void + { + $this->assertSame('92233720368547758.07', (new Money(PHP_INT_MAX, 'USD'))->toDecimal()); + $this->assertSame('-92233720368547758.08', (new Money(PHP_INT_MIN, 'USD'))->toDecimal()); + $this->assertSame('90071992547409.93', (new Money(9007199254740993, 'USD'))->toDecimal()); + } + + public function test_to_decimal_pads_small_and_negative_amounts(): void + { + $this->assertSame('0.00', (new Money(0, 'USD'))->toDecimal()); + $this->assertSame('-0.05', (new Money(-5, 'USD'))->toDecimal()); + $this->assertSame('0.005', (new Money(5, 'BHD'))->toDecimal(3)); + $this->assertSame('-12.34', (new Money(-1234, 'USD'))->toDecimal()); + $this->assertSame('100', (new Money(1, 'X'))->toDecimal(-2)); + } + public function test_money_equals(): void { $this->assertTrue((new Money(1099, 'USD'))->equals(new Money(1099, 'USD'))); diff --git a/tests/PrimitiveCastsTest.php b/tests/PrimitiveCastsTest.php index 64be9a3..450b5c9 100644 --- a/tests/PrimitiveCastsTest.php +++ b/tests/PrimitiveCastsTest.php @@ -547,4 +547,38 @@ public function __toString(): string $this->assertSame('secret', $cast->get($cast->set($value))); } + + public function test_comma_separated_cast_max_items_limits_strings_and_arrays(): void + { + $cast = new CommaSeparatedCast(maxItems: 3); + + $this->assertSame(['a', 'b', 'c'], $cast->get('a, b ,c')); + $this->assertSame(['a', 'b', 'c'], $cast->get(['a', 'b', 'c'])); + + foreach (['a,b,c,d', ['a', 'b', 'c', 'd']] as $tooMany) { + try { + $cast->get($tooMany); + $this->fail('Expected InvalidArgumentException.'); + } catch (InvalidArgumentException $e) { + $this->assertSame('CommaSeparatedCast accepts at most 3 items.', $e->getMessage()); + } + } + } + + public function test_comma_separated_cast_max_items_survives_export(): void + { + $cast = eval('return '.var_export(new CommaSeparatedCast('|', false, 1), true).';'); + + $this->assertSame([' a '], $cast->get(' a ')); + $this->expectException(InvalidArgumentException::class); + $cast->get('a|b'); + } + + public function test_comma_separated_cast_rejects_a_non_positive_max_items(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$maxItems must be at least 1'); + + new CommaSeparatedCast(maxItems: 0); + } } diff --git a/tests/SensitiveTest.php b/tests/SensitiveTest.php index 8aea598..9bd7bd7 100644 --- a/tests/SensitiveTest.php +++ b/tests/SensitiveTest.php @@ -11,6 +11,7 @@ use StdOut\SimpleDataObjects\Support\ParameterMeta; use StdOut\SimpleDataObjects\Tests\Fixtures\SensitiveCardData; use StdOut\SimpleDataObjects\Tests\Fixtures\SensitiveOptionalData; +use StdOut\SimpleDataObjects\Tests\Fixtures\SensitivePrivateData; use StdOut\SimpleDataObjects\Tests\Fixtures\SensitiveSubclassData; use StdOut\SimpleDataObjects\Tests\Fixtures\SensitiveWithoutTraitData; use StdOut\SimpleDataObjects\Tests\Fixtures\Status; @@ -157,4 +158,17 @@ public function test_with_casts_sensitive_and_plain_fields(): void $this->assertSame(Status::Inactive, $card->status); $this->assertSame('Bob', $card->holder); } + + public function test_private_sensitive_property_is_redacted_and_blocks_serialize(): void + { + $data = new SensitivePrivateData('Alice', '1234'); + + $this->assertStringNotContainsString('1234', print_r($data, true)); + $this->assertStringContainsString('[redacted]', print_r($data, true)); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage("field 'pin' is #[Sensitive]"); + + serialize($data); + } } diff --git a/tests/SensitiveVarDumperCasterTest.php b/tests/SensitiveVarDumperCasterTest.php new file mode 100644 index 0000000..79b0bfc --- /dev/null +++ b/tests/SensitiveVarDumperCasterTest.php @@ -0,0 +1,65 @@ + */ + private array $casters; + + protected function setUp(): void + { + $this->casters = AbstractCloner::$defaultCasters; + } + + protected function tearDown(): void + { + AbstractCloner::$defaultCasters = $this->casters; + } + + private static function dump(mixed $value): string + { + return (string) (new CliDumper)->dump((new VarCloner)->cloneVar($value), true); + } + + public function test_without_registration_var_dumper_shows_the_value(): void + { + $this->assertStringContainsString('4111111111111111', self::dump(new SensitiveCardData('Alice', '4111111111111111'))); + } + + public function test_registered_caster_redacts_sensitive_fields_at_every_level(): void + { + SensitiveVarDumperCaster::register(); + + $dump = self::dump([ + new SensitiveCardData('Alice', '4111111111111111', cvv: '123'), + new SensitivePrivateData('Bob', '9876'), + ]); + + $this->assertStringNotContainsString('4111111111111111', $dump); + $this->assertStringNotContainsString('9876', $dump); + $this->assertStringNotContainsString('"123"', $dump); + $this->assertStringContainsString('Alice', $dump); + $this->assertStringContainsString('[redacted]', $dump); + } + + public function test_null_sensitive_fields_and_plain_dtos_are_left_alone(): void + { + SensitiveVarDumperCaster::register(); + + $this->assertMatchesRegularExpression('/\+cvv: null/', self::dump(new SensitiveOptionalData('Alice'))); + $this->assertStringContainsString('a@example.com', self::dump(new UserData('a', 'a@example.com'))); + } +} diff --git a/tests/SerializerCompilerTest.php b/tests/SerializerCompilerTest.php new file mode 100644 index 0000000..062da9c --- /dev/null +++ b/tests/SerializerCompilerTest.php @@ -0,0 +1,90 @@ +assertSame(['label' => 'label', 'detail' => ['name' => 'Alice']], $data->toArray("context-{$i}")); + } + + $this->assertCount(64, SerializerCompiler::$contextualSerializers[ContextNestedData::class]); + $this->assertCount(1, array_unique(array_map(spl_object_id(...), SerializerCompiler::$contextualSerializers[ContextNestedData::class]))); + } + + public function test_undeclared_context_is_still_passed_to_nested_objects(): void + { + $data = self::nested(); + + for ($i = 0; $i < 100; $i++) { + $data->toArray("context-{$i}"); + } + + $this->assertSame(['label' => 'label', 'detail' => ['name' => 'Alice', 'internalNote' => 'note']], $data->toArray('admin')); + $this->assertArrayNotHasKey('admin', SerializerCompiler::$contextualSerializers[ContextNestedData::class]); + } + + public function test_declared_contexts_are_cached_even_beyond_the_bound(): void + { + $data = new ContextHiddenData('Alice', 'note', 'secret'); + + for ($i = 0; $i < 100; $i++) { + $data->toArray("context-{$i}"); + } + + $this->assertSame(['name' => 'Alice', 'internalNote' => 'note'], $data->toArray('admin')); + $this->assertArrayHasKey('admin', SerializerCompiler::$contextualSerializers[ContextHiddenData::class]); + $this->assertNotSame( + SerializerCompiler::$contextualSerializers[ContextHiddenData::class]['admin'], + SerializerCompiler::$contextualSerializers[ContextHiddenData::class]['context-0'], + ); + } + + public function test_compiling_the_default_serializer_twice_returns_the_cached_closure(): void + { + $first = SerializerCompiler::compile(UserData::class); + + $this->assertSame($first, SerializerCompiler::compile(UserData::class)); + $this->assertSame($first, SerializerCompiler::$serializers[UserData::class]); + } + + public function test_flatten_of_a_non_data_value_keeps_it_under_its_key_in_every_context(): void + { + $data = new NullableFlattenData('Alice'); + + $this->assertSame(['name' => 'Alice', 'address' => null], $data->toArray()); + $this->assertSame(['name' => 'Alice', 'address' => null], $data->toArray('admin')); + } + + public function test_ignore_if_null_on_an_optional_field_drops_both_null_and_missing(): void + { + $this->assertSame(['id' => 1], OptionalIgnoredData::from(['id' => 1])->toArray()); + $this->assertSame(['id' => 1], OptionalIgnoredData::from(['id' => 1, 'bio' => null])->toArray()); + $this->assertSame(['id' => 1, 'bio' => 'x'], OptionalIgnoredData::from(['id' => 1, 'bio' => 'x'])->toArray()); + } +} diff --git a/tests/WildcardRuleExpansionTest.php b/tests/WildcardRuleExpansionTest.php new file mode 100644 index 0000000..b304c18 --- /dev/null +++ b/tests/WildcardRuleExpansionTest.php @@ -0,0 +1,64 @@ +, array}> */ + public static function inputs(): iterable + { + $item = static fn (mixed $name = 'a', mixed $price = 1.5): array => ['name' => $name, 'price' => $price]; + + yield 'valid' => [InferredCollectionData::class, ['name' => 'x', 'items' => [$item(), $item('b')]]]; + yield 'missing and invalid item fields' => [InferredCollectionData::class, ['name' => 'x', 'items' => [['price' => 1], $item(5, 'cheap'), $item()]]]; + yield 'empty list' => [InferredCollectionData::class, ['name' => 'x', 'items' => []]]; + yield 'missing list' => [InferredCollectionData::class, ['name' => 'x']]; + yield 'string keys' => [InferredCollectionData::class, ['name' => 'x', 'items' => ['first' => ['price' => 1], 'second' => $item()]]]; + yield 'non-array item' => [InferredCollectionData::class, ['name' => 'x', 'items' => ['oops', ['price' => 1]]]]; + yield 'list is a string' => [InferredCollectionData::class, ['name' => 'x', 'items' => 'oops']]; + yield 'distinct duplicates' => [DistinctCollectionData::class, ['items' => [['sku' => 'a'], ['sku' => 'a'], ['sku' => 'b']]]]; + } + + /** + * @param class-string $class + * @param array $input + */ + #[DataProvider('inputs')] + public function test_errors_match_laravel_wildcard_validation(string $class, array $input): void + { + $factory = new Factory(new Translator(new ArrayLoader, 'en')); + $expected = $factory->make($input, MetadataRegistry::get($class)->validationRules)->errors()->toArray(); + + [$data, $rules] = MetadataRegistry::get($class)->validationInput($input); + + $this->assertSame($expected, $factory->make($data, $rules)->errors()->toArray()); + } + + public function test_index_independent_rules_are_expanded_per_item(): void + { + [, $rules] = MetadataRegistry::get(InferredCollectionData::class)->validationInput(['name' => 'x', 'items' => [[], []]]); + + $this->assertSame(['name', 'items', 'items.0.name', 'items.1.name', 'items.0.price', 'items.1.price'], array_keys($rules)); + } + + public function test_distinct_and_non_array_items_keep_the_wildcard(): void + { + [, $distinct] = MetadataRegistry::get(DistinctCollectionData::class)->validationInput(['items' => [['sku' => 'a']]]); + [, $mixed] = MetadataRegistry::get(InferredCollectionData::class)->validationInput(['name' => 'x', 'items' => ['oops']]); + + $this->assertArrayHasKey('items.*.sku', $distinct); + $this->assertArrayHasKey('items.*.name', $mixed); + } +} diff --git a/tests/XmlStreamTest.php b/tests/XmlStreamTest.php index 0e58b1a..bae5f1d 100644 --- a/tests/XmlStreamTest.php +++ b/tests/XmlStreamTest.php @@ -297,6 +297,38 @@ public function test_it_throws_on_malformed_xml_after_yielding_the_readable_item $this->assertSame(['Alice'], $names); } + public function test_a_stale_libxml_error_from_earlier_code_does_not_abort_reading(): void + { + $previous = libxml_use_internal_errors(true); + simplexml_load_string(''); + libxml_use_internal_errors($previous); + $this->write('Alicea@example.com'); + + $this->assertCount(1, UserData::lazyXml($this->file, 'users/user')->all()); + } + + public function test_items_under_repeated_parent_elements_are_all_read(): void + { + $user = static fn (string $name): string => "{$name}{$name}@example.com"; + $this->write(''.$user('a').''.$user('b').$user('c').''); + + $names = UserData::lazyXml($this->file, 'root/group/user')->map(static fn (UserData $u): string => $u->name)->all(); + + $this->assertSame(['a', 'b', 'c'], $names); + } + + public function test_malformed_xml_error_names_the_parser_message_and_line(): void + { + $this->write("\nAlice"); + + try { + UserData::lazyXml($this->file, 'users/user')->all(); + $this->fail('Expected a DataHydrationException.'); + } catch (DataHydrationException $e) { + $this->assertMatchesRegularExpression("#^Cannot read XML from '.+': \\S.*\\S on line 2\\.$#", $e->getMessage()); + } + } + /** @return LazyCollection */ private function offers(): LazyCollection {