From b01e5a68add39971edca65a5b5c6399bd3826217 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:27:28 +0000 Subject: [PATCH 01/15] fix(support): define consistent JSON nesting semantics Treat the public depth argument as a maximum number of nested containers and translate it to PHP's distinct decode and validation depth unit. Preserve caller flags, forward throwing behavior to Jsonable values, use json_validate for predicates, and align framework response test readers with the shared contract. Add boundary, native-error, and flag-forwarding coverage. --- src/support/src/Json.php | 30 ++++- src/support/src/Str.php | 2 +- src/testing/src/AssertableJsonString.php | 12 +- src/testing/src/TestResponse.php | 12 +- tests/Support/JsonTest.php | 135 ++++++++++++++++++++--- tests/Support/SupportStrTest.php | 13 +++ tests/Support/SupportStringableTest.php | 15 ++- tests/Testing/TestResponseTest.php | 53 +++++++++ 8 files changed, 245 insertions(+), 27 deletions(-) diff --git a/src/support/src/Json.php b/src/support/src/Json.php index 030b94ae6..e105262f2 100644 --- a/src/support/src/Json.php +++ b/src/support/src/Json.php @@ -10,15 +10,19 @@ class Json { + /** Maximum number of nested JSON containers. */ + public const int MAXIMUM_NESTING_DEPTH = 512; + /** * Encode a value to JSON. * * @throws JsonException */ - public static function encode(mixed $data, int $flags = JSON_UNESCAPED_UNICODE, int $depth = 512): string + public static function encode(mixed $data, int $flags = JSON_UNESCAPED_UNICODE, int $depth = self::MAXIMUM_NESTING_DEPTH): string { if ($data instanceof Jsonable) { - return $data->toJson(); + // Jsonable owns its nesting limit because its contract accepts only flags. + return $data->toJson($flags | JSON_THROW_ON_ERROR); } if ($data instanceof Arrayable) { @@ -33,8 +37,26 @@ public static function encode(mixed $data, int $flags = JSON_UNESCAPED_UNICODE, * * @throws JsonException */ - public static function decode(string $json, bool $assoc = true, int $depth = 512, int $flags = 0): mixed + public static function decode(string $json, bool $assoc = true, int $depth = self::MAXIMUM_NESTING_DEPTH, int $flags = 0): mixed + { + return json_decode($json, $assoc, self::nativeDecodingDepth($depth), $flags | JSON_THROW_ON_ERROR); + } + + /** + * Validate a JSON string. + * + * @param int-mask $flags + */ + public static function validate(string $json, int $depth = self::MAXIMUM_NESTING_DEPTH, int $flags = 0): bool + { + return json_validate($json, self::nativeDecodingDepth($depth), $flags); + } + + /** + * Convert the public container limit to PHP's decoding depth unit. + */ + private static function nativeDecodingDepth(int $depth): int { - return json_decode($json, $assoc, $depth, $flags | JSON_THROW_ON_ERROR); + return $depth > 0 && $depth < PHP_INT_MAX ? $depth + 1 : $depth; } } diff --git a/src/support/src/Str.php b/src/support/src/Str.php index 1d5b83db9..4e6ce9a0b 100644 --- a/src/support/src/Str.php +++ b/src/support/src/Str.php @@ -485,7 +485,7 @@ public static function isJson(mixed $value): bool return false; } - return json_validate($value, 512); + return Json::validate($value); } /** diff --git a/src/testing/src/AssertableJsonString.php b/src/testing/src/AssertableJsonString.php index 14972c29f..4d6bf59c9 100644 --- a/src/testing/src/AssertableJsonString.php +++ b/src/testing/src/AssertableJsonString.php @@ -10,8 +10,10 @@ use Hypervel\Contracts\Support\Jsonable; use Hypervel\Support\Arr; use Hypervel\Support\Collection; +use Hypervel\Support\Json; use Hypervel\Support\Str; use Hypervel\Testing\Assert as PHPUnit; +use JsonException; use JsonSerializable; use function data_get; @@ -38,12 +40,16 @@ public function __construct(Jsonable|JsonSerializable|array|string $jsonable) if ($jsonable instanceof JsonSerializable) { $this->decoded = $jsonable->jsonSerialize(); - } elseif ($jsonable instanceof Jsonable) { - $this->decoded = json_decode($jsonable->toJson(), true); } elseif (is_array($jsonable)) { $this->decoded = $jsonable; } else { - $this->decoded = json_decode($jsonable, true); + $json = $jsonable instanceof Jsonable ? $jsonable->toJson() : $jsonable; + + try { + $this->decoded = Json::decode($json); + } catch (JsonException) { + $this->decoded = null; + } } } diff --git a/src/testing/src/TestResponse.php b/src/testing/src/TestResponse.php index 3261c862e..1cf3f4b11 100644 --- a/src/testing/src/TestResponse.php +++ b/src/testing/src/TestResponse.php @@ -17,6 +17,7 @@ use Hypervel\Support\Arr; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Collection; +use Hypervel\Support\Json; use Hypervel\Support\Str; use Hypervel\Support\Traits\Conditionable; use Hypervel\Support\Traits\Dumpable; @@ -1616,7 +1617,7 @@ public function ddBody(?string $key = null): never { $content = $this->content(); - if (json_validate($content)) { + if (Json::validate($content)) { $this->ddJson($key); } @@ -1648,10 +1649,11 @@ public function dump(?string $key = null): static { $content = $this->getContent(); - $json = json_decode($content); - - if (json_last_error() === JSON_ERROR_NONE) { - $content = $json; + try { + // Keep debugging output object-shaped like Laravel's native decode. + $content = Json::decode($content, assoc: false); + } catch (JsonException) { + // Invalid response bodies are still useful when dumped verbatim. } if (! is_null($key)) { diff --git a/tests/Support/JsonTest.php b/tests/Support/JsonTest.php index c2ddd3237..4fea646dd 100644 --- a/tests/Support/JsonTest.php +++ b/tests/Support/JsonTest.php @@ -9,49 +9,60 @@ use Hypervel\Support\Json; use Hypervel\Tests\TestCase; use JsonException; +use PHPUnit\Framework\Attributes\DataProvider; +use ValueError; class JsonTest extends TestCase { - public function testEncodeArray() + public function testEncodeArray(): void { $this->assertSame('{"name":"test"}', Json::encode(['name' => 'test'])); } - public function testEncodeString() + public function testEncodeString(): void { $this->assertSame('"hello"', Json::encode('hello')); } - public function testEncodeInteger() + public function testEncodeInteger(): void { $this->assertSame('42', Json::encode(42)); } - public function testEncodeNull() + public function testEncodeNull(): void { $this->assertSame('null', Json::encode(null)); } - public function testEncodeUnicode() + public function testEncodeUnicode(): void { $result = Json::encode(['name' => '日本語']); $this->assertSame('{"name":"日本語"}', $result); } - public function testEncodeJsonable() + public function testEncodeJsonable(): void { $jsonable = new class implements Jsonable { + public int $options = 0; + public function toJson(int $options = 0): string { - return '{"custom":true}'; + $this->options = $options; + + return json_encode(['name' => '日本語'], $options); } }; - $this->assertSame('{"custom":true}', Json::encode($jsonable)); + $this->assertSame('{"name":"日本語"}', Json::encode($jsonable)); + $this->assertSame(JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR, $jsonable->options); + + Json::encode($jsonable, JSON_PRETTY_PRINT); + + $this->assertSame(JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR, $jsonable->options); } - public function testEncodeArrayable() + public function testEncodeArrayable(): void { $arrayable = new class implements Arrayable { public function toArray(): array @@ -63,14 +74,14 @@ public function toArray(): array $this->assertSame('{"key":"value"}', Json::encode($arrayable)); } - public function testDecodeReturnsArray() + public function testDecodeReturnsArray(): void { $result = Json::decode('{"name":"test","count":5}'); $this->assertSame(['name' => 'test', 'count' => 5], $result); } - public function testDecodeReturnsObject() + public function testDecodeReturnsObject(): void { $result = Json::decode('{"name":"test"}', false); @@ -78,17 +89,115 @@ public function testDecodeReturnsObject() $this->assertSame('test', $result->name); } - public function testDecodeThrowsOnInvalidJson() + public function testDecodePassesCallerFlagsToNativeJsonDecoder(): void + { + $this->assertSame( + ['number' => '12345678901234567890'], + Json::decode('{"number":12345678901234567890}', flags: JSON_BIGINT_AS_STRING), + ); + } + + public function testDecodeThrowsOnInvalidJson(): void { $this->expectException(JsonException::class); Json::decode('{invalid}'); } - public function testEncodeThrowsOnInvalidValue() + public function testEncodeThrowsOnInvalidValue(): void { $this->expectException(JsonException::class); Json::encode(NAN); } + + public function testDefaultMaximumNestingDepthRoundTrips(): void + { + $value = $this->nestedValue(Json::MAXIMUM_NESTING_DEPTH); + $json = Json::encode($value); + + $this->assertSame($value, Json::decode($json)); + $this->assertTrue(Json::validate($json)); + } + + public function testExplicitMaximumNestingDepthRoundTrips(): void + { + $value = $this->nestedValue(8); + $json = Json::encode($value, depth: 8); + + $this->assertSame($value, Json::decode($json, depth: 8)); + $this->assertTrue(Json::validate($json, depth: 8)); + } + + public function testOneLevelOverMaximumFailsEncoding(): void + { + $this->expectException(JsonException::class); + + Json::encode($this->nestedValue(Json::MAXIMUM_NESTING_DEPTH + 1)); + } + + public function testDecodeAndValidateRejectOneLevelOverMaximum(): void + { + $json = json_encode( + $this->nestedValue(Json::MAXIMUM_NESTING_DEPTH + 1), + JSON_THROW_ON_ERROR, + Json::MAXIMUM_NESTING_DEPTH + 1 + ); + + $this->assertFalse(Json::validate($json)); + + $this->expectException(JsonException::class); + + Json::decode($json); + } + + #[DataProvider('invalidDepths')] + public function testDecodeRetainsNativeValueErrorsForInvalidPublicDepths(int $depth): void + { + $this->expectException(ValueError::class); + + Json::decode('null', depth: $depth); + } + + #[DataProvider('invalidDepths')] + public function testValidateRetainsNativeValueErrorsForInvalidPublicDepths(int $depth): void + { + $this->expectException(ValueError::class); + + Json::validate('null', depth: $depth); + } + + public static function invalidDepths(): array + { + return [[0], [-1], [PHP_INT_MAX]]; + } + + public function testValidateReturnsFalseForMalformedJson(): void + { + $this->assertFalse(Json::validate('{invalid}')); + } + + public function testValidateSupportsInvalidUtf8Ignore(): void + { + $this->assertFalse(Json::validate("\"\xB1\"")); + $this->assertTrue(Json::validate("\"\xB1\"", flags: JSON_INVALID_UTF8_IGNORE)); + } + + public function testValidateRejectsUnsupportedFlags(): void + { + $this->expectException(ValueError::class); + + Json::validate('null', flags: JSON_THROW_ON_ERROR); + } + + private function nestedValue(int $containers): array|string + { + $value = 'leaf'; + + for ($index = 0; $index < $containers; ++$index) { + $value = ['value' => $value]; + } + + return $value; + } } diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php index 0ceee89f8..b436d3c62 100644 --- a/tests/Support/SupportStrTest.php +++ b/tests/Support/SupportStrTest.php @@ -9,6 +9,7 @@ use DateTimeInterface; use Exception; use Hypervel\Container\Container; +use Hypervel\Support\Json; use Hypervel\Support\Str; use Hypervel\Tests\Support\Fixtures\StringableObjectStub; use Hypervel\Tests\TestCase; @@ -912,6 +913,18 @@ public function testIsJson(): void $this->assertFalse(Str::isJson('')); $this->assertFalse(Str::isJson(null)); $this->assertFalse(Str::isJson([])); + + $value = 'leaf'; + + for ($index = 0; $index < Json::MAXIMUM_NESTING_DEPTH; ++$index) { + $value = ['value' => $value]; + } + + $this->assertTrue(Str::isJson(Json::encode($value))); + + $value = ['value' => $value]; + + $this->assertFalse(Str::isJson(json_encode($value, JSON_THROW_ON_ERROR, Json::MAXIMUM_NESTING_DEPTH + 1))); } public function testIsMatch(): void diff --git a/tests/Support/SupportStringableTest.php b/tests/Support/SupportStringableTest.php index 8979c7f50..63d56d781 100644 --- a/tests/Support/SupportStringableTest.php +++ b/tests/Support/SupportStringableTest.php @@ -11,6 +11,7 @@ use Hypervel\Support\Collection; use Hypervel\Support\Facades\Date; use Hypervel\Support\HtmlString; +use Hypervel\Support\Json; use Hypervel\Support\Stringable; use Hypervel\Support\Uri; use Hypervel\Tests\Support\Fixtures\StringableObjectStub; @@ -79,7 +80,7 @@ public function testIsUlid() $this->assertFalse($this->stringable('01GJSNW9MAF-792C0XYY8RX6ssssss-QFT')->isUlid()); } - public function testIsJson() + public function testIsJson(): void { $this->assertTrue($this->stringable('1')->isJson()); $this->assertTrue($this->stringable('[1,2,3]')->isJson()); @@ -94,6 +95,18 @@ public function testIsJson() $this->assertFalse($this->stringable('[{first: "John"}, {first: "Jane"}]')->isJson()); $this->assertFalse($this->stringable('')->isJson()); $this->assertFalse($this->stringable(null)->isJson()); + + $value = 'leaf'; + + for ($index = 0; $index < Json::MAXIMUM_NESTING_DEPTH; ++$index) { + $value = ['value' => $value]; + } + + $this->assertTrue($this->stringable(Json::encode($value))->isJson()); + + $value = ['value' => $value]; + + $this->assertFalse($this->stringable(json_encode($value, JSON_THROW_ON_ERROR, Json::MAXIMUM_NESTING_DEPTH + 1))->isJson()); } public function testIsMatch() diff --git a/tests/Testing/TestResponseTest.php b/tests/Testing/TestResponseTest.php index becccee84..c19bfc5b9 100644 --- a/tests/Testing/TestResponseTest.php +++ b/tests/Testing/TestResponseTest.php @@ -16,14 +16,17 @@ use Hypervel\Session\ArraySessionHandler; use Hypervel\Session\Store; use Hypervel\Support\Collection; +use Hypervel\Support\Json; use Hypervel\Support\MessageBag; use Hypervel\Support\ViewErrorBag; use Hypervel\Testing\TestResponse; use Hypervel\Tests\TestCase; use Mockery as m; use PHPUnit\Framework\AssertionFailedError; +use stdClass; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\StreamedResponse; +use Symfony\Component\VarDumper\VarDumper; class TestResponseTest extends TestCase { @@ -456,6 +459,56 @@ public function testDecodeResponseJsonAcceptsEveryJsonRootAndMemoizesTheWrapper( } } + public function testDecodeResponseJsonAcceptsTheMaximumSupportedNestingDepth(): void + { + $value = 'leaf'; + + for ($index = 1; $index < Json::MAXIMUM_NESTING_DEPTH; ++$index) { + $value = ['value' => $value]; + } + + $response = TestResponse::fromBaseResponse(new Response(Json::encode(['nested' => $value]))); + + $this->assertSame(['nested' => $value], $response->decodeResponseJson()->json()); + } + + public function testDecodeResponseJsonRejectsOneLevelOverTheMaximumNestingDepth(): void + { + $value = 'leaf'; + + for ($index = 0; $index < Json::MAXIMUM_NESTING_DEPTH; ++$index) { + $value = ['value' => $value]; + } + + $response = TestResponse::fromBaseResponse(new Response( + json_encode(['nested' => $value], JSON_THROW_ON_ERROR, Json::MAXIMUM_NESTING_DEPTH + 1) + )); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Invalid JSON was returned from the route.'); + + $response->decodeResponseJson(); + } + + public function testDumpDecodesJsonAsObjectsAndPreservesInvalidBytes(): void + { + $dumped = []; + VarDumper::setHandler(function (mixed $value) use (&$dumped): void { + $dumped[] = $value; + }); + + try { + TestResponse::fromBaseResponse(new Response('{"nested":{"value":1}}'))->dump(); + TestResponse::fromBaseResponse(new Response('{invalid'))->dump(); + } finally { + VarDumper::setHandler(null); + } + + $this->assertInstanceOf(stdClass::class, $dumped[0]); + $this->assertInstanceOf(stdClass::class, $dumped[0]->nested); + $this->assertSame('{invalid', $dumped[1]); + } + public function testDecodeResponseJsonRejectsNullLikeContentWithInvalidWhitespace(): void { foreach (["null\0", "\vnull"] as $content) { From 071fd79312738e7dfa322eac10c1a2cfd626d908 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:27:38 +0000 Subject: [PATCH 02/15] fix(collections): preserve maximum-depth JSON round trips Raise native decode depths where Collections consumes JSON produced at the framework's 512-container limit. Keep the dependency direction intact rather than coupling Collections back to Support. Cover collection decoding, Jsonable item serialization, and Arr conversion at the supported boundary and one level beyond it. --- src/collections/src/Arr.php | 3 +- .../src/Traits/EnumeratesValues.php | 6 ++- tests/Support/SupportArrTest.php | 24 +++++++++ tests/Support/SupportCollectionTest.php | 53 +++++++++++++++++++ 4 files changed, 83 insertions(+), 3 deletions(-) diff --git a/src/collections/src/Arr.php b/src/collections/src/Arr.php index 0b503805a..c9cd85a82 100644 --- a/src/collections/src/Arr.php +++ b/src/collections/src/Arr.php @@ -459,7 +459,8 @@ public static function from(mixed $items): array $items instanceof Arrayable => $items->toArray(), $items instanceof WeakMap => iterator_to_array($items, false), $items instanceof Traversable => iterator_to_array($items), - $items instanceof Jsonable => json_decode($items->toJson(), true), + // Support depends on Collections, so this native depth cannot reference Support\Json; 513 reads 512 containers. + $items instanceof Jsonable => json_decode($items->toJson(), true, 513), $items instanceof JsonSerializable => (array) $items->jsonSerialize(), is_object($items) => (array) $items, // @phpstan-ignore function.alreadyNarrowedType default => throw new InvalidArgumentException('Items cannot be represented by a scalar value.'), diff --git a/src/collections/src/Traits/EnumeratesValues.php b/src/collections/src/Traits/EnumeratesValues.php index 1d842e321..8a8b89c75 100644 --- a/src/collections/src/Traits/EnumeratesValues.php +++ b/src/collections/src/Traits/EnumeratesValues.php @@ -189,8 +189,9 @@ public static function times(int $number, ?callable $callback = null, mixed ...$ * * @return static */ - public static function fromJson(string $json, int $depth = 512, int $flags = 0, mixed ...$args): static + public static function fromJson(string $json, int $depth = 513, int $flags = 0, mixed ...$args): static { + // Support depends on Collections, so this native depth cannot reference Support\Json; 513 reads 512 containers. return new static(json_decode($json, true, $depth, $flags), ...$args); } @@ -934,7 +935,8 @@ public function jsonSerialize(): array return array_map(function ($value) { return match (true) { $value instanceof JsonSerializable => $value->jsonSerialize(), - $value instanceof Jsonable => json_decode($value->toJson(), true), + // Support depends on Collections, so this native depth cannot reference Support\Json; 513 reads 512 containers. + $value instanceof Jsonable => json_decode($value->toJson(), true, 513), $value instanceof Arrayable => $value->toArray(), default => $value, }; diff --git a/tests/Support/SupportArrTest.php b/tests/Support/SupportArrTest.php index ed347e1ee..2cd6a28b2 100644 --- a/tests/Support/SupportArrTest.php +++ b/tests/Support/SupportArrTest.php @@ -6,10 +6,12 @@ use ArrayObject; use DateTime; +use Hypervel\Contracts\Support\Jsonable; use Hypervel\Support\Arr; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Collection; use Hypervel\Support\ItemNotFoundException; +use Hypervel\Support\Json; use Hypervel\Support\MultipleItemsFoundException; use Hypervel\Tests\TestCase; use InvalidArgumentException; @@ -1854,6 +1856,28 @@ public function testFrom(): void Arr::from(123); } + public function testFromDecodesJsonableAtTheSupportNestingLimit(): void + { + $value = 'leaf'; + + for ($index = 0; $index < Json::MAXIMUM_NESTING_DEPTH; ++$index) { + $value = ['value' => $value]; + } + + $jsonable = new class(Json::encode($value)) implements Jsonable { + public function __construct(private readonly string $json) + { + } + + public function toJson(int $options = 0): string + { + return $this->json; + } + }; + + $this->assertSame($value, Arr::from($jsonable)); + } + public function testWrap(): void { $string = 'a'; diff --git a/tests/Support/SupportCollectionTest.php b/tests/Support/SupportCollectionTest.php index da2794ae3..1259df755 100644 --- a/tests/Support/SupportCollectionTest.php +++ b/tests/Support/SupportCollectionTest.php @@ -11,10 +11,12 @@ use Error; use Exception; use Hypervel\Contracts\Support\Arrayable; +use Hypervel\Contracts\Support\Jsonable; use Hypervel\Database\Eloquent\Model; use Hypervel\Support\Collection; use Hypervel\Support\HtmlString; use Hypervel\Support\ItemNotFoundException; +use Hypervel\Support\Json; use Hypervel\Support\LazyCollection; use Hypervel\Support\MultipleItemsFoundException; use Hypervel\Support\Str; @@ -3119,6 +3121,57 @@ public function testFromJson($collection): void $this->assertSame($array, $instance->toArray()); } + #[DataProvider('collectionClassProvider')] + public function testJsonRoundTripsAtTheSupportNestingLimit($collection): void + { + $value = 'leaf'; + + for ($index = 0; $index < Json::MAXIMUM_NESTING_DEPTH; ++$index) { + $value = ['value' => $value]; + } + + $instance = new $collection($value); + + $this->assertSame($value, $collection::fromJson($instance->toJson())->all()); + } + + #[DataProvider('collectionClassProvider')] + public function testToJsonRejectsOneLevelOverTheSupportNestingLimit($collection): void + { + $value = 'leaf'; + + for ($index = 0; $index <= Json::MAXIMUM_NESTING_DEPTH; ++$index) { + $value = ['value' => $value]; + } + + $this->expectException(JsonException::class); + + (new $collection($value))->toJson(); + } + + #[DataProvider('collectionClassProvider')] + public function testJsonSerializeDecodesJsonableItemsAtTheSupportNestingLimit($collection): void + { + $value = 'leaf'; + + for ($index = 0; $index < Json::MAXIMUM_NESTING_DEPTH; ++$index) { + $value = ['value' => $value]; + } + + $jsonable = new class(Json::encode($value)) implements Jsonable { + public function __construct(private readonly string $json) + { + } + + public function toJson(int $options = 0): string + { + return $this->json; + } + }; + + $this->assertSame([$value], (new $collection([$jsonable]))->jsonSerialize()); + } + #[DataProvider('collectionClassProvider')] public function testFromJsonWithDepth($collection): void { From 7799f7cac1ae7d6e496d3d88e85b40b3ca2e042f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:27:47 +0000 Subject: [PATCH 03/15] fix(filesystem): read JSON at the supported nesting limit Use PHP's native 513 decode depth in both filesystem JSON readers so documents written with 512 nested containers remain readable. Retain the existing flags-controlled error behavior and missing-file contract, with regressions for maximum depth, overflow, malformed input, and throwing mode. --- src/filesystem/src/Filesystem.php | 3 +- src/filesystem/src/FilesystemAdapter.php | 3 +- tests/Filesystem/FilesystemAdapterTest.php | 55 +++++++++++++++++++++- tests/Filesystem/FilesystemTest.php | 44 ++++++++++++++++- 4 files changed, 99 insertions(+), 6 deletions(-) diff --git a/src/filesystem/src/Filesystem.php b/src/filesystem/src/Filesystem.php index 5ed476194..e636a38d3 100644 --- a/src/filesystem/src/Filesystem.php +++ b/src/filesystem/src/Filesystem.php @@ -7,6 +7,7 @@ use ErrorException; use FilesystemIterator; use Hypervel\Contracts\Filesystem\FileNotFoundException; +use Hypervel\Support\Json; use Hypervel\Support\LazyCollection; use Hypervel\Support\Traits\Conditionable; use Hypervel\Support\Traits\Macroable; @@ -69,7 +70,7 @@ public function get(string $path, bool $lock = false): string */ public function json(string $path, int $flags = 0, bool $lock = false): mixed { - return json_decode($this->get($path, $lock), true, 512, $flags); + return json_decode($this->get($path, $lock), true, Json::MAXIMUM_NESTING_DEPTH + 1, $flags); } /** diff --git a/src/filesystem/src/FilesystemAdapter.php b/src/filesystem/src/FilesystemAdapter.php index 59f2d3205..9656789a5 100644 --- a/src/filesystem/src/FilesystemAdapter.php +++ b/src/filesystem/src/FilesystemAdapter.php @@ -15,6 +15,7 @@ use Hypervel\Http\Request; use Hypervel\Http\UploadedFile; use Hypervel\Support\Arr; +use Hypervel\Support\Json; use Hypervel\Support\Str; use Hypervel\Support\Traits\Conditionable; use Hypervel\Support\Traits\Macroable; @@ -275,7 +276,7 @@ public function json(string $path, int $flags = 0): array|bool|float|int|string| { $content = $this->get($path); - return is_null($content) ? null : json_decode($content, true, 512, $flags); + return is_null($content) ? null : json_decode($content, true, Json::MAXIMUM_NESTING_DEPTH + 1, $flags); } /** diff --git a/tests/Filesystem/FilesystemAdapterTest.php b/tests/Filesystem/FilesystemAdapterTest.php index 5c3a390bb..3da720a33 100644 --- a/tests/Filesystem/FilesystemAdapterTest.php +++ b/tests/Filesystem/FilesystemAdapterTest.php @@ -18,9 +18,11 @@ use Hypervel\Http\Response; use Hypervel\Http\UploadedFile; use Hypervel\Support\CarbonImmutable; +use Hypervel\Support\Json; use Hypervel\Testbench\TestCase; use Hypervel\Testing\ParallelTesting; use InvalidArgumentException; +use JsonException; use League\Flysystem\Filesystem; use League\Flysystem\Ftp\FtpAdapter; use League\Flysystem\Local\LocalFilesystemAdapter; @@ -272,20 +274,69 @@ public function testGetFileNotFound() $this->assertNull($filesystemAdapter->get('file.txt')); } - public function testJsonReturnsDecodedJsonData() + public function testJsonReturnsDecodedJsonData(): void { $this->filesystem->write('file.json', '{"foo": "bar"}'); $filesystemAdapter = new FilesystemAdapter($this->filesystem, $this->adapter); $this->assertSame(['foo' => 'bar'], $filesystemAdapter->json('file.json')); } - public function testJsonReturnsNullIfJsonDataIsInvalid() + public function testJsonReturnsNullIfJsonDataIsInvalid(): void { $this->filesystem->write('file.json', '{"foo":'); $filesystemAdapter = new FilesystemAdapter($this->filesystem, $this->adapter); $this->assertNull($filesystemAdapter->json('file.json')); } + public function testJsonReturnsNullIfFileIsMissing(): void + { + $filesystemAdapter = new FilesystemAdapter($this->filesystem, $this->adapter); + + $this->assertNull($filesystemAdapter->json('missing.json')); + } + + public function testJsonReadsTheMaximumSupportedNestingDepth(): void + { + $value = 'leaf'; + + for ($index = 0; $index < Json::MAXIMUM_NESTING_DEPTH; ++$index) { + $value = ['value' => $value]; + } + + $this->filesystem->write('file.json', Json::encode($value)); + + $filesystemAdapter = new FilesystemAdapter($this->filesystem, $this->adapter); + + $this->assertSame($value, $filesystemAdapter->json('file.json')); + } + + public function testJsonRejectsOneLevelOverTheMaximumNestingDepth(): void + { + $value = 'leaf'; + + for ($index = 0; $index <= Json::MAXIMUM_NESTING_DEPTH; ++$index) { + $value = ['value' => $value]; + } + + $this->filesystem->write( + 'file.json', + json_encode($value, JSON_THROW_ON_ERROR, Json::MAXIMUM_NESTING_DEPTH + 1) + ); + + $filesystemAdapter = new FilesystemAdapter($this->filesystem, $this->adapter); + + $this->assertNull($filesystemAdapter->json('file.json')); + } + + public function testJsonSupportsThrowingDecodeFlags(): void + { + $this->filesystem->write('file.json', '{"foo":'); + + $this->expectException(JsonException::class); + + (new FilesystemAdapter($this->filesystem, $this->adapter))->json('file.json', JSON_THROW_ON_ERROR); + } + public function testJsonReturnsDecodedScalarData(): void { $filesystemAdapter = new FilesystemAdapter($this->filesystem, $this->adapter); diff --git a/tests/Filesystem/FilesystemTest.php b/tests/Filesystem/FilesystemTest.php index cf831cc58..ea1938e59 100755 --- a/tests/Filesystem/FilesystemTest.php +++ b/tests/Filesystem/FilesystemTest.php @@ -6,9 +6,11 @@ use Hypervel\Contracts\Filesystem\FileNotFoundException; use Hypervel\Filesystem\Filesystem; +use Hypervel\Support\Json; use Hypervel\Support\LazyCollection; use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; +use JsonException; use Mockery as m; use PHPUnit\Framework\Attributes\RequiresOperatingSystem; use PHPUnit\Framework\Attributes\RequiresPhpExtension; @@ -575,20 +577,58 @@ public function testGetRequireThrowsExceptionNonExistingFile() (new Filesystem)->getRequire($this->tempDir . '/unknown-file.txt'); } - public function testJsonReturnsDecodedJsonData() + public function testJsonReturnsDecodedJsonData(): void { file_put_contents($this->tempDir . '/file.json', '{"foo": "bar"}'); $files = new Filesystem; $this->assertSame(['foo' => 'bar'], $files->json($this->tempDir . '/file.json')); } - public function testJsonReturnsNullIfJsonDataIsInvalid() + public function testJsonReturnsNullIfJsonDataIsInvalid(): void { file_put_contents($this->tempDir . '/file.json', '{"foo":'); $files = new Filesystem; $this->assertNull($files->json($this->tempDir . '/file.json')); } + public function testJsonReadsTheMaximumSupportedNestingDepth(): void + { + $value = 'leaf'; + + for ($index = 0; $index < Json::MAXIMUM_NESTING_DEPTH; ++$index) { + $value = ['value' => $value]; + } + + file_put_contents($this->tempDir . '/file.json', Json::encode($value)); + + $this->assertSame($value, (new Filesystem)->json($this->tempDir . '/file.json')); + } + + public function testJsonRejectsOneLevelOverTheMaximumNestingDepth(): void + { + $value = 'leaf'; + + for ($index = 0; $index <= Json::MAXIMUM_NESTING_DEPTH; ++$index) { + $value = ['value' => $value]; + } + + file_put_contents( + $this->tempDir . '/file.json', + json_encode($value, JSON_THROW_ON_ERROR, Json::MAXIMUM_NESTING_DEPTH + 1) + ); + + $this->assertNull((new Filesystem)->json($this->tempDir . '/file.json')); + } + + public function testJsonSupportsThrowingDecodeFlags(): void + { + file_put_contents($this->tempDir . '/file.json', '{"foo":'); + + $this->expectException(JsonException::class); + + (new Filesystem)->json($this->tempDir . '/file.json', JSON_THROW_ON_ERROR); + } + public function testAppendAddsDataToFile() { file_put_contents($this->tempDir . '/file.txt', 'foo'); From f84ef1208d2ddb94d216ce85c81f7f9942f166f7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:27:55 +0000 Subject: [PATCH 04/15] fix(support): preserve Composer JSON file round trips Route Composer file reads and writes through the shared JSON contract so maximum-depth metadata can be read after it is written. Encode before inspecting file mode or replacing bytes, ensuring over-depth or otherwise invalid callback results fail without changing the original file. Cover the supported boundary and byte-for-byte failure preservation. --- src/support/src/Composer.php | 17 +++++---- tests/Support/ComposerFileTest.php | 61 +++++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/src/support/src/Composer.php b/src/support/src/Composer.php index 0f4ce9722..152d3d1a4 100644 --- a/src/support/src/Composer.php +++ b/src/support/src/Composer.php @@ -50,7 +50,7 @@ public function __construct(Filesystem $files, ?string $workingPath = null) */ public function hasPackage(string $package): bool { - $composer = json_decode($this->files->get($this->findComposerFile()), true, 512, JSON_THROW_ON_ERROR); + $composer = Json::decode($this->files->get($this->findComposerFile())); return array_key_exists($package, $composer['require'] ?? []) || array_key_exists($package, $composer['require-dev'] ?? []); @@ -118,15 +118,18 @@ public function modify(callable $callback): void { $composerFile = $this->findComposerFile(); - $composer = json_decode($this->files->get($composerFile), true, 512, JSON_THROW_ON_ERROR); + $composer = Json::decode($this->files->get($composerFile)); + $updatedComposer = call_user_func($callback, $composer); + $encodedComposer = Json::encode( + $updatedComposer, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE + ); + $fileMode = $this->fileMode($composerFile); $this->files->replace( $composerFile, - json_encode( - call_user_func($callback, $composer), - JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR - ), - $this->fileMode($composerFile), + $encodedComposer, + $fileMode, ); } diff --git a/tests/Support/ComposerFileTest.php b/tests/Support/ComposerFileTest.php index c4240c1d6..73229947f 100644 --- a/tests/Support/ComposerFileTest.php +++ b/tests/Support/ComposerFileTest.php @@ -6,6 +6,7 @@ use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Composer; +use Hypervel\Support\Json; use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; use JsonException; @@ -78,17 +79,56 @@ public function testModifyReplacesComposerFileAndPreservesItsMode(): void public function testModifyRejectsUnencodableCallbackOutput(): void { - file_put_contents($this->composerFile, '{}'); + file_put_contents($this->composerFile, $original = '{}'); $stream = fopen('php://memory', 'r'); try { - $this->expectException(JsonException::class); + try { + (new Composer(new Filesystem, $this->tempDirectory))->modify( + fn (): array => ['stream' => $stream], + ); + + $this->fail('Expected JSON encoding to fail.'); + } catch (JsonException) { + $this->assertSame($original, file_get_contents($this->composerFile)); + } + } finally { + fclose($stream); + } + } + + public function testModifyRoundTripsTheMaximumSupportedNestingDepth(): void + { + file_put_contents($this->composerFile, '{}'); + $value = $this->nestedValue(Json::MAXIMUM_NESTING_DEPTH - 1); + $composer = new Composer(new Filesystem, $this->tempDirectory); + + $composer->modify(fn (): array => ['nested' => $value]); + $composer->modify(function (array $metadata) use ($value): array { + $this->assertSame($value, $metadata['nested']); + + return [...$metadata, 'verified' => true]; + }); + + $this->assertSame( + ['nested' => $value, 'verified' => true], + Json::decode(file_get_contents($this->composerFile)) + ); + } + public function testModifyRejectsOneLevelOverTheMaximumBeforeReplacingTheFile(): void + { + file_put_contents($this->composerFile, $original = '{"name":"hypervel/app"}'); + $value = $this->nestedValue(Json::MAXIMUM_NESTING_DEPTH); + + try { (new Composer(new Filesystem, $this->tempDirectory))->modify( - fn (): array => ['stream' => $stream], + fn (): array => ['nested' => $value], ); - } finally { - fclose($stream); + + $this->fail('Expected JSON encoding to fail.'); + } catch (JsonException) { + $this->assertSame($original, file_get_contents($this->composerFile)); } } @@ -109,6 +149,17 @@ public function testModifyPreservesTheOriginalFileWhenReplacementFails(): void $this->assertSame($original, file_get_contents($this->composerFile)); } } + + private function nestedValue(int $containers): array|string + { + $value = 'leaf'; + + for ($index = 0; $index < $containers; ++$index) { + $value = ['value' => $value]; + } + + return $value; + } } class FailingComposerFilesystem extends Filesystem From 95fbcb6ccb07a2bb918f6b147e072e20b6b40cf3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:28:12 +0000 Subject: [PATCH 05/15] fix: preserve framework-owned JSON round trips Align maintenance data, HTTP request and response payloads, JSON sessions, XML normalization, and Inertia test data with the shared nesting contract. Values encoded at 512 nested containers now decode through their owning boundary, while one-level-over values fail at encoding or validation instead of becoming null or unrelated type errors. Existing output shapes and non-throwing session recovery remain unchanged. --- .../src/FileBasedMaintenanceMode.php | 5 +- src/http/src/Client/Request.php | 3 +- src/http/src/JsonResponse.php | 3 +- src/inertia/src/Testing/AssertableInertia.php | 3 +- src/session/src/Store.php | 7 ++- src/support/src/Xml.php | 2 +- ...FoundationFileBasedMaintenanceModeTest.php | 40 ++++++++++++-- tests/Http/HttpClientTest.php | 39 +++++++++++++ tests/Http/HttpJsonResponseTest.php | 32 +++++++++++ .../Inertia/Testing/AssertableInertiaTest.php | 30 ++++++++++ tests/Session/SessionStoreTest.php | 55 +++++++++++++++++++ tests/Support/XmlTest.php | 28 ++++++++++ 12 files changed, 234 insertions(+), 13 deletions(-) diff --git a/src/foundation/src/FileBasedMaintenanceMode.php b/src/foundation/src/FileBasedMaintenanceMode.php index 03eaf1380..347569d5a 100644 --- a/src/foundation/src/FileBasedMaintenanceMode.php +++ b/src/foundation/src/FileBasedMaintenanceMode.php @@ -6,6 +6,7 @@ use Hypervel\Contracts\Foundation\MaintenanceMode as MaintenanceModeContract; use Hypervel\Filesystem\Filesystem; +use Hypervel\Support\Json; use RuntimeException; class FileBasedMaintenanceMode implements MaintenanceModeContract @@ -21,7 +22,7 @@ public function activate(array $payload): void { $this->files->replace( $this->path(), - json_encode($payload, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) + Json::encode($payload, JSON_PRETTY_PRINT) ); } @@ -52,7 +53,7 @@ public function active(): bool */ public function data(): array { - $data = json_decode($this->files->get($this->path()), true, flags: JSON_THROW_ON_ERROR); + $data = Json::decode($this->files->get($this->path())); if (! is_array($data)) { throw new RuntimeException('The maintenance mode file does not contain a valid payload.'); diff --git a/src/http/src/Client/Request.php b/src/http/src/Client/Request.php index 89b9d35cd..40f012b2e 100644 --- a/src/http/src/Client/Request.php +++ b/src/http/src/Client/Request.php @@ -6,6 +6,7 @@ use ArrayAccess; use Hypervel\Support\Collection; +use Hypervel\Support\Json; use Hypervel\Support\Traits\Macroable; use Hypervel\Support\Uri; use InvalidArgumentException; @@ -183,7 +184,7 @@ protected function json(): array return $this->data; } - $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + $data = Json::decode($body); if (! is_array($data)) { throw new InvalidArgumentException('The request JSON body must decode to an array.'); diff --git a/src/http/src/JsonResponse.php b/src/http/src/JsonResponse.php index 33b527b1b..6b8ee3ddc 100755 --- a/src/http/src/JsonResponse.php +++ b/src/http/src/JsonResponse.php @@ -6,6 +6,7 @@ use Hypervel\Contracts\Support\Arrayable; use Hypervel\Contracts\Support\Jsonable; +use Hypervel\Support\Json; use Hypervel\Support\Traits\Macroable; use InvalidArgumentException; use JsonSerializable; @@ -48,7 +49,7 @@ public function withCallback(?string $callback = null): static /** * Get the decoded JSON data from the response. */ - public function getData(bool $assoc = false, int $depth = 512): mixed + public function getData(bool $assoc = false, int $depth = Json::MAXIMUM_NESTING_DEPTH + 1): mixed { return json_decode($this->data, $assoc, $depth); } diff --git a/src/inertia/src/Testing/AssertableInertia.php b/src/inertia/src/Testing/AssertableInertia.php index b65ff450d..fa71635c7 100644 --- a/src/inertia/src/Testing/AssertableInertia.php +++ b/src/inertia/src/Testing/AssertableInertia.php @@ -7,6 +7,7 @@ use Closure; use Hypervel\Http\Response; use Hypervel\Support\Arr; +use Hypervel\Support\Json; use Hypervel\Testing\Fluent\AssertableJson; use Hypervel\Testing\TestResponse; use InvalidArgumentException; @@ -63,7 +64,7 @@ public static function fromTestResponse(TestResponse $response): self { try { $response->assertViewHas('page'); - $page = json_decode(json_encode($response->viewData('page')), true); + $page = Json::decode(Json::encode($response->viewData('page'))); PHPUnit::assertIsArray($page); PHPUnit::assertArrayHasKey('component', $page); diff --git a/src/session/src/Store.php b/src/session/src/Store.php index f51e669f5..73737f441 100644 --- a/src/session/src/Store.php +++ b/src/session/src/Store.php @@ -15,6 +15,7 @@ use Hypervel\Support\Arr; use Hypervel\Support\Facades\Cache; use Hypervel\Support\Facades\Date; +use Hypervel\Support\Json; use Hypervel\Support\MessageBag; use Hypervel\Support\Str; use Hypervel\Support\Traits\Macroable; @@ -168,7 +169,11 @@ protected function readFromHandler(): array { if ($data = $this->handler->read($this->getId())) { if ($this->serialization === 'json') { - $data = json_decode($this->prepareForUnserialize($data), true); + $data = json_decode( + $this->prepareForUnserialize($data), + true, + Json::MAXIMUM_NESTING_DEPTH + 1 + ); } else { $data = @unserialize($this->prepareForUnserialize($data)); } diff --git a/src/support/src/Xml.php b/src/support/src/Xml.php index 749fba50a..1328bd912 100644 --- a/src/support/src/Xml.php +++ b/src/support/src/Xml.php @@ -54,6 +54,6 @@ public static function toArray(string $xml): array throw new InvalidArgumentException('Syntax error.'); } - return json_decode(json_encode($respObject), true); + return Json::decode(Json::encode($respObject)); } } diff --git a/tests/Foundation/FoundationFileBasedMaintenanceModeTest.php b/tests/Foundation/FoundationFileBasedMaintenanceModeTest.php index 7cd04b1ca..ef0eee9e8 100644 --- a/tests/Foundation/FoundationFileBasedMaintenanceModeTest.php +++ b/tests/Foundation/FoundationFileBasedMaintenanceModeTest.php @@ -6,6 +6,7 @@ use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\FileBasedMaintenanceMode; +use Hypervel\Support\Json; use Hypervel\Testbench\TestCase; use JsonException; use Mockery as m; @@ -20,14 +21,14 @@ protected function tearDown(): void parent::tearDown(); } - public function testActiveReturnsFalseWhenFileDoesNotExist() + public function testActiveReturnsFalseWhenFileDoesNotExist(): void { $mode = new FileBasedMaintenanceMode; $this->assertFalse($mode->active()); } - public function testActivateWritesJsonToCorrectPath() + public function testActivateWritesJsonToCorrectPath(): void { $mode = new FileBasedMaintenanceMode; @@ -40,7 +41,7 @@ public function testActivateWritesJsonToCorrectPath() $this->assertSame(60, $data['retry']); } - public function testActiveReturnsTrueWhenFileExists() + public function testActiveReturnsTrueWhenFileExists(): void { $mode = new FileBasedMaintenanceMode; @@ -49,7 +50,7 @@ public function testActiveReturnsTrueWhenFileExists() $this->assertTrue($mode->active()); } - public function testDataReturnsDecodedPayload() + public function testDataReturnsDecodedPayload(): void { $mode = new FileBasedMaintenanceMode; @@ -62,7 +63,7 @@ public function testDataReturnsDecodedPayload() $this->assertNull($data['retry']); } - public function testDeactivateDeletesFile() + public function testDeactivateDeletesFile(): void { $mode = new FileBasedMaintenanceMode; @@ -74,7 +75,7 @@ public function testDeactivateDeletesFile() $this->assertFileDoesNotExist(storage_path('framework/down')); } - public function testDeactivateDoesNothingWhenNotActive() + public function testDeactivateDoesNothingWhenNotActive(): void { $mode = new FileBasedMaintenanceMode; @@ -138,6 +139,33 @@ public function testDataRejectsNonArrayJson(): void (new FileBasedMaintenanceMode)->data(); } + public function testPayloadRoundTripsAtTheMaximumSupportedNestingDepth(): void + { + $value = 'leaf'; + + for ($index = 1; $index < Json::MAXIMUM_NESTING_DEPTH; ++$index) { + $value = ['value' => $value]; + } + + $mode = new FileBasedMaintenanceMode; + $mode->activate(['nested' => $value]); + + $this->assertSame(['nested' => $value], $mode->data()); + } + + public function testActivateRejectsOneLevelOverTheMaximumNestingDepth(): void + { + $value = 'leaf'; + + for ($index = 0; $index < Json::MAXIMUM_NESTING_DEPTH; ++$index) { + $value = ['value' => $value]; + } + + $this->expectException(JsonException::class); + + (new FileBasedMaintenanceMode)->activate(['nested' => $value]); + } + public function testDeactivateFailsWhenDeleteReturnsFalseAndFileRemains(): void { $path = storage_path('framework/down'); diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index dcd989e21..87c10b5a1 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -40,6 +40,7 @@ use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Collection; use Hypervel\Support\Fluent; +use Hypervel\Support\Json; use Hypervel\Support\Sleep; use Hypervel\Support\Str; use Hypervel\Support\Stringable; @@ -1380,6 +1381,44 @@ public function testRequestDataPreservesMalformedJsonDiagnostics(): void $request->data(); } + public function testRequestDataReadsTheMaximumSupportedNestingDepth(): void + { + $value = 'leaf'; + + for ($index = 1; $index < Json::MAXIMUM_NESTING_DEPTH; ++$index) { + $value = ['value' => $value]; + } + + $request = new Request(new GuzzleRequest( + 'POST', + 'https://example.test', + ['Content-Type' => 'application/json'], + Json::encode(['nested' => $value]), + )); + + $this->assertSame(['nested' => $value], $request->data()); + } + + public function testRequestDataRejectsOneLevelOverTheMaximumNestingDepth(): void + { + $value = 'leaf'; + + for ($index = 0; $index < Json::MAXIMUM_NESTING_DEPTH; ++$index) { + $value = ['value' => $value]; + } + + $request = new Request(new GuzzleRequest( + 'POST', + 'https://example.test', + ['Content-Type' => 'application/json'], + json_encode(['nested' => $value], JSON_THROW_ON_ERROR, Json::MAXIMUM_NESTING_DEPTH + 1), + )); + + $this->expectException(JsonException::class); + + $request->data(); + } + #[DataProvider('emptyJsonReadMethodProvider')] public function testJsonReadRequestsWithoutABodyHaveEmptyData(string $method): void { diff --git a/tests/Http/HttpJsonResponseTest.php b/tests/Http/HttpJsonResponseTest.php index 7d2356cf8..0f38a1282 100644 --- a/tests/Http/HttpJsonResponseTest.php +++ b/tests/Http/HttpJsonResponseTest.php @@ -7,6 +7,7 @@ use Hypervel\Contracts\Support\Arrayable; use Hypervel\Contracts\Support\Jsonable; use Hypervel\Http\JsonResponse; +use Hypervel\Support\Json; use Hypervel\Tests\TestCase; use InvalidArgumentException; use JsonSerializable; @@ -110,6 +111,37 @@ public function testFromJsonString(): void $this->assertSame('bar', $response->getData()->foo); } + + public function testDataRoundTripsAtTheMaximumSupportedNestingDepth(): void + { + $value = 'leaf'; + + for ($index = 1; $index < Json::MAXIMUM_NESTING_DEPTH; ++$index) { + $value = ['value' => $value]; + } + + $response = new JsonResponse(['nested' => $value]); + + $this->assertSame(['nested' => $value], $response->getData(assoc: true)); + + $response->setEncodingOptions(JSON_UNESCAPED_SLASHES); + + $this->assertSame(['nested' => $value], $response->getData(assoc: true)); + $this->assertNotSame('null', $response->getContent()); + } + + public function testDataRejectsOneLevelOverTheMaximumNestingDepth(): void + { + $value = 'leaf'; + + for ($index = 0; $index < Json::MAXIMUM_NESTING_DEPTH; ++$index) { + $value = ['value' => $value]; + } + + $this->expectException(InvalidArgumentException::class); + + new JsonResponse(['nested' => $value]); + } } class JsonResponseTestJsonableObject implements Jsonable diff --git a/tests/Inertia/Testing/AssertableInertiaTest.php b/tests/Inertia/Testing/AssertableInertiaTest.php index 0016bd0f5..5dd51c73f 100644 --- a/tests/Inertia/Testing/AssertableInertiaTest.php +++ b/tests/Inertia/Testing/AssertableInertiaTest.php @@ -9,7 +9,9 @@ use Hypervel\Inertia\Testing\AssertableInertia; use Hypervel\Session\Middleware\StartSession; use Hypervel\Support\Facades\Route; +use Hypervel\Support\Json; use Hypervel\Tests\Inertia\TestCase; +use JsonException; use PHPUnit\Framework\AssertionFailedError; class AssertableInertiaTest extends TestCase @@ -34,6 +36,34 @@ public function testTheViewIsNotServedByInertia(): void $response->assertInertia(); } + public function testPagePropsRoundTripAtTheMaximumSupportedNestingDepth(): void + { + $value = 'leaf'; + + for ($index = 0; $index < Json::MAXIMUM_NESTING_DEPTH - 2; ++$index) { + $value = ['value' => $value]; + } + + $response = $this->makeMockRequest(Inertia::render('foo', ['nested' => $value])); + + $response->assertInertia(fn (AssertableInertia $page) => $page->where('nested', $value)); + } + + public function testPagePropsOverTheMaximumNestingDepthRaiseJsonException(): void + { + $value = 'leaf'; + + for ($index = 0; $index < Json::MAXIMUM_NESTING_DEPTH - 1; ++$index) { + $value = ['value' => $value]; + } + + $response = $this->makeMockRequest(Inertia::render('foo', ['nested' => $value])); + + $this->expectException(JsonException::class); + + $response->assertInertia(); + } + public function testTheComponentMatches(): void { $response = $this->makeMockRequest( diff --git a/tests/Session/SessionStoreTest.php b/tests/Session/SessionStoreTest.php index b5bec1f1d..019a28a23 100644 --- a/tests/Session/SessionStoreTest.php +++ b/tests/Session/SessionStoreTest.php @@ -10,6 +10,7 @@ use Hypervel\Http\Request; use Hypervel\Session\CookieSessionHandler; use Hypervel\Session\Store; +use Hypervel\Support\Json; use Hypervel\Support\MessageBag; use Hypervel\Support\Str; use Hypervel\Support\Uri; @@ -1015,6 +1016,60 @@ public function testConsecutiveJsonSavesKeepTheLiveErrorBag(): void $this->assertInstanceOf(ViewErrorBag::class, $session->get('errors')); } + public function testJsonSessionRoundTripsAtTheMaximumSupportedNestingDepth(): void + { + $value = 'leaf'; + + for ($index = 1; $index < Json::MAXIMUM_NESTING_DEPTH; ++$index) { + $value = ['value' => $value]; + } + + $storedPayload = null; + $writer = m::mock(SessionHandlerInterface::class); + $writer->shouldReceive('read')->once()->andReturn('{}'); + $writer->shouldReceive('write')->once()->andReturnUsing( + function (string $sessionId, string $payload) use (&$storedPayload): bool { + $storedPayload = $payload; + + return true; + } + ); + + $session = new Store('name', $writer, $this->getSessionId(), 'json'); + $session->start(); + $session->put('nested', $value); + $session->save(); + + $reader = m::mock(SessionHandlerInterface::class); + $reader->shouldReceive('read')->once()->andReturn($storedPayload); + + $restored = new Store('name', $reader, $this->getSessionId(), 'json'); + $restored->start(); + + $this->assertSame($value, $restored->get('nested')); + } + + public function testJsonSessionRejectsOneLevelOverTheMaximumNestingDepth(): void + { + $value = 'leaf'; + + for ($index = 0; $index < Json::MAXIMUM_NESTING_DEPTH; ++$index) { + $value = ['value' => $value]; + } + + $handler = m::mock(SessionHandlerInterface::class); + $handler->shouldReceive('read')->once()->andReturn('{}'); + $handler->shouldReceive('write')->never(); + + $session = new Store('name', $handler, $this->getSessionId(), 'json'); + $session->start(); + $session->put('nested', $value); + + $this->expectException(JsonException::class); + + $session->save(); + } + public function testStartingJsonSessionRetainsLiveErrorBagWhenStorageHasNone(): void { $handler = m::mock(SessionHandlerInterface::class); diff --git a/tests/Support/XmlTest.php b/tests/Support/XmlTest.php index f074061d0..41f0c8034 100644 --- a/tests/Support/XmlTest.php +++ b/tests/Support/XmlTest.php @@ -7,6 +7,7 @@ use Hypervel\Support\Xml; use Hypervel\Tests\TestCase; use InvalidArgumentException; +use JsonException; class XmlTest extends TestCase { @@ -54,4 +55,31 @@ public function testXmlFailed(): void $this->expectException(InvalidArgumentException::class); Xml::toArray('{"hype'); } + + public function testToArrayReadsAParsedDocumentWith512JsonContainers(): void + { + $expected = []; + + for ($level = 0; $level < 255; ++$level) { + $expected = ['n' => [['a' => []], $expected]]; + } + + $this->assertSame($expected, Xml::toArray($this->siblingProjectedXml())); + } + + public function testToArrayRejectsAParsedDocumentWith513JsonContainers(): void + { + $this->expectException(JsonException::class); + + Xml::toArray($this->siblingProjectedXml('')); + } + + private function siblingProjectedXml(string $innermost = ''): string + { + for ($level = 255; $level >= 1; --$level) { + $innermost = "{$innermost}"; + } + + return '' . $innermost . ''; + } } From 7982f8604138a407f00da38e9f22cbf6fcce5c91 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:28:27 +0000 Subject: [PATCH 06/15] refactor(concurrency): own serialized closure responses Move the serialized-closure command to Concurrency and centralize response-envelope decoding, remote exception reconstruction, binary result handling, and malformed transport errors in SerializedClosureResult. Delegate ProcessDriver and Testbench process results to the shared decoder, declare their direct package dependencies, and move the command, fixture, and process tests to the owning package. Preserve raw non-closure output and transport-specific encoding behavior. --- src/concurrency/composer.json | 3 +- .../InvokeSerializedClosureCommand.php | 3 +- src/concurrency/src/ProcessDriver.php | 66 +--- .../src/SerializedClosureResult.php | 85 +++++ .../Providers/FoundationServiceProvider.php | 2 +- src/testbench/composer.json | 1 + .../src/Foundation/Process/ProcessResult.php | 64 +--- .../Concurrency/ConcurrencyTest.php | 206 +----------- .../InvokeSerializedClosureCommandTest.php | 69 +++- .../ConcurrentProcessExceptionFixtures.php | 30 +- tests/Concurrency/PackageMetadataTest.php | 8 +- .../SerializedClosureResultTest.php | 296 ++++++++++++++++++ .../Foundation/Process/ProcessResultTest.php | 272 +--------------- tests/Testbench/PackageMetadataTest.php | 4 + 14 files changed, 503 insertions(+), 606 deletions(-) rename src/{foundation => concurrency}/src/Console/InvokeSerializedClosureCommand.php (97%) create mode 100644 src/concurrency/src/SerializedClosureResult.php rename tests/{Integration => }/Concurrency/ConcurrencyTest.php (68%) rename tests/{Foundation => Concurrency}/Console/InvokeSerializedClosureCommandTest.php (85%) rename tests/{Foundation/Console => Concurrency}/Fixtures/ConcurrentProcessExceptionFixtures.php (87%) create mode 100644 tests/Concurrency/SerializedClosureResultTest.php diff --git a/src/concurrency/composer.json b/src/concurrency/composer.json index 5b5fe055a..6002558d5 100644 --- a/src/concurrency/composer.json +++ b/src/concurrency/composer.json @@ -39,7 +39,8 @@ "hypervel/coroutine": "^0.4", "hypervel/process": "^0.4", "hypervel/support": "^0.4", - "nesbot/carbon": "^3.13.1" + "nesbot/carbon": "^3.13.1", + "symfony/console": "^8.1" }, "config": { "sort-packages": true diff --git a/src/foundation/src/Console/InvokeSerializedClosureCommand.php b/src/concurrency/src/Console/InvokeSerializedClosureCommand.php similarity index 97% rename from src/foundation/src/Console/InvokeSerializedClosureCommand.php rename to src/concurrency/src/Console/InvokeSerializedClosureCommand.php index 84e9080a8..f8e4b34d7 100644 --- a/src/foundation/src/Console/InvokeSerializedClosureCommand.php +++ b/src/concurrency/src/Console/InvokeSerializedClosureCommand.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Foundation\Console; +namespace Hypervel\Concurrency\Console; use Error; use Exception; @@ -53,6 +53,7 @@ public function handle(): int if ($parameters !== null) { // Named arguments must survive JSON without changing types or nested state. + // This subtree is one container shallower than the envelope decoded at native depth 513. $encodedParameters = json_encode($parameters, self::JSON_FLAGS); if (json_decode($encodedParameters, true, 512, JSON_THROW_ON_ERROR) !== $parameters) { diff --git a/src/concurrency/src/ProcessDriver.php b/src/concurrency/src/ProcessDriver.php index 58c6914cd..929bfe85a 100644 --- a/src/concurrency/src/ProcessDriver.php +++ b/src/concurrency/src/ProcessDriver.php @@ -14,8 +14,6 @@ use Hypervel\Support\Arr; use Hypervel\Support\Defer\DeferredCallback; use Laravel\SerializableClosure\SerializableClosure; -use RuntimeException; -use Throwable; use function Hypervel\Support\defer; @@ -55,69 +53,7 @@ public function run(Closure|array $tasks, CarbonInterval|int|null $timeout = nul throw new Exception('Concurrent process failed with exit code [' . $result->exitCode() . ']. Message: ' . $result->errorOutput()); } - $output = $result->output(); - - if (($position = strpos($output, "\x1f\x8b")) !== false) { - $output = substr($output, 0, $position); - } - - $payload = json_decode($output, true, 512, JSON_THROW_ON_ERROR); - - if (! is_array($payload) - || ! array_key_exists('successful', $payload) - || ! is_bool($payload['successful'])) { - throw new RuntimeException('Invalid concurrent process response envelope.'); - } - - /** @var array{ - * successful: bool, - * result?: string, - * exception?: class-string, - * message?: string, - * parameters?: array - * } $payload - */ - if ($payload['successful'] === false) { - if ((array_key_exists('exception', $payload) && ! is_string($payload['exception'])) - || (array_key_exists('message', $payload) && ! is_string($payload['message'])) - || (array_key_exists('parameters', $payload) && ! is_array($payload['parameters']))) { - throw new RuntimeException('Invalid concurrent process response envelope.'); - } - - $exceptionClass = $payload['exception'] ?? RuntimeException::class; - $message = $payload['message'] ?? 'Serialized closure execution failed.'; - $parameters = $payload['parameters'] ?? ['message' => $message]; - - try { - $exception = new $exceptionClass(...$parameters); - } catch (Throwable $constructionException) { - throw new RuntimeException($message, previous: $constructionException); - } - - if (! $exception instanceof Throwable) { - throw new RuntimeException($message); - } - - throw $exception; - } - - $encodedResult = $payload['result'] ?? null; - $serializedResult = is_string($encodedResult) - ? base64_decode($encodedResult, true) - : false; - - if ($serializedResult === false) { - throw new RuntimeException('Unable to decode the concurrent process result.'); - } - - // Malformed payloads warn and return false, which is also a valid serialized result. - $unserializedResult = @unserialize($serializedResult); - - if ($unserializedResult === false && $serializedResult !== serialize(false)) { - throw new RuntimeException('Unable to decode the concurrent process result.'); - } - - return [$key => $unserializedResult]; + return [$key => SerializedClosureResult::decode($result->output())]; })->all(); } diff --git a/src/concurrency/src/SerializedClosureResult.php b/src/concurrency/src/SerializedClosureResult.php new file mode 100644 index 000000000..151b3d3a6 --- /dev/null +++ b/src/concurrency/src/SerializedClosureResult.php @@ -0,0 +1,85 @@ +, + * message?: string, + * parameters?: array + * } $payload + */ + if ($payload['successful'] === false) { + if ((array_key_exists('exception', $payload) && ! is_string($payload['exception'])) + || (array_key_exists('message', $payload) && ! is_string($payload['message'])) + || (array_key_exists('parameters', $payload) && ! is_array($payload['parameters']))) { + throw new RuntimeException('Invalid serialized closure response envelope.'); + } + + $exceptionClass = $payload['exception'] ?? RuntimeException::class; + $message = $payload['message'] ?? 'Serialized closure execution failed.'; + $parameters = $payload['parameters'] ?? ['message' => $message]; + + try { + $exception = new $exceptionClass(...$parameters); + } catch (Throwable $constructionException) { + throw new RuntimeException($message, previous: $constructionException); + } + + if (! $exception instanceof Throwable) { + throw new RuntimeException($message); + } + + throw $exception; + } + + $encodedResult = $payload['result'] ?? null; + $serializedResult = is_string($encodedResult) + ? base64_decode($encodedResult, true) + : false; + + if ($serializedResult === false) { + throw new RuntimeException('Unable to decode the serialized closure result.'); + } + + // Malformed payloads warn and return false, which is also a valid serialized result. + $unserializedResult = @unserialize($serializedResult); + + if ($unserializedResult === false && $serializedResult !== serialize(false)) { + throw new RuntimeException('Unable to decode the serialized closure result.'); + } + + return $unserializedResult; + } +} diff --git a/src/foundation/src/Providers/FoundationServiceProvider.php b/src/foundation/src/Providers/FoundationServiceProvider.php index 7bb4f0d8e..440adfe89 100644 --- a/src/foundation/src/Providers/FoundationServiceProvider.php +++ b/src/foundation/src/Providers/FoundationServiceProvider.php @@ -5,6 +5,7 @@ namespace Hypervel\Foundation\Providers; use Carbon\FactoryImmutable; +use Hypervel\Concurrency\Console\InvokeSerializedClosureCommand; use Hypervel\Config\Repository; use Hypervel\Console\Events\CommandFinished; use Hypervel\Console\Scheduling\Schedule; @@ -48,7 +49,6 @@ use Hypervel\Foundation\Console\EventMakeCommand; use Hypervel\Foundation\Console\ExceptionMakeCommand; use Hypervel\Foundation\Console\InterfaceMakeCommand; -use Hypervel\Foundation\Console\InvokeSerializedClosureCommand; use Hypervel\Foundation\Console\JobMakeCommand; use Hypervel\Foundation\Console\JobMiddlewareMakeCommand; use Hypervel\Foundation\Console\LangPublishCommand; diff --git a/src/testbench/composer.json b/src/testbench/composer.json index 4dd9aa450..68c2afdf6 100644 --- a/src/testbench/composer.json +++ b/src/testbench/composer.json @@ -33,6 +33,7 @@ "symfony/yaml": "^8.1", "vlucas/phpdotenv": "^5.6.1", "hypervel/collections": "^0.4", + "hypervel/concurrency": "^0.4", "hypervel/console": "^0.4", "hypervel/context": "^0.4", "hypervel/contracts": "^0.4", diff --git a/src/testbench/src/Foundation/Process/ProcessResult.php b/src/testbench/src/Foundation/Process/ProcessResult.php index d50266961..c1bc04ccc 100644 --- a/src/testbench/src/Foundation/Process/ProcessResult.php +++ b/src/testbench/src/Foundation/Process/ProcessResult.php @@ -6,10 +6,10 @@ use BadMethodCallException; use Closure; +use Hypervel\Concurrency\SerializedClosureResult; use Hypervel\Process\Exceptions\ProcessFailedException; use Hypervel\Process\ProcessResult as BaseProcessResult; use Hypervel\Support\Traits\ForwardsCalls; -use RuntimeException; use Symfony\Component\Process\Process; use Throwable; @@ -92,67 +92,7 @@ public function output(): mixed return $output; } - if (($position = strpos($output, "\x1f\x8b")) !== false) { - $output = substr($output, 0, $position); - } - - $result = json_decode($output, true, 512, JSON_THROW_ON_ERROR); - - if (! is_array($result) - || ! array_key_exists('successful', $result) - || ! is_bool($result['successful'])) { - throw new RuntimeException('Invalid remote process response envelope.'); - } - - /** @var array{ - * successful: bool, - * result?: string, - * exception?: class-string, - * message?: string, - * parameters?: array - * } $result - */ - if ($result['successful'] === false) { - if ((array_key_exists('exception', $result) && ! is_string($result['exception'])) - || (array_key_exists('message', $result) && ! is_string($result['message'])) - || (array_key_exists('parameters', $result) && ! is_array($result['parameters']))) { - throw new RuntimeException('Invalid remote process response envelope.'); - } - - $exceptionClass = $result['exception'] ?? RuntimeException::class; - $message = $result['message'] ?? 'Serialized closure execution failed.'; - $parameters = $result['parameters'] ?? ['message' => $message]; - - try { - $exception = new $exceptionClass(...$parameters); - } catch (Throwable $constructionException) { - throw new RuntimeException($message, previous: $constructionException); - } - - if (! $exception instanceof Throwable) { - throw new RuntimeException($message); - } - - throw $exception; - } - - $encodedResult = $result['result'] ?? null; - $serializedResult = is_string($encodedResult) - ? base64_decode($encodedResult, true) - : false; - - if ($serializedResult === false) { - throw new RuntimeException('Unable to decode the remote process result.'); - } - - // Malformed payloads warn and return false, which is also a valid serialized result. - $unserializedResult = @unserialize($serializedResult); - - if ($unserializedResult === false && $serializedResult !== serialize(false)) { - throw new RuntimeException('Unable to decode the remote process result.'); - } - - return $unserializedResult; + return SerializedClosureResult::decode($output); } /** diff --git a/tests/Integration/Concurrency/ConcurrencyTest.php b/tests/Concurrency/ConcurrencyTest.php similarity index 68% rename from tests/Integration/Concurrency/ConcurrencyTest.php rename to tests/Concurrency/ConcurrencyTest.php index 9e32a358d..1381fed57 100644 --- a/tests/Integration/Concurrency/ConcurrencyTest.php +++ b/tests/Concurrency/ConcurrencyTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Integration\Concurrency; +namespace Hypervel\Tests\Concurrency; use Carbon\CarbonInterval; use Exception; @@ -18,13 +18,10 @@ use Hypervel\Support\Defer\DeferredCallbackCollection; use Hypervel\Support\Facades\Concurrency as ConcurrencyFacade; use Hypervel\Testbench\TestCase; +use Hypervel\Tests\Concurrency\Fixtures\ConcurrentProcessExceptionFixtures; use Hypervel\Tests\Context\Fixtures\ThrowingReplicableContext; -use Hypervel\Tests\Foundation\Console\Fixtures\ConcurrentProcessExceptionFixtures; -use JsonException; use RuntimeException; -use stdClass; use Swoole\Coroutine as SwooleCoroutine; -use TypeError; class ConcurrencyTest extends TestCase { @@ -451,86 +448,6 @@ public function testProcessDriverSetsEnvironmentVariable() }); } - public function testProcessDriverReturnsBinaryResultsLosslessly(): void - { - $driver = $this->processDriverFor([ - 'successful' => true, - 'result' => base64_encode(serialize("binary-\xFF\x00\x8B")), - ]); - - $this->assertSame(["binary-\xFF\x00\x8B"], $driver->run(static fn () => null)); - } - - public function testProcessDriverIgnoresAppendedGzipOutput(): void - { - $driver = $this->processDriverFor([ - 'successful' => true, - 'result' => base64_encode(serialize('result')), - ], "\x1f\x8bcompressed-output"); - - $this->assertSame(['result'], $driver->run(static fn () => null)); - } - - public function testProcessDriverRejectsMalformedJsonOutput(): void - { - $this->expectException(JsonException::class); - - $this->processDriverForOutput('{malformed')->run(static fn () => null); - } - - public function testProcessDriverRejectsInvalidResponseEnvelopes(): void - { - $outputs = [ - 'scalar' => json_encode('invalid', JSON_THROW_ON_ERROR), - 'missing status' => json_encode([], JSON_THROW_ON_ERROR), - 'non-boolean status' => json_encode(['successful' => 1], JSON_THROW_ON_ERROR), - 'non-string exception' => json_encode(['successful' => false, 'exception' => []], JSON_THROW_ON_ERROR), - 'non-string message' => json_encode(['successful' => false, 'message' => []], JSON_THROW_ON_ERROR), - 'non-array parameters' => json_encode(['successful' => false, 'parameters' => 'invalid'], JSON_THROW_ON_ERROR), - ]; - - foreach ($outputs as $description => $output) { - try { - $this->processDriverForOutput($output)->run(static fn () => null); - $this->fail("Expected the {$description} response envelope to be rejected."); - } catch (RuntimeException $exception) { - $this->assertSame('Invalid concurrent process response envelope.', $exception->getMessage()); - } - } - } - - public function testProcessDriverRejectsMalformedBase64Results(): void - { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Unable to decode the concurrent process result.'); - - $this->processDriverFor([ - 'successful' => true, - 'result' => '*not-base64*', - ])->run(static fn () => null); - } - - public function testProcessDriverRejectsMalformedSerializedResults(): void - { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Unable to decode the concurrent process result.'); - - $this->processDriverFor([ - 'successful' => true, - 'result' => base64_encode('not-serialized'), - ])->run(static fn () => null); - } - - public function testProcessDriverPreservesFalseResults(): void - { - $driver = $this->processDriverFor([ - 'successful' => true, - 'result' => base64_encode(serialize(false)), - ]); - - $this->assertSame([false], $driver->run(static fn () => null)); - } - public function testProcessDriverPreservesPublicFalseyExceptionParameters(): void { $driver = $this->processDriverFor([ @@ -557,104 +474,17 @@ public function testProcessDriverPreservesPublicFalseyExceptionParameters(): voi } } - public function testProcessDriverUsesInaccessibleOptionalDefaults(): void + public function testProcessDriverReportsFailedChildProcessesBeforeDecoding(): void { - $driver = $this->processDriverFor([ - 'successful' => false, - 'exception' => ConcurrentProcessExceptionFixtures::HIDDEN_OPTIONAL_EXCEPTION, - 'message' => 'status=7', - 'parameters' => ['status' => 0], - ]); - - try { - $driver->run(static fn () => null); - $this->fail('Expected the transported exception to be thrown.'); - } catch (Exception $exception) { - $this->assertSame(ConcurrentProcessExceptionFixtures::HIDDEN_OPTIONAL_EXCEPTION, $exception::class); - $this->assertSame('status=0', $exception->getMessage()); - } - } - - public function testProcessDriverReconstructsNamedVariadicAndInheritedParameters(): void - { - $variadic = $this->processDriverFor([ - 'successful' => false, - 'exception' => ConcurrentProcessExceptionFixtures::VARIADIC_EXCEPTION, - 'message' => 'context:first,second', - 'parameters' => ['context' => 'context'], - ]); - - try { - $variadic->run(static fn () => null); - $this->fail('Expected the transported exception to be thrown.'); - } catch (Exception $exception) { - $this->assertSame(ConcurrentProcessExceptionFixtures::VARIADIC_EXCEPTION, $exception::class); - $this->assertSame('context:', $exception->getMessage()); - } - - $inherited = $this->processDriverFor([ - 'successful' => false, - 'exception' => ConcurrentProcessExceptionFixtures::INHERITED_PUBLIC_EXCEPTION, - 'message' => 'status=7', - 'parameters' => ['status' => 7], - ]); - - try { - $inherited->run(static fn () => null); - $this->fail('Expected the transported exception to be thrown.'); - } catch (Exception $exception) { - $this->assertSame(ConcurrentProcessExceptionFixtures::INHERITED_PUBLIC_EXCEPTION, $exception::class); - $this->assertSame('status=7', $exception->getMessage()); - } - } - - public function testProcessDriverReconstructsZeroArgumentExceptionsWithoutSyntheticArguments(): void - { - $driver = $this->processDriverFor([ - 'successful' => false, - 'exception' => ConcurrentProcessExceptionFixtures::ZERO_ARGUMENT_EXCEPTION, - 'message' => 'zero arguments', - 'parameters' => [], - ]); - - try { - $driver->run(static fn () => null); - $this->fail('Expected the transported exception to be thrown.'); - } catch (Exception $exception) { - $this->assertSame(ConcurrentProcessExceptionFixtures::ZERO_ARGUMENT_EXCEPTION, $exception::class); - $this->assertSame(0, $exception->argumentCount); - } - } - - public function testProcessDriverContainsConstructorFailuresDuringReconstruction(): void - { - $driver = $this->processDriverFor([ - 'successful' => false, - 'exception' => ConcurrentProcessExceptionFixtures::MISMATCHED_PUBLIC_PROPERTY_EXCEPTION, - 'message' => 'status=5', - 'parameters' => ['status' => 'v5'], - ]); - - try { - $driver->run(static fn () => null); - $this->fail('Expected the transported exception to be thrown.'); - } catch (RuntimeException $exception) { - $this->assertSame('status=5', $exception->getMessage()); - $this->assertInstanceOf(TypeError::class, $exception->getPrevious()); - } - } - - public function testProcessDriverRejectsNonThrowableExceptionClasses(): void - { - $driver = $this->processDriverFor([ - 'successful' => false, - 'exception' => stdClass::class, - 'message' => 'remote failure', - 'parameters' => [], - ]); + $factory = $this->app->make(ProcessFactory::class); + $factory->fake(fn () => $factory->result( + errorOutput: 'child failed', + exitCode: 5, + )); + $driver = new ProcessDriver($factory); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('remote failure'); + $this->expectException(Exception::class); + $this->expectExceptionMessage('Concurrent process failed with exit code [5]. Message: child failed'); $driver->run(static fn () => null); } @@ -696,18 +526,12 @@ public function testCoroutineAndSyncDriversAcceptProcessOnlyTimeouts(): void * * @param array $payload */ - private function processDriverFor(array $payload, string $suffix = ''): ProcessDriver - { - return $this->processDriverForOutput(json_encode($payload, JSON_THROW_ON_ERROR) . $suffix); - } - - /** - * Create a process driver that returns the given output. - */ - private function processDriverForOutput(string $output): ProcessDriver + private function processDriverFor(array $payload): ProcessDriver { $factory = $this->app->make(ProcessFactory::class); - $factory->fake(fn () => $factory->result(output: $output)); + $factory->fake(fn () => $factory->result( + output: json_encode($payload, JSON_THROW_ON_ERROR) + )); return new ProcessDriver($factory); } diff --git a/tests/Foundation/Console/InvokeSerializedClosureCommandTest.php b/tests/Concurrency/Console/InvokeSerializedClosureCommandTest.php similarity index 85% rename from tests/Foundation/Console/InvokeSerializedClosureCommandTest.php rename to tests/Concurrency/Console/InvokeSerializedClosureCommandTest.php index fd664d304..724c64e53 100644 --- a/tests/Foundation/Console/InvokeSerializedClosureCommandTest.php +++ b/tests/Concurrency/Console/InvokeSerializedClosureCommandTest.php @@ -2,14 +2,16 @@ declare(strict_types=1); -namespace Hypervel\Tests\Foundation\Console; +namespace Hypervel\Tests\Concurrency\Console; use Closure; use ErrorException; +use Hypervel\Concurrency\SerializedClosureResult; use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use Hypervel\Support\Facades\Artisan; +use Hypervel\Support\Json; use Hypervel\Testbench\TestCase; -use Hypervel\Tests\Foundation\Console\Fixtures\ConcurrentProcessExceptionFixtures; +use Hypervel\Tests\Concurrency\Fixtures\ConcurrentProcessExceptionFixtures; use Laravel\SerializableClosure\SerializableClosure; use Mockery as m; use RuntimeException; @@ -28,7 +30,7 @@ public function testItCanInvokeSerializedClosureFromArgument(): void ], $output); /** @var array{successful: bool, result: string} $result */ - $result = json_decode($output->fetch(), true); + $result = Json::decode($output->fetch()); $this->assertTrue($result['successful']); $this->assertSame('Hello, World!', $this->decodeResult($result)); @@ -49,7 +51,7 @@ public function testItCanInvokeSerializedClosureFromEnvironment(): void Artisan::call('invoke-serialized-closure', [], $output); /** @var array{successful: bool, result: string} $result */ - $result = json_decode($output->fetch(), true); + $result = Json::decode($output->fetch()); $this->assertTrue($result['successful']); $this->assertSame('From Environment', $this->decodeResult($result)); @@ -69,7 +71,7 @@ public function testItReturnsNullWhenNoClosureIsProvided(): void Artisan::call('invoke-serialized-closure', [], $output); /** @var array{successful: bool, result: string} $result */ - $result = json_decode($output->fetch(), true); + $result = Json::decode($output->fetch()); $this->assertTrue($result['successful']); $this->assertNull($this->decodeResult($result)); @@ -88,7 +90,7 @@ public function testItHandlesExceptionsGracefully(): void ], $output); /** @var array{successful: bool, exception: string, message: string, parameters: array} $result */ - $result = json_decode($output->fetch(), true); + $result = Json::decode($output->fetch()); $this->assertFalse($result['successful']); $this->assertSame(RuntimeException::class, $result['exception']); @@ -109,7 +111,7 @@ public function testItHandlesCustomExceptionWithParameters(): void ], $output); /** @var array{successful: bool, exception: string, parameters: array} $result */ - $result = json_decode($output->fetch(), true); + $result = Json::decode($output->fetch()); $this->assertFalse($result['successful']); $this->assertSame(InvokeSerializedClosureCustomParameterException::class, $result['exception']); @@ -219,6 +221,35 @@ public function testItPreservesJsonStableFloatParameters(): void $this->assertSame(['value' => 1.0], $result['parameters']); } + public function testItTransportsTheMaximumReconstructibleExceptionParameterDepth(): void + { + $value = $this->nestedValue(510); + $output = $this->invokeSerializedClosureOutput( + static fn () => ConcurrentProcessExceptionFixtures::throwPublicValue($value) + ); + + try { + SerializedClosureResult::decode($output); + $this->fail('Expected the transported exception to be thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame(ConcurrentProcessExceptionFixtures::PUBLIC_VALUE_EXCEPTION, $exception::class); + $this->assertSame($value, $exception->value); + } + } + + public function testItDegradesExceptionParametersBeyondTheTransportDepth(): void + { + $value = $this->nestedValue(511); + $result = $this->invokeSerializedClosure( + static fn () => ConcurrentProcessExceptionFixtures::throwPublicValue($value) + ); + + $this->assertSame(RuntimeException::class, $result['exception']); + $this->assertStringContainsString('PublicValueException', $result['message']); + $this->assertStringContainsString('could not be encoded', $result['message']); + $this->assertSame(['message' => $result['message']], $result['parameters']); + } + public function testItPreservesPublicFalseyExceptionParameters(): void { $result = $this->invokeSerializedClosure( @@ -345,6 +376,14 @@ public function testItExtractsNativeDeclaredExceptionConstructors(): void * @return array */ private function invokeSerializedClosure(Closure $closure): array + { + return Json::decode($this->invokeSerializedClosureOutput($closure)); + } + + /** + * Invoke a serialized closure and return its response envelope. + */ + private function invokeSerializedClosureOutput(Closure $closure): string { $output = new BufferedOutput; @@ -352,7 +391,7 @@ private function invokeSerializedClosure(Closure $closure): array 'code' => serialize(new SerializableClosure($closure)), ], $output); - return json_decode($output->fetch(), true, 512, JSON_THROW_ON_ERROR); + return $output->fetch(); } /** @@ -368,6 +407,20 @@ private function decodeResult(array $result): mixed return unserialize($serialized); } + + /** + * Build a value with the given number of array containers. + */ + private function nestedValue(int $containers): array + { + $value = ['leaf']; + + for ($depth = 1; $depth < $containers; ++$depth) { + $value = [$value]; + } + + return $value; + } } class InvokeSerializedClosureCustomParameterException extends RuntimeException diff --git a/tests/Foundation/Console/Fixtures/ConcurrentProcessExceptionFixtures.php b/tests/Concurrency/Fixtures/ConcurrentProcessExceptionFixtures.php similarity index 87% rename from tests/Foundation/Console/Fixtures/ConcurrentProcessExceptionFixtures.php rename to tests/Concurrency/Fixtures/ConcurrentProcessExceptionFixtures.php index 1a5e1fee1..1307be872 100644 --- a/tests/Foundation/Console/Fixtures/ConcurrentProcessExceptionFixtures.php +++ b/tests/Concurrency/Fixtures/ConcurrentProcessExceptionFixtures.php @@ -2,32 +2,32 @@ declare(strict_types=1); -namespace Hypervel\Tests\Foundation\Console\Fixtures; +namespace Hypervel\Tests\Concurrency\Fixtures; use DateTimeImmutable; use RuntimeException; class ConcurrentProcessExceptionFixtures { - public const PUBLIC_FALSEY_EXCEPTION = PublicFalseyValuesException::class; + public const string PUBLIC_FALSEY_EXCEPTION = PublicFalseyValuesException::class; - public const OPTIONAL_MESSAGE_EXCEPTION = OptionalMessageException::class; + public const string OPTIONAL_MESSAGE_EXCEPTION = OptionalMessageException::class; - public const HIDDEN_OPTIONAL_EXCEPTION = HiddenOptionalException::class; + public const string HIDDEN_OPTIONAL_EXCEPTION = HiddenOptionalException::class; - public const VARIADIC_EXCEPTION = VariadicException::class; + public const string VARIADIC_EXCEPTION = VariadicException::class; - public const INHERITED_PUBLIC_EXCEPTION = InheritedPublicException::class; + public const string INHERITED_PUBLIC_EXCEPTION = InheritedPublicException::class; - public const PUBLIC_VALUE_EXCEPTION = PublicValueException::class; + public const string PUBLIC_VALUE_EXCEPTION = PublicValueException::class; - public const ZERO_ARGUMENT_EXCEPTION = ZeroArgumentException::class; + public const string ZERO_ARGUMENT_EXCEPTION = ZeroArgumentException::class; - public const TYPED_STORED_VARIADIC_EXCEPTION = TypedStoredVariadicException::class; + public const string TYPED_STORED_VARIADIC_EXCEPTION = TypedStoredVariadicException::class; - public const UNTYPED_STORED_VARIADIC_EXCEPTION = UntypedStoredVariadicException::class; + public const string UNTYPED_STORED_VARIADIC_EXCEPTION = UntypedStoredVariadicException::class; - public const MISMATCHED_PUBLIC_PROPERTY_EXCEPTION = MismatchedPublicPropertyException::class; + public const string MISMATCHED_PUBLIC_PROPERTY_EXCEPTION = MismatchedPublicPropertyException::class; /** * Throw an exception containing public falsey constructor values. @@ -133,6 +133,14 @@ public static function throwFloatValue(): never throw new PublicValueException(1.0); } + /** + * Throw an exception with the given public constructor value. + */ + public static function throwPublicValue(mixed $value): never + { + throw new PublicValueException($value); + } + /** * Throw an exception with recursive public constructor state. */ diff --git a/tests/Concurrency/PackageMetadataTest.php b/tests/Concurrency/PackageMetadataTest.php index 284a27a42..6d45a4782 100644 --- a/tests/Concurrency/PackageMetadataTest.php +++ b/tests/Concurrency/PackageMetadataTest.php @@ -29,8 +29,10 @@ public function testDependenciesAreDeclared(): void JSON_THROW_ON_ERROR, ); - $this->assertArrayHasKey('nesbot/carbon', $rootComposer['require']); - $this->assertArrayHasKey('nesbot/carbon', $composer['require']); - $this->assertSame($rootComposer['require']['nesbot/carbon'], $composer['require']['nesbot/carbon']); + foreach (['nesbot/carbon', 'symfony/console'] as $dependency) { + $this->assertArrayHasKey($dependency, $rootComposer['require']); + $this->assertArrayHasKey($dependency, $composer['require']); + $this->assertSame($rootComposer['require'][$dependency], $composer['require'][$dependency]); + } } } diff --git a/tests/Concurrency/SerializedClosureResultTest.php b/tests/Concurrency/SerializedClosureResultTest.php new file mode 100644 index 000000000..32ac2bf0a --- /dev/null +++ b/tests/Concurrency/SerializedClosureResultTest.php @@ -0,0 +1,296 @@ +assertSame( + "binary-\xFF\x00\x8B", + $this->decodeResult("binary-\xFF\x00\x8B") + ); + $this->assertFalse($this->decodeResult(false)); + } + + public function testItIgnoresAppendedGzipOutput(): void + { + $this->assertSame( + 'result', + $this->decodePayload([ + 'successful' => true, + 'result' => base64_encode(serialize('result')), + ], "\x1f\x8bcompressed-output") + ); + } + + public function testItRejectsMalformedJsonOutput(): void + { + $this->expectException(JsonException::class); + + SerializedClosureResult::decode('{malformed'); + } + + public function testItRejectsInvalidResponseEnvelopes(): void + { + $payloads = [ + 'scalar' => 'invalid', + 'missing status' => [], + 'non-boolean status' => ['successful' => 1], + 'non-string exception' => ['successful' => false, 'exception' => []], + 'non-string message' => ['successful' => false, 'message' => []], + 'non-array parameters' => ['successful' => false, 'parameters' => 'invalid'], + ]; + + foreach ($payloads as $description => $payload) { + try { + SerializedClosureResult::decode(Json::encode($payload)); + $this->fail("Expected the {$description} response envelope to be rejected."); + } catch (RuntimeException $exception) { + $this->assertSame('Invalid serialized closure response envelope.', $exception->getMessage()); + } + } + } + + public function testItRejectsMalformedEncodedAndSerializedResults(): void + { + foreach ([ + 'base64' => '*not-base64*', + 'serialized value' => base64_encode('not-serialized'), + ] as $description => $result) { + try { + $this->decodePayload([ + 'successful' => true, + 'result' => $result, + ]); + $this->fail("Expected the malformed {$description} to be rejected."); + } catch (RuntimeException $exception) { + $this->assertSame('Unable to decode the serialized closure result.', $exception->getMessage()); + } + } + } + + public function testItPreservesPublicFalseyExceptionParameters(): void + { + try { + $this->decodePayload([ + 'successful' => false, + 'exception' => ConcurrentProcessExceptionFixtures::PUBLIC_FALSEY_EXCEPTION, + 'message' => 'public falsey values', + 'parameters' => [ + 'status' => 0, + 'retry' => false, + 'reason' => '', + 'detail' => null, + ], + ]); + $this->fail('Expected the transported exception to be thrown.'); + } catch (Exception $exception) { + $this->assertSame(ConcurrentProcessExceptionFixtures::PUBLIC_FALSEY_EXCEPTION, $exception::class); + $this->assertSame(0, $exception->status); + $this->assertFalse($exception->retry); + $this->assertSame('', $exception->reason); + $this->assertNull($exception->detail); + } + } + + public function testItReconstructsOptionalVariadicAndInheritedParameters(): void + { + $payloads = [ + [ + 'exception' => ConcurrentProcessExceptionFixtures::HIDDEN_OPTIONAL_EXCEPTION, + 'message' => 'status=7', + 'parameters' => ['status' => 0], + 'expectedMessage' => 'status=0', + ], + [ + 'exception' => ConcurrentProcessExceptionFixtures::VARIADIC_EXCEPTION, + 'message' => 'context:first,second', + 'parameters' => ['context' => 'context'], + 'expectedMessage' => 'context:', + ], + [ + 'exception' => ConcurrentProcessExceptionFixtures::INHERITED_PUBLIC_EXCEPTION, + 'message' => 'status=7', + 'parameters' => ['status' => 7], + 'expectedMessage' => 'status=7', + ], + ]; + + foreach ($payloads as $payload) { + try { + $this->decodePayload(['successful' => false, ...$payload]); + $this->fail('Expected the transported exception to be thrown.'); + } catch (Exception $exception) { + $this->assertSame($payload['exception'], $exception::class); + $this->assertSame($payload['expectedMessage'], $exception->getMessage()); + } + } + } + + public function testItReconstructsNativeDeclaredExceptionConstructors(): void + { + try { + $this->decodePayload([ + 'successful' => false, + 'exception' => ErrorException::class, + 'message' => 'original message', + 'parameters' => [ + 'message' => '', + 'code' => 0, + 'severity' => E_ERROR, + 'filename' => null, + 'line' => null, + 'previous' => null, + ], + ]); + $this->fail('Expected the transported exception to be thrown.'); + } catch (ErrorException $exception) { + $this->assertSame('', $exception->getMessage()); + $this->assertSame(E_ERROR, $exception->getSeverity()); + } + } + + public function testItReconstructsZeroArgumentAndStoredVariadicExceptions(): void + { + $payloads = [ + ConcurrentProcessExceptionFixtures::ZERO_ARGUMENT_EXCEPTION => 'argumentCount', + ConcurrentProcessExceptionFixtures::TYPED_STORED_VARIADIC_EXCEPTION => 'details', + ConcurrentProcessExceptionFixtures::UNTYPED_STORED_VARIADIC_EXCEPTION => 'details', + ]; + + foreach ($payloads as $exceptionClass => $property) { + try { + $this->decodePayload([ + 'successful' => false, + 'exception' => $exceptionClass, + 'message' => 'remote failure', + 'parameters' => [], + ]); + $this->fail('Expected the transported exception to be thrown.'); + } catch (Exception $exception) { + $this->assertSame($exceptionClass, $exception::class); + $this->assertSame($property === 'argumentCount' ? 0 : [], $exception->{$property}); + } + } + } + + public function testItContainsConstructorFailuresDuringReconstruction(): void + { + try { + $this->decodePayload([ + 'successful' => false, + 'exception' => ConcurrentProcessExceptionFixtures::MISMATCHED_PUBLIC_PROPERTY_EXCEPTION, + 'message' => 'status=5', + 'parameters' => ['status' => 'v5'], + ]); + $this->fail('Expected exception reconstruction to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame('status=5', $exception->getMessage()); + $this->assertInstanceOf(TypeError::class, $exception->getPrevious()); + } + } + + public function testItContainsUnavailableExceptionClassesDuringReconstruction(): void + { + try { + $this->decodePayload([ + 'successful' => false, + 'exception' => 'Missing\SerializedClosureException', + 'message' => 'remote failure', + 'parameters' => [], + ]); + $this->fail('Expected exception reconstruction to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame('remote failure', $exception->getMessage()); + $this->assertNotNull($exception->getPrevious()); + } + } + + public function testItRejectsNonThrowableExceptionClasses(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('remote failure'); + + $this->decodePayload([ + 'successful' => false, + 'exception' => stdClass::class, + 'message' => 'remote failure', + 'parameters' => [], + ]); + } + + public function testItUsesTheGenericFailureFallback(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Serialized closure execution failed.'); + + $this->decodePayload(['successful' => false]); + } + + public function testItReconstructsTheMaximumExceptionParameterDepth(): void + { + $value = $this->nestedValue(510); + + try { + $this->decodePayload([ + 'successful' => false, + 'exception' => ConcurrentProcessExceptionFixtures::PUBLIC_VALUE_EXCEPTION, + 'message' => 'public value', + 'parameters' => ['value' => $value], + ]); + $this->fail('Expected the transported exception to be thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame(ConcurrentProcessExceptionFixtures::PUBLIC_VALUE_EXCEPTION, $exception::class); + $this->assertSame($value, $exception->value); + } + } + + /** + * Decode an ordinary serialized result. + */ + private function decodeResult(mixed $result): mixed + { + return $this->decodePayload([ + 'successful' => true, + 'result' => base64_encode(serialize($result)), + ]); + } + + /** + * Decode the given response payload. + * + * @param array $payload + */ + private function decodePayload(array $payload, string $suffix = ''): mixed + { + return SerializedClosureResult::decode(Json::encode($payload) . $suffix); + } + + /** + * Build a value with the given number of array containers. + */ + private function nestedValue(int $containers): array + { + $value = ['leaf']; + + for ($depth = 1; $depth < $containers; ++$depth) { + $value = [$value]; + } + + return $value; + } +} diff --git a/tests/Testbench/Foundation/Process/ProcessResultTest.php b/tests/Testbench/Foundation/Process/ProcessResultTest.php index 774288699..431143f55 100644 --- a/tests/Testbench/Foundation/Process/ProcessResultTest.php +++ b/tests/Testbench/Foundation/Process/ProcessResultTest.php @@ -4,17 +4,12 @@ namespace Hypervel\Tests\Testbench\Foundation\Process; -use ErrorException; use Exception; use Hypervel\Testbench\Foundation\Process\ProcessResult; -use Hypervel\Tests\Foundation\Console\Fixtures\ConcurrentProcessExceptionFixtures; +use Hypervel\Tests\Concurrency\Fixtures\ConcurrentProcessExceptionFixtures; use Hypervel\Tests\TestCase; -use JsonException; use Mockery as m; -use RuntimeException; -use stdClass; use Symfony\Component\Process\Process; -use TypeError; class ProcessResultTest extends TestCase { @@ -28,76 +23,6 @@ public function testItReturnsBinaryResultsLosslessly(): void $this->assertSame("binary-\xFF\x00\x8B", $result->output()); } - public function testItIgnoresAppendedGzipOutput(): void - { - $result = $this->processResultFor([ - 'successful' => true, - 'result' => base64_encode(serialize('result')), - ], "\x1f\x8bcompressed-output"); - - $this->assertSame('result', $result->output()); - } - - public function testItRejectsMalformedJsonOutput(): void - { - $this->expectException(JsonException::class); - - $this->processResultForOutput('{malformed')->output(); - } - - public function testItRejectsInvalidResponseEnvelopes(): void - { - $outputs = [ - 'scalar' => json_encode('invalid', JSON_THROW_ON_ERROR), - 'missing status' => json_encode([], JSON_THROW_ON_ERROR), - 'non-boolean status' => json_encode(['successful' => 1], JSON_THROW_ON_ERROR), - 'non-string exception' => json_encode(['successful' => false, 'exception' => []], JSON_THROW_ON_ERROR), - 'non-string message' => json_encode(['successful' => false, 'message' => []], JSON_THROW_ON_ERROR), - 'non-array parameters' => json_encode(['successful' => false, 'parameters' => 'invalid'], JSON_THROW_ON_ERROR), - ]; - - foreach ($outputs as $description => $output) { - try { - $this->processResultForOutput($output)->output(); - $this->fail("Expected the {$description} response envelope to be rejected."); - } catch (RuntimeException $exception) { - $this->assertSame('Invalid remote process response envelope.', $exception->getMessage()); - } - } - } - - public function testItRejectsMalformedBase64Results(): void - { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Unable to decode the remote process result.'); - - $this->processResultFor([ - 'successful' => true, - 'result' => '*not-base64*', - ])->output(); - } - - public function testItRejectsMalformedSerializedResults(): void - { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Unable to decode the remote process result.'); - - $this->processResultFor([ - 'successful' => true, - 'result' => base64_encode('not-serialized'), - ])->output(); - } - - public function testItPreservesFalseResults(): void - { - $result = $this->processResultFor([ - 'successful' => true, - 'result' => base64_encode(serialize(false)), - ]); - - $this->assertFalse($result->output()); - } - public function testItPreservesPublicFalseyExceptionParameters(): void { $result = $this->processResultFor([ @@ -124,185 +49,14 @@ public function testItPreservesPublicFalseyExceptionParameters(): void } } - public function testItUsesInaccessibleOptionalDefaults(): void - { - $result = $this->processResultFor([ - 'successful' => false, - 'exception' => ConcurrentProcessExceptionFixtures::HIDDEN_OPTIONAL_EXCEPTION, - 'message' => 'status=7', - 'parameters' => ['status' => 0], - ]); - - try { - $result->output(); - $this->fail('Expected the transported exception to be thrown.'); - } catch (Exception $exception) { - $this->assertSame(ConcurrentProcessExceptionFixtures::HIDDEN_OPTIONAL_EXCEPTION, $exception::class); - $this->assertSame('status=0', $exception->getMessage()); - } - } - - public function testItReconstructsNamedVariadicAndInheritedParameters(): void - { - $variadic = $this->processResultFor([ - 'successful' => false, - 'exception' => ConcurrentProcessExceptionFixtures::VARIADIC_EXCEPTION, - 'message' => 'context:first,second', - 'parameters' => ['context' => 'context'], - ]); - - try { - $variadic->output(); - $this->fail('Expected the transported exception to be thrown.'); - } catch (Exception $exception) { - $this->assertSame(ConcurrentProcessExceptionFixtures::VARIADIC_EXCEPTION, $exception::class); - $this->assertSame('context:', $exception->getMessage()); - } - - $inherited = $this->processResultFor([ - 'successful' => false, - 'exception' => ConcurrentProcessExceptionFixtures::INHERITED_PUBLIC_EXCEPTION, - 'message' => 'status=7', - 'parameters' => ['status' => 7], - ]); - - try { - $inherited->output(); - $this->fail('Expected the transported exception to be thrown.'); - } catch (Exception $exception) { - $this->assertSame(ConcurrentProcessExceptionFixtures::INHERITED_PUBLIC_EXCEPTION, $exception::class); - $this->assertSame('status=7', $exception->getMessage()); - } - } - - public function testItReconstructsNativeDeclaredExceptionConstructors(): void - { - $result = $this->processResultFor([ - 'successful' => false, - 'exception' => ErrorException::class, - 'message' => 'original message', - 'parameters' => [ - 'message' => '', - 'code' => 0, - 'severity' => E_ERROR, - 'filename' => null, - 'line' => null, - 'previous' => null, - ], - ]); - - try { - $result->output(); - $this->fail('Expected the transported exception to be thrown.'); - } catch (ErrorException $exception) { - $this->assertSame('', $exception->getMessage()); - $this->assertSame(E_ERROR, $exception->getSeverity()); - } - } - - public function testItReconstructsZeroArgumentAndVariadicExceptionsWithoutSyntheticArguments(): void + public function testItReturnsRawNonClosureOutputWithoutInterpretingGzipMarkers(): void { - $zero = $this->processResultFor([ - 'successful' => false, - 'exception' => ConcurrentProcessExceptionFixtures::ZERO_ARGUMENT_EXCEPTION, - 'message' => 'zero arguments', - 'parameters' => [], - ]); - - try { - $zero->output(); - $this->fail('Expected the transported exception to be thrown.'); - } catch (Exception $exception) { - $this->assertSame(ConcurrentProcessExceptionFixtures::ZERO_ARGUMENT_EXCEPTION, $exception::class); - $this->assertSame(0, $exception->argumentCount); - } - - foreach ([ - ConcurrentProcessExceptionFixtures::TYPED_STORED_VARIADIC_EXCEPTION, - ConcurrentProcessExceptionFixtures::UNTYPED_STORED_VARIADIC_EXCEPTION, - ] as $exceptionClass) { - $result = $this->processResultFor([ - 'successful' => false, - 'exception' => $exceptionClass, - 'message' => 'count=2', - 'parameters' => [], - ]); - - try { - $result->output(); - $this->fail('Expected the transported exception to be thrown.'); - } catch (Exception $exception) { - $this->assertSame($exceptionClass, $exception::class); - $this->assertSame([], $exception->details); - $this->assertSame('count=0', $exception->getMessage()); - } - } - } - - public function testItContainsConstructorTypeErrorsDuringReconstruction(): void - { - $result = $this->processResultFor([ - 'successful' => false, - 'exception' => ConcurrentProcessExceptionFixtures::MISMATCHED_PUBLIC_PROPERTY_EXCEPTION, - 'message' => 'status=5', - 'parameters' => ['status' => 'v5'], - ]); - - try { - $result->output(); - $this->fail('Expected the transported exception to be thrown.'); - } catch (RuntimeException $exception) { - $this->assertSame('status=5', $exception->getMessage()); - $this->assertInstanceOf(TypeError::class, $exception->getPrevious()); - } - } - - public function testItContainsUnavailableExceptionClassesDuringReconstruction(): void - { - $result = $this->processResultFor([ - 'successful' => false, - 'exception' => 'Missing\RemoteProcessException', - 'message' => 'remote failure', - 'parameters' => [], - ]); - - try { - $result->output(); - $this->fail('Expected the transported exception to be thrown.'); - } catch (RuntimeException $exception) { - $this->assertSame('remote failure', $exception->getMessage()); - $this->assertNotNull($exception->getPrevious()); - } - } - - public function testItRejectsNonThrowableExceptionClasses(): void - { - $result = $this->processResultFor([ - 'successful' => false, - 'exception' => stdClass::class, - 'message' => 'remote failure', - 'parameters' => [], - ]); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('remote failure'); - - $result->output(); - } - - public function testItReconstructsGenericMessageFallbacks(): void - { - $result = $this->processResultFor([ - 'successful' => false, - 'exception' => RuntimeException::class, - 'message' => 'original message', - 'parameters' => ['message' => 'original message'], - ]); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('original message'); + $output = "raw\x1f\x8boutput"; + $process = m::mock(Process::class); + $process->shouldReceive('getOutput')->once()->andReturn($output); + $result = new ProcessResult($process, ['php', '--version']); - $result->output(); + $this->assertSame($output, $result->output()); } /** @@ -310,18 +64,10 @@ public function testItReconstructsGenericMessageFallbacks(): void * * @param array $payload */ - private function processResultFor(array $payload, string $suffix = ''): ProcessResult - { - return $this->processResultForOutput(json_encode($payload, JSON_THROW_ON_ERROR) . $suffix); - } - - /** - * Create a closure process result for the given output. - */ - private function processResultForOutput(string $output): ProcessResult + private function processResultFor(array $payload): ProcessResult { $process = m::mock(Process::class); - $process->shouldReceive('getOutput')->once()->andReturn($output); + $process->shouldReceive('getOutput')->once()->andReturn(json_encode($payload, JSON_THROW_ON_ERROR)); return new ProcessResult($process, static fn () => null); } diff --git a/tests/Testbench/PackageMetadataTest.php b/tests/Testbench/PackageMetadataTest.php index 881620397..bbb2f3e82 100644 --- a/tests/Testbench/PackageMetadataTest.php +++ b/tests/Testbench/PackageMetadataTest.php @@ -40,6 +40,10 @@ public function testDirectRuntimeDependenciesAreDeclared(): void $this->assertSame('^0.4', $composer['require']['hypervel/di']); $this->assertArrayHasKey('hypervel/di', $rootComposer['replace']); $this->assertSame('self.version', $rootComposer['replace']['hypervel/di']); + $this->assertArrayHasKey('hypervel/concurrency', $composer['require']); + $this->assertSame('^0.4', $composer['require']['hypervel/concurrency']); + $this->assertArrayHasKey('hypervel/concurrency', $rootComposer['replace']); + $this->assertSame('self.version', $rootComposer['replace']['hypervel/concurrency']); $this->assertArrayNotHasKey('brianium/paratest', $composer['suggest'] ?? []); } From cae3ec66dddd47485f848acc5c473d23419eca62 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:28:36 +0000 Subject: [PATCH 07/15] fix(validation): validate JSON casts consistently Route request JSON casts and both validator execution paths through the shared JSON contract, removing the unused request encoder and the dead PHP-version fallback. Malformed, empty, and over-depth JSON strings now fail consistently in interpreted and compiled validation before array, collection, object, or JSON casting. Correct the public example and cover the normal validated form-request path. --- src/docs/validation.md | 6 +- src/foundation/src/Http/Traits/HasCasts.php | 13 +-- .../src/Concerns/ValidatesAttributes.php | 10 +- src/validation/src/PlanExecutor.php | 14 +-- tests/Foundation/Http/CustomCastingTest.php | 93 +++++++++++++++++-- .../Validation/ValidationPlanExecutorTest.php | 22 +++++ tests/Validation/ValidationValidatorTest.php | 17 +++- 7 files changed, 137 insertions(+), 38 deletions(-) diff --git a/src/docs/validation.md b/src/docs/validation.md index a25ec7ef1..719c8b3a2 100644 --- a/src/docs/validation.md +++ b/src/docs/validation.md @@ -711,7 +711,9 @@ class StorePostRequest extends FormRequest 'views' => 'integer', 'is_featured' => 'boolean', 'tags' => 'array', + 'related_posts' => 'collection', 'metadata' => 'json', + 'settings' => 'object', 'rating' => 'decimal:2', ]; @@ -727,8 +729,10 @@ class StorePostRequest extends FormRequest 'published_at' => ['required', 'date'], 'views' => ['required', 'integer', 'min:0'], 'is_featured' => ['required', 'boolean'], - 'tags' => ['required', 'array'], + 'tags' => ['required', 'json'], + 'related_posts' => ['required', 'json'], 'metadata' => ['required', 'json'], + 'settings' => ['required', 'json'], 'rating' => ['required', 'numeric', 'between:0,5'], ]; } diff --git a/src/foundation/src/Http/Traits/HasCasts.php b/src/foundation/src/Http/Traits/HasCasts.php index 94ece55d5..5949cde81 100644 --- a/src/foundation/src/Http/Traits/HasCasts.php +++ b/src/foundation/src/Http/Traits/HasCasts.php @@ -14,6 +14,7 @@ use Hypervel\Support\Collection; use Hypervel\Support\DataObject; use Hypervel\Support\Facades\Date; +use Hypervel\Support\Json; use RuntimeException; use UnitEnum; @@ -384,9 +385,9 @@ protected function parseCasterClass(string $class): string /** * Decode the given JSON back into an array or object. */ - public function fromJson(string $value, bool $asObject = false) + public function fromJson(string $value, bool $asObject = false): mixed { - return json_decode($value, ! $asObject); + return Json::decode($value, ! $asObject); } /** @@ -422,14 +423,6 @@ public function getDateFormat(): string return $this->dateFormat; } - /** - * Encode the given value as JSON. - */ - protected function asJson(mixed $value): false|string - { - return json_encode($value); - } - /** * Return a decimal as string. * diff --git a/src/validation/src/Concerns/ValidatesAttributes.php b/src/validation/src/Concerns/ValidatesAttributes.php index beacdafa8..29f84f347 100644 --- a/src/validation/src/Concerns/ValidatesAttributes.php +++ b/src/validation/src/Concerns/ValidatesAttributes.php @@ -24,6 +24,7 @@ use Hypervel\Support\Collection; use Hypervel\Support\Exceptions\MathException; use Hypervel\Support\Facades\Date; +use Hypervel\Support\Json; use Hypervel\Support\Str; use Hypervel\Validation\Enums\SizeMode; use Hypervel\Validation\FakeDnsGetRecordWrapper; @@ -1524,14 +1525,7 @@ public function validateJson(string $attribute, mixed $value): bool return false; } - $value = (string) $value; - if (function_exists('json_validate')) { - return json_validate($value); - } - - json_decode($value); - - return json_last_error() === JSON_ERROR_NONE; + return Json::validate((string) $value); } /** diff --git a/src/validation/src/PlanExecutor.php b/src/validation/src/PlanExecutor.php index 8e0f21af3..860ac5991 100644 --- a/src/validation/src/PlanExecutor.php +++ b/src/validation/src/PlanExecutor.php @@ -7,6 +7,7 @@ use Brick\Math\BigNumber; use Hypervel\Http\UploadedFile; use Hypervel\Support\Arr; +use Hypervel\Support\Json; use Hypervel\Support\Str; use Hypervel\Validation\Enums\CheckType; use Hypervel\Validation\Enums\SizeMode; @@ -230,8 +231,7 @@ protected function executeInline(InlineCheck $check, mixed $value, string $attri /** * Inline JSON validation matching validateJson() behavior. * - * Checks for array/null, non-stringable objects, then validates via - * json_validate() (PHP 8.3+) or json_decode() + json_last_error(). + * Checks for array/null and non-stringable objects before validation. */ private function executeInlineJson(mixed $value): bool { @@ -243,15 +243,7 @@ private function executeInlineJson(mixed $value): bool return false; } - $value = (string) $value; - - if (function_exists('json_validate')) { - return json_validate($value); - } - - json_decode($value); - - return json_last_error() === JSON_ERROR_NONE; + return Json::validate((string) $value); } /** diff --git a/tests/Foundation/Http/CustomCastingTest.php b/tests/Foundation/Http/CustomCastingTest.php index 17a550d22..ffaeccd09 100644 --- a/tests/Foundation/Http/CustomCastingTest.php +++ b/tests/Foundation/Http/CustomCastingTest.php @@ -12,13 +12,18 @@ use Hypervel\Foundation\Http\Casts\AsEnumCollection; use Hypervel\Foundation\Http\Contracts\CastInputs; use Hypervel\Foundation\Http\FormRequest; +use Hypervel\Routing\Redirector; use Hypervel\Support\Carbon; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Collection; use Hypervel\Support\DataObject; use Hypervel\Support\Facades\Date; +use Hypervel\Support\Json; use Hypervel\Testbench\TestCase; use Hypervel\Validation\Rule; +use Hypervel\Validation\ValidationException; +use JsonException; +use stdClass; class CustomCastingTest extends TestCase { @@ -185,12 +190,13 @@ public function testPrimitiveBoolCasting() /** * Test primitive type casting - array. */ - public function testPrimitiveArrayCasting() + public function testPrimitiveArrayCasting(): void { $request = PrimitiveCastingRequest::create('/', 'POST', ['tags' => '["tag1","tag2"]']); $request->setContainer($this->app); + $request->validateResolved(); - $tags = $request->casted('tags', false); + $tags = $request->casted('tags'); $this->assertIsArray($tags); $this->assertSame(['tag1', 'tag2'], $tags); } @@ -198,16 +204,85 @@ public function testPrimitiveArrayCasting() /** * Test primitive type casting - collection. */ - public function testPrimitiveCollectionCasting() + public function testPrimitiveCollectionCasting(): void { - $request = PrimitiveCastingRequest::create('/', 'POST', ['items' => json_encode(['item1', 'item2'])]); + $request = PrimitiveCastingRequest::create('/', 'POST', ['items' => '["item1","item2"]']); $request->setContainer($this->app); + $request->validateResolved(); - $items = $request->casted('items', false); + $items = $request->casted('items'); $this->assertInstanceOf(Collection::class, $items); $this->assertSame(['item1', 'item2'], $items->all()); } + public function testPrimitiveJsonCastsUseValidatedJsonStringsAtTheSupportNestingLimit(): void + { + $value = 'leaf'; + + for ($index = 0; $index < Json::MAXIMUM_NESTING_DEPTH; ++$index) { + $value = ['value' => $value]; + } + + $json = Json::encode($value); + $request = PrimitiveCastingRequest::create('/', 'POST', [ + 'tags' => $json, + 'items' => $json, + 'metadata' => $json, + 'settings' => $json, + ]); + $request->setContainer($this->app); + $request->validateResolved(); + + $this->assertSame($value, $request->casted('tags')); + $this->assertSame($value, $request->casted('metadata')); + $this->assertSame($value, $request->casted('items')->all()); + $settings = $request->casted('settings'); + $this->assertInstanceOf(stdClass::class, $settings); + $this->assertEquals(Json::decode($json, assoc: false), $settings); + } + + public function testPrimitiveJsonCastsRejectMalformedEmptyAndOverDepthRawInput(): void + { + $value = 'leaf'; + + for ($index = 0; $index <= Json::MAXIMUM_NESTING_DEPTH; ++$index) { + $value = ['value' => $value]; + } + + $overDepth = json_encode($value, JSON_THROW_ON_ERROR, Json::MAXIMUM_NESTING_DEPTH + 1); + + foreach (['{invalid', '', $overDepth] as $json) { + foreach (['tags', 'items', 'settings'] as $key) { + $request = PrimitiveCastingRequest::create('/', 'POST', [$key => $json]); + $request->setContainer($this->app); + + $this->assertThrows( + fn () => $request->casted($key, false), + JsonException::class, + ); + } + } + } + + public function testValidatedJsonCastsRejectOneLevelOverTheSupportNestingLimit(): void + { + $value = 'leaf'; + + for ($index = 0; $index <= Json::MAXIMUM_NESTING_DEPTH; ++$index) { + $value = ['value' => $value]; + } + + $request = PrimitiveCastingRequest::create('/', 'POST', [ + 'tags' => json_encode($value, JSON_THROW_ON_ERROR, Json::MAXIMUM_NESTING_DEPTH + 1), + ]); + $request->setContainer($this->app) + ->setRedirector($this->app->make(Redirector::class)); + + $this->expectException(ValidationException::class); + + $request->validateResolved(); + } + /** * Test primitive type casting - datetime. */ @@ -455,6 +530,8 @@ class PrimitiveCastingRequest extends FormRequest 'is_active' => 'bool', 'tags' => 'array', 'items' => 'collection', + 'metadata' => 'json', + 'settings' => 'object', ]; public function rules(): array @@ -463,8 +540,10 @@ public function rules(): array 'age' => 'numeric', 'price' => 'numeric', 'is_active' => 'boolean', - 'tags' => 'string', - 'items' => 'array', + 'tags' => 'json', + 'items' => 'json', + 'metadata' => 'json', + 'settings' => 'json', ]; } } diff --git a/tests/Validation/ValidationPlanExecutorTest.php b/tests/Validation/ValidationPlanExecutorTest.php index dab2cc144..6fa07b077 100644 --- a/tests/Validation/ValidationPlanExecutorTest.php +++ b/tests/Validation/ValidationPlanExecutorTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Validation; +use Hypervel\Support\Json; use Hypervel\Tests\TestCase; use Hypervel\Translation\ArrayLoader; use Hypervel\Translation\Translator; @@ -74,6 +75,27 @@ public static function formatCheckCases(): iterable yield 'MacAddress fails' => [CheckType::MacAddress, 'not-mac', false]; } + public function testJsonCheckUsesTheSupportNestingLimit(): void + { + $validator = $this->makeValidator(); + $check = new InlineCheck(CheckType::Json); + $value = 'leaf'; + + for ($index = 0; $index < Json::MAXIMUM_NESTING_DEPTH; ++$index) { + $value = ['value' => $value]; + } + + $this->assertTrue($validator->publicExecuteInline($check, Json::encode($value), 'field')); + + $value = ['value' => $value]; + + $this->assertFalse($validator->publicExecuteInline( + $check, + json_encode($value, JSON_THROW_ON_ERROR, Json::MAXIMUM_NESTING_DEPTH + 1), + 'field' + )); + } + #[DataProvider('charClassCases')] public function testCharacterClassChecks(CheckType $type, mixed $value, bool $expected) { diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php index eb7c1ac16..76ff62c01 100755 --- a/tests/Validation/ValidationValidatorTest.php +++ b/tests/Validation/ValidationValidatorTest.php @@ -25,6 +25,7 @@ use Hypervel\Support\Arr; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Exceptions\MathException; +use Hypervel\Support\Json; use Hypervel\Support\Stringable; use Hypervel\Tests\TestCase; use Hypervel\Translation\ArrayLoader; @@ -3338,7 +3339,7 @@ public function testValidateString() $this->assertFalse($v->passes()); } - public function testValidateJson() + public function testValidateJson(): void { $trans = $this->getArrayTranslator(); $v = new Validator($trans, ['foo' => 'aslksd'], ['foo' => 'json']); @@ -3363,6 +3364,20 @@ public function testValidateJson() $trans = $this->getArrayTranslator(); $v = new Validator($trans, ['foo' => new Stringable('[]')], ['foo' => 'json']); $this->assertTrue($v->passes()); + + $value = 'leaf'; + + for ($index = 0; $index < Json::MAXIMUM_NESTING_DEPTH; ++$index) { + $value = ['value' => $value]; + } + + $v = new Validator($trans, ['foo' => Json::encode($value)], ['foo' => 'json']); + $this->assertTrue($v->passes()); + + $value = ['value' => $value]; + $json = json_encode($value, JSON_THROW_ON_ERROR, Json::MAXIMUM_NESTING_DEPTH + 1); + $v = new Validator($trans, ['foo' => $json], ['foo' => 'json']); + $this->assertFalse($v->passes()); } public function testValidateBoolean() From 80375afcb0248f8e25983e97c804a45e2d676309 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:28:51 +0000 Subject: [PATCH 08/15] fix(database): make Eloquent JSON corruption explicit Give Eloquent's codec matching write and read depth limits, keep contextual model errors for failed encodes, and validate decoded shapes before constructing first-party JSON class casts. Use the existing JSON attribute encoder for path assignments and let valid values replace malformed readable originals without swallowing decryption failures. Cover primitive, encrypted, enum, collection, fluent, data-object, custom codec, and cross-engine repair behavior. --- .../src/Eloquent/Casts/AsArrayObject.php | 16 +- .../src/Eloquent/Casts/AsCollection.php | 14 +- .../src/Eloquent/Casts/AsDataObject.php | 15 +- .../Eloquent/Casts/AsEncryptedArrayObject.php | 20 +- .../Eloquent/Casts/AsEncryptedCollection.php | 22 +- .../src/Eloquent/Casts/AsEnumArrayObject.php | 16 +- .../src/Eloquent/Casts/AsEnumCollection.php | 16 +- src/database/src/Eloquent/Casts/AsFluent.php | 27 ++- src/database/src/Eloquent/Casts/Json.php | 22 +- .../src/Eloquent/Concerns/HasAttributes.php | 47 +++- .../Database/DatabaseEloquentJsonCastTest.php | 209 ++++++++++++++++++ .../EloquentModelEncryptedCastingTest.php | 44 ++++ .../Database/EloquentModelJsonCastingTest.php | 34 +++ 13 files changed, 456 insertions(+), 46 deletions(-) create mode 100644 tests/Database/DatabaseEloquentJsonCastTest.php diff --git a/src/database/src/Eloquent/Casts/AsArrayObject.php b/src/database/src/Eloquent/Casts/AsArrayObject.php index 43b4575be..995188744 100644 --- a/src/database/src/Eloquent/Casts/AsArrayObject.php +++ b/src/database/src/Eloquent/Casts/AsArrayObject.php @@ -6,6 +6,8 @@ use Hypervel\Contracts\Database\Eloquent\Castable; use Hypervel\Contracts\Database\Eloquent\CastsAttributes; +use Hypervel\Database\Eloquent\JsonEncodingException; +use Hypervel\Database\Eloquent\Model; class AsArrayObject implements Castable { @@ -17,7 +19,7 @@ class AsArrayObject implements Castable public static function castUsing(array $arguments): CastsAttributes { return new class implements CastsAttributes { - public function get(mixed $model, string $key, mixed $value, array $attributes): ?ArrayObject + public function get(Model $model, string $key, mixed $value, array $attributes): ?ArrayObject { if (! isset($attributes[$key])) { return null; @@ -28,12 +30,18 @@ public function get(mixed $model, string $key, mixed $value, array $attributes): return is_array($data) ? new ArrayObject($data, ArrayObject::ARRAY_AS_PROPS) : null; } - public function set(mixed $model, string $key, mixed $value, array $attributes): array + public function set(Model $model, string $key, mixed $value, array $attributes): array { - return [$key => Json::encode($value)]; + $encoded = Json::encode($value); + + if ($encoded === false) { + throw JsonEncodingException::forAttribute($model, $key, json_last_error_msg()); + } + + return [$key => $encoded]; } - public function serialize(mixed $model, string $key, mixed $value, array $attributes): array + public function serialize(Model $model, string $key, mixed $value, array $attributes): array { return $value->getArrayCopy(); } diff --git a/src/database/src/Eloquent/Casts/AsCollection.php b/src/database/src/Eloquent/Casts/AsCollection.php index 8a635a83b..004d96059 100644 --- a/src/database/src/Eloquent/Casts/AsCollection.php +++ b/src/database/src/Eloquent/Casts/AsCollection.php @@ -6,6 +6,8 @@ use Hypervel\Contracts\Database\Eloquent\Castable; use Hypervel\Contracts\Database\Eloquent\CastsAttributes; +use Hypervel\Database\Eloquent\JsonEncodingException; +use Hypervel\Database\Eloquent\Model; use Hypervel\Support\Collection; use Hypervel\Support\Str; use InvalidArgumentException; @@ -25,7 +27,7 @@ public function __construct(protected array $arguments) $this->arguments = array_pad(array_values($this->arguments), 2, ''); } - public function get(mixed $model, string $key, mixed $value, array $attributes): ?Collection + public function get(Model $model, string $key, mixed $value, array $attributes): ?Collection { if (! isset($attributes[$key])) { return null; @@ -58,9 +60,15 @@ public function get(mixed $model, string $key, mixed $value, array $attributes): : $instance->mapInto($this->arguments[1][0]); } - public function set(mixed $model, string $key, mixed $value, array $attributes): array + public function set(Model $model, string $key, mixed $value, array $attributes): array { - return [$key => Json::encode($value)]; + $encoded = Json::encode($value); + + if ($encoded === false) { + throw JsonEncodingException::forAttribute($model, $key, json_last_error_msg()); + } + + return [$key => $encoded]; } }; } diff --git a/src/database/src/Eloquent/Casts/AsDataObject.php b/src/database/src/Eloquent/Casts/AsDataObject.php index fbcbafe45..5a5e263b8 100644 --- a/src/database/src/Eloquent/Casts/AsDataObject.php +++ b/src/database/src/Eloquent/Casts/AsDataObject.php @@ -5,6 +5,7 @@ namespace Hypervel\Database\Eloquent\Casts; use Hypervel\Contracts\Database\Eloquent\CastsAttributes; +use Hypervel\Database\Eloquent\JsonEncodingException; use Hypervel\Database\Eloquent\Model; use Hypervel\Support\DataObject; use InvalidArgumentException; @@ -34,7 +35,9 @@ public function get( mixed $value, array $attributes, ): ?DataObject { - if (! $data = json_decode((string) $value, true)) { + $data = Json::decode((string) $value); + + if (! is_array($data)) { return null; } @@ -54,8 +57,14 @@ public function set( string $key, mixed $value, array $attributes, - ): string { - return json_encode($value); + ): array { + $encoded = Json::encode($value); + + if ($encoded === false) { + throw JsonEncodingException::forAttribute($model, $key, json_last_error_msg()); + } + + return [$key => $encoded]; } /** diff --git a/src/database/src/Eloquent/Casts/AsEncryptedArrayObject.php b/src/database/src/Eloquent/Casts/AsEncryptedArrayObject.php index aa0815734..0214cd673 100644 --- a/src/database/src/Eloquent/Casts/AsEncryptedArrayObject.php +++ b/src/database/src/Eloquent/Casts/AsEncryptedArrayObject.php @@ -6,6 +6,8 @@ use Hypervel\Contracts\Database\Eloquent\Castable; use Hypervel\Contracts\Database\Eloquent\CastsAttributes; +use Hypervel\Database\Eloquent\JsonEncodingException; +use Hypervel\Database\Eloquent\Model; use Hypervel\Support\Facades\Crypt; class AsEncryptedArrayObject implements Castable @@ -18,25 +20,33 @@ class AsEncryptedArrayObject implements Castable public static function castUsing(array $arguments): CastsAttributes { return new class implements CastsAttributes { - public function get(mixed $model, string $key, mixed $value, array $attributes): ?ArrayObject + public function get(Model $model, string $key, mixed $value, array $attributes): ?ArrayObject { if (isset($attributes[$key])) { - return new ArrayObject(Json::decode(Crypt::decryptString($attributes[$key])), ArrayObject::ARRAY_AS_PROPS); + $data = Json::decode(Crypt::decryptString($attributes[$key])); + + return is_array($data) ? new ArrayObject($data, ArrayObject::ARRAY_AS_PROPS) : null; } return null; } - public function set(mixed $model, string $key, mixed $value, array $attributes): ?array + public function set(Model $model, string $key, mixed $value, array $attributes): ?array { if (! is_null($value)) { - return [$key => Crypt::encryptString(Json::encode($value))]; + $encoded = Json::encode($value); + + if ($encoded === false) { + throw JsonEncodingException::forAttribute($model, $key, json_last_error_msg()); + } + + return [$key => Crypt::encryptString($encoded)]; } return null; } - public function serialize(mixed $model, string $key, mixed $value, array $attributes): ?array + public function serialize(Model $model, string $key, mixed $value, array $attributes): ?array { return ! is_null($value) ? $value->getArrayCopy() : null; } diff --git a/src/database/src/Eloquent/Casts/AsEncryptedCollection.php b/src/database/src/Eloquent/Casts/AsEncryptedCollection.php index 86a4a3d12..79ff71517 100644 --- a/src/database/src/Eloquent/Casts/AsEncryptedCollection.php +++ b/src/database/src/Eloquent/Casts/AsEncryptedCollection.php @@ -6,6 +6,8 @@ use Hypervel\Contracts\Database\Eloquent\Castable; use Hypervel\Contracts\Database\Eloquent\CastsAttributes; +use Hypervel\Database\Eloquent\JsonEncodingException; +use Hypervel\Database\Eloquent\Model; use Hypervel\Support\Collection; use Hypervel\Support\Facades\Crypt; use Hypervel\Support\Str; @@ -26,7 +28,7 @@ public function __construct(protected array $arguments) $this->arguments = array_pad(array_values($this->arguments), 2, ''); } - public function get(mixed $model, string $key, mixed $value, array $attributes): ?Collection + public function get(Model $model, string $key, mixed $value, array $attributes): ?Collection { $collectionClass = empty($this->arguments[0]) ? Collection::class : $this->arguments[0]; @@ -38,7 +40,13 @@ public function get(mixed $model, string $key, mixed $value, array $attributes): return null; } - $instance = new $collectionClass(Json::decode(Crypt::decryptString($attributes[$key]))); + $data = Json::decode(Crypt::decryptString($attributes[$key])); + + if (! is_array($data)) { + return null; + } + + $instance = new $collectionClass($data); if (! isset($this->arguments[1]) || ! $this->arguments[1]) { return $instance; @@ -53,10 +61,16 @@ public function get(mixed $model, string $key, mixed $value, array $attributes): : $instance->mapInto($this->arguments[1][0]); } - public function set(mixed $model, string $key, mixed $value, array $attributes): ?array + public function set(Model $model, string $key, mixed $value, array $attributes): ?array { if (! is_null($value)) { - return [$key => Crypt::encryptString(Json::encode($value))]; + $encoded = Json::encode($value); + + if ($encoded === false) { + throw JsonEncodingException::forAttribute($model, $key, json_last_error_msg()); + } + + return [$key => Crypt::encryptString($encoded)]; } return null; diff --git a/src/database/src/Eloquent/Casts/AsEnumArrayObject.php b/src/database/src/Eloquent/Casts/AsEnumArrayObject.php index 454fdba1e..f123248b1 100644 --- a/src/database/src/Eloquent/Casts/AsEnumArrayObject.php +++ b/src/database/src/Eloquent/Casts/AsEnumArrayObject.php @@ -7,6 +7,8 @@ use BackedEnum; use Hypervel\Contracts\Database\Eloquent\Castable; use Hypervel\Contracts\Database\Eloquent\CastsAttributes; +use Hypervel\Database\Eloquent\JsonEncodingException; +use Hypervel\Database\Eloquent\Model; use Hypervel\Support\Collection; use function Hypervel\Support\enum_value; @@ -31,7 +33,7 @@ public function __construct(array $arguments) $this->arguments = $arguments; } - public function get(mixed $model, string $key, mixed $value, array $attributes): ?ArrayObject + public function get(Model $model, string $key, mixed $value, array $attributes): ?ArrayObject { if (! isset($attributes[$key])) { return null; @@ -52,7 +54,7 @@ public function get(mixed $model, string $key, mixed $value, array $attributes): })->toArray()); } - public function set(mixed $model, string $key, mixed $value, array $attributes): array + public function set(Model $model, string $key, mixed $value, array $attributes): array { if ($value === null) { return [$key => null]; @@ -64,10 +66,16 @@ public function set(mixed $model, string $key, mixed $value, array $attributes): $storable[] = $this->getStorableEnumValue($enum); } - return [$key => Json::encode($storable)]; + $encoded = Json::encode($storable); + + if ($encoded === false) { + throw JsonEncodingException::forAttribute($model, $key, json_last_error_msg()); + } + + return [$key => $encoded]; } - public function serialize(mixed $model, string $key, mixed $value, array $attributes): array + public function serialize(Model $model, string $key, mixed $value, array $attributes): array { return (new Collection($value->getArrayCopy())) ->map(fn ($enum) => $this->getStorableEnumValue($enum)) diff --git a/src/database/src/Eloquent/Casts/AsEnumCollection.php b/src/database/src/Eloquent/Casts/AsEnumCollection.php index 23b2847c0..d3b09ff69 100644 --- a/src/database/src/Eloquent/Casts/AsEnumCollection.php +++ b/src/database/src/Eloquent/Casts/AsEnumCollection.php @@ -7,6 +7,8 @@ use BackedEnum; use Hypervel\Contracts\Database\Eloquent\Castable; use Hypervel\Contracts\Database\Eloquent\CastsAttributes; +use Hypervel\Database\Eloquent\JsonEncodingException; +use Hypervel\Database\Eloquent\Model; use Hypervel\Support\Collection; use function Hypervel\Support\enum_value; @@ -31,7 +33,7 @@ public function __construct(array $arguments) $this->arguments = $arguments; } - public function get(mixed $model, string $key, mixed $value, array $attributes): ?Collection + public function get(Model $model, string $key, mixed $value, array $attributes): ?Collection { if (! isset($attributes[$key])) { return null; @@ -52,18 +54,22 @@ public function get(mixed $model, string $key, mixed $value, array $attributes): }); } - public function set(mixed $model, string $key, mixed $value, array $attributes): array + public function set(Model $model, string $key, mixed $value, array $attributes): array { - $value = $value !== null + $encoded = $value !== null ? Json::encode((new Collection($value))->map(function ($enum) { return $this->getStorableEnumValue($enum); })->jsonSerialize()) : null; - return [$key => $value]; + if ($encoded === false) { + throw JsonEncodingException::forAttribute($model, $key, json_last_error_msg()); + } + + return [$key => $encoded]; } - public function serialize(mixed $model, string $key, mixed $value, array $attributes): array + public function serialize(Model $model, string $key, mixed $value, array $attributes): array { return (new Collection($value)) ->map(fn ($enum) => $this->getStorableEnumValue($enum)) diff --git a/src/database/src/Eloquent/Casts/AsFluent.php b/src/database/src/Eloquent/Casts/AsFluent.php index 52a00f69e..f24803da4 100644 --- a/src/database/src/Eloquent/Casts/AsFluent.php +++ b/src/database/src/Eloquent/Casts/AsFluent.php @@ -6,6 +6,8 @@ use Hypervel\Contracts\Database\Eloquent\Castable; use Hypervel\Contracts\Database\Eloquent\CastsAttributes; +use Hypervel\Database\Eloquent\JsonEncodingException; +use Hypervel\Database\Eloquent\Model; use Hypervel\Support\Fluent; class AsFluent implements Castable @@ -18,14 +20,31 @@ class AsFluent implements Castable public static function castUsing(array $arguments): CastsAttributes { return new class implements CastsAttributes { - public function get(mixed $model, string $key, mixed $value, array $attributes): ?Fluent + public function get(Model $model, string $key, mixed $value, array $attributes): ?Fluent { - return isset($value) ? new Fluent(Json::decode($value)) : null; + if (! isset($value)) { + return null; + } + + $data = Json::decode($value); + + // Custom decoders may return objects, which Fluent supports alongside arrays. + return is_array($data) || is_object($data) ? new Fluent($data) : null; } - public function set(mixed $model, string $key, mixed $value, array $attributes): ?array + public function set(Model $model, string $key, mixed $value, array $attributes): ?array { - return isset($value) ? [$key => Json::encode($value)] : null; + if (! isset($value)) { + return null; + } + + $encoded = Json::encode($value); + + if ($encoded === false) { + throw JsonEncodingException::forAttribute($model, $key, json_last_error_msg()); + } + + return [$key => $encoded]; } }; } diff --git a/src/database/src/Eloquent/Casts/Json.php b/src/database/src/Eloquent/Casts/Json.php index c35098bfa..28f5b4c93 100644 --- a/src/database/src/Eloquent/Casts/Json.php +++ b/src/database/src/Eloquent/Casts/Json.php @@ -6,6 +6,8 @@ class Json { + private const int MAXIMUM_NESTING_DEPTH = 512; + /** * The custom JSON encoder. * @@ -25,9 +27,12 @@ class Json */ public static function encode(mixed $value, int $flags = 0): mixed { - return isset(static::$encoder) - ? (static::$encoder)($value, $flags) - : json_encode($value, $flags); + if (isset(static::$encoder)) { + return (static::$encoder)($value, $flags); + } + + // Eloquent writers replace false with an error that names the model attribute. + return json_encode($value, $flags, self::MAXIMUM_NESTING_DEPTH); } /** @@ -35,9 +40,14 @@ public static function encode(mixed $value, int $flags = 0): mixed */ public static function decode(mixed $value, ?bool $associative = true): mixed { - return isset(static::$decoder) - ? (static::$decoder)($value, $associative) - : json_decode($value, $associative); + if (isset(static::$decoder)) { + return (static::$decoder)($value, $associative); + } + + // An empty string is Eloquent's established empty stored representation. + return $value === '' + ? null + : json_decode($value, $associative, self::MAXIMUM_NESTING_DEPTH + 1, JSON_THROW_ON_ERROR); } /** diff --git a/src/database/src/Eloquent/Concerns/HasAttributes.php b/src/database/src/Eloquent/Concerns/HasAttributes.php index eef009d5e..e975174de 100644 --- a/src/database/src/Eloquent/Concerns/HasAttributes.php +++ b/src/database/src/Eloquent/Concerns/HasAttributes.php @@ -45,6 +45,7 @@ use Hypervel\Support\Str; use Hypervel\Support\StrCache; use InvalidArgumentException; +use JsonException; use LogicException; use ReflectionClass; use ReflectionMethod; @@ -1161,11 +1162,11 @@ public function fillJsonAttribute(string $key, mixed $value): static { [$key, $path] = explode('->', $key, 2); - $value = $this->asJson($this->getArrayAttributeWithValue( + $value = $this->castAttributeAsJson($key, $this->getArrayAttributeWithValue( $path, $key, $value - ), $this->getJsonCastFlags($key)); + )); $this->attributes[$key] = $this->isEncryptedCastable($key) ? $this->castAttributeAsEncryptedString($key, $value) @@ -2188,8 +2189,15 @@ public function originalIsEquivalent(string $key): bool === $this->fromDateTime($original); } if ($this->hasCast($key, ['object', 'collection'])) { - return $this->fromJson($attribute) - === $this->fromJson($original); + $current = $this->fromJson($attribute); + + try { + $original = $this->fromJson($original); + } catch (JsonException) { + return false; + } + + return $current === $original; } if ($this->hasCast($key, ['real', 'float', 'double'])) { if ($original === null) { @@ -2202,14 +2210,37 @@ public function originalIsEquivalent(string $key): bool return false; } if ($this->hasCast($key, static::$primitiveCastTypes)) { - return $this->castAttribute($key, $attribute) - === $this->castAttribute($key, $original); + $current = $this->castAttribute($key, $attribute); + + try { + $original = $this->castAttribute($key, $original); + } catch (JsonException) { + return false; + } + + return $current === $original; } if ($this->isClassCastable($key) && Str::startsWith($this->getCasts()[$key], [AsArrayObject::class, AsCollection::class])) { - return $this->fromJson($attribute) === $this->fromJson($original); + $current = $this->fromJson($attribute); + + try { + $original = $this->fromJson($original); + } catch (JsonException) { + return false; + } + + return $current === $original; } if ($this->isClassCastable($key) && Str::startsWith($this->getCasts()[$key], [AsEnumArrayObject::class, AsEnumCollection::class])) { - return $this->fromJson($attribute) === $this->fromJson($original); + $current = $this->fromJson($attribute); + + try { + $original = $this->fromJson($original); + } catch (JsonException) { + return false; + } + + return $current === $original; } if ($this->isClassCastable($key) && $original !== null && Str::startsWith($this->getCasts()[$key], [AsEncryptedArrayObject::class, AsEncryptedCollection::class])) { if (empty(static::currentEncrypter()->getPreviousKeys())) { diff --git a/tests/Database/DatabaseEloquentJsonCastTest.php b/tests/Database/DatabaseEloquentJsonCastTest.php new file mode 100644 index 000000000..fecb031f7 --- /dev/null +++ b/tests/Database/DatabaseEloquentJsonCastTest.php @@ -0,0 +1,209 @@ +nestedValue(512); + $model = new JsonCastModel; + + $model->payload = $value; + + $this->assertSame($value, $model->payload); + } + + public function testPrimitiveJsonCastRejectsOneLevelOverWithModelContext(): void + { + $model = new JsonCastModel; + + $this->assertThrows( + fn () => $model->payload = $this->nestedValue(513), + JsonEncodingException::class, + 'Unable to encode attribute [payload] for model [' . JsonCastModel::class . ']', + ); + } + + public function testDefaultDecoderDistinguishesNullEmptyAndMalformedJson(): void + { + $this->assertNull(Json::decode('null')); + $this->assertNull(Json::decode('')); + $this->assertThrows(fn () => Json::decode('{invalid'), JsonException::class); + } + + public function testCustomDecoderReceivesTheStoredEmptyString(): void + { + $decoded = null; + + try { + Json::decodeUsing(function (mixed $value) use (&$decoded): null { + $decoded = $value; + + return null; + }); + + $this->assertNull(Json::decode('')); + $this->assertSame('', $decoded); + } finally { + Json::flushState(); + } + } + + public function testEveryFirstPartyJsonClassCastRejectsEncoderFalseWithModelContext(): void + { + $model = new JsonCastModel; + $casters = [ + 'array_object' => [AsArrayObject::castUsing([]), ['value']], + 'collection' => [AsCollection::castUsing([]), ['value']], + 'encrypted_array_object' => [AsEncryptedArrayObject::castUsing([]), ['value']], + 'encrypted_collection' => [AsEncryptedCollection::castUsing([]), ['value']], + 'enum_array_object' => [AsEnumArrayObject::castUsing([JsonCastStatus::class]), [JsonCastStatus::Ready]], + 'enum_collection' => [AsEnumCollection::castUsing([JsonCastStatus::class]), [JsonCastStatus::Ready]], + 'fluent' => [AsFluent::castUsing([]), new Fluent(['value' => true])], + 'data_object' => [new AsDataObject(JsonCastData::class), new JsonCastData('value')], + ]; + + try { + Json::encodeUsing(static fn (): false => false); + + foreach ($casters as $key => [$caster, $value]) { + try { + $caster->set($model, $key, $value, []); + $this->fail("The [{$key}] caster accepted an encoder false result."); + } catch (JsonEncodingException $exception) { + $this->assertStringContainsString( + "Unable to encode attribute [{$key}] for model [" . JsonCastModel::class . ']', + $exception->getMessage(), + ); + } + } + } finally { + Json::flushState(); + } + } + + public function testJsonClassCastReadersRejectSuccessfullyDecodedWrongShapes(): void + { + $model = new JsonCastModel; + $encryptedNull = Crypt::encryptString('null'); + + $this->assertNull(AsEncryptedArrayObject::castUsing([])->get($model, 'value', null, ['value' => $encryptedNull])); + $this->assertNull(AsEncryptedCollection::castUsing([])->get($model, 'value', null, ['value' => $encryptedNull])); + $this->assertNull((new AsDataObject(JsonCastData::class))->get($model, 'value', 'null', ['value' => 'null'])); + $this->assertNull(AsFluent::castUsing([])->get($model, 'value', 'null', ['value' => 'null'])); + } + + public function testFluentCastAcceptsAnObjectFromACustomDecoder(): void + { + try { + Json::decodeUsing(static fn (): object => (object) ['name' => 'Taylor']); + + $fluent = AsFluent::castUsing([])->get(new JsonCastModel, 'value', '{}', ['value' => '{}']); + + $this->assertInstanceOf(Fluent::class, $fluent); + $this->assertSame('Taylor', $fluent->name); + } finally { + Json::flushState(); + } + } + + public function testDataObjectCastAcceptsEmptyMapsAndUsesTheCustomCodec(): void + { + $caster = new AsDataObject(JsonCastData::class); + $model = new JsonCastModel; + + $this->assertInstanceOf(JsonCastData::class, $caster->get($model, 'value', '{}', ['value' => '{}'])); + $this->assertInstanceOf(JsonCastData::class, $caster->get($model, 'value', '[]', ['value' => '[]'])); + + try { + Json::decodeUsing(static fn (): array => ['name' => 'decoded']); + Json::encodeUsing(static fn (): string => 'encoded'); + + $this->assertSame('decoded', $caster->get($model, 'value', 'ignored', ['value' => 'ignored'])->name); + $this->assertSame(['value' => 'encoded'], $caster->set($model, 'value', new JsonCastData('value'), [])); + } finally { + Json::flushState(); + } + } + + public function testJsonPathAssignmentRejectsEncoderFalseBeforeStorageOrEncryption(): void + { + $model = new JsonCastModel; + $model->mergeCasts(['encrypted_payload' => 'encrypted:array']); + $encrypter = m::mock(Encrypter::class); + $encrypter->expects('encrypt')->never(); + Model::encryptUsing($encrypter); + + try { + Json::encodeUsing(static fn (): false => false); + + $this->assertThrows( + fn () => $model->{'payload->key'} = 'value', + JsonEncodingException::class, + 'Unable to encode attribute [payload]', + ); + $this->assertThrows( + fn () => $model->{'encrypted_payload->key'} = 'value', + JsonEncodingException::class, + 'Unable to encode attribute [encrypted_payload]', + ); + $this->assertArrayNotHasKey('payload', $model->getAttributes()); + $this->assertArrayNotHasKey('encrypted_payload', $model->getAttributes()); + } finally { + Json::flushState(); + Model::encryptUsing(null); + } + } + + private function nestedValue(int $depth): array + { + $value = 'leaf'; + + for ($index = 0; $index < $depth; ++$index) { + $value = ['value' => $value]; + } + + return $value; + } +} + +class JsonCastModel extends Model +{ + protected array $casts = [ + 'payload' => 'array', + ]; +} + +class JsonCastData extends DataObject +{ + public function __construct(public readonly string $name = 'default') + { + } +} + +enum JsonCastStatus: string +{ + case Ready = 'ready'; +} diff --git a/tests/Integration/Database/EloquentModelEncryptedCastingTest.php b/tests/Integration/Database/EloquentModelEncryptedCastingTest.php index 4aa3c1abb..53ff50371 100644 --- a/tests/Integration/Database/EloquentModelEncryptedCastingTest.php +++ b/tests/Integration/Database/EloquentModelEncryptedCastingTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Integration\Database; +use Hypervel\Contracts\Encryption\DecryptException; use Hypervel\Contracts\Encryption\Encrypter; use Hypervel\Database\Eloquent\Casts\ArrayObject; use Hypervel\Database\Eloquent\Casts\AsEncryptedArrayObject; @@ -376,6 +377,49 @@ public function testChangedEncryptedArrayObjectMatchesStoredValueAtUpdatedEvent( ); } + public function testValidAssignmentCanReplaceDecryptableMalformedJson(): void + { + $encrypter = new RealEncrypter(str_repeat('a', 16)); + Crypt::swap($encrypter); + Model::encryptUsing($encrypter); + + $original = $encrypter->encrypt('{invalid', false); + $id = EncryptedCast::query()->insertGetId(['secret_json' => $original]); + $subject = EncryptedCast::query()->findOrFail($id); + + $subject->secret_json = ['valid' => true]; + + $this->assertTrue($subject->isDirty('secret_json')); + $this->assertTrue($subject->save()); + $this->assertSame(['valid' => true], $subject->fresh()->secret_json); + $this->assertNotSame( + $original, + EncryptedCast::query()->whereKey($id)->toBase()->value('secret_json'), + ); + } + + public function testUndecryptableOriginalCannotBeOverwrittenThroughDirtyChecking(): void + { + $encrypter = new RealEncrypter(str_repeat('a', 16)); + $wrongEncrypter = new RealEncrypter(str_repeat('b', 16)); + Crypt::swap($encrypter); + Model::encryptUsing($encrypter); + + $original = $wrongEncrypter->encrypt('{"recoverable":true}', false); + $id = EncryptedCast::query()->insertGetId(['secret_json' => $original]); + $subject = EncryptedCast::query()->findOrFail($id); + $subject->secret_json = ['replacement' => true]; + + $this->assertThrows( + fn () => $subject->isDirty('secret_json'), + DecryptException::class, + ); + $this->assertSame( + $original, + EncryptedCast::query()->whereKey($id)->toBase()->value('secret_json'), + ); + } + public function testCustomEncrypterCanBeSpecified() { $customEncrypter = $this->mock(Encrypter::class); diff --git a/tests/Integration/Database/EloquentModelJsonCastingTest.php b/tests/Integration/Database/EloquentModelJsonCastingTest.php index a61286d18..fd05f2305 100644 --- a/tests/Integration/Database/EloquentModelJsonCastingTest.php +++ b/tests/Integration/Database/EloquentModelJsonCastingTest.php @@ -4,6 +4,9 @@ namespace Hypervel\Tests\Integration\Database\EloquentModelJsonCastingTest; +use ArrayObject; +use Hypervel\Database\Eloquent\Casts\AsArrayObject; +use Hypervel\Database\Eloquent\Casts\AsEnumArrayObject; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Collection; @@ -22,6 +25,7 @@ protected function afterRefreshingDatabase(): void $table->json('array_as_json_field')->nullable(); $table->json('object_as_json_field')->nullable(); $table->json('collection_as_json_field')->nullable(); + $table->text('malformed_json_field')->nullable(); }); } @@ -71,6 +75,30 @@ public function testCollectionsAreCastable() $this->assertInstanceOf(Collection::class, $user->collection_as_json_field); $this->assertSame('value1', $user->collection_as_json_field->get('key1')); } + + public function testValidAssignmentsCanReplaceMalformedStoredJson(): void + { + $casts = [ + 'array' => ['value' => ['key' => 'value'], 'stored' => '{"key":"value"}'], + 'object' => ['value' => (object) ['key' => 'value'], 'stored' => '{"key":"value"}'], + AsArrayObject::class => ['value' => new ArrayObject(['key' => 'value']), 'stored' => '{"key":"value"}'], + AsEnumArrayObject::of(JsonCastStatus::class) => ['value' => [JsonCastStatus::Ready], 'stored' => '["ready"]'], + ]; + + foreach ($casts as $cast => $replacement) { + $id = JsonCast::query()->insertGetId(['malformed_json_field' => '{invalid']); + $model = JsonCast::query()->findOrFail($id)->mergeCasts(['malformed_json_field' => $cast]); + + $model->malformed_json_field = $replacement['value']; + + $this->assertTrue($model->isDirty('malformed_json_field')); + $this->assertTrue($model->save()); + $this->assertSame( + $replacement['stored'], + JsonCast::query()->whereKey($id)->value('malformed_json_field'), + ); + } + } } /** @@ -79,6 +107,7 @@ public function testCollectionsAreCastable() * @property $array_as_json_field * @property $object_as_json_field * @property $collection_as_json_field + * @property $malformed_json_field */ class JsonCast extends Model { @@ -96,3 +125,8 @@ class JsonCast extends Model 'collection_as_json_field' => 'collection', ]; } + +enum JsonCastStatus: string +{ + case Ready = 'ready'; +} From 2f155e9e79bc2e290ca491a4f9e2940cd8252199 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:29:01 +0000 Subject: [PATCH 09/15] fix(database): reject invalid JSON query bindings Enable native throwing JSON encoding in base, MySQL, MariaDB, PostgreSQL, and SQLite binding preparation so recursion, non-finite values, and depth failures cannot reach query execution as false. Keep each grammar's existing encoding flags and binding shapes, tighten an adjacent PostgreSQL comparison, and exercise the protected preparation methods directly across all supported grammar families. --- src/database/src/Query/Grammars/Grammar.php | 2 +- .../src/Query/Grammars/MySqlGrammar.php | 2 +- .../src/Query/Grammars/PostgresGrammar.php | 6 ++--- .../src/Query/Grammars/SQLiteGrammar.php | 2 +- .../DatabaseMariaDbQueryGrammarTest.php | 9 ++++++++ .../DatabaseMySqlQueryGrammarTest.php | 9 ++++++++ .../DatabasePostgresQueryGrammarTest.php | 23 +++++++++++++++++++ tests/Database/DatabaseQueryGrammarTest.php | 8 +++++++ .../DatabaseSQLiteQueryGrammarTest.php | 9 ++++++++ 9 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/database/src/Query/Grammars/Grammar.php b/src/database/src/Query/Grammars/Grammar.php index cc9018af6..5593e3c9b 100755 --- a/src/database/src/Query/Grammars/Grammar.php +++ b/src/database/src/Query/Grammars/Grammar.php @@ -583,7 +583,7 @@ protected function compileJsonOverlaps(string $column, string $value): string */ public function prepareBindingForJsonContains(mixed $binding): mixed { - return json_encode($binding, JSON_UNESCAPED_UNICODE); + return json_encode($binding, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); } /** diff --git a/src/database/src/Query/Grammars/MySqlGrammar.php b/src/database/src/Query/Grammars/MySqlGrammar.php index 8acf440e1..09ac0a6c4 100755 --- a/src/database/src/Query/Grammars/MySqlGrammar.php +++ b/src/database/src/Query/Grammars/MySqlGrammar.php @@ -416,7 +416,7 @@ public function prepareBindingsForUpdate(array $bindings, array $values): array { $values = (new Collection($values)) ->reject(fn ($value, $column) => $this->isJsonSelector($column) && is_bool($value)) - ->map(fn ($value) => is_array($value) ? json_encode($value) : $value) + ->map(fn ($value) => is_array($value) ? json_encode($value, JSON_THROW_ON_ERROR) : $value) ->all(); return parent::prepareBindingsForUpdate($bindings, $values); diff --git a/src/database/src/Query/Grammars/PostgresGrammar.php b/src/database/src/Query/Grammars/PostgresGrammar.php index bd4f0c0a5..35cb9ba4e 100755 --- a/src/database/src/Query/Grammars/PostgresGrammar.php +++ b/src/database/src/Query/Grammars/PostgresGrammar.php @@ -460,7 +460,7 @@ protected function compileUpdateWheres(Builder $query): string // strip the leading boolean we will do so when using as the only where. $joinWheres = $this->compileUpdateJoinWheres($query); - if (trim($baseWheres) == '') { + if (trim($baseWheres) === '') { return 'where ' . $this->removeLeadingBoolean($joinWheres); } @@ -496,7 +496,7 @@ public function prepareBindingsForUpdateFrom(array $bindings, array $values): ar $values = (new Collection($values)) ->map(function ($value, $column) { return is_array($value) || ($this->isJsonSelector($column) && ! $this->isExpression($value)) - ? json_encode($value) + ? json_encode($value, JSON_THROW_ON_ERROR) : $value; }) ->all(); @@ -532,7 +532,7 @@ public function prepareBindingsForUpdate(array $bindings, array $values): array { $values = (new Collection($values))->map(function ($value, $column) { return is_array($value) || ($this->isJsonSelector($column) && ! $this->isExpression($value)) - ? json_encode($value) + ? json_encode($value, JSON_THROW_ON_ERROR) : $value; })->all(); diff --git a/src/database/src/Query/Grammars/SQLiteGrammar.php b/src/database/src/Query/Grammars/SQLiteGrammar.php index a306780eb..e64e86a88 100755 --- a/src/database/src/Query/Grammars/SQLiteGrammar.php +++ b/src/database/src/Query/Grammars/SQLiteGrammar.php @@ -343,7 +343,7 @@ public function prepareBindingsForUpdate(array $bindings, array $values): array $values = (new Collection($values)) ->reject(fn ($value, $key) => $this->isJsonSelector($key)) ->merge($groups) - ->map(fn ($value) => is_array($value) ? json_encode($value) : $value) + ->map(fn ($value) => is_array($value) ? json_encode($value, JSON_THROW_ON_ERROR) : $value) ->all(); $cleanBindings = Arr::except($bindings, 'select'); diff --git a/tests/Database/DatabaseMariaDbQueryGrammarTest.php b/tests/Database/DatabaseMariaDbQueryGrammarTest.php index 9c738d6f4..89c9cd818 100755 --- a/tests/Database/DatabaseMariaDbQueryGrammarTest.php +++ b/tests/Database/DatabaseMariaDbQueryGrammarTest.php @@ -7,10 +7,19 @@ use Hypervel\Database\Connection; use Hypervel\Database\Query\Grammars\MariaDbGrammar; use Hypervel\Tests\TestCase; +use JsonException; use Mockery as m; class DatabaseMariaDbQueryGrammarTest extends TestCase { + public function testUpdateBindingsRejectUnencodableArrays(): void + { + $this->expectException(JsonException::class); + + (new MariaDbGrammar(m::mock(Connection::class))) + ->prepareBindingsForUpdate([], ['payload' => [NAN]]); + } + public function testToRawSql() { $connection = m::mock(Connection::class); diff --git a/tests/Database/DatabaseMySqlQueryGrammarTest.php b/tests/Database/DatabaseMySqlQueryGrammarTest.php index 72e6fbb78..8872279cc 100755 --- a/tests/Database/DatabaseMySqlQueryGrammarTest.php +++ b/tests/Database/DatabaseMySqlQueryGrammarTest.php @@ -10,10 +10,19 @@ use Hypervel\Database\Query\Processors\Processor; use Hypervel\Tests\TestCase; use InvalidArgumentException; +use JsonException; use Mockery as m; class DatabaseMySqlQueryGrammarTest extends TestCase { + public function testUpdateBindingsRejectUnencodableArrays(): void + { + $this->expectException(JsonException::class); + + (new MySqlGrammar(m::mock(Connection::class))) + ->prepareBindingsForUpdate([], ['payload' => [NAN]]); + } + public function testToRawSql() { $connection = m::mock(Connection::class); diff --git a/tests/Database/DatabasePostgresQueryGrammarTest.php b/tests/Database/DatabasePostgresQueryGrammarTest.php index d30ef4371..a3ac57837 100755 --- a/tests/Database/DatabasePostgresQueryGrammarTest.php +++ b/tests/Database/DatabasePostgresQueryGrammarTest.php @@ -8,10 +8,33 @@ use Hypervel\Database\Query\Builder; use Hypervel\Database\Query\Grammars\PostgresGrammar; use Hypervel\Tests\TestCase; +use JsonException; use Mockery as m; class DatabasePostgresQueryGrammarTest extends TestCase { + public function testUpdateBindingsRejectUnencodableArrays(): void + { + $this->expectException(JsonException::class); + + (new PostgresGrammar(m::mock(Connection::class))) + ->prepareBindingsForUpdate([], ['payload' => [NAN]]); + } + + public function testUpdateFromBindingsRejectArraysOverTheNativeDepthLimit(): void + { + $value = 'leaf'; + + for ($index = 0; $index < 513; ++$index) { + $value = ['value' => $value]; + } + + $this->expectException(JsonException::class); + + (new PostgresGrammar(m::mock(Connection::class))) + ->prepareBindingsForUpdateFrom([], ['payload' => $value]); + } + public function testToRawSql() { $connection = m::mock(Connection::class); diff --git a/tests/Database/DatabaseQueryGrammarTest.php b/tests/Database/DatabaseQueryGrammarTest.php index 5aaf67993..255aa0ade 100644 --- a/tests/Database/DatabaseQueryGrammarTest.php +++ b/tests/Database/DatabaseQueryGrammarTest.php @@ -11,12 +11,20 @@ use Hypervel\Database\Query\Grammars\MySqlGrammar; use Hypervel\Database\SQLiteConnection; use Hypervel\Tests\TestCase; +use JsonException; use Mockery as m; use PDO; use ReflectionClass; class DatabaseQueryGrammarTest extends TestCase { + public function testJsonContainsBindingRejectsUnencodableValues(): void + { + $this->expectException(JsonException::class); + + (new Grammar(m::mock(Connection::class)))->prepareBindingForJsonContains(NAN); + } + public function testWrapIdentifierEscapesOneIdentifierWithoutApplyingTheTablePrefix(): void { $connection = m::mock(Connection::class); diff --git a/tests/Database/DatabaseSQLiteQueryGrammarTest.php b/tests/Database/DatabaseSQLiteQueryGrammarTest.php index c4ee59dde..a8b6e136e 100755 --- a/tests/Database/DatabaseSQLiteQueryGrammarTest.php +++ b/tests/Database/DatabaseSQLiteQueryGrammarTest.php @@ -7,10 +7,19 @@ use Hypervel\Database\Connection; use Hypervel\Database\Query\Grammars\SQLiteGrammar; use Hypervel\Tests\TestCase; +use JsonException; use Mockery as m; class DatabaseSQLiteQueryGrammarTest extends TestCase { + public function testUpdateBindingsRejectUnencodableArrays(): void + { + $this->expectException(JsonException::class); + + (new SQLiteGrammar(m::mock(Connection::class))) + ->prepareBindingsForUpdate([], ['payload' => [NAN]]); + } + public function testToRawSql() { $connection = m::mock(Connection::class); From c835b9dc9c69f79cfd52d13a39dfa8ba108ddf5f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:29:09 +0000 Subject: [PATCH 10/15] fix(database): fail loudly on invalid console JSON Make database show and table commands raise the native JSON error at serialization time instead of passing false into Symfony output. Add focused probes for valid output and non-finite metadata so command rendering preserves its existing format while failures retain their real cause. --- src/database/src/Console/ShowCommand.php | 2 +- src/database/src/Console/TableCommand.php | 2 +- tests/Database/DatabaseConsoleJsonTest.php | 92 ++++++++++++++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 tests/Database/DatabaseConsoleJsonTest.php diff --git a/src/database/src/Console/ShowCommand.php b/src/database/src/Console/ShowCommand.php index 77a8b8e53..88c7cbd75 100644 --- a/src/database/src/Console/ShowCommand.php +++ b/src/database/src/Console/ShowCommand.php @@ -121,7 +121,7 @@ protected function display(array $data): void */ protected function displayJson(array $data): void { - $this->output->writeln(json_encode($data)); + $this->output->writeln(json_encode($data, JSON_THROW_ON_ERROR)); } /** diff --git a/src/database/src/Console/TableCommand.php b/src/database/src/Console/TableCommand.php index d5aadb7cb..c9bd00f92 100644 --- a/src/database/src/Console/TableCommand.php +++ b/src/database/src/Console/TableCommand.php @@ -179,7 +179,7 @@ protected function display(array $data): void */ protected function displayJson(array $data): void { - $this->output->writeln(json_encode($data)); + $this->output->writeln(json_encode($data, JSON_THROW_ON_ERROR)); } /** diff --git a/tests/Database/DatabaseConsoleJsonTest.php b/tests/Database/DatabaseConsoleJsonTest.php new file mode 100644 index 000000000..ea19513be --- /dev/null +++ b/tests/Database/DatabaseConsoleJsonTest.php @@ -0,0 +1,92 @@ +tableCommand(); + + $command->renderJson(['value' => 'text']); + + $this->assertSame("{\"value\":\"text\"}\n", $output->fetch()); + } + + public function testTableCommandRejectsUnencodableJson(): void + { + [$command] = $this->tableCommand(); + + $this->expectException(JsonException::class); + + $command->renderJson(['value' => NAN]); + } + + public function testShowCommandRendersValidJson(): void + { + [$command, $output] = $this->showCommand(); + + $command->renderJson(['value' => 'text']); + + $this->assertSame("{\"value\":\"text\"}\n", $output->fetch()); + } + + public function testShowCommandRejectsUnencodableJson(): void + { + [$command] = $this->showCommand(); + + $this->expectException(JsonException::class); + + $command->renderJson(['value' => NAN]); + } + + /** + * @return array{TableCommandProbe, BufferedOutput} + */ + private function tableCommand(): array + { + $output = new BufferedOutput; + $command = new TableCommandProbe; + $command->setOutput(new OutputStyle(new ArrayInput([]), $output)); + + return [$command, $output]; + } + + /** + * @return array{ShowCommandProbe, BufferedOutput} + */ + private function showCommand(): array + { + $output = new BufferedOutput; + $command = new ShowCommandProbe; + $command->setOutput(new OutputStyle(new ArrayInput([]), $output)); + + return [$command, $output]; + } +} + +class TableCommandProbe extends TableCommand +{ + public function renderJson(array $data): void + { + $this->displayJson($data); + } +} + +class ShowCommandProbe extends ShowCommand +{ + public function renderJson(array $data): void + { + $this->displayJson($data); + } +} From 9b68b70f1549229643d2818617dd3469ba25e1e6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:29:23 +0000 Subject: [PATCH 11/15] fix(telescope): harden JSON storage and redaction Store entries through one readable codec, purge only top-level fields that exceed the entry envelope, and keep exception visibility updates and replacement inserts atomic in deterministic family order. Normalize diagnostic objects with fail-loud encoding, parse application responses once, and unify client request and response masking before size checks. Structured JSON and form bodies can no longer fall through to raw storage with configured secrets, while opaque and explicit text payload behavior remains unchanged. Cover maximum-depth storage, field recovery, failure ordering, exception family state, updates, structured and raw redaction, response parsing, and watcher normalization. --- src/telescope/src/ExtractProperties.php | 6 +- .../src/Storage/DatabaseEntriesRepository.php | 74 ++++-- .../src/Watchers/ClientRequestWatcher.php | 101 +++++--- src/telescope/src/Watchers/EventWatcher.php | 4 +- src/telescope/src/Watchers/ModelWatcher.php | 3 +- src/telescope/src/Watchers/RequestWatcher.php | 21 +- tests/Telescope/ExtractPropertiesTest.php | 47 ++++ .../Storage/DatabaseEntriesRepositoryTest.php | 220 ++++++++++++++++++ .../Watchers/ClientRequestWatcherTest.php | 196 ++++++++++++++++ tests/Telescope/Watchers/EventWatcherTest.php | 49 ++++ tests/Telescope/Watchers/ModelWatcherTest.php | 15 ++ .../Watchers/RequestWatchersTest.php | 73 ++++++ 12 files changed, 749 insertions(+), 60 deletions(-) create mode 100644 tests/Telescope/ExtractPropertiesTest.php diff --git a/src/telescope/src/ExtractProperties.php b/src/telescope/src/ExtractProperties.php index 52a25cd0b..423a509e8 100644 --- a/src/telescope/src/ExtractProperties.php +++ b/src/telescope/src/ExtractProperties.php @@ -6,6 +6,7 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Support\Collection; +use Hypervel\Support\Json; use ReflectionClass; class ExtractProperties @@ -17,6 +18,7 @@ class ExtractProperties */ public static function from(mixed $target): array { + // Native encoding captures reflected state instead of an object's published representation. return Collection::make((new ReflectionClass($target))->getProperties()) ->mapWithKeys(function ($property) use ($target) { if (! $property->isInitialized($target)) { @@ -32,11 +34,11 @@ public static function from(mixed $target): array 'class' => get_class($value), 'properties' => method_exists($value, 'formatForTelescope') ? $value->formatForTelescope() - : json_decode(json_encode($value), true), + : Json::decode(json_encode($value, JSON_THROW_ON_ERROR)), ], ]; } - return [$property->getName() => json_decode(json_encode($value), true)]; + return [$property->getName() => Json::decode(json_encode($value, JSON_THROW_ON_ERROR))]; })->toArray(); } } diff --git a/src/telescope/src/Storage/DatabaseEntriesRepository.php b/src/telescope/src/Storage/DatabaseEntriesRepository.php index 03aff7716..a424bcafe 100644 --- a/src/telescope/src/Storage/DatabaseEntriesRepository.php +++ b/src/telescope/src/Storage/DatabaseEntriesRepository.php @@ -10,6 +10,7 @@ use Hypervel\Database\UniqueConstraintViolationException; use Hypervel\Support\Collection; use Hypervel\Support\Facades\DB; +use Hypervel\Support\Json; use Hypervel\Telescope\Contracts\ClearableRepository; use Hypervel\Telescope\Contracts\EntriesRepository; use Hypervel\Telescope\Contracts\PrunableRepository; @@ -18,6 +19,7 @@ use Hypervel\Telescope\EntryType; use Hypervel\Telescope\EntryUpdate; use Hypervel\Telescope\IncomingEntry; +use JsonException; use Throwable; class DatabaseEntriesRepository implements EntriesRepository, ClearableRepository, PrunableRepository, TerminableRepository @@ -126,7 +128,9 @@ public function store(Collection $entries): void $entries->chunk($this->chunkSize)->each(function ($chunked) use ($table) { $table->insert($chunked->map(function ($entry) { - $entry->content = json_encode($entry->content, JSON_INVALID_UTF8_SUBSTITUTE); + /** @var array $content */ + $content = $entry->content; + $entry->content = $this->encodeContent($content); return $entry->toArray(); })->toArray()); @@ -135,6 +139,35 @@ public function store(Collection $entries): void $this->storeTags($entries->pluck('tags', 'uuid')); } + /** + * Encode entry content for storage. + */ + protected function encodeContent(array $content): string + { + try { + return Json::encode($content, JSON_INVALID_UTF8_SUBSTITUTE); + } catch (JsonException $exception) { + if ($exception->getCode() !== JSON_ERROR_DEPTH) { + throw $exception; + } + } + + // A one-key wrapper has the same root depth as the field in the full content array. + foreach ($content as $key => $value) { + try { + Json::encode([$key => $value], JSON_INVALID_UTF8_SUBSTITUTE); + } catch (JsonException $exception) { + if ($exception->getCode() !== JSON_ERROR_DEPTH) { + throw $exception; + } + + $content[$key] = 'Purged By Telescope'; + } + } + + return Json::encode($content, JSON_INVALID_UTF8_SUBSTITUTE); + } + /** * Store the given array of exception entries. */ @@ -144,31 +177,41 @@ protected function storeExceptions(Collection $exceptions): void $occurrences = []; $lastUuids = []; - $chunked->groupBy(fn ($exception) => $exception->familyHash()) + $families = $chunked->groupBy(fn ($exception) => $exception->familyHash()) + ->sortKeys(); + + $families ->each(function ($family, $familyHash) use (&$occurrences, &$lastUuids): void { $occurrences[$familyHash] = $this->countExceptionOccurences($family->first()); $lastUuids[$familyHash] = $family->last()->uuid; - - $this->table('telescope_entries') - ->where('type', EntryType::EXCEPTION) - ->where('family_hash', $familyHash) - ->where('should_display_on_index', true) - ->update(['should_display_on_index' => false]); }); - $this->table('telescope_entries')->insert($chunked->map(function ($exception) use (&$occurrences, $lastUuids) { + $rows = $chunked->map(function ($exception) use (&$occurrences, $lastUuids) { $familyHash = $exception->familyHash(); ++$occurrences[$familyHash]; return array_merge($exception->toArray(), [ 'family_hash' => $familyHash, 'should_display_on_index' => $exception->uuid === $lastUuids[$familyHash], - 'content' => json_encode( - array_merge($exception->content, ['occurrences' => $occurrences[$familyHash]]), - JSON_INVALID_UTF8_SUBSTITUTE + 'content' => $this->encodeContent( + array_merge($exception->content, ['occurrences' => $occurrences[$familyHash]]) ), ]); - })->toArray()); + })->toArray(); + + $connection = DB::connection($this->connection); + + $connection->transaction(function () use ($connection, $families, $rows): void { + $families->each(function ($family, $familyHash) use ($connection): void { + $connection->table('telescope_entries') + ->where('type', EntryType::EXCEPTION) + ->where('family_hash', $familyHash) + ->where('should_display_on_index', true) + ->update(['should_display_on_index' => false]); + }); + + $connection->table('telescope_entries')->insert($rows); + }); }); $this->storeTags($exceptions->pluck('tags', 'uuid')); @@ -231,9 +274,8 @@ public function update(Collection $updates): Collection continue; } - $content = json_encode( - array_merge(json_decode($entry->content, true) ?: [], $update->changes), - JSON_INVALID_UTF8_SUBSTITUTE, + $content = $this->encodeContent( + array_merge(Json::decode($entry->content), $update->changes) ); $this->table('telescope_entries') diff --git a/src/telescope/src/Watchers/ClientRequestWatcher.php b/src/telescope/src/Watchers/ClientRequestWatcher.php index d62f4955e..840a77c12 100644 --- a/src/telescope/src/Watchers/ClientRequestWatcher.php +++ b/src/telescope/src/Watchers/ClientRequestWatcher.php @@ -10,6 +10,7 @@ use Hypervel\Di\Aop\ProceedingJoinPoint; use Hypervel\Http\Client\Request; use Hypervel\Support\Arr; +use Hypervel\Support\Json; use Hypervel\Support\Str; use Hypervel\Telescope\IncomingEntry; use Hypervel\Telescope\Telescope; @@ -223,21 +224,35 @@ protected function formatMultipartData(array $data): array */ protected function payload(array $payload): array|string { - $encoded = json_encode($payload); - $sizeLimit = ($this->options['request_size_limit'] ?? 64) * 1024; + return $this->formatStructuredPayload( + $payload, + Telescope::$hiddenRequestParameters, + ($this->options['request_size_limit'] ?? 64) * 1024, + ); + } - if ($encoded !== false && strlen($encoded) >= $sizeLimit) { - if (! ($this->options['truncate_oversized'] ?? false)) { - return 'Purged By Telescope'; - } + /** + * Format a structured payload for entry storage. + */ + protected function formatStructuredPayload(array $payload, array $hidden, int $sizeLimit): array|string + { + $masked = $this->hideParameters($payload, $hidden); - $masked = $this->hideParameters($payload, Telescope::$hiddenRequestParameters); - $maskedEncoded = json_encode($masked); + // One container of the storage limit is reserved for the entry-content root. + $maximumContainers = Json::MAXIMUM_NESTING_DEPTH - 1; + $encoded = json_encode($masked, JSON_INVALID_UTF8_SUBSTITUTE, $maximumContainers); - return substr($maskedEncoded, 0, $sizeLimit) . ' (truncated...)'; + if ($encoded === false) { + return 'Purged By Telescope'; } - return $this->hideParameters($payload, Telescope::$hiddenRequestParameters); + if (strlen($encoded) >= $sizeLimit) { + return ($this->options['truncate_oversized'] ?? false) + ? substr($encoded, 0, $sizeLimit) . ' (truncated...)' + : 'Purged By Telescope'; + } + + return $masked; } /** @@ -260,20 +275,38 @@ protected function getRequestPayload(RequestInterface $request): array|string } $content = $stream->getContents(); + $contentType = strtolower($request->getHeaderLine('content-type')); + $maximumContainers = Json::MAXIMUM_NESTING_DEPTH - 1; + $decoded = json_decode($content, true, $maximumContainers + 1); + $jsonError = json_last_error(); + + if (is_array($decoded) && $jsonError === JSON_ERROR_NONE) { + return $this->formatStructuredPayload( + $decoded, + Telescope::$hiddenRequestParameters, + $sizeLimit, + ); + } - if (is_array($decoded = json_decode($content, true)) - && json_last_error() === JSON_ERROR_NONE - ) { - $masked = $this->hideParameters($decoded, Telescope::$hiddenRequestParameters); - $encoded = json_encode($masked); + if (str_contains($contentType, 'application/x-www-form-urlencoded')) { + parse_str($content, $form); - if ($encoded !== false && strlen($encoded) >= $sizeLimit) { - return $truncate - ? substr($encoded, 0, $sizeLimit) . ' (truncated...)' - : 'Purged By Telescope'; - } + return $this->formatStructuredPayload( + $form, + Telescope::$hiddenRequestParameters, + $sizeLimit, + ); + } - return $masked; + $firstContentByte = $content[strspn($content, " \t\n\r")] ?? null; + + if ($jsonError !== JSON_ERROR_NONE + && (str_contains($contentType, '/json') + || str_contains($contentType, '+json') + || $firstContentByte === '{' + || $firstContentByte === '[') + ) { + return 'Purged By Telescope'; } if (strlen($content) >= $sizeLimit) { @@ -326,20 +359,20 @@ protected function getResponsePayload(ResponseInterface $response): array|string $sizeLimit = ($this->options['response_size_limit'] ?? 64) * 1024; $content = $stream->getContents(); + $maximumContainers = Json::MAXIMUM_NESTING_DEPTH - 1; + $decoded = json_decode($content, true, $maximumContainers + 1); + $jsonError = json_last_error(); + + if (is_array($decoded) && $jsonError === JSON_ERROR_NONE) { + return $this->formatStructuredPayload( + $decoded, + Telescope::$hiddenResponseParameters, + $sizeLimit, + ); + } - if (is_array($decoded = json_decode($content, true)) - && json_last_error() === JSON_ERROR_NONE - ) { - $masked = $this->hideParameters($decoded, Telescope::$hiddenResponseParameters); - $encoded = json_encode($masked); - - if ($encoded !== false && strlen($encoded) >= $sizeLimit) { - return $truncate - ? substr($encoded, 0, $sizeLimit) . ' (truncated...)' - : 'Purged By Telescope'; - } - - return $masked; + if ($jsonError === JSON_ERROR_DEPTH) { + return 'Purged By Telescope'; } if (Str::startsWith(strtolower($response->getHeaderLine('content-type') ?: ''), 'text/plain')) { diff --git a/src/telescope/src/Watchers/EventWatcher.php b/src/telescope/src/Watchers/EventWatcher.php index 118610137..ea4c5d797 100644 --- a/src/telescope/src/Watchers/EventWatcher.php +++ b/src/telescope/src/Watchers/EventWatcher.php @@ -10,6 +10,7 @@ use Hypervel\Contracts\Queue\ShouldQueue; use Hypervel\Events\Dispatcher; use Hypervel\Support\Collection; +use Hypervel\Support\Json; use Hypervel\Support\Str; use Hypervel\Telescope\ExtractProperties; use Hypervel\Telescope\ExtractTags; @@ -66,10 +67,11 @@ protected function extractPayload(string $event, array $payload): array return ExtractProperties::from($payload[0]); } + // Native encoding captures event object state instead of its published representation. return Collection::make($payload)->map(function ($value) { return is_object($value) ? [ 'class' => get_class($value), - 'properties' => json_decode(json_encode($value), true), + 'properties' => Json::decode(json_encode($value, JSON_THROW_ON_ERROR)), ] : $value; })->toArray(); } diff --git a/src/telescope/src/Watchers/ModelWatcher.php b/src/telescope/src/Watchers/ModelWatcher.php index bc2b34f98..82dd77c17 100644 --- a/src/telescope/src/Watchers/ModelWatcher.php +++ b/src/telescope/src/Watchers/ModelWatcher.php @@ -9,6 +9,7 @@ use Hypervel\Contracts\Foundation\Application; use Hypervel\Database\Eloquent\Model; use Hypervel\Support\Collection; +use Hypervel\Support\Json; use Hypervel\Support\Str; use Hypervel\Telescope\FormatModel; use Hypervel\Telescope\IncomingEntry; @@ -128,7 +129,7 @@ public function recordHydrations(Model $data): void Telescope::recordModelEvent($this->getHydration($modelClass)); } else { if (is_string($entry->content)) { - $entry->content = json_decode($entry->content, true); + $entry->content = Json::decode($entry->content); } ++$entry->content['count']; diff --git a/src/telescope/src/Watchers/RequestWatcher.php b/src/telescope/src/Watchers/RequestWatcher.php index 50af55fb1..269bd1803 100644 --- a/src/telescope/src/Watchers/RequestWatcher.php +++ b/src/telescope/src/Watchers/RequestWatcher.php @@ -15,6 +15,7 @@ use Hypervel\Log\Context\Repository as ContextRepository; use Hypervel\Support\Arr; use Hypervel\Support\Collection; +use Hypervel\Support\Json; use Hypervel\Support\Str; use Hypervel\Telescope\Contracts\EntriesRepository; use Hypervel\Telescope\FormatModel; @@ -183,14 +184,21 @@ protected function response(Response $response): array|string $content = $response->getContent(); if (is_string($content)) { - if (is_array(json_decode($content, true)) - && json_last_error() === JSON_ERROR_NONE - ) { + // One container of the storage limit is reserved for the entry-content root. + $maximumContainers = Json::MAXIMUM_NESTING_DEPTH - 1; + $decoded = json_decode($content, true, $maximumContainers + 1); + $jsonError = json_last_error(); + + if (is_array($decoded) && $jsonError === JSON_ERROR_NONE) { return $this->contentWithinLimits($content) - ? $this->hideParameters(json_decode($content, true), Telescope::$hiddenResponseParameters) + ? $this->hideParameters($decoded, Telescope::$hiddenResponseParameters) : 'Purged By Telescope'; } + if ($jsonError === JSON_ERROR_DEPTH) { + return 'Purged By Telescope'; + } + if (Str::startsWith(strtolower($response->headers->get('Content-Type') ?? ''), 'text/plain')) { return $this->contentWithinLimits($content) ? $content : 'Purged By Telescope'; } @@ -229,6 +237,7 @@ public function contentWithinLimits(string $content): bool */ protected function extractDataFromView(View $view): array { + // Native encoding captures view object state instead of its published representation. return Collection::make($view->getData())->map(function ($value) { if ($value instanceof Model) { return FormatModel::given($value); @@ -238,11 +247,11 @@ protected function extractDataFromView(View $view): array 'class' => get_class($value), 'properties' => method_exists($value, 'formatForTelescope') ? $value->formatForTelescope() - : json_decode(json_encode($value), true), + : Json::decode(json_encode($value, JSON_THROW_ON_ERROR)), ]; } - return json_decode(json_encode($value), true); + return Json::decode(json_encode($value, JSON_THROW_ON_ERROR)); })->toArray(); } diff --git a/tests/Telescope/ExtractPropertiesTest.php b/tests/Telescope/ExtractPropertiesTest.php new file mode 100644 index 000000000..8cf8033ba --- /dev/null +++ b/tests/Telescope/ExtractPropertiesTest.php @@ -0,0 +1,47 @@ +nestedValue(511); + $target = new PropertyTarget((object) ['nested' => $nested]); + + $properties = ExtractProperties::from($target); + + $this->assertSame($nested, $properties['value']['properties']['nested']); + } + + public function testUnencodablePropertyRaisesTheNativeJsonException(): void + { + $this->expectException(JsonException::class); + + ExtractProperties::from(new PropertyTarget(NAN)); + } + + private function nestedValue(int $depth): array + { + $value = 'leaf'; + + for ($index = 0; $index < $depth; ++$index) { + $value = ['value' => $value]; + } + + return $value; + } +} + +class PropertyTarget +{ + public function __construct(public mixed $value) + { + } +} diff --git a/tests/Telescope/Storage/DatabaseEntriesRepositoryTest.php b/tests/Telescope/Storage/DatabaseEntriesRepositoryTest.php index c7e017b0a..931f46b8e 100644 --- a/tests/Telescope/Storage/DatabaseEntriesRepositoryTest.php +++ b/tests/Telescope/Storage/DatabaseEntriesRepositoryTest.php @@ -6,6 +6,7 @@ use Exception; use Hypervel\Support\Facades\DB; +use Hypervel\Support\Json; use Hypervel\Support\Str; use Hypervel\Telescope\Database\Factories\EntryModelFactory; use Hypervel\Telescope\EntryType; @@ -15,6 +16,8 @@ use Hypervel\Telescope\Storage\DatabaseEntriesRepository; use Hypervel\Telescope\Storage\EntryQueryOptions; use Hypervel\Tests\Telescope\FeatureTestCase; +use JsonException; +use TypeError; class DatabaseEntriesRepositoryTest extends FeatureTestCase { @@ -196,6 +199,200 @@ public function testStoreBinaryContent(): void }); } + public function testNormalAndExceptionEntriesRoundTripAtTheMaximumNestingDepth(): void + { + $batchId = (string) Str::uuid(); + $normal = (new IncomingEntry(['nested' => $this->nestedValue(511)])) + ->batchId($batchId) + ->type(EntryType::LOG); + $exception = $this->exceptionEntry($batchId, 511); + $expectedNormalContent = $normal->content; + $repository = $this->app->make(DatabaseEntriesRepository::class); + + $repository->store(collect([$normal, $exception])); + + $normalContent = Json::decode( + DB::table('telescope_entries')->where('uuid', $normal->uuid)->value('content'), + ); + $exceptionContent = Json::decode( + DB::table('telescope_entries')->where('uuid', $exception->uuid)->value('content'), + ); + + $this->assertSame($expectedNormalContent, $normalContent); + $this->assertSame($exception->content['nested'], $exceptionContent['nested']); + $this->assertSame(1, $exceptionContent['occurrences']); + } + + public function testStorePurgesEveryTopLevelFieldOverTheMaximumNestingDepth(): void + { + $batchId = (string) Str::uuid(); + $entry = (new IncomingEntry([ + 'first' => $this->nestedValue(512), + 'second' => $this->nestedValue(512), + 'safe' => 'retained', + ])) + ->batchId($batchId) + ->type(EntryType::LOG) + ->tags(['deep']); + $sibling = (new IncomingEntry(['message' => 'stored'])) + ->batchId($batchId) + ->type(EntryType::LOG) + ->tags(['sibling']); + $repository = $this->app->make(DatabaseEntriesRepository::class); + + $repository->store(collect([$entry, $sibling])); + + $content = Json::decode( + DB::table('telescope_entries')->where('uuid', $entry->uuid)->value('content'), + ); + + $this->assertSame('Purged By Telescope', $content['first']); + $this->assertSame('Purged By Telescope', $content['second']); + $this->assertSame('retained', $content['safe']); + $this->assertDatabaseHas('telescope_entries', ['uuid' => $sibling->uuid]); + $this->assertDatabaseHas('telescope_entries_tags', ['entry_uuid' => $entry->uuid, 'tag' => 'deep']); + $this->assertDatabaseHas('telescope_entries_tags', ['entry_uuid' => $sibling->uuid, 'tag' => 'sibling']); + } + + public function testStoreRethrowsNonDepthErrorsFoundAfterDepthRecoveryStarts(): void + { + // Depth must fail first so the field-level retry exposes the later INF/NAN error. + $entry = (new IncomingEntry([ + 'deep' => $this->nestedValue(512), + 'invalid' => INF, + ]))->type(EntryType::LOG); + $repository = $this->app->make(DatabaseEntriesRepository::class); + + try { + $repository->store(collect([$entry])); + $this->fail('Expected the non-depth JSON encoding error to be rethrown.'); + } catch (JsonException $exception) { + $this->assertSame(JSON_ERROR_INF_OR_NAN, $exception->getCode()); + } + + $this->assertDatabaseMissing('telescope_entries', ['uuid' => $entry->uuid]); + } + + public function testExceptionPurgesDeepContextWithoutLosingFamilyStateOrTags(): void + { + $batchId = (string) Str::uuid(); + $exception = $this->exceptionEntry($batchId, 512)->tags(['deep']); + $repository = $this->app->make(DatabaseEntriesRepository::class); + + $repository->store(collect([$exception])); + + $row = DB::table('telescope_entries')->where('uuid', $exception->uuid)->first(); + $content = Json::decode($row->content); + + $this->assertSame('error', $content['message']); + $this->assertSame('Purged By Telescope', $content['nested']); + $this->assertSame(1, $content['occurrences']); + $this->assertTrue((bool) $row->should_display_on_index); + $this->assertDatabaseHas('telescope_entries_tags', ['entry_uuid' => $exception->uuid, 'tag' => 'deep']); + } + + public function testUnencodableExceptionLeavesThePreviousFamilyEntryVisibleAndStopsTheBatch(): void + { + $batchId = (string) Str::uuid(); + $repository = $this->app->make(DatabaseEntriesRepository::class); + $persisted = $this->exceptionEntry($batchId, 1)->tags(['persisted']); + $repository->store(collect([$persisted])); + + $original = DB::table('telescope_entries')->where('uuid', $persisted->uuid)->first(); + $error = new Exception('error'); + $invalid = (new IncomingExceptionEntry($error, [ + 'file' => 'same.php', + 'line' => 10, + 'message' => 'error', + 'invalid' => INF, + ]))->batchId($batchId)->type(EntryType::EXCEPTION)->tags(['invalid']); + $ordinary = (new IncomingEntry(['message' => 'not stored'])) + ->batchId($batchId) + ->type(EntryType::LOG) + ->tags(['ordinary']); + + try { + $repository->store(collect([$invalid, $ordinary])); + $this->fail('Expected the non-depth JSON encoding error to be rethrown.'); + } catch (JsonException $exception) { + $this->assertSame(JSON_ERROR_INF_OR_NAN, $exception->getCode()); + } + + $retained = DB::table('telescope_entries')->where('uuid', $persisted->uuid)->first(); + + $this->assertSame($original->content, $retained->content); + $this->assertTrue((bool) $retained->should_display_on_index); + $this->assertDatabaseMissing('telescope_entries', ['uuid' => $invalid->uuid]); + $this->assertDatabaseMissing('telescope_entries', ['uuid' => $ordinary->uuid]); + $this->assertDatabaseMissing('telescope_entries_tags', ['entry_uuid' => $invalid->uuid]); + $this->assertDatabaseMissing('telescope_entries_tags', ['entry_uuid' => $ordinary->uuid]); + } + + public function testUpdatePreservesMaximumDepthContentWhileMergingChanges(): void + { + $entry = EntryModelFactory::new()->create(['content' => $this->nestedValue(512)]); + $repository = $this->app->make(DatabaseEntriesRepository::class); + + $repository->update(collect([ + new EntryUpdate($entry->uuid, $entry->type, ['updated' => true]), + ])); + + $content = Json::decode( + DB::table('telescope_entries')->where('uuid', $entry->uuid)->value('content'), + ); + + $this->assertTrue($content['updated']); + unset($content['updated']); + $this->assertSame($entry->content, $content); + } + + public function testUpdatePurgesDeepFieldsAndContinuesWithLaterUpdates(): void + { + $deep = EntryModelFactory::new()->create(['content' => ['existing' => true]]); + $later = EntryModelFactory::new()->create(['content' => ['existing' => true]]); + $repository = $this->app->make(DatabaseEntriesRepository::class); + + $failedUpdates = $repository->update(collect([ + (new EntryUpdate($deep->uuid, $deep->type, ['nested' => $this->nestedValue(512)])) + ->addTags(['updated']), + new EntryUpdate($later->uuid, $later->type, ['later' => true]), + ])); + + $deepContent = Json::decode( + DB::table('telescope_entries')->where('uuid', $deep->uuid)->value('content'), + ); + $laterContent = Json::decode( + DB::table('telescope_entries')->where('uuid', $later->uuid)->value('content'), + ); + + $this->assertTrue($failedUpdates->isEmpty()); + $this->assertTrue($deepContent['existing']); + $this->assertSame('Purged By Telescope', $deepContent['nested']); + $this->assertTrue($laterContent['later']); + $this->assertDatabaseHas('telescope_entries_tags', ['entry_uuid' => $deep->uuid, 'tag' => 'updated']); + } + + public function testUpdateRejectsMalformedAndWrongShapeContentWithoutChangingStoredBytes(): void + { + $repository = $this->app->make(DatabaseEntriesRepository::class); + + foreach (['{invalid' => JsonException::class, 'null' => TypeError::class] as $content => $exceptionClass) { + $entry = EntryModelFactory::new()->create(); + DB::table('telescope_entries')->where('uuid', $entry->uuid)->update(['content' => $content]); + $update = new EntryUpdate($entry->uuid, $entry->type, ['updated' => true]); + + $this->assertThrows( + fn () => $repository->update(collect([$update])), + $exceptionClass, + ); + + $this->assertSame( + $content, + DB::table('telescope_entries')->where('uuid', $entry->uuid)->value('content'), + ); + } + } + public function testStoreExceptionsAggregatesFamiliesWithinEachChunk(): void { $batchId = (string) Str::uuid(); @@ -232,4 +429,27 @@ public function testStoreExceptionsAggregatesFamiliesWithinEachChunk(): void $this->assertSame(1, json_decode($entries[$other->uuid]->content, true)['occurrences']); $this->assertSame(3, json_decode($entries[$last->uuid]->content, true)['occurrences']); } + + private function exceptionEntry(string $batchId, int $nestedDepth): IncomingExceptionEntry + { + $exception = new Exception('error'); + + return (new IncomingExceptionEntry($exception, [ + 'file' => 'same.php', + 'line' => 10, + 'message' => 'error', + 'nested' => $this->nestedValue($nestedDepth), + ]))->batchId($batchId)->type(EntryType::EXCEPTION); + } + + private function nestedValue(int $depth): array + { + $value = 'leaf'; + + for ($index = 0; $index < $depth; ++$index) { + $value = ['value' => $value]; + } + + return $value; + } } diff --git a/tests/Telescope/Watchers/ClientRequestWatcherTest.php b/tests/Telescope/Watchers/ClientRequestWatcherTest.php index 5eaa49066..5832af4a7 100644 --- a/tests/Telescope/Watchers/ClientRequestWatcherTest.php +++ b/tests/Telescope/Watchers/ClientRequestWatcherTest.php @@ -15,11 +15,13 @@ use Hypervel\Foundation\Testing\Concerns\InteractsWithAop; use Hypervel\Http\UploadedFile; use Hypervel\Support\Facades\DB; +use Hypervel\Support\Json; use Hypervel\Telescope\EntryType; use Hypervel\Telescope\Telescope; use Hypervel\Telescope\Watchers\ClientRequestWatcher; use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Tests\Telescope\FeatureTestCase; +use PHPUnit\Framework\Attributes\DataProvider; use Psr\Http\Message\RequestInterface; enum ClientRequestWatcherTestIntTag: int @@ -94,6 +96,55 @@ public function testClientRequestWatcherHidesNestedFalseySecrets(): void $this->assertSame($masked, $entry->content['response']['secret']); } + public function testStructuredRequestAndResponseRetainAndMaskAtTheEntryContentChildLimit(): void + { + Telescope::hideRequestParameters(['password']); + Telescope::hideResponseParameters(['password']); + + $payload = [ + 'password' => 'secret', + 'nested' => $this->nestedValue(Json::MAXIMUM_NESTING_DEPTH - 2), + ]; + $client = $this->makeClient([ + new Response(200, ['Content-Type' => 'application/json'], Json::encode($payload)), + ]); + + $this->executeTransfer( + $client, + new Request('POST', 'https://hypervel.org', ['Content-Type' => 'application/json'], Json::encode($payload)), + ['hypervel_data' => $payload], + ); + + $entry = $this->loadTelescopeEntries()->first(); + + $this->assertSame('********', $entry->content['payload']['password']); + $this->assertSame($payload['nested'], $entry->content['payload']['nested']); + $this->assertSame('********', $entry->content['response']['password']); + $this->assertSame($payload['nested'], $entry->content['response']['nested']); + } + + public function testStructuredRequestAndMislabeledResponsePurgeOverTheEntryContentChildLimit(): void + { + $payload = [ + 'password' => 'secret', + 'nested' => $this->nestedValue(Json::MAXIMUM_NESTING_DEPTH - 1), + ]; + $client = $this->makeClient([ + new Response(200, ['Content-Type' => 'text/html'], Json::encode($payload)), + ]); + + $this->executeTransfer( + $client, + new Request('POST', 'https://hypervel.org', ['Content-Type' => 'application/json'], Json::encode($payload)), + ['hypervel_data' => $payload], + ); + + $entry = $this->loadTelescopeEntries()->first(); + + $this->assertSame('Purged By Telescope', $entry->content['payload']); + $this->assertSame('Purged By Telescope', $entry->content['response']); + } + public function testRawBodyUsesThePsrPayloadFallback(): void { Telescope::hideRequestParameters(['password']); @@ -117,6 +168,140 @@ public function testRawBodyUsesThePsrPayloadFallback(): void ], $entry->content['payload']); } + public function testRawJsonRequestRetainsAndMasksAtTheEntryContentChildLimit(): void + { + Telescope::hideRequestParameters(['password']); + $payload = [ + 'password' => 'secret', + 'nested' => $this->nestedValue(Json::MAXIMUM_NESTING_DEPTH - 2), + ]; + $client = $this->makeClient([new Response(204)]); + + $this->executeTransfer( + $client, + new Request('POST', 'https://hypervel.org/raw', ['Content-Type' => 'application/json'], Json::encode($payload)), + ); + + $entry = $this->loadTelescopeEntries()->first(); + + $this->assertSame('********', $entry->content['payload']['password']); + $this->assertSame($payload['nested'], $entry->content['payload']['nested']); + } + + #[DataProvider('jsonContentTypeProvider')] + public function testMalformedDeclaredJsonRequestIsPurged(string $contentType): void + { + $client = $this->makeClient([new Response(204)]); + + $this->executeTransfer( + $client, + new Request('POST', 'https://hypervel.org/raw', ['Content-Type' => $contentType], '{"password":"secret"'), + ); + + $entry = $this->loadTelescopeEntries()->first(); + + $this->assertSame('Purged By Telescope', $entry->content['payload']); + } + + public static function jsonContentTypeProvider(): array + { + return [ + ['application/json'], + ['application/problem+json'], + ]; + } + + public function testHeaderlessJsonRequestIsMaskedAndDeepHeaderlessJsonIsPurged(): void + { + Telescope::hideRequestParameters(['password']); + $client = $this->makeClient([ + new Response(204), + new Response(204), + ]); + + $this->executeTransfer( + $client, + new Request('POST', 'https://hypervel.org/shallow', body: '{"password":"secret"}'), + ); + $this->executeTransfer( + $client, + new Request('POST', 'https://hypervel.org/deep', body: "\t\n" . Json::encode([ + 'password' => 'secret', + 'nested' => $this->nestedValue(Json::MAXIMUM_NESTING_DEPTH - 1), + ])), + ); + + $entries = $this->loadTelescopeEntries()->keyBy(fn ($entry) => $entry->content['uri']); + + $this->assertSame('********', $entries['https://hypervel.org/shallow']->content['payload']['password']); + $this->assertSame('Purged By Telescope', $entries['https://hypervel.org/deep']->content['payload']); + } + + public function testRawUrlEncodedRequestMasksNestedFields(): void + { + Telescope::hideRequestParameters(['password', 'account.password']); + $client = $this->makeClient([new Response(204)]); + + $this->executeTransfer( + $client, + new Request( + 'POST', + 'https://hypervel.org/form', + ['Content-Type' => 'application/x-www-form-urlencoded; charset=UTF-8'], + 'password=secret&account[password]=nested-secret&name=Taylor', + ), + ); + + $entry = $this->loadTelescopeEntries()->first(); + + $this->assertSame('********', $entry->content['payload']['password']); + $this->assertSame('********', $entry->content['payload']['account']['password']); + $this->assertSame('Taylor', $entry->content['payload']['name']); + } + + public function testExplicitPlainTextRequestRetainsItsRawBody(): void + { + $client = $this->makeClient([new Response(204)]); + + $this->executeTransfer( + $client, + new Request('POST', 'https://hypervel.org/text', ['Content-Type' => 'text/plain'], 'password=secret'), + ); + + $entry = $this->loadTelescopeEntries()->first(); + + $this->assertSame('password=secret', $entry->content['payload']); + } + + public function testValidScalarJsonRequestRetainsItsRawRepresentation(): void + { + $client = $this->makeClient([new Response(204)]); + + $this->executeTransfer( + $client, + new Request('POST', 'https://hypervel.org/scalar', ['Content-Type' => 'application/json'], '42'), + ); + + $entry = $this->loadTelescopeEntries()->first(); + + $this->assertSame('42', $entry->content['payload']); + } + + public function testUnencodableStructuredRequestIsPurged(): void + { + $client = $this->makeClient([new Response(204)]); + + $this->executeTransfer( + $client, + new Request('POST', 'https://hypervel.org/invalid'), + ['hypervel_data' => ['invalid' => INF]], + ); + + $entry = $this->loadTelescopeEntries()->first(); + + $this->assertSame('Purged By Telescope', $entry->content['payload']); + } + public function testClientRequestWatcherRegistersRedirectResponse() { $client = $this->makeClient([ @@ -895,6 +1080,17 @@ private function makeClient(array $responses, array $config = []): Client ])); } + private function nestedValue(int $depth): array + { + $value = 'leaf'; + + for ($index = 0; $index < $depth; ++$index) { + $value = ['value' => $value]; + } + + return $value; + } + private function executeTransfer( Client $client, RequestInterface $request, diff --git a/tests/Telescope/Watchers/EventWatcherTest.php b/tests/Telescope/Watchers/EventWatcherTest.php index ce3c65e5d..777197c59 100644 --- a/tests/Telescope/Watchers/EventWatcherTest.php +++ b/tests/Telescope/Watchers/EventWatcherTest.php @@ -13,6 +13,7 @@ use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\Telescope\FeatureTestCase; +use JsonException; use PHPUnit\Framework\Attributes\DataProvider; use ReflectionMethod; use Telescope\Dummies\DummyEvent; @@ -221,6 +222,54 @@ public function testClosureListenerPathContainingAtIsNotTreatedAsAClass(): void $filesystem->deleteDirectory($directory); } } + + public function testPlainObjectPayloadRoundTripsAtTheMaximumNestingDepth(): void + { + $nested = $this->nestedValue(511); + $payload = (new ReflectionMethod(EventWatcher::class, 'extractPayload'))->invoke( + $this->app->make(EventWatcher::class), + 'custom-event', + [(object) ['nested' => $nested]], + ); + + $this->assertSame($nested, $payload[0]['properties']['nested']); + } + + public function testEventWatcherPurgesLiftedDeepPayloadWithoutLosingTheEntry(): void + { + $this->app->make(EventWatcher::class)->recordEvent('custom-event', [ + (object) ['nested' => $this->nestedValue(511)], + ]); + + $entry = $this->loadTelescopeEntries()->first(); + + $this->assertSame(EntryType::EVENT, $entry->type); + $this->assertSame('custom-event', $entry->content['name']); + $this->assertSame('Purged By Telescope', $entry->content['payload']); + $this->assertSame([], $entry->content['listeners']); + } + + public function testUnencodablePlainObjectPayloadRaisesTheNativeJsonException(): void + { + $this->expectException(JsonException::class); + + (new ReflectionMethod(EventWatcher::class, 'extractPayload'))->invoke( + $this->app->make(EventWatcher::class), + 'custom-event', + [(object) ['value' => NAN]], + ); + } + + private function nestedValue(int $depth): array + { + $value = 'leaf'; + + for ($index = 0; $index < $depth; ++$index) { + $value = ['value' => $value]; + } + + return $value; + } } namespace Telescope\Dummies; diff --git a/tests/Telescope/Watchers/ModelWatcherTest.php b/tests/Telescope/Watchers/ModelWatcherTest.php index c71800713..f8d292147 100644 --- a/tests/Telescope/Watchers/ModelWatcherTest.php +++ b/tests/Telescope/Watchers/ModelWatcherTest.php @@ -76,6 +76,21 @@ public function testModelWatcherRegistersHydrationEntry() $this->assertCount(1, $this->loadTelescopeEntries()); } + public function testModelWatcherIncrementsHydrationEntryAfterItsContentWasStored(): void + { + $watcher = $this->app->make(ModelWatcher::class); + $model = new UserEloquent; + + $watcher->recordHydrations($model); + $this->terminateTelescope(); + + $this->assertIsString($watcher->getHydration(UserEloquent::class)->content); + + $watcher->recordHydrations($model); + + $this->assertSame(2, $watcher->getHydration(UserEloquent::class)->content['count']); + } + protected function createUser() { UserEloquent::create([ diff --git a/tests/Telescope/Watchers/RequestWatchersTest.php b/tests/Telescope/Watchers/RequestWatchersTest.php index 29e4281ee..4902354c5 100644 --- a/tests/Telescope/Watchers/RequestWatchersTest.php +++ b/tests/Telescope/Watchers/RequestWatchersTest.php @@ -12,6 +12,7 @@ use Hypervel\Support\Facades\Response; use Hypervel\Support\Facades\Route; use Hypervel\Support\Facades\View; +use Hypervel\Support\Json; use Hypervel\Telescope\EntryType; use Hypervel\Telescope\Telescope; use Hypervel\Telescope\Watchers\RequestWatcher; @@ -45,6 +46,33 @@ public function testRequestWatcherRegistersRequests(): void $this->assertSame(5000, $entry->content['duration']); } + public function testRequestWatcherRecordsResponseAtTheEntryContentChildLimit(): void + { + $response = $this->nestedValue(Json::MAXIMUM_NESTING_DEPTH - 1); + Route::get('/deep-response', fn () => response()->json($response)); + + $this->get('/deep-response')->assertSuccessful(); + + $entry = $this->loadTelescopeEntries()->first(); + + $this->assertSame($response, $entry->content['response']); + } + + public function testRequestWatcherPurgesResponseOverTheEntryContentChildLimitWithoutMediaTypeGate(): void + { + $response = $this->nestedValue(Json::MAXIMUM_NESTING_DEPTH); + Route::get('/deep-response', fn () => Response::make( + Json::encode($response), + headers: ['Content-Type' => 'text/html'], + )); + + $this->get('/deep-response')->assertSuccessful(); + + $entry = $this->loadTelescopeEntries()->first(); + + $this->assertSame('Purged By Telescope', $entry->content['response']); + } + public function testRequestWatcherRegisters404() { $this->get('/whatever'); @@ -153,6 +181,27 @@ public function testRequestWatcherHidesNestedFalseySecrets(): void $this->assertSame($masked, $entry->content['response']['secret']); } + public function testRequestWatcherPurgesDeepProgrammaticPayloadAndSessionWithoutLosingTheEntry(): void + { + $deep = $this->nestedValue(Json::MAXIMUM_NESTING_DEPTH - 1); + + Route::post('/deep-input', function (Request $request) use ($deep) { + $request->merge(['deep' => $deep]); + $request->session()->put('deep', $deep); + + return 'ok'; + })->middleware(StartSession::class); + + $this->post('/deep-input')->assertSuccessful(); + + $entry = $this->loadTelescopeEntries()->first(); + + $this->assertSame(EntryType::REQUEST, $entry->type); + $this->assertSame('Purged By Telescope', $entry->content['payload']); + $this->assertSame('Purged By Telescope', $entry->content['session']); + $this->assertSame('HTML Response', $entry->content['response']); + } + public function testRequestWatcherAppliesExactByteLimit(): void { $watcher = new RequestWatcher(['size_limit' => 1]); @@ -288,6 +337,19 @@ public function testRequestWatcherStoresFacadeContextWhenPresent() $this->assertSame(['api_key' => 'secret'], $entry->content['context']['hidden']); } + public function testRepositoryEncodingFailureDoesNotInterruptTheRequestOrPublishTheDiagnosticBatch(): void + { + Route::get('/invalid-context', function () { + ContextRepository::getInstance()->add('invalid', INF); + + return 'ok'; + }); + + $this->get('/invalid-context')->assertSuccessful(); + + $this->assertCount(0, $this->loadTelescopeEntries()); + } + public function testRequestWatcherOmitsFacadeContextWhenAbsent() { Route::get('/no-context', fn () => 'ok'); @@ -310,6 +372,17 @@ public function testRequestWatcherRecordsCoroutineContext() $this->assertArrayHasKey('coroutine_context', $entry->content); $this->assertIsArray($entry->content['coroutine_context']); } + + private function nestedValue(int $depth): array + { + $value = 'leaf'; + + for ($index = 0; $index < $depth; ++$index) { + $value = ['value' => $value]; + } + + return $value; + } } class FormatForTelescopeClass From ae127864312e60c2bf70667ac745c6465edda681 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:29:36 +0000 Subject: [PATCH 12/15] fix(foundation): reject corrupt package manifests Distinguish missing Composer metadata from malformed or structurally invalid metadata during framework and Testbench package discovery. Validate package names, versions, and extra.hypervel containers only when they are consumed, preserving wildcard and package-specific ignore semantics. Share focused package-name and Hypervel-extra readers without adding a parser abstraction, keep protected formatting parity, and fail before publishing a replacement manifest. Cover root and installed metadata, ignored packages, cache preservation, test-state registration, and subprocess startup diagnostics. --- src/foundation/src/PackageManifest.php | 138 +++++-- .../src/Foundation/PackageManifest.php | 18 +- .../FoundationPackageManifestTest.php | 369 ++++++++++++++---- .../PackageManifest/build-manifest.php | 3 +- .../PackageManifestPackageTesterTest.php | 105 ++++- .../Foundation/PackageManifestTest.php | 69 +++- .../PHPUnit/TestStateRegistrarsTest.php | 32 +- 7 files changed, 604 insertions(+), 130 deletions(-) diff --git a/src/foundation/src/PackageManifest.php b/src/foundation/src/PackageManifest.php index 6c3653139..623e31c6d 100644 --- a/src/foundation/src/PackageManifest.php +++ b/src/foundation/src/PackageManifest.php @@ -8,7 +8,9 @@ use Hypervel\Filesystem\Filesystem; use Hypervel\Support\Collection; use Hypervel\Support\Env; +use Hypervel\Support\Json; use RuntimeException; +use UnexpectedValueException; class PackageManifest { @@ -189,40 +191,74 @@ protected function getManifest(): array */ public static function discoverInstalledPackages(Filesystem $files, string $vendorPath, array $baseIgnore): array { - $packages = []; + if (in_array('*', $baseIgnore, true)) { + return []; + } - if ($files->exists($path = $vendorPath . '/composer/installed.json')) { - $installed = json_decode($files->get($path), true); + $path = $vendorPath . '/composer/installed.json'; - if (is_array($installed)) { - $installedPackages = $installed['packages'] ?? $installed; + if (! $files->exists($path)) { + return []; + } - if (is_array($installedPackages)) { - $packages = $installedPackages; - } - } + $installed = Json::decode($files->get($path)); + + if (! is_array($installed)) { + throw new UnexpectedValueException("Composer metadata [{$path}] must contain an array."); } + $installedPackages = array_key_exists('packages', $installed) + ? $installed['packages'] + : $installed; + + if (! is_array($installedPackages)) { + throw new UnexpectedValueException("Composer metadata [{$path}] member [packages] must contain an array."); + } + + $packages = []; $ignore = $baseIgnore; - return (new Collection($packages))->filter(function (mixed $package): bool { - return is_array($package) && is_string($package['name'] ?? null); - })->mapWithKeys(function (array $package) use ($vendorPath) { - $configuration = $package['extra']['hypervel'] ?? []; + foreach ($installedPackages as $index => $package) { + $location = "package [{$index}] in [{$path}]"; + + if (! is_array($package)) { + throw new UnexpectedValueException("Composer metadata {$location} must contain an array."); + } + + $name = static::packageName($package, $location, $vendorPath); + + if (in_array($name, $baseIgnore, true)) { + continue; + } + + $version = $package['version'] ?? null; - if (! is_array($configuration)) { - $configuration = []; + if (! is_null($version) && ! is_string($version)) { + throw new UnexpectedValueException( + "Composer metadata package [{$name}] in [{$path}] member [version] must be a string or null." + ); } - return [static::formatPackageName($package['name'], $vendorPath) => [ + $configuration = static::hypervelExtra($package, $location); + + $packages[$name] = [ ...$configuration, - 'version' => $package['version'] ?? null, - ]]; - })->each(function (array $configuration) use (&$ignore) { + 'version' => $version, + ]; + } + + foreach ($packages as $configuration) { $ignore = array_merge($ignore, (array) ($configuration['dont-discover'] ?? [])); - })->reject(function (array $configuration, string $package) use ($ignore) { - return in_array('*', $ignore, true) || in_array($package, $ignore, true); - })->filter()->all(); + } + + $ignoreAll = in_array('*', $ignore, true); + + return array_filter( + $packages, + fn (int|string $package): bool => ! $ignoreAll + && ! in_array((string) $package, $ignore, true), + ARRAY_FILTER_USE_KEY + ); } /** @@ -258,6 +294,48 @@ protected static function formatPackageName(string $package, string $vendorPath) return str_replace($vendorPath . '/', '', $package); } + /** + * Get the formatted package name from Composer metadata. + */ + protected static function packageName(array $package, string $location, string $vendorPath): string + { + $name = $package['name'] ?? null; + + if (! is_string($name) || $name === '') { + throw new UnexpectedValueException( + "Composer metadata {$location} member [name] must be a non-empty string." + ); + } + + $formatted = static::formatPackageName($name, $vendorPath); + + if ($formatted === '') { + throw new UnexpectedValueException("Composer metadata {$location} has an empty formatted package name."); + } + + return $formatted; + } + + /** + * Get the Hypervel configuration from Composer metadata. + */ + protected static function hypervelExtra(array $package, string $location): array + { + $extra = $package['extra'] ?? null; + + if (! is_array($extra) || ! array_key_exists('hypervel', $extra)) { + return []; + } + + if (! is_array($extra['hypervel'])) { + throw new UnexpectedValueException( + "Composer metadata {$location} member [extra.hypervel] must contain an array." + ); + } + + return $extra['hypervel']; + } + /** * Get the package names ignored by root composer metadata. * @@ -275,23 +353,19 @@ public static function packagesToIgnoreFromComposer(Filesystem $files, string $b */ public static function rootHypervelExtra(Filesystem $files, string $basePath, string $key): mixed { - if (! $files->isFile($basePath . '/composer.json')) { + $path = $basePath . '/composer.json'; + + if (! $files->isFile($path)) { return null; } - $composer = json_decode($files->get( - $basePath . '/composer.json' - ), true); + $composer = Json::decode($files->get($path)); if (! is_array($composer)) { - return null; + throw new UnexpectedValueException("Composer metadata [{$path}] must contain an array."); } - $hypervel = $composer['extra']['hypervel'] ?? null; - - if (! is_array($hypervel)) { - return null; - } + $hypervel = static::hypervelExtra($composer, "root package in [{$path}]"); return $hypervel[$key] ?? null; } diff --git a/src/testbench/src/Foundation/PackageManifest.php b/src/testbench/src/Foundation/PackageManifest.php index 945504dc7..6a978a649 100644 --- a/src/testbench/src/Foundation/PackageManifest.php +++ b/src/testbench/src/Foundation/PackageManifest.php @@ -10,6 +10,7 @@ use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Override; +use UnexpectedValueException; use function Hypervel\Testbench\is_testbench_cli; use function Hypervel\Testbench\package_path; @@ -134,19 +135,22 @@ protected function providersFromRoot(): array { $package = $this->providersFromTestbench(); - if (! is_array($package)) { + if ($package === null) { return []; } + $composerFile = package_path('composer.json'); + $location = "root package in [{$composerFile}]"; + return [ - $this->format($package['name']) => $package['extra']['hypervel'] ?? [], + static::packageName($package, $location, $this->vendorPath) => static::hypervelExtra($package, $location), ]; } /** * Get the root package composer metadata. * - * @return null|array{name: string, extra?: array{hypervel?: array}} + * @return null|array */ protected function providersFromTestbench(): ?array { @@ -155,7 +159,13 @@ protected function providersFromTestbench(): ?array // clone manifest. if ((is_testbench_cli() || Env::has('TESTBENCH_PACKAGE_TESTER')) && is_file($composerFile = package_path('composer.json'))) { - return $this->files->json($composerFile); + $package = $this->files->json($composerFile, JSON_THROW_ON_ERROR); + + if (! is_array($package)) { + throw new UnexpectedValueException("Composer metadata [{$composerFile}] must contain an array."); + } + + return $package; } return null; diff --git a/tests/Foundation/FoundationPackageManifestTest.php b/tests/Foundation/FoundationPackageManifestTest.php index 2f05ec9e4..77baa1c78 100644 --- a/tests/Foundation/FoundationPackageManifestTest.php +++ b/tests/Foundation/FoundationPackageManifestTest.php @@ -6,8 +6,11 @@ use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\PackageManifest; +use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\TestCase; +use JsonException; use RuntimeException; +use UnexpectedValueException; class FoundationPackageManifestTest extends TestCase { @@ -15,32 +18,26 @@ class FoundationPackageManifestTest extends TestCase private string $manifestPath; - /** - * Temporary directories created by this test. - * - * @var array - */ - private array $tempDirectories = []; + private Filesystem $filesystem; + + private string $tempDirectory; protected function setUp(): void { parent::setUp(); + $this->filesystem = new Filesystem; $this->basePath = __DIR__ . '/Fixtures'; - $this->manifestPath = sys_get_temp_dir() . '/hypervel_test_packages_' . getmypid() . '.php'; + $this->tempDirectory = ParallelTesting::tempDir('FoundationPackageManifestTest'); + $this->manifestPath = $this->tempDirectory . '/packages.php'; - @unlink($this->manifestPath); + $this->filesystem->deleteDirectory($this->tempDirectory); + $this->filesystem->ensureDirectoryExists($this->tempDirectory); } protected function tearDown(): void { - @unlink($this->manifestPath); - - $filesystem = new Filesystem; - - foreach ($this->tempDirectories as $directory) { - $filesystem->deleteDirectory($directory); - } + $this->filesystem->deleteDirectory($this->tempDirectory); PackageManifest::flushState(); @@ -49,18 +46,15 @@ protected function tearDown(): void private function makeManifest(): PackageManifest { - return new PackageManifest(new Filesystem, $this->basePath, $this->manifestPath); + return new PackageManifest($this->filesystem, $this->basePath, $this->manifestPath); } private function makeTempComposerRoot(string $name): string { - $path = sys_get_temp_dir() . '/hypervel_package_manifest_' . getmypid() . '_' . $name; - $filesystem = new Filesystem; - - $filesystem->deleteDirectory($path); - $filesystem->ensureDirectoryExists($path . '/vendor/composer'); + $path = $this->tempDirectory . '/' . $name; - $this->tempDirectories[] = $path; + $this->filesystem->deleteDirectory($path); + $this->filesystem->ensureDirectoryExists($path . '/vendor/composer'); return $path; } @@ -192,93 +186,318 @@ public function testDiscoverInstalledPackagesReturnsEmptyArrayForMissingInstalle ); } - public function testDiscoverInstalledPackagesReturnsEmptyArrayForMalformedInstalledJson(): void + public function testDiscoverInstalledPackagesFailsForMalformedInstalledJson(): void { - $filesystem = new Filesystem; $basePath = $this->makeTempComposerRoot('malformed-installed-json'); - $filesystem->put($basePath . '/vendor/composer/installed.json', '{'); + $this->filesystem->put($basePath . '/vendor/composer/installed.json', '{'); + + $this->expectException(JsonException::class); + $this->expectExceptionMessage('Syntax error'); + + PackageManifest::discoverInstalledPackages($this->filesystem, $basePath . '/vendor', []); + } + + public function testRootWildcardSkipsMalformedInstalledJsonBeforeParsing(): void + { + $basePath = $this->makeTempComposerRoot('wildcard-malformed-installed-json'); + $this->filesystem->put($basePath . '/composer.json', json_encode([ + 'extra' => [ + 'hypervel' => [ + 'dont-discover' => ['*'], + ], + ], + ], JSON_THROW_ON_ERROR)); + $this->filesystem->put($basePath . '/vendor/composer/installed.json', '{'); + + $ignore = PackageManifest::packagesToIgnoreFromComposer($this->filesystem, $basePath); $this->assertSame( [], - PackageManifest::discoverInstalledPackages($filesystem, $basePath . '/vendor', []) + PackageManifest::discoverInstalledPackages($this->filesystem, $basePath . '/vendor', $ignore) ); } - public function testDiscoverInstalledPackagesReturnsEmptyArrayForMalformedPackagesShape(): void + public function testDiscoverInstalledPackagesFailsForNonArrayRoot(): void + { + $basePath = $this->makeTempComposerRoot('non-array-installed-root'); + $path = $basePath . '/vendor/composer/installed.json'; + $this->filesystem->put($path, 'null'); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage("Composer metadata [{$path}] must contain an array."); + + PackageManifest::discoverInstalledPackages($this->filesystem, $basePath . '/vendor', []); + } + + public function testDiscoverInstalledPackagesFailsForNonArrayPackagesMember(): void { - $filesystem = new Filesystem; $basePath = $this->makeTempComposerRoot('malformed-packages-shape'); - $filesystem->put($basePath . '/vendor/composer/installed.json', json_encode([ + $path = $basePath . '/vendor/composer/installed.json'; + $this->filesystem->put($path, json_encode([ 'packages' => 'invalid', ], JSON_THROW_ON_ERROR)); - $this->assertSame( - [], - PackageManifest::discoverInstalledPackages($filesystem, $basePath . '/vendor', []) + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage("Composer metadata [{$path}] member [packages] must contain an array."); + + PackageManifest::discoverInstalledPackages($this->filesystem, $basePath . '/vendor', []); + } + + public function testDiscoverInstalledPackagesFailsForNonArrayPackageEntry(): void + { + $basePath = $this->makeTempComposerRoot('non-array-package-entry'); + $path = $basePath . '/vendor/composer/installed.json'; + $this->filesystem->put($path, json_encode([ + 'packages' => ['invalid'], + ], JSON_THROW_ON_ERROR)); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage("Composer metadata package [0] in [{$path}] must contain an array."); + + PackageManifest::discoverInstalledPackages($this->filesystem, $basePath . '/vendor', []); + } + + public function testDiscoverInstalledPackagesFailsForNamelessPackageEntry(): void + { + $basePath = $this->makeTempComposerRoot('nameless-package-entry'); + $path = $basePath . '/vendor/composer/installed.json'; + $this->filesystem->put($path, json_encode([ + 'packages' => [['version' => 'v1.0.0']], + ], JSON_THROW_ON_ERROR)); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage( + "Composer metadata package [0] in [{$path}] member [name] must be a non-empty string." ); + + PackageManifest::discoverInstalledPackages($this->filesystem, $basePath . '/vendor', []); } - public function testDiscoverInstalledPackagesSkipsMalformedPackageEntries(): void + public function testDiscoverInstalledPackagesFailsForEmptyFormattedPackageName(): void { - $filesystem = new Filesystem; - $basePath = $this->makeTempComposerRoot('malformed-package-entries'); - $filesystem->put($basePath . '/vendor/composer/installed.json', json_encode([ + $basePath = $this->makeTempComposerRoot('empty-formatted-package-name'); + $vendorPath = $basePath . '/vendor'; + $path = $vendorPath . '/composer/installed.json'; + $this->filesystem->put($path, json_encode([ + 'packages' => [['name' => $vendorPath . '/']], + ], JSON_THROW_ON_ERROR)); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage( + "Composer metadata package [0] in [{$path}] has an empty formatted package name." + ); + + PackageManifest::discoverInstalledPackages($this->filesystem, $vendorPath, []); + } + + public function testDiscoverInstalledPackagesFailsForInvalidVersion(): void + { + $basePath = $this->makeTempComposerRoot('invalid-version'); + $path = $basePath . '/vendor/composer/installed.json'; + $this->filesystem->put($path, json_encode([ + 'packages' => [[ + 'name' => 'vendor/package', + 'version' => [], + ]], + ], JSON_THROW_ON_ERROR)); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage( + "Composer metadata package [vendor/package] in [{$path}] member [version] must be a string or null." + ); + + PackageManifest::discoverInstalledPackages($this->filesystem, $basePath . '/vendor', []); + } + + public function testDiscoverInstalledPackagesFailsForInvalidHypervelExtra(): void + { + $basePath = $this->makeTempComposerRoot('invalid-package-hypervel-extra'); + $path = $basePath . '/vendor/composer/installed.json'; + $this->filesystem->put($path, json_encode([ + 'packages' => [[ + 'name' => 'vendor/package', + 'extra' => ['hypervel' => 'invalid'], + ]], + ], JSON_THROW_ON_ERROR)); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage( + "Composer metadata package [0] in [{$path}] member [extra.hypervel] must contain an array." + ); + + PackageManifest::discoverInstalledPackages($this->filesystem, $basePath . '/vendor', []); + } + + public function testSpecificallyIgnoredPackagesSkipInvalidConsumedMetadata(): void + { + $basePath = $this->makeTempComposerRoot('ignored-invalid-metadata'); + $path = $basePath . '/vendor/composer/installed.json'; + $this->filesystem->put($path, json_encode([ 'packages' => [ - 'invalid', [ - 'version' => 'v1.0.0', + 'name' => 'vendor/invalid-version', + 'version' => [], ], [ - 'name' => 'vendor-a/package-a', - 'version' => 'v1.0.0', + 'name' => 'vendor/invalid-extra', + 'extra' => ['hypervel' => 'invalid'], ], - [ - 'name' => 'vendor-a/package-b', - 'version' => 'v2.0.0', - 'extra' => [ - 'hypervel' => 'invalid', + ], + ], JSON_THROW_ON_ERROR)); + + $this->assertSame( + [], + PackageManifest::discoverInstalledPackages( + $this->filesystem, + $basePath . '/vendor', + ['vendor/invalid-version', 'vendor/invalid-extra'] + ) + ); + } + + public function testDiscoverInstalledPackagesToleratesNonArrayParentExtra(): void + { + $basePath = $this->makeTempComposerRoot('non-array-parent-extra'); + $path = $basePath . '/vendor/composer/installed.json'; + $this->filesystem->put($path, json_encode([ + 'packages' => [[ + 'name' => 'vendor/package', + 'extra' => 'invalid', + ]], + ], JSON_THROW_ON_ERROR)); + + $this->assertSame( + ['vendor/package' => ['version' => null]], + PackageManifest::discoverInstalledPackages($this->filesystem, $basePath . '/vendor', []) + ); + } + + public function testDiscoverInstalledPackagesPreservesConsumerOwnedHypervelValues(): void + { + $basePath = $this->makeTempComposerRoot('consumer-owned-hypervel-values'); + $path = $basePath . '/vendor/composer/installed.json'; + $this->filesystem->put($path, json_encode([ + 'packages' => [[ + 'name' => 'vendor/package', + 'version' => 'v1.2.3', + 'extra' => [ + 'hypervel' => [ + 'providers' => 'Vendor\Package\Provider', + 'aliases' => ['Package' => 'Vendor\Package\Facade'], ], ], - ], + ]], ], JSON_THROW_ON_ERROR)); $this->assertSame( [ - 'vendor-a/package-a' => [ - 'version' => 'v1.0.0', - ], - 'vendor-a/package-b' => [ - 'version' => 'v2.0.0', + 'vendor/package' => [ + 'providers' => 'Vendor\Package\Provider', + 'aliases' => ['Package' => 'Vendor\Package\Facade'], + 'version' => 'v1.2.3', ], ], - PackageManifest::discoverInstalledPackages($filesystem, $basePath . '/vendor', []) + PackageManifest::discoverInstalledPackages($this->filesystem, $basePath . '/vendor', []) ); } - public function testPackagesToIgnoreFromComposerReturnsEmptyArrayForMalformedComposerJson(): void + public function testRootHypervelExtraReturnsNullForMissingComposerJson(): void + { + $basePath = $this->makeTempComposerRoot('missing-root-composer-json'); + + $this->assertNull(PackageManifest::rootHypervelExtra($this->filesystem, $basePath, 'test-state')); + $this->assertSame([], PackageManifest::packagesToIgnoreFromComposer($this->filesystem, $basePath)); + } + + public function testRootHypervelExtraFailsForMalformedComposerJson(): void { - $filesystem = new Filesystem; $basePath = $this->makeTempComposerRoot('malformed-composer-json'); - $filesystem->put($basePath . '/composer.json', '{'); + $this->filesystem->put($basePath . '/composer.json', '{'); - $this->assertSame( - [], - PackageManifest::packagesToIgnoreFromComposer($filesystem, $basePath) + $this->expectException(JsonException::class); + $this->expectExceptionMessage('Syntax error'); + + PackageManifest::rootHypervelExtra($this->filesystem, $basePath, 'test-state'); + } + + public function testRootHypervelExtraFailsForNonArrayRoot(): void + { + $basePath = $this->makeTempComposerRoot('non-array-root-composer'); + $path = $basePath . '/composer.json'; + $this->filesystem->put($path, 'null'); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage("Composer metadata [{$path}] must contain an array."); + + PackageManifest::rootHypervelExtra($this->filesystem, $basePath, 'test-state'); + } + + public function testRootHypervelExtraFailsForInvalidExplicitHypervelMetadata(): void + { + $basePath = $this->makeTempComposerRoot('invalid-root-hypervel-metadata'); + $path = $basePath . '/composer.json'; + $this->filesystem->put($path, json_encode([ + 'extra' => ['hypervel' => 'invalid'], + ], JSON_THROW_ON_ERROR)); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage( + "Composer metadata root package in [{$path}] member [extra.hypervel] must contain an array." ); + + PackageManifest::rootHypervelExtra($this->filesystem, $basePath, 'test-state'); } - public function testRootHypervelExtraReturnsNullForMalformedHypervelMetadata(): void + public function testRootHypervelExtraToleratesNonArrayParentExtra(): void { - $filesystem = new Filesystem; - $basePath = $this->makeTempComposerRoot('malformed-root-hypervel-metadata'); - $filesystem->put($basePath . '/composer.json', json_encode([ + $basePath = $this->makeTempComposerRoot('non-array-root-extra'); + $this->filesystem->put($basePath . '/composer.json', json_encode([ + 'extra' => 'invalid', + ], JSON_THROW_ON_ERROR)); + + $this->assertNull(PackageManifest::rootHypervelExtra($this->filesystem, $basePath, 'test-state')); + } + + public function testRootHypervelExtraPreservesConsumerOwnedValues(): void + { + $basePath = $this->makeTempComposerRoot('consumer-owned-root-extra'); + $this->filesystem->put($basePath . '/composer.json', json_encode([ 'extra' => [ - 'hypervel' => 'invalid', + 'hypervel' => [ + 'test-state' => 'Vendor\Package\Registrar', + 'dont-discover' => ['vendor/package'], + ], ], ], JSON_THROW_ON_ERROR)); - $this->assertNull(PackageManifest::rootHypervelExtra($filesystem, $basePath, 'test-state')); - $this->assertSame([], PackageManifest::packagesToIgnoreFromComposer($filesystem, $basePath)); + $this->assertSame( + 'Vendor\Package\Registrar', + PackageManifest::rootHypervelExtra($this->filesystem, $basePath, 'test-state') + ); + $this->assertSame( + ['vendor/package'], + PackageManifest::packagesToIgnoreFromComposer($this->filesystem, $basePath) + ); + } + + public function testBuildFailurePreservesExistingManifest(): void + { + $basePath = $this->makeTempComposerRoot('build-failure'); + $manifestPath = $basePath . '/packages.php'; + $existingManifest = " ['version' => 'v1.0.0']];"; + $this->filesystem->put($basePath . '/composer.json', '{}'); + $this->filesystem->put($basePath . '/vendor/composer/installed.json', '{'); + $this->filesystem->put($manifestPath, $existingManifest); + $manifest = new PackageManifest($this->filesystem, $basePath, $manifestPath); + + try { + $manifest->build(); + $this->fail('Expected malformed installed metadata to fail the manifest build.'); + } catch (JsonException $exception) { + $this->assertSame('Syntax error', $exception->getMessage()); + } + + $this->assertSame($existingManifest, $this->filesystem->get($manifestPath)); } public function testVersionReturnsPackageVersion() @@ -349,19 +568,21 @@ public function testIgnoreSpecificPackage() $this->assertContains('Hypervel\Tests\Foundation\Bootstrap\TestTwoServiceProvider', $providers); } - public function testManifestIsCachedAfterFirstRead() + public function testManifestIsCachedAfterFirstRead(): void { - $manifest = $this->makeManifest(); - - // First call builds and caches - $providers1 = $manifest->providers(); + $basePath = $this->makeTempComposerRoot('cached-manifest'); + $this->filesystem->copy( + $this->basePath . '/vendor/composer/installed.json', + $basePath . '/vendor/composer/installed.json' + ); + $manifest = new PackageManifest($this->filesystem, $basePath, $this->manifestPath); - // Delete the installed.json — should still work from cache - $manifest->build(); + $providers = $manifest->providers(); - $providers2 = $manifest->providers(); + $this->filesystem->delete($basePath . '/vendor/composer/installed.json'); + $this->filesystem->delete($this->manifestPath); - $this->assertSame($providers1, $providers2); + $this->assertSame($providers, $manifest->providers()); } public function testBuildDoesNotApplyRuntimeIgnoresToDiskCache() diff --git a/tests/Testbench/Foundation/Fixtures/PackageManifest/build-manifest.php b/tests/Testbench/Foundation/Fixtures/PackageManifest/build-manifest.php index 7e6ef2351..8161a91b7 100644 --- a/tests/Testbench/Foundation/Fixtures/PackageManifest/build-manifest.php +++ b/tests/Testbench/Foundation/Fixtures/PackageManifest/build-manifest.php @@ -7,7 +7,8 @@ require dirname(__DIR__, 5) . '/vendor/autoload.php'; -$basePath = __DIR__; +$packageRoot = getenv('TESTBENCH_PACKAGE_ROOT'); +$basePath = is_string($packageRoot) && $packageRoot !== '' ? $packageRoot : __DIR__; $manifestPath = $argv[1] ?? null; if (($argv[2] ?? null) === '--testbench-core' && ! defined('TESTBENCH_CORE')) { diff --git a/tests/Testbench/Foundation/PackageManifestPackageTesterTest.php b/tests/Testbench/Foundation/PackageManifestPackageTesterTest.php index c6cb648a2..357432dbc 100644 --- a/tests/Testbench/Foundation/PackageManifestPackageTesterTest.php +++ b/tests/Testbench/Foundation/PackageManifestPackageTesterTest.php @@ -15,26 +15,35 @@ class PackageManifestPackageTesterTest extends TestCase { + private Filesystem $filesystem; + private string $manifestDirectory; private string $fixturePath; + private string $packagePath; + + private string $tempDirectory; + protected function setUp(): void { parent::setUp(); - $this->manifestDirectory = ParallelTesting::tempDir('PackageManifestPackageTesterTest'); + $this->filesystem = new Filesystem; $this->fixturePath = __DIR__ . '/Fixtures/PackageManifest'; + $this->tempDirectory = ParallelTesting::tempDir('PackageManifestPackageTesterTest'); + $this->manifestDirectory = $this->tempDirectory . '/manifests'; + $this->packagePath = $this->tempDirectory . '/package'; - $files = new Filesystem; - $files->deleteDirectory($this->manifestDirectory); - $files->ensureDirectoryExists($this->manifestDirectory); + $this->filesystem->deleteDirectory($this->tempDirectory); + $this->filesystem->ensureDirectoryExists($this->manifestDirectory); + $this->filesystem->copyDirectory($this->fixturePath, $this->packagePath); } #[Override] protected function tearDown(): void { - (new Filesystem)->deleteDirectory($this->manifestDirectory); + $this->filesystem->deleteDirectory($this->tempDirectory); parent::tearDown(); } @@ -73,6 +82,54 @@ public function itAddsRootMetadataWhenRunningInsideTheTestbenchCli(): void $this->assertArrayHasKey('testbench/example', $manifest); } + #[Test] + public function itFailsForMalformedRootMetadataWithoutPublishingAManifest(): void + { + $this->filesystem->put($this->packagePath . '/composer.json', '{'); + + $process = $this->runManifest( + manifestName: 'malformed-root', + env: ['TESTBENCH_PACKAGE_TESTER' => '(true)'], + ); + + $this->assertFalse($process->isSuccessful()); + $this->assertStringContainsString('Syntax error', $process->getErrorOutput()); + $this->assertFileDoesNotExist($this->manifestPath('malformed-root')); + } + + #[Test] + public function itFailsForNonArrayRootMetadataWithoutPublishingAManifest(): void + { + $this->filesystem->put($this->packagePath . '/composer.json', 'null'); + + $process = $this->runManifest( + manifestName: 'non-array-root', + env: ['TESTBENCH_PACKAGE_TESTER' => '(true)'], + ); + + $this->assertFalse($process->isSuccessful()); + $this->assertStringContainsString( + "Composer metadata [{$this->packagePath}/composer.json] must contain an array.", + $process->getErrorOutput() + ); + $this->assertFileDoesNotExist($this->manifestPath('non-array-root')); + } + + #[Test] + public function itFailsForMalformedInstalledMetadataWithoutPublishingAManifest(): void + { + $this->filesystem->put($this->packagePath . '/vendor/composer/installed.json', '{'); + + $process = $this->runManifest( + manifestName: 'malformed-installed', + env: ['TESTBENCH_PACKAGE_TESTER' => '(true)'], + ); + + $this->assertFalse($process->isSuccessful()); + $this->assertStringContainsString('Syntax error', $process->getErrorOutput()); + $this->assertFileDoesNotExist($this->manifestPath('malformed-installed')); + } + /** * Build the package manifest in a fresh PHP process. * @@ -82,7 +139,27 @@ public function itAddsRootMetadataWhenRunningInsideTheTestbenchCli(): void */ private function buildManifest(string $manifestName, array $env = [], array $arguments = []): array { - $manifestPath = $this->manifestDirectory . '/' . $manifestName . '.php'; + $process = $this->runManifest($manifestName, $env, $arguments); + $manifestPath = $this->manifestPath($manifestName); + + $this->assertTrue($process->isSuccessful(), $process->getErrorOutput()); + $this->assertFileExists($manifestPath); + + /** @var array $manifest */ + $manifest = require $manifestPath; + + return $manifest; + } + + /** + * Run the package manifest builder in a fresh PHP process. + * + * @param array $env + * @param array $arguments + */ + private function runManifest(string $manifestName, array $env = [], array $arguments = []): Process + { + $manifestPath = $this->manifestPath($manifestName); $process = new Process( command: [ @@ -92,16 +169,22 @@ private function buildManifest(string $manifestName, array $env = [], array $arg ...$arguments, ], env: [ - 'TESTBENCH_WORKING_PATH' => $this->fixturePath, + 'TESTBENCH_PACKAGE_ROOT' => $this->packagePath, + 'TESTBENCH_WORKING_PATH' => $this->packagePath, ...$env, ], ); - $process->mustRun(); + $process->run(); - /** @var array $manifest */ - $manifest = require $manifestPath; + return $process; + } - return $manifest; + /** + * Get the generated manifest path. + */ + private function manifestPath(string $manifestName): string + { + return $this->manifestDirectory . '/' . $manifestName . '.php'; } } diff --git a/tests/Testbench/Foundation/PackageManifestTest.php b/tests/Testbench/Foundation/PackageManifestTest.php index 4a97961cd..44a0e4a3e 100644 --- a/tests/Testbench/Foundation/PackageManifestTest.php +++ b/tests/Testbench/Foundation/PackageManifestTest.php @@ -7,9 +7,11 @@ use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\PackageManifest as FoundationPackageManifest; use Hypervel\Testbench\Foundation\PackageManifest; +use Hypervel\Testing\ParallelTesting; use Hypervel\Tests\Testbench\TestCase; use Override; use PHPUnit\Framework\Attributes\Test; +use UnexpectedValueException; class PackageManifestTest extends TestCase { @@ -17,20 +19,27 @@ class PackageManifestTest extends TestCase private string $manifestPath; + private Filesystem $filesystem; + + private string $tempDirectory; + protected function setUp(): void { parent::setUp(); + $this->filesystem = new Filesystem; $this->basePath = __DIR__ . '/Fixtures/PackageManifest'; - $this->manifestPath = sys_get_temp_dir() . '/hypervel_testbench_packages_' . getmypid() . '.php'; + $this->tempDirectory = ParallelTesting::tempDir('PackageManifestTest'); + $this->manifestPath = $this->tempDirectory . '/packages.php'; - @unlink($this->manifestPath); + $this->filesystem->deleteDirectory($this->tempDirectory); + $this->filesystem->ensureDirectoryExists($this->tempDirectory); } #[Override] protected function tearDown(): void { - @unlink($this->manifestPath); + $this->filesystem->deleteDirectory($this->tempDirectory); FoundationPackageManifest::flushState(); @@ -42,7 +51,7 @@ protected function tearDown(): void */ private function makeManifest(?object $testbench = null, ?array $rootPackage = null): PackageManifest { - return new class(new Filesystem, $this->basePath, $this->manifestPath, $testbench, $rootPackage) extends PackageManifest { + return new class($this->filesystem, $this->basePath, $this->manifestPath, $testbench, $rootPackage) extends PackageManifest { /** * Create a new fixture-backed package manifest instance. */ @@ -59,7 +68,7 @@ public function __construct( /** * Get the root package composer metadata. * - * @return null|array{name: string, extra?: array{hypervel?: array}} + * @return null|array */ #[Override] protected function providersFromTestbench(): ?array @@ -105,7 +114,9 @@ public function ignorePackageDiscoveriesFrom(): array private function rootPackageFixture(): array { /** @var array{name: string, extra?: array{hypervel?: array}} $composer */ - return json_decode((string) file_get_contents($this->basePath . '/composer.json'), true); + $composer = $this->filesystem->json($this->basePath . '/composer.json', JSON_THROW_ON_ERROR); + + return $composer; } #[Test] @@ -159,6 +170,52 @@ public function itCanBuildManifestWithoutRootComposerMetadata(): void $this->assertArrayHasKey('vendor-a/package-b', $cached); } + #[Test] + public function itRejectsAnEmptyRootPackageName(): void + { + $manifest = $this->makeManifest( + testbench: $this->makeTestbench([]), + rootPackage: ['name' => ''] + ); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('member [name] must be a non-empty string.'); + + $manifest->build(); + } + + #[Test] + public function itRejectsAnEmptyFormattedRootPackageName(): void + { + $manifest = $this->makeManifest( + testbench: $this->makeTestbench([]), + rootPackage: ['name' => '/custom/vendor/'] + ); + $manifest->vendorPath = '/custom/vendor'; + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('has an empty formatted package name.'); + + $manifest->build(); + } + + #[Test] + public function itRejectsInvalidExplicitRootHypervelMetadata(): void + { + $manifest = $this->makeManifest( + testbench: $this->makeTestbench([]), + rootPackage: [ + 'name' => 'testbench/example', + 'extra' => ['hypervel' => 'invalid'], + ] + ); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('member [extra.hypervel] must contain an array.'); + + $manifest->build(); + } + #[Test] public function itCanFilterManifestUsingTheTestbenchIgnoreList(): void { diff --git a/tests/Testing/PHPUnit/TestStateRegistrarsTest.php b/tests/Testing/PHPUnit/TestStateRegistrarsTest.php index 6e6576e78..692cddafa 100644 --- a/tests/Testing/PHPUnit/TestStateRegistrarsTest.php +++ b/tests/Testing/PHPUnit/TestStateRegistrarsTest.php @@ -10,6 +10,7 @@ use Hypervel\Testing\PHPUnit\AfterEachTestCleanup; use Hypervel\Testing\PHPUnit\TestStateRegistrars; use Hypervel\Tests\TestCase; +use JsonException; use Override; use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; @@ -122,12 +123,39 @@ public function testMissingInstalledJsonDoesNotThrow(): void $this->assertSame([], TestStateRegistrarRecorder::$calls); } - public function testMalformedComposerAndInstalledJsonDoNotThrow(): void + public function testMalformedRootComposerJsonFailsBeforeRegisteringPackageRegistrar(): void { $this->filesystem->put($this->basePath . '/composer.json', '{'); + $this->writeInstalledPackages([ + $this->package('vendor/package', [PackageTestStateRegistrar::class]), + ]); + + try { + $this->makeRegistrars()->register(); + $this->fail('Expected malformed root Composer metadata to stop registrar discovery.'); + } catch (JsonException $exception) { + $this->assertSame('Syntax error', $exception->getMessage()); + } + + AfterEachTestCleanup::runCallbacks(); + + $this->assertSame([], TestStateRegistrarRecorder::$calls); + } + + public function testMalformedInstalledJsonFailsBeforeRegisteringRootRegistrar(): void + { + $this->writeRootComposer([ + 'test-state' => [RootTestStateRegistrar::class], + ]); $this->filesystem->put($this->basePath . '/vendor/composer/installed.json', '{'); - $this->makeRegistrars()->register(); + try { + $this->makeRegistrars()->register(); + $this->fail('Expected malformed installed Composer metadata to stop registrar discovery.'); + } catch (JsonException $exception) { + $this->assertSame('Syntax error', $exception->getMessage()); + } + AfterEachTestCleanup::runCallbacks(); $this->assertSame([], TestStateRegistrarRecorder::$calls); From f8fb1d7814030191097ed062ebe3f24cde8935a8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:29:53 +0000 Subject: [PATCH 13/15] docs: add JSON correctness implementation plan Record the final depth, storage, redaction, Eloquent repair, serialized transport, and package metadata contracts implemented by this branch. Include the verified native JSON behavior, ownership boundaries, anti-overengineering constraints, file map, testing matrix, performance expectations, compatibility notes, and primary references needed to maintain the changes. --- ...2-json-correctness-and-package-metadata.md | 501 ++++++++++++++++++ 1 file changed, 501 insertions(+) create mode 100644 docs/plans/2026-08-11-0932-json-correctness-and-package-metadata.md diff --git a/docs/plans/2026-08-11-0932-json-correctness-and-package-metadata.md b/docs/plans/2026-08-11-0932-json-correctness-and-package-metadata.md new file mode 100644 index 000000000..f3ee7ce67 --- /dev/null +++ b/docs/plans/2026-08-11-0932-json-correctness-and-package-metadata.md @@ -0,0 +1,501 @@ +# JSON correctness and package metadata plan + +## Status and objective + +Correct Hypervel's JSON nesting and failure contracts across framework storage, transport, validation, diagnostics, testing, and package discovery. Values accepted for storage must remain readable; invalid JSON must not become `null`, `false`, an empty collection, missing discovery metadata, an unredacted raw body, or an unrelated downstream type error. + +This is one normal-sized Components change, not a staged rollout. Implementation is complete; verification and review are current. Re-read this file with root `CLAUDE.md` and `AGENTS.md` after every context compaction. + +The final design must: + +- share one generic nesting contract through `Support\Json` without replacing Eloquent's distinct Laravel-style codec; +- preserve contextual Eloquent write errors, custom codecs, and the stored-empty-string convention; +- fail before executing queries, encrypting values, publishing package manifests, or rendering invalid console output; +- prevent Telescope's supported structured request paths from retaining configured secrets when parsing fails; +- keep intentional non-throwing boundaries, including malformed maintenance cookies, unchanged; +- add no locks, caches, container resolution, I/O, worker state, or extra successful-path JSON traversal outside the exception-chunk transaction required to keep Telescope's visibility update and replacement insert atomic. + +## Core anti-overengineering rules + +The following wording is retained verbatim from the core audit plan. Its principle numbering is also retained; principles 1–6 remain in that plan. In principle 9, “later in this plan” refers to the core audit plan's established remediation vocabulary. + +### What this audit is not + +This audit is not permission to add defensive machinery for every imaginable failure. Do not add an abstraction, state machine, retry loop, configurable timeout, registry, mutex, context slot, cache, or compatibility API merely because it sounds robust. + +Complexity must pay for itself with at least one of: + +- a demonstrated failure; +- a complete source trace proving a realistic vulnerable schedule; +- a clear general capability with real consumers and owner approval; +- deletion of greater or riskier complexity elsewhere. + +Typical Laravel lifecycle semantics define the supported contract. A package that intentionally relies on model events, middleware, listeners, transactions, or another documented mechanism is not defective merely because userland can explicitly bypass that mechanism. Do not build a parallel enforcement path for `withoutEvents()`, raw database writes, disabled middleware, direct transport access, or comparable deliberate bypasses unless the public contract explicitly promises behavior through that bypass. + +Underengineering is equally a failure. Fix every verified defect completely at its lowest owning boundary, never with a partial fix or a local patch over a broken shared contract, and always surface meaningful evidence-backed improvements rather than dropping them to avoid effort. Restraint applies to speculative machinery and cosmetic change, not to complete fixes or worthwhile opportunities. + +Do not treat an upstream difference as a bug without tracing it. Do not treat upstream parity as proof of correctness. A real Hypervel defect remains a defect when Laravel, Hyperf, Symfony, or an SDK has the same hole. + +The audit categories are discovery lenses, not boundaries around what may be corrected. Any genuine issue discovered while auditing, implementing, testing, or reviewing must be investigated, assigned to its lowest owning boundary, and taken through the applicable consensus, implementation, validation, review, and approval workflow—even when it is outside the current package, initial taxonomy, or changed diff. Do not dismiss a verified issue as unrelated or defer it merely to preserve package order. This rule applies only after the evidence threshold is met; it does not turn speculative concerns, deliberate bypasses, unsupported use, or contract violations into work. + +### 7. Preserve hot-path quality + +For every fix, inspect: + +- additional allocations; +- container or facade resolutions; +- locking and atomics; +- hashing and serialization; +- new yields or sleeps; +- retries and polling; +- logging or exception construction; +- retained worker memory; +- cache invalidation and eviction. + +A correctness guard on a cold failure path has a different cost from a new lock or resolver on every request. State the difference explicitly. + +Any proposed change with a measured or source-proven hot-path regression requires explicit owner approval before implementation, even when it fixes a defect. Present the expected frequency and magnitude, the evidence, and the viable alternatives. Do not hide an unavoidable tradeoff inside a general correctness claim. + +Performance improvements must provide a meaningful practical benefit after accounting for code complexity and divergence from upstream. Measure representative behavior where practical. Always surface an evidence-backed opportunity to the owner, but do not implement it without approval; a micro-optimization within measurement noise is neither a reason to diverge nor an actionable finding. + +### 8. Remove superseded design completely + +When a fix changes the owning model, delete obsolete helpers, callbacks, properties, config keys, comments, tests, and documentation. Do not leave a compatibility path or comment describing behavior that no longer exists. Preserve intentional upstream comments unless the new design makes them incorrect. + +### 9. Treat remediation patterns as candidates + +The established patterns later in this plan are a vocabulary, not a lookup table. Choose among per-call parameters, immutable values, scoped bindings, cloning, CoroutineContext, factories, explicit ownership, static reset, or resource teardown only after proving the real lifetime and owner. + +### 10. Reject speculative complexity + +Record low-confidence concerns under rejected or unresolved analysis. Do not implement them. Surface every evidence-backed, meaningful non-defect improvement to the owner with its benefit, cost, and alternatives, then stop for explicit approval. This requirement exists to keep worthwhile opportunities visible, not to discourage finding them. + +## Verified behavior and contracts + +### Native depth asymmetry + +PHP 8.4 accepts an array nested through 512 containers with `json_encode(..., depth: 512)`, but the resulting bytes require `json_decode(..., depth: 513)` and `json_validate(..., depth: 513)`. A 513-container value fails encoding at 512. The public Hypervel unit will therefore be **maximum nested containers**, translated only at native read/validation boundaries. + +Native facts that remain authoritative: + +- encode returns `false` unless `JSON_THROW_ON_ERROR` is set; +- decode returns `null` for both valid JSON `null` and failure unless throwing is enabled; +- validate returns `false`, accepts only `JSON_INVALID_UTF8_IGNORE`, and raises `ValueError` for invalid depth/flags; +- validate should replace decode-only validation because it avoids building an unused value; +- a `Jsonable` receives flags through `toJson(int $options)`, but that interface cannot receive a depth. + +### Separate JSON owners + +`Hypervel\Support\Json` is Hypervel-owned generic infrastructure introduced when `hyperf/codec` was removed. Foundation and Validation already depend on Support. It owns generic encode/decode/validate depth units and throwing behavior. + +`Hypervel\Database\Eloquent\Casts\Json` remains independent. It owns custom encoder/decoder callbacks, worker-lifetime reset, Laravel's non-throwing encoder result, and Eloquent's `'' => null` storage convention. Its callers preserve contextual model errors. Routing it through Support would erase those contracts and add the wrong behavior. + +### Telescope JSON ownership + +Telescope has three distinct JSON boundaries: + +- database entries and diagnostic object normalization are framework-produced storage round trips and fail loudly before corrupting a row; +- watcher request/response bodies are external observations and retain non-throwing fallbacks, but declared structured request media types must never fall through to unredacted raw storage; +- Telescope's private response parsers may accept the framework ceiling without changing the public `Http\Client\Response` decoding contract. + +The client watcher's normal `asJson()`, `asForm()`, and `asMultipart()` requests carry `hypervel_data` and use the structured masking path. Its raw path is still reachable through supported `withBody()`, an explicit Guzzle `body`, and third-party PSR-7 traffic. Redaction covers declared JSON, declared URL-encoded form data, and headerless bodies that parse or look like JSON objects/arrays. Other undeclared, opaque, and explicit plain-text bodies have no supported field model and retain their existing raw representation; do not guess that a headerless `k=v` string is form data. + +### Fail-loud package metadata + +Missing `composer.json` or `vendor/composer/installed.json` is a supported partial-repository/no-vendor state and remains empty. Existing code instead also treats corrupt syntax, invalid containers, nameless entries, non-string versions, and invalid `extra.hypervel` as absent. That can publish an empty package cache and omit providers, aliases, versions, and PHPUnit test-state cleanup. + +Root `extra.hypervel.dont-discover` is the application-controlled recovery surface. A root `*` skips parsing entirely. A specifically ignored package is identified and skipped before validating metadata the application chose not to consume. Package-owned `dont-discover` remains a discovery value, not a circular error-suppression system. + +## Implementation + +### 1. Generic Support JSON contract + +Update `src/support/src/Json.php`: + +- Add `public const int MAXIMUM_NESTING_DEPTH = 512`. +- Preserve parameter order and named arguments: + +```php +encode(mixed $data, int $flags = JSON_UNESCAPED_UNICODE, int $depth = self::MAXIMUM_NESTING_DEPTH): string +decode(string $json, bool $assoc = true, int $depth = self::MAXIMUM_NESTING_DEPTH, int $flags = 0): mixed +validate(string $json, int $depth = self::MAXIMUM_NESTING_DEPTH, int $flags = 0): bool +``` + +- `encode()` passes `$depth` to native encode and continues forcing `JSON_THROW_ON_ERROR`. +- For `Jsonable`, call `toJson($flags | JSON_THROW_ON_ERROR)`. This fixes discarded caller/default flags while retaining object-owned serialization. State in the docblock that `Jsonable` owns nesting because its contract cannot accept depth; do not reparse its output. +- `decode()` and `validate()` translate a positive public depth below `PHP_INT_MAX` to native `$depth + 1`. Pass non-positive and `PHP_INT_MAX` values through so native PHP raises `ValueError` instead of making zero valid or overflowing the helper arithmetic. +- `decode()` keeps `JSON_THROW_ON_ERROR` and passes supported caller flags. +- `validate()` passes flags unchanged. Do not copy the sibling THROW pattern: native validation rejects `JSON_THROW_ON_ERROR`. +- Use one private `nativeDecodingDepth()` helper for the two real callers and the non-obvious unit rule. Do not add a codec interface, service, container binding, facade, cache, or configurable ceiling. + +Update `Str::isJson()` to keep its non-string guard and delegate to `Support\Json::validate()`. `Stringable::isJson()` already delegates to `Str`; do not duplicate the call or depth rule there. The current explicit native depth 512 rejects documents produced at Support's supported maximum. + +Use the same contract in Hypervel's response-test readers: + +- `AssertableJsonString` decodes string and `Jsonable` input through Support, catches only `JsonException`, and retains its existing `null` sentinel for invalid JSON. Keep `JsonSerializable` and array input unchanged. +- `TestResponse::ddBody()` uses `Support\Json::validate()` for JSON detection. +- `TestResponse::dump()` decodes through Support and falls back to the original bytes only on `JsonException`. Pass `assoc: false` and add a concise WHY comment so debugging output remains object-shaped like Laravel's instead of silently changing to arrays. + +Correct the same native-depth unit mismatch at the two Filesystem-owned JSON readers: + +- `Filesystem::json()` and `FilesystemAdapter::json()` pass `Json::MAXIMUM_NESTING_DEPTH + 1` to native decode. +- Preserve their existing flags-controlled contract exactly: malformed JSON returns null by default, callers may opt into `JSON_THROW_ON_ERROR`, and a missing adapter file remains null. +- Do not route these methods through throwing `Support\Json`, add a second depth constant, or add a helper for the one derived expression. + +Route `Support\Composer::hasPackage()` and both sides of `modify()` through `Support\Json`: + +- Both existing decodes are associative, so `Json::decode()` preserves their shape while translating the public 512-container limit to native 513. +- `modify()` calls `Json::encode()` with only `JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE`; Support already adds THROW. Its callback contract returns an array, so neither `Jsonable` nor `Arrayable` can bypass those formatting flags. +- Assign the callback result and encoded bytes to local variables before looking up the file mode or calling `replace()`. This closes a direct read-after-write defect and makes failure ordering explicit: the current class can encode 512 containers and then reject the same file on its next decode, while a 513-container callback result throws before mode lookup or replacement and leaves the original bytes unchanged. +- Do not add a Composer-specific depth ceiling or root-shape validator. Composer metadata is schema-shallow, the external binary's internal ceiling is not Hypervel's contract, and `hasPackage()` has no production caller whose valid-scalar false result causes meaningful harm. + +Correct the remaining framework-owned self-round-trips: + +- `FileBasedMaintenanceMode::activate()` and `data()` use Support encode/decode, preserving pretty-print, associative output, malformed JSON exceptions, and the separate non-array payload exception. +- JSON `Session\Store` keeps its throwing native writer and intentional malformed-data-to-empty-session reader. Only pass `Json::MAXIMUM_NESTING_DEPTH + 1` to native decode so every successful 512-container write remains readable. +- `Http\Client\Request::json()` uses Support decode for the framework-produced outgoing request body, retaining its associative array check and cached result. +- `Http\JsonResponse::getData()` changes only its default native depth from 512 to `Json::MAXIMUM_NESTING_DEPTH + 1`. Explicit caller depths remain native. This prevents `setEncodingOptions()` from decoding a 512-container response as null and silently replacing its body with JSON `null`. +- `Support\Xml::toArray()` uses Support encode/decode for its SimpleXML-to-array round trip. +- `Inertia\Testing\AssertableInertia::fromTestResponse()` uses Support encode/decode for arbitrary page props. + +Correct the Collections-owned self-round-trips without creating a dependency cycle: + +- `EnumeratesValues::fromJson()` changes only its default native depth from 512 to 513; explicit depths remain native. +- `EnumeratesValues::jsonSerialize()` and `Arr::from()` pass native depth 513 when decoding `Jsonable::toJson()` output. +- Add the same concise comment at all three native decode sites: Support depends on Collections, so the depth cannot reference `Support\Json`; the cross-package behavioral tests keep the 512-container contracts aligned. +- Do not add a public `Arr` constant, constants-only interface, internal class, or duplicated trait/private constants. JSON depth is not an Arr-owned API, PHP has no package-private shared location, and three explained native literals are smaller and clearer. + +Move serialized-closure response ownership to Concurrency and remove its duplicated readers: + +- Move `InvokeSerializedClosureCommand` from Foundation to `Hypervel\Concurrency\Console`, matching current Laravel ownership while keeping registration in `FoundationServiceProvider`. +- Add internal `Hypervel\Concurrency\SerializedClosureResult::decode(string $output): mixed`. It owns gzip-marker truncation, Support decode, envelope validation, remote-exception reconstruction, strict base64 decoding, and guarded unserialization. Its docblock states that it returns the unserialized result or throws the reconstructed remote `Throwable`. +- Have `ProcessDriver::run()` and Testbench's `Foundation\Process\ProcessResult::output()` delegate to it after their caller-owned process/raw-command checks. Use domain errors—`Invalid serialized closure response envelope.` and `Unable to decode the serialized closure result.`—instead of parameterizing caller nouns. +- Keep the command's explicit native calls, transport flags, and native-512 parameter stability precheck together. The parent reads the complete envelope at Support's public 512-container limit, translated to native 513; the parameters subtree is one container shallower, so its native-512 precheck is deliberate. Routing only the outer writer through Support would add no behavior and would obscure the transport-specific `JSON_INVALID_UTF8_SUBSTITUTE` / `JSON_PRESERVE_ZERO_FRACTION` contract and subtree depth math. +- Retain the command's caught `report()` call as Laravel's accepted Foundation soft dependency. An undefined helper is already contained by the `Throwable` catch; Concurrency must not declare Foundation or add reporting machinery that creates a package cycle. +- Add direct `symfony/console:^8.1` to `src/concurrency/composer.json` and `hypervel/concurrency:^0.4` to `src/testbench/composer.json`. +- Move the command test and shared exception fixture from Foundation to Concurrency, update all three fixture consumers, and centralize exhaustive envelope tests in `SerializedClosureResultTest`. Delete duplicated decoder-contract matrices from the two callers while retaining their own process, mapping, environment, raw-command, and delegation behavior. +- Move `tests/Integration/Concurrency/ConcurrencyTest.php` to `tests/Concurrency/ConcurrencyTest.php`; it uses fake processes and no external service. + +Do not generalize this into a framework-wide native-decode rewrite. Protocol, external-request/response, developer-authored file, and upstream API readers own different limits and failure contracts. In particular, do not change `Http\Request::json()`, `Http\Client\Response`, or `Translation\FileLoader`: they consume bytes another owner produced, and the validated request-cast path carries deep JSON inside a shallow string field. The audited framework-produced queue payload is also not exposed to this asymmetry: the job object graph is a serialized string within a shallow JSON envelope, and payload creation already throws `InvalidPayloadException` after encoding failure. + +### 2. Foundation request casts and Validation + +Update `src/foundation/src/Http/Traits/HasCasts.php`: + +- Type `fromJson()` as `mixed` and delegate to `Support\Json::decode($value, ! $asObject)`. +- Do not special-case empty strings. Normal validated requests reject empty JSON; raw/unvalidated casting must surface invalid input rather than turn it into empty data. +- Remove the unused protected `asJson()` method. It was copied into Hypervel's request-casting feature, has no source/test caller, is undocumented, and does not participate in casting. Backwards compatibility with unreleased Hypervel-only code is not a reason to retain dead code. + +Update both JSON rule paths: + +- `src/validation/src/Concerns/ValidatesAttributes.php::validateJson()` +- `src/validation/src/PlanExecutor.php::executeInlineJson()` + +Retain each path's rule-specific scalar/stringable prechecks, then call `Support\Json::validate()`. Remove the dead PHP 8.4 `function_exists('json_validate')` and decode fallback. Keep the compiled executor inline rather than creating a shared validator object or adding dispatch overhead. + +Correct `src/docs/validation.md` so values cast as `array`, `collection`, `json`, or `object` are demonstrated as JSON strings validated by `json`, rather than a PHP array validated by `array`. Keep the prose Laravel-style and do not introduce PHP-array pass-through behavior. + +Correct the corresponding `tests/Foundation/Http/CustomCastingTest.php` fixture/rules to exercise the validated JSON-string path instead of bypassing validation to hide the mismatch. + +### 3. Eloquent JSON codec and writers + +Update `src/database/src/Eloquent/Casts/Json.php`: + +- Add private typed `MAXIMUM_NESTING_DEPTH = 512` beside the codec's static callbacks. +- Run custom callbacks first and unchanged. +- Default encode calls native encode at depth 512 and deliberately returns `string|false`, without `JSON_THROW_ON_ERROR`; the public method remains `mixed` because custom encoders are unconstrained. Add a short WHY comment: Eloquent writers convert `false` to `JsonEncodingException::forAttribute()` with model/key context. +- Default decode preserves `'' => null`; comment that this is Eloquent's established empty stored representation, not malformed JSON recovery. +- Default non-empty decode uses native depth 513 with `JSON_THROW_ON_ERROR`. Valid JSON `null` still returns null; malformed/deep JSON raises `JsonException`. + +Update all eight first-party JSON class-cast writers: + +- `AsArrayObject`, `AsCollection`, `AsEncryptedArrayObject`, `AsEncryptedCollection`, `AsEnumArrayObject`, `AsEnumCollection`, `AsFluent`, and `AsDataObject`. +- In the seven anonymous casters, type the contract's `$model` parameter as `Model`. +- Check the encoded result before returning it or encrypting it. Throw `JsonEncodingException::forAttribute($model, $key, json_last_error_msg())` on exact `false`. +- Keep the same exact-`false` signal and diagnostic owner as primitive casts. A custom encoder that delegates to native JSON retains its native error; an encoder that returns `false` without performing JSON encoding violates the callback's string-producing contract and does not justify callback-result metadata or worker-global error tracking. +- Keep the guard local at each writer; a new public helper or callback abstraction would make a simple failure path harder to follow. +- `AsDataObject` uses the Eloquent codec for both directions, accepts decoded arrays including `[]`, returns null for other successfully decoded shapes, and returns the normal `[$key => $encoded]` set response. + +Representative writer shape: + +```php +$encoded = Json::encode($value); + +if ($encoded === false) { + throw JsonEncodingException::forAttribute($model, $key, json_last_error_msg()); +} + +return [$key => $encoded]; +``` + +Make reader output types explicit after either default or custom decode: + +- unencrypted `AsArrayObject`, `AsCollection`, `AsEnumArrayObject`, and `AsEnumCollection` retain their array guards; +- encrypted ArrayObject/Collection casts add the same array guard before construction; +- `AsDataObject` requires an array; +- `AsFluent` accepts arrays or objects because `Fluent` supports both. Preserve object-returning custom decoders and add a concise WHY comment. + +Update `HasAttributes::fillJsonAttribute()` to call existing `castAttributeAsJson()`. This removes duplicate flag/error handling and prevents storing or encrypting `false`. + +### 4. Eloquent repair semantics + +`HasAttributes::originalIsEquivalent()` currently decodes/casts current and original values together. A corrupt original therefore makes a valid assignment impossible to save through Eloquent. Change the four JSON comparison branches: + +1. object/collection primitives; +2. all primitive casts, including encrypted JSON variants; +3. `AsArrayObject` / `AsCollection`; +4. `AsEnumArrayObject` / `AsEnumCollection`. + +For each branch, evaluate the current assigned value outside the catch. Evaluate only the original value inside `catch (JsonException)`, returning `false` when the original is readable/decryptable but invalid JSON. This marks a valid replacement dirty and permits repair. + +```php +$current = /* decode or cast the assigned value */; + +try { + $original = /* decode or cast the original value */; +} catch (JsonException) { + return false; +} + +return $current === $original; +``` + +Do not catch `DecryptException`. A wrong key or corrupt ciphertext may still contain recoverable data under the correct key and must fail without authorizing overwrite. Encrypted class-cast comparison remains unchanged because it compares decrypted serialized strings rather than decoded JSON. + +### 5. Query and console native encodes + +Add `JSON_THROW_ON_ERROR` at every remaining Database native encode whose result is consumed as a string/binding: + +- `Query\Grammars\Grammar::prepareBindingForJsonContains()` retains `JSON_UNESCAPED_UNICODE`; +- `MySqlGrammar::prepareBindingsForUpdate()` (also inherited by MariaDB); +- `PostgresGrammar::prepareBindingsForUpdateFrom()`; +- `PostgresGrammar::prepareBindingsForUpdate()`; +- `SQLiteGrammar::prepareBindingsForUpdate()`; +- `Database\Console\TableCommand::displayJson()`; +- `Database\Console\ShowCommand::displayJson()`. + +This keeps errors at the encoding call rather than passing `false` to a query or Symfony output. Native exceptions remain unwrapped. While touching `PostgresGrammar`, replace the adjacent `trim($baseWheres) == ''` with the required strict string comparison; its operand is already a string and behavior remains unchanged. + +Do not replace direct native grammar/console calls with Support or Eloquent codecs. These sites need only native failure signaling and have neither generic public depth semantics nor model context. + +### 6. Telescope storage and redaction + +Update `src/telescope/src/Storage/DatabaseEntriesRepository.php` so every stored entry uses the same readable codec: + +- Add one focused `encodeContent(array $content): string` helper used by ordinary rows, exception rows after adding `occurrences`, and updates after merging changes. It first encodes the complete content through `Support\Json` with `JSON_INVALID_UTF8_SUBSTITUTE`. +- On `JSON_ERROR_DEPTH` only, encode each top-level field as `[$key => $value]` with the same flags and ceiling, replace every depth-failing value with the existing scalar `Purged By Telescope`, then encode the corrected complete content. A one-key wrapper has the same root reservation as the field in the full content array, so siblings do not affect the result and at least one field must be replaced. Rethrow every non-depth error from either pass; this includes a later INF/NAN, recursion, or unsupported-type error hidden behind an earlier depth failure. +- This failure-only recovery preserves each watcher's required shallow schema and all other useful fields. Do not replace the complete content with a sentinel: event and request screens structurally consume shallow fields such as `listeners` and `middleware`. Do not add a recursive sanitizer, watcher/type map, storage ceiling override, or successful-path preflight. +- `update()` decodes the retained content through Support before merging changes, then uses `encodeContent()`. Let `array_merge()` fail naturally if a corrupt stored value is not an array; do not replace it with an empty value. A depth-heavy changed top-level field is purged and later updates continue, while malformed retained JSON and non-depth encoding defects remain fail-loud. +- For each exception chunk, group by family hash and `sortKeys()` before querying occurrence counts and collecting last UUIDs. Build and encode the complete insert-row array before any write. The deterministic family order is required because the transaction below retains locks across family updates; first-appearance order would let two concurrent multi-family chunks acquire the same locks in opposite orders and deadlock. +- On `DB::connection($this->connection)`, use `transaction(Closure)` with its default single attempt around only the existing sorted per-family visibility clears and the already-built chunk insert. Encoding stays outside the transaction. This makes clear-then-insert atomic: encoding, update, or insert failure leaves the prior visible row intact. Keep update-before-insert because insert-first writers can clear each other's newly inserted visible rows. The transaction preserves the existing possibility of duplicate visible rows during concurrent first writes; eliminating that cosmetic, self-repairing race would require unsupported cross-engine schema or family-lock machinery. +- Keep the existing chunked and partial-batch model. Depth recovery changes only offending top-level field values and retains the row UUID, type, family state, and tags, so it requires no filtering or tag bookkeeping. A non-depth failure in a later exception chunk leaves prior chunks stored but prevents tags for every exception chunk and prevents every ordinary entry and its tags from being stored; `Telescope::executeStore()` reports the failure, skips later updates/hooks, and flushes the in-memory queues. Do not add a whole-batch transaction, whole-batch pre-encoding, duplicate validation, row filtering, or recovery for non-depth defects: they would widen locks, remove the memory bound, serialize twice, or hide an instrumentation defect. +- The exception transaction adds one begin/commit pair per nonempty exception chunk and holds matched family-row locks plus its pooled connection through the update/insert pair. This can serialize same-family writers during an exception storm, but batches without exceptions pay nothing. If the configured connection already has an outer transaction, the normal savepoint behavior applies and Telescope retains its existing participation in the application's eventual commit or rollback. + +Correct Telescope's diagnostic object normalization: + +```php +Json::decode(json_encode($value, JSON_THROW_ON_ERROR)) +``` + +- Apply this shape to the native normalization expressions in `ExtractProperties::from()`, `EventWatcher::extractPayload()`, and `RequestWatcher::extractDataFromView()`. +- Keep native encode because Telescope is inspecting the object's actual shape. `Support\Json::encode()` would instead invoke `Jsonable` or `Arrayable` and record the representation the object chooses to publish. +- Add that WHY once before each method's normalization group—three comments cover all expressions without repetition. +- Use Support decode in `ModelWatcher::recordHydrations()` when the framework-produced entry has already become a stored JSON string. This aligns a fixed shallow shape with its storage codec; it is not presented as a reachable depth defect. + +Refactor `ClientRequestWatcher` around one protected structured-payload formatter used by request and response paths. It accepts the array, hidden-field list, and byte limit; masks first, encodes once with `JSON_INVALID_UTF8_SUBSTITUTE` at `Json::MAXIMUM_NESTING_DEPTH - 1`, purges on exact `false`, and applies the existing truncate-or-purge option to those exact masked bytes. One container is reserved for the entry-content root; do not add a Telescope depth constant. The request-facing `payload()` delegates to it with request settings. The normal path remains two traversals (mask and encode), and oversized truncation drops from three to two. The default oversized purge path rises from one traversal to two and allocates the masked encoded bytes because the limit must describe retainable data rather than pre-redaction secrets. The existing raw-JSON path already masks before measuring; this unifies the structured path with that correct order and closes its current encode-failure fallthrough. + +Correct the raw request boundary without turning arbitrary bodies into a new parsing framework: + +- Decode JSON once, associatively and non-throwing. Derive `$maximumContainers = Json::MAXIMUM_NESTING_DEPTH - 1` for the value beneath the entry-content root and pass native depth `$maximumContainers + 1`. An array goes through the request structured formatter; valid scalar JSON keeps its raw representation because it has no addressable fields. +- If decoding fails, return `Purged By Telescope` when the normalized content type contains `/json` or `+json`, or the first non-JSON-whitespace byte is `{` / `[`. Find that byte with `strspn($content, " \t\n\r")` and an offset lookup; do not allocate a full `ltrim()` copy of a potentially large body. The raised native depth is a security guard against entering the raw branch, not a change to the public HTTP response decoder's external-input contract. +- For `application/x-www-form-urlencoded`, call native `parse_str()` and always pass its array through the request structured formatter. It has no failure/raw fallback, so supported form bodies cannot retain the default hidden `password` fields. Native bracket syntax composes with `Arr` dot notation; `parse_str()` converts literal dots in field names to underscores, while Telescope dot notation continues to mean nesting. +- Keep opaque/custom and explicit `text/plain` content raw under the existing byte limit. Do not add XML/custom parsers or purge bodies that have no supported field model. +- Keep stream rewind/restoration and the existing early purge for a known oversized non-truncated stream. + +Update client and application response capture: + +- `ClientRequestWatcher::getResponsePayload()` performs one non-throwing associative native decode at `$maximumContainers + 1`, sends arrays through the shared structured formatter with response settings, purges immediately on `JSON_ERROR_DEPTH`, and preserves redirect, plain-text, empty, and HTML fallbacks. The error code alone proves a deeply nested JSON value; do not add a media-type gate that would miss missing or incorrect response headers. This is a private diagnostic-parser correction, not a new contract for `Http\Client\Response::decodeBody()`. +- `RequestWatcher::response()` performs the same single decode and reuses its result, purging on `JSON_ERROR_DEPTH` before its remaining fallbacks. Its content is framework-produced, so this is a real `JsonResponse` read-after-write boundary as well as removal of a duplicate parse. +- Do not add RequestWatcher preflight encodes for request input, session attributes, facade context, or view data. These values can legitimately exceed the child ceiling, but `encodeContent()` purges only the offending top-level field with no successful-path traversal and covers the same lifted values in EventWatcher, JobWatcher, CacheWatcher, LogWatcher, and future watchers. +- Do not change the multipart `json_encode()` representability probe. Supported multipart contents reach the watcher as string/resource/stream/file values; unsupported array contents fail HTTP request construction before recording. + +### 7. Package manifest discovery + +Update `src/foundation/src/PackageManifest.php` without adding Composer runtime dependencies, schema DTOs, or a generic metadata parser. + +Add two focused protected static helpers: + +- a package-name helper used by installed-package discovery and Testbench root-package discovery, accepting already-array metadata plus its location, formatting the name through `formatPackageName()`, and requiring both the raw and formatted names to be non-empty strings; +- an `extra.hypervel` helper used by installed-package discovery, Testbench root-package discovery, and `rootHypervelExtra()`, accepting already-array metadata plus its location, treating absent or unaddressable parent `extra` as empty, and rejecting an explicitly present non-array `extra.hypervel`. + +The split preserves installed discovery's required name → root-ignore → version/configuration order without duplicate validation, flags, callbacks, or parsing. Both helpers have multiple real callers and one diagnostic owner. Keep installed discovery static. `TestStateRegistrars` discovers and registers registrars during PHPUnit extension bootstrap, before any Testbench application exists; only the callbacks they install run after each test application is destroyed. An instance path would make Testing construct a partly usable Foundation `PackageManifest` with a null manifest path solely to reach a formatting seam already supplied across installed and root discovery by late-static-bound `formatPackageName()`. Do not add a parser class, DTO, public API, mixed-input helper, callback, or instance-discovery path. + +`discoverInstalledPackages()`: + +1. Return `[]` before reading `installed.json` when `$baseIgnore` contains `*`. +2. Keep a missing file as `[]`; otherwise decode through `Support\Json`. +3. Require an array root and, when present, an array Composer 2 `packages` member. Throw `UnexpectedValueException` naming the path for structural failures; native syntax/depth/UTF-8 failures remain `JsonException`. +4. For every entry, first require an array, then use the package-name helper. Give the entry-array failure the same diagnostic shape as Testbench's root-array failure. Compare the returned manifest key to root ignores. This matches existing manifest key semantics without adding full Composer-schema validation. +5. Skip a specifically root-ignored package before reading its version or `extra.hypervel`. +6. For consumed packages, allow a missing/null/string version and reject other types with the package/index in the error. +7. Read configuration through the `extra.hypervel` helper. Do not validate values inside it: providers, aliases, `dont-discover`, and `test-state` keep their existing consumer-owned Laravel-style casts/validation. +8. Collect package-owned `dont-discover`, apply the final ignore list, and return the same manifest shape as today. + +An invalid/nameless entry remains fatal because a specific ignore cannot identify it and Composer always emits a package name. Do not add a second pass allowing dependency-owned metadata to suppress another dependency's corruption. + +`rootHypervelExtra()`: + +- Keep missing root `composer.json` and absent `extra.hypervel` as null. +- Decode through Support and require an array root, then use the shared `extra.hypervel` helper. Throw precise native/`UnexpectedValueException` failures rather than silently dropping root discovery/test-state metadata. +- Do not validate arbitrary values inside `extra.hypervel`. + +Keep structural diagnostics stable and actionable without introducing exception classes or a parser abstraction. Each `UnexpectedValueException` names the metadata path and failing location: root/`packages`, entry index, formatted package name, `version`, or `extra.hypervel`. Syntax, UTF-8, and depth errors remain native `JsonException` messages. + +`build()` already discovers before `write()`. Preserve this order so an explicit rebuild over a valid cache throws before the atomic cache replacement. The generated PHP cache path needs no new validation: it is written from validated arrays through `var_export()` and atomic `Filesystem::replace()`, invalid PHP naturally throws, and hand-editing a valid cache is a deliberate low-level bypass. + +Update Testbench's `Foundation\PackageManifest` without weakening its typed boundary: + +- `providersFromTestbench(): ?array` reads its `composer.json` through `Filesystem::json(..., JSON_THROW_ON_ERROR)`, checks the decoded root is an array before returning, and throws a precise metadata-path `UnexpectedValueException` for a valid non-array root. This check must remain here: widening the return type or parsing twice to move it into the shared helper would make the API worse. +- Narrow `providersFromTestbench()`'s docblock to `null|array` so it promises only the decoded array root the method guarantees after that check. +- `providersFromRoot()` treats only null as absence, then obtains the formatted root key from the shared package-name helper and reads configuration through the shared `extra.hypervel` helper. Its existing root package remains merged after installed discovery. +- Retain protected `format()` unchanged as current Laravel 13.x protected parity surface even though no first-party caller remains. Existing Testbench manifest assertions already pin the formatted root key; do not add a synthetic override test. +- Keep `packagesToIgnore()` returning `[]`. Base installed discovery still applies package-owned ignores; Testbench's application/runtime ignores remain read-time behavior in `getManifest()`. +- Malformed installed metadata fails before the root merge and manifest write, so no partial cache is published. Testbench's runtime filtering no longer hides corrupt installed metadata; record this intentional fail-loud behavior in the PR/change summary. + +The Testbench subprocess regression must use a scratch package root, call `Process::run()`, and assert nonzero status plus useful native failure text on stderr. Do not wrap the production exception merely to customize subprocess output. + +## File map + +| Owner | Source/docs | Tests | +|---|---|---| +| Generic JSON | `src/support/src/Json.php` | `tests/Support/JsonTest.php` | +| Collections round trips | `src/collections/src/Arr.php`, `src/collections/src/Traits/EnumeratesValues.php` | `tests/Support/{SupportArrTest,SupportCollectionTest}.php` | +| Composer file JSON | `src/support/src/Composer.php` | `tests/Support/ComposerFileTest.php` | +| Filesystem JSON | `src/filesystem/src/{Filesystem,FilesystemAdapter}.php` | `tests/Filesystem/{FilesystemTest,FilesystemAdapterTest}.php` | +| Framework-owned JSON round trips | `src/foundation/src/FileBasedMaintenanceMode.php`, `src/http/src/{JsonResponse,Client/Request}.php`, `src/session/src/Store.php`, `src/support/src/Xml.php`, `src/inertia/src/Testing/AssertableInertia.php` | `tests/Foundation/FoundationFileBasedMaintenanceModeTest.php`, `tests/Http/{HttpJsonResponseTest,HttpClientTest}.php`, `tests/Session/SessionStoreTest.php`, `tests/Support/XmlTest.php`, `tests/Inertia/Testing/AssertableInertiaTest.php` | +| Serialized-closure transport | move `src/foundation/src/Console/InvokeSerializedClosureCommand.php` to `src/concurrency/src/Console/`; add `src/concurrency/src/SerializedClosureResult.php`; update `src/concurrency/src/ProcessDriver.php`, `src/testbench/src/Foundation/Process/ProcessResult.php`, provider registration, and Concurrency/Testbench manifests | move command test and fixture to `tests/Concurrency/`; add `SerializedClosureResultTest.php`; move/update `ConcurrencyTest.php`; narrow `tests/Testbench/Foundation/Process/ProcessResultTest.php` | +| JSON predicates/test readers | `src/support/src/Str.php`, `src/testing/src/{AssertableJsonString,TestResponse}.php` | `tests/Support/{SupportStrTest,SupportStringableTest}.php`, `tests/Testing/TestResponseTest.php` | +| Request casts | `src/foundation/src/Http/Traits/HasCasts.php`, `src/docs/validation.md` | `tests/Foundation/Http/CustomCastingTest.php` | +| Validation | `src/validation/src/Concerns/ValidatesAttributes.php`, `src/validation/src/PlanExecutor.php` | `tests/Validation/ValidationValidatorTest.php`, `tests/Validation/ValidationPlanExecutorTest.php`; compiler classification remains in `ValidationRuleCompilerTest.php` | +| Eloquent codec/writers | `src/database/src/Eloquent/Casts/{Json,AsArrayObject,AsCollection,AsEncryptedArrayObject,AsEncryptedCollection,AsEnumArrayObject,AsEnumCollection,AsFluent,AsDataObject}.php` | new Testbench-based `tests/Database/DatabaseEloquentJsonCastTest.php`; reset coverage in `DatabaseEloquentModelTest.php` | +| Eloquent assignment/repair | `src/database/src/Eloquent/Concerns/HasAttributes.php` | `tests/Integration/Database/EloquentModelJsonCastingTest.php`, encrypted casting/dirty tests | +| Query bindings | base/MySQL/PostgreSQL/SQLite query grammars | `Database{Query,MySql,MariaDb,Postgres,SQLite}QueryGrammarTest.php` | +| Console output | `src/database/src/Console/{ShowCommand,TableCommand}.php` | new `Hypervel\Tests\TestCase`-based `tests/Database/DatabaseConsoleJsonTest.php` | +| Telescope entry storage | `src/telescope/src/Storage/DatabaseEntriesRepository.php` | `tests/Telescope/Storage/DatabaseEntriesRepositoryTest.php` | +| Telescope normalization | `src/telescope/src/ExtractProperties.php`, `src/telescope/src/Watchers/{EventWatcher,ModelWatcher,RequestWatcher}.php` | new `tests/Telescope/ExtractPropertiesTest.php`, `tests/Telescope/Watchers/{EventWatcherTest,ModelWatcherTest,RequestWatchersTest}.php` | +| Telescope client redaction | `src/telescope/src/Watchers/ClientRequestWatcher.php` | `tests/Telescope/Watchers/ClientRequestWatcherTest.php` | +| Package metadata | `src/foundation/src/PackageManifest.php` | `tests/Foundation/FoundationPackageManifestTest.php`, `tests/Testing/PHPUnit/TestStateRegistrarsTest.php` | +| Testbench package metadata | `src/testbench/src/Foundation/PackageManifest.php` | `tests/Testbench/Foundation/{PackageManifestTest,PackageManifestPackageTesterTest}.php` | + +## Testing plan + +Run every changed/new test file immediately after editing it. Use small local depth builders inside tests; do not add production fixture APIs solely to manufacture nested values. + +### Support, request, and validation + +- Default and explicit `Support\Json` max-depth encode/decode round trips reconstruct exactly; one level over fails encode. +- Decode/validate accept the same maximum; non-positive/`PHP_INT_MAX` depth and unsupported validate flags raise native `ValueError` rather than helper `TypeError`. +- `validate()` returns true/false for valid/malformed JSON and honors `JSON_INVALID_UTF8_IGNORE`. +- `Jsonable` receives explicit and default flags, including unescaped Unicode and throwing behavior; document/test that depth remains object-owned. +- Arrayable and ordinary scalar behavior remains green. +- `Str::isJson()` and `Stringable::isJson()` accept 512 containers and reject 513, pinning both sides of the public boundary. +- `AssertableJsonString` and `TestResponse::decodeResponseJson()` accept a 512-container JSON response while preserving the friendly invalid-JSON failure path. `TestResponse::dump()` still emits decoded objects rather than associative arrays and falls back to raw invalid bytes. +- `Filesystem::json()` and `FilesystemAdapter::json()` accept 512 containers and reject 513. Keep existing malformed-default-null coverage and add throwing-flag coverage where it is not already pinned; adapter missing-file behavior remains null. +- `Support\Composer::modify()` writes a 512-container callback result and its next `modify()` reads the exact structure. A 513-container result throws before replacement and preserves the original file byte-for-byte; existing formatting, mode, and malformed-input coverage remains green. +- `Collection::toJson()` output round-trips through default `Collection::fromJson()` at the Support ceiling; one level above fails. Jsonable-item conversion in Collection and `Arr::from()` accepts the same ceiling. Build these boundaries from `Support\Json::MAXIMUM_NESTING_DEPTH` so behavior—not a duplicated constant—guards package drift. +- File maintenance payloads, JSON session attributes, `JsonResponse` data/encoding-option changes, outgoing client-request data, XML parsing, and Inertia page props each round-trip at 512 containers and reject one level above. +- Build the XML boundary fixture from 255 levels of repeated same-named siblings: SimpleXML projects each sibling pair through an array, producing 512 JSON containers within libxml's default element-depth limit. A plain element chain cannot reach this boundary. At 512 containers, the current null decode causes a return-type `TypeError`; the Support codec makes the document readable. Adding one innermost attribute produces 513 containers: the current encode `false` causes a nested `json_decode()` parameter `TypeError`, while the Support codec raises `JsonException`. Do not add `LIBXML_PARSEHUGE` or pin intermediate encoded bytes. +- Inertia deliberately replaces the nested native-call `TypeError` on over-depth values with encode-side `JsonException`. Assert that Inertia lets this `JsonException` propagate rather than converting it to `Not a valid Inertia response.`; its assertion-only catch remains unchanged. +- Pin the serialized-closure boundary exactly: 510 array containers in a public exception value become 511 in the named-parameter map and 512 in the complete envelope. The command must emit it, and the shared decoder must reconstruct the original exception class and exact value; 509 already works and 511 deliberately degrades before emission, so either neighboring value would miss the regression. +- Central decoder coverage owns malformed output/envelopes, gzip suffixes, remote exception reconstruction, binary/false results, and unserialization failures. The command's six test decoders use throwing Support decode. ProcessDriver and Testbench retain thin success/failure delegation tests plus their non-envelope branches; Testbench proves non-Closure raw output preserves gzip-marker bytes. +- Request `array`, `object`, and `collection` casts accept valid JSON at the maximum and reject malformed, empty, and over-depth raw input with `JsonException`. +- The default validated form-request path uses the `json` rule and the same maximum. +- Public validator and compiled plan paths both accept the maximum, reject one level over, and return validation failure—not an exception—for empty/malformed fields. + +### Eloquent + +- Default primitive JSON casts round-trip exactly at 512 containers and reject a 513-container write contextually. +- Default decode distinguishes valid `null`, stored `''`, and malformed non-empty JSON. +- Custom decoder receives `''` unchanged and may return null; custom encoder/decoder reset coverage remains green. +- Every one of the eight class-cast writers rejects encoder `false` with model/key context before storage or encryption. +- A custom encoder that returns exact `false` follows that same contextual failure path; do not add callback-result wrappers or worker-global error state for an invalid callback that returns no encoded string and sets no JSON error. +- `AsFluent` accepts an object returned by a custom decoder; encrypted ArrayObject/Collection and AsDataObject return null for successfully decoded wrong shapes rather than raising constructor/type errors. +- `AsDataObject` accepts an empty decoded map (`{}` or `[]` under associative decoding) and proves custom codec participation on read/write. +- JSON-path assignment rejects before storing/encrypting `false`; malformed nested reads surface `JsonException`, not a return-type error. +- Add a text column to the cross-engine JSON-cast fixture for intentionally malformed stored bytes; database JSON/JSONB columns reject those bytes before Eloquent can exercise repair. +- A valid assignment can replace malformed original JSON through Eloquent. +- Good ciphertext containing malformed JSON can be replaced. +- Undecryptable ciphertext raises `DecryptException` and leaves the raw original unchanged. +- Existing valid dirty/equivalence behavior stays green. + +### Query and console + +- Exercise all five grammar methods with an unencodable binding; include MariaDB's inherited MySQL path. Include a depth failure so both main native failure classes are pinned without duplicating every case at every site. +- Call each preparation method directly and assert it raises `JsonException`. +- Small probe subclasses expose each protected console JSON renderer, use `OutputStyle`/`BufferedOutput`, and assert invalid metadata raises `JsonException` rather than a Symfony `TypeError`; valid output remains unchanged. + +### Telescope + +- Normal and exception entries store and read exact 512-container complete content. A top-level field that pushes complete content past that ceiling is replaced with `Purged By Telescope`; two overflowing fields in one entry are both replaced and the final content remains readable. +- Depth-heavy exception context is purged while class, message, occurrences, visibility, and tags remain valid. If a non-depth-unencodable same-family exception follows a persisted visible exception, the throw leaves the original row visible and byte-for-byte unchanged, publishes no replacement or tags, and prevents an ordinary entry from the same batch from being stored. Do not invent an insert-failure seam solely to test the database transaction primitive. +- A complete encode that first encounters depth but whose field pass then encounters INF/NAN or another non-depth defect rethrows that later error. This pins that depth recovery never hides instrumentation defects. +- Updating retained 512-container content preserves every original field while merging changes. Malformed or wrong-shape stored content raises and remains byte-for-byte unchanged; invalid UTF-8 substitution stays green. +- A depth-heavy changed field is purged and a later update is still applied. +- `ExtractProperties`, the plain-object EventWatcher branch, and RequestWatcher view data preserve supported nested object state and raise a precise `JsonException` when native object encoding fails. +- Reusing a stored ModelWatcher hydration entry decodes through the shared storage contract and increments its fixed count without changing queue/coroutine behavior. +- Structured and raw JSON client requests redact a hidden field at 511 containers. Malformed and 512-container `/json` and `+json` requests are purged and never retain the raw secret. +- Headerless shallow object/array JSON remains masked; a 512-container headerless body prefixed by a tab and newline is purged without retaining the secret, pinning the failure-only sniff and exact whitespace set. +- A supported `withBody()` URL-encoded request redacts default and nested hidden fields, including bracket notation. An explicit plain-text body remains raw. +- Structured payloads that cannot be encoded are purged. Masked truncation uses the encoded masked bytes and never contains the original secret; existing exact size-limit behavior stays green. +- Client responses and application responses retain and mask 511-container arrays, purge 512-container arrays while keeping the entry storable, and retain the existing redirect/plain-text/empty/HTML fallbacks. Standalone `JsonResponse::getData()` remains supported at 512 because it has no Telescope envelope. +- Request session and programmatically merged input at 512 containers, lifted Event/ExtractProperties payloads, and other watcher-owned deep top-level fields exercise the storage backstop: only that field is purged and unrelated entries/tags survive. +- Test repository failures directly because `Telescope::executeStore()` intentionally reports and isolates them from the application. Keep one end-to-end assertion that the request completes while a bad diagnostic batch is not published. + +### Package manifest and test cleanup + +- Missing root/installed files remain clean. +- Root wildcard returns before parsing malformed installed metadata. +- A specifically ignored formatted package name skips invalid version/`extra.hypervel`. +- Invalid JSON, non-array root/`packages`, malformed/nameless entries, bad version, and bad package/root `extra.hypervel` throw precise exceptions naming the path and failing location. +- A non-array parent `extra` remains tolerated because it contains no addressable `extra.hypervel`; an explicitly present non-array `extra.hypervel` fails. +- Valid package/root `extra.hypervel` values remain consumer-owned and retain scalar/array behavior. +- Explicit `build()` over an existing valid cache fails before write and preserves that cache byte-for-byte. +- Keep the committed `tests/Foundation/Fixtures` base path read-only. Add one `ParallelTesting::tempDir('FoundationPackageManifestTest')` scratch root; place the manifest and every generated Composer root beneath it, recreate/delete the whole root through `Filesystem` in setup/teardown, and remove direct system-temp paths and the manual directory ledger. +- Replace TestState's malformed-does-not-throw case with malformed root/installed fail-loud cases. Assert no registrar runs before failure, while missing files remain tolerated. +- Testbench accepts an absent root package, preserves the formatted `testbench/example` root key, and rejects malformed syntax, scalar roots, empty/formatted-empty names, and invalid explicit `extra.hypervel` with the same package-shape rules as installed discovery. +- Use a `ParallelTesting::tempDir('PackageManifestTest')` scratch root for Testbench's manifest output instead of a direct system-temp path, while retaining its committed read-only fixtures. +- Package-tester subprocess coverage builds from a scratch package root, proves malformed metadata exits nonzero with the native error in stderr, and proves no manifest is published. + +## Documentation, performance, and compatibility audit + +- Update only the incorrect validation/casting example in user docs. Codec internals, grammar flags, Eloquent's stored-empty convention, and package-manifest parsing are method/PR details rather than new user workflows. +- Record in the PR/change summary: corrected explicit/default depth units; Collection `fromJson()` and `JsonResponse::getData()` defaults widen by one native level while explicit depths remain native; `JsonResponse::setEncodingOptions()` no longer replaces a supported deep payload with null; `Jsonable` default flags now produce unescaped Unicode; serialized-closure command/result ownership moves to Concurrency and supported maximum-depth envelopes become readable; Telescope storage purges depth-overflowing top-level fields, rejects other unreadable content, and prevents supported JSON/form request paths from retaining configured secrets through raw fallback; malformed Composer metadata now fails package discovery and Testbench startup; missing metadata remains supported. +- Compare each port with its current owner: Laravel framework 13.x, Telescope 5.x, Orchestra Testbench, or Inertia Laravel as applicable. Preserve public/protected signatures, named arguments, method order, callbacks, and normal output except for approved correctness changes and typed anonymous-caster parameters. +- `Str::isJson()` and cold framework-owned file/round-trip operations are the added production consumers of Support's depth translation. Inertia, `AssertableJsonString`, and `TestResponse` changes are test-only. Successful-path overhead is limited to one branch and integer increment for Support decode/validate—including process-envelope reads already dominated by process creation and IPC—inline depth expressions/literals at native decode calls, exact `false`/shape checks in Eloquent writers/readers, and a non-throwing try boundary around original-value comparison. Telescope adds URL-encoded parsing only on its existing raw form-body path. Its normal structured path remains two traversals and oversized truncation drops from three to two; the default oversized purge path rises from one to two traversals and materializes masked encoded bytes so its limit applies to retainable data. Application responses remove one JSON traversal. Storage's per-field passes run only after a depth failure; successful entries still encode once. Exception chunks add the bounded transaction and sorted family keys described above; ordinary Telescope batches do not. There is no other added query, I/O, allocation-heavy abstraction, container lookup, lock, coroutine state, or worker memory. +- Failure-only exception construction is intentional. Package metadata and Testbench work remain cold bootstrap/build work. +- Remove superseded fallbacks, silent filters, duplicate JSON-path encoding, unused request `asJson()`, and stale comments/tests in the same implementation. Do not leave compatibility shims for unreleased 0.4 behavior. + +## Verification and completion + +1. Run each changed/new test file immediately. +2. Run the focused Collections, Support, Filesystem, Foundation HTTP/maintenance, Validation, Http, Session, Inertia, Concurrency/process transport, Database grammar/Eloquent/console, Telescope storage/watcher, Foundation PackageManifest, Testing registrar, and Testbench PackageManifest suites. +3. Run the real SQLite, PostgreSQL, MySQL, and MariaDB database integration groups because JSON column behavior, repair fixtures, and grammar inheritance are engine-sensitive. +4. Run `composer test:testbench`, then run `composer fix` once at the complete checkpoint. If either fails, correct with targeted checks, then run the failed and remaining script entries as required by `AGENTS.md`. +5. Freshly review every diff through all callers/callees for Eloquent repair safety, custom codec behavior, Telescope storage/redaction failure policy, package ignore ordering, strict typing, Laravel-style APIs, stale code/comments/docs, overengineering, and hot-path cost. +6. Request adversarial peer code review and loop until signoff before commit/PR. + +Completion requires every accepted 512-container value to be readable at the same public limit, Telescope to purge only top-level fields that exceed its complete-content ceiling while non-depth invalid writes fail before side effects, valid replacements to repair only readable/decryptable corrupt JSON, supported structured Telescope request bodies never to retain configured secrets through raw fallback, package discovery to distinguish missing from corrupt metadata, all supported engines to pass, and no speculative mechanism or dead path to remain. + +## Primary references + +- [PHP `json_encode`](https://www.php.net/manual/en/function.json-encode.php) +- [PHP `json_decode`](https://www.php.net/manual/en/function.json-decode.php) +- [PHP `json_validate`](https://www.php.net/manual/en/function.json-validate.php) +- [Composer schema](https://getcomposer.org/doc/04-schema.md) +- [Laravel 13.x Eloquent JSON codec](https://github.com/laravel/framework/blob/13.x/src/Illuminate/Database/Eloquent/Casts/Json.php) +- [Laravel 13.x Eloquent attribute casting](https://github.com/laravel/framework/blob/13.x/src/Illuminate/Database/Eloquent/Concerns/HasAttributes.php) +- [Laravel 13.x package manifest](https://github.com/laravel/framework/blob/13.x/src/Illuminate/Foundation/PackageManifest.php) +- [Laravel 13.x serialized-closure command](https://github.com/laravel/framework/blob/13.x/src/Illuminate/Concurrency/Console/InvokeSerializedClosureCommand.php) +- [Laravel Telescope 5.x](https://github.com/laravel/telescope/tree/5.x) +- [Orchestra Testbench](https://github.com/orchestral/testbench-core) +- [Inertia Laravel](https://github.com/inertiajs/inertia-laravel) From e4e5e6119e02e6b97dcf2f5314329abeb64c01af Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:43:07 +0000 Subject: [PATCH 14/15] test(testbench): accept native errors from either stream Read both subprocess output streams when asserting native PHP failures and reporting unexpected manifest-build exits. PHP routes displayed fatal errors according to its runtime configuration: the CI image writes them to stdout while local logging also supplies stderr. Keep the test focused on the nonzero exit, useful diagnostic, and absent manifest rather than a php.ini-dependent stream. Update the implementation plan to match. --- ...0932-json-correctness-and-package-metadata.md | 4 ++-- .../PackageManifestPackageTesterTest.php | 16 ++++++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/docs/plans/2026-08-11-0932-json-correctness-and-package-metadata.md b/docs/plans/2026-08-11-0932-json-correctness-and-package-metadata.md index f3ee7ce67..973e85944 100644 --- a/docs/plans/2026-08-11-0932-json-correctness-and-package-metadata.md +++ b/docs/plans/2026-08-11-0932-json-correctness-and-package-metadata.md @@ -362,7 +362,7 @@ Update Testbench's `Foundation\PackageManifest` without weakening its typed boun - Keep `packagesToIgnore()` returning `[]`. Base installed discovery still applies package-owned ignores; Testbench's application/runtime ignores remain read-time behavior in `getManifest()`. - Malformed installed metadata fails before the root merge and manifest write, so no partial cache is published. Testbench's runtime filtering no longer hides corrupt installed metadata; record this intentional fail-loud behavior in the PR/change summary. -The Testbench subprocess regression must use a scratch package root, call `Process::run()`, and assert nonzero status plus useful native failure text on stderr. Do not wrap the production exception merely to customize subprocess output. +The Testbench subprocess regression must use a scratch package root, call `Process::run()`, and assert nonzero status plus useful native failure text across the process output streams. Do not wrap the production exception merely to customize subprocess output. ## File map @@ -464,7 +464,7 @@ Run every changed/new test file immediately after editing it. Use small local de - Replace TestState's malformed-does-not-throw case with malformed root/installed fail-loud cases. Assert no registrar runs before failure, while missing files remain tolerated. - Testbench accepts an absent root package, preserves the formatted `testbench/example` root key, and rejects malformed syntax, scalar roots, empty/formatted-empty names, and invalid explicit `extra.hypervel` with the same package-shape rules as installed discovery. - Use a `ParallelTesting::tempDir('PackageManifestTest')` scratch root for Testbench's manifest output instead of a direct system-temp path, while retaining its committed read-only fixtures. -- Package-tester subprocess coverage builds from a scratch package root, proves malformed metadata exits nonzero with the native error in stderr, and proves no manifest is published. +- Package-tester subprocess coverage builds from a scratch package root, proves malformed metadata exits nonzero with the native error in the combined process output, and proves no manifest is published. ## Documentation, performance, and compatibility audit diff --git a/tests/Testbench/Foundation/PackageManifestPackageTesterTest.php b/tests/Testbench/Foundation/PackageManifestPackageTesterTest.php index 357432dbc..a227dcb15 100644 --- a/tests/Testbench/Foundation/PackageManifestPackageTesterTest.php +++ b/tests/Testbench/Foundation/PackageManifestPackageTesterTest.php @@ -93,7 +93,7 @@ public function itFailsForMalformedRootMetadataWithoutPublishingAManifest(): voi ); $this->assertFalse($process->isSuccessful()); - $this->assertStringContainsString('Syntax error', $process->getErrorOutput()); + $this->assertStringContainsString('Syntax error', $this->processOutput($process)); $this->assertFileDoesNotExist($this->manifestPath('malformed-root')); } @@ -110,7 +110,7 @@ public function itFailsForNonArrayRootMetadataWithoutPublishingAManifest(): void $this->assertFalse($process->isSuccessful()); $this->assertStringContainsString( "Composer metadata [{$this->packagePath}/composer.json] must contain an array.", - $process->getErrorOutput() + $this->processOutput($process) ); $this->assertFileDoesNotExist($this->manifestPath('non-array-root')); } @@ -126,7 +126,7 @@ public function itFailsForMalformedInstalledMetadataWithoutPublishingAManifest() ); $this->assertFalse($process->isSuccessful()); - $this->assertStringContainsString('Syntax error', $process->getErrorOutput()); + $this->assertStringContainsString('Syntax error', $this->processOutput($process)); $this->assertFileDoesNotExist($this->manifestPath('malformed-installed')); } @@ -142,7 +142,7 @@ private function buildManifest(string $manifestName, array $env = [], array $arg $process = $this->runManifest($manifestName, $env, $arguments); $manifestPath = $this->manifestPath($manifestName); - $this->assertTrue($process->isSuccessful(), $process->getErrorOutput()); + $this->assertTrue($process->isSuccessful(), $this->processOutput($process)); $this->assertFileExists($manifestPath); /** @var array $manifest */ @@ -180,6 +180,14 @@ private function runManifest(string $manifestName, array $env = [], array $argum return $process; } + /** + * Get the process output from both streams. + */ + private function processOutput(Process $process): string + { + return $process->getOutput() . $process->getErrorOutput(); + } + /** * Get the generated manifest path. */ From e33ba4d5e6a5ea0a12d75623a878e3f42d391f8f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:13:54 +0000 Subject: [PATCH 15/15] fix(concurrency): validate transported exception classes Document the framework-only trust boundary for serialized closure responses and validate that a transported exception class is an available Throwable before invoking its constructor. Preserve the remote message while retaining class-resolution and constructor failures as the previous exception for useful diagnostics. Make the transport regression tests immune to PHPUnit assertion interception, prove that non-Throwable constructors are never called, and keep combined Testbench subprocess diagnostics separated by a newline. Record the accepted Telescope occurrence-count race and why moving a non-locking count into the existing transaction would not serialize writers. The full formatter, static analysis, parallel suite, Testbench contract suite, dogfood checks, and targeted review tests are green. --- ...2-json-correctness-and-package-metadata.md | 6 +- .../src/SerializedClosureResult.php | 15 +- tests/Concurrency/ConcurrencyTest.php | 24 ++-- .../InvokeSerializedClosureCommandTest.php | 9 +- .../SerializedClosureResultTest.php | 130 +++++++++++++----- .../PackageManifestPackageTesterTest.php | 2 +- .../Foundation/Process/ProcessResultTest.php | 15 +- 7 files changed, 140 insertions(+), 61 deletions(-) diff --git a/docs/plans/2026-08-11-0932-json-correctness-and-package-metadata.md b/docs/plans/2026-08-11-0932-json-correctness-and-package-metadata.md index 973e85944..76e5af012 100644 --- a/docs/plans/2026-08-11-0932-json-correctness-and-package-metadata.md +++ b/docs/plans/2026-08-11-0932-json-correctness-and-package-metadata.md @@ -168,7 +168,7 @@ Correct the Collections-owned self-round-trips without creating a dependency cyc Move serialized-closure response ownership to Concurrency and remove its duplicated readers: - Move `InvokeSerializedClosureCommand` from Foundation to `Hypervel\Concurrency\Console`, matching current Laravel ownership while keeping registration in `FoundationServiceProvider`. -- Add internal `Hypervel\Concurrency\SerializedClosureResult::decode(string $output): mixed`. It owns gzip-marker truncation, Support decode, envelope validation, remote-exception reconstruction, strict base64 decoding, and guarded unserialization. Its docblock states that it returns the unserialized result or throws the reconstructed remote `Throwable`. +- Add internal `Hypervel\Concurrency\SerializedClosureResult::decode(string $output): mixed`. It owns gzip-marker truncation, Support decode, envelope validation, remote-exception reconstruction, strict base64 decoding, and guarded unserialization. Before invoking a transported exception constructor, require its class to be an available `Throwable`; retain the remote message as the outer error and the local class or constructor failure as `previous`. Its class docblock records that the subprocess stream may also contain application-task output and that unrestricted unserialization accepts only framework-controlled input. Its method docblock states that it returns the unserialized result or throws the reconstructed remote `Throwable`. - Have `ProcessDriver::run()` and Testbench's `Foundation\Process\ProcessResult::output()` delegate to it after their caller-owned process/raw-command checks. Use domain errors—`Invalid serialized closure response envelope.` and `Unable to decode the serialized closure result.`—instead of parameterizing caller nouns. - Keep the command's explicit native calls, transport flags, and native-512 parameter stability precheck together. The parent reads the complete envelope at Support's public 512-container limit, translated to native 513; the parameters subtree is one container shallower, so its native-512 precheck is deliberate. Routing only the outer writer through Support would add no behavior and would obscure the transport-specific `JSON_INVALID_UTF8_SUBSTITUTE` / `JSON_PRESERVE_ZERO_FRACTION` contract and subtree depth math. - Retain the command's caught `report()` call as Laravel's accepted Foundation soft dependency. An undefined helper is already contained by the `Throwable` catch; Concurrency must not declare Foundation or add reporting machinery that creates a package cycle. @@ -287,7 +287,7 @@ Update `src/telescope/src/Storage/DatabaseEntriesRepository.php` so every stored - This failure-only recovery preserves each watcher's required shallow schema and all other useful fields. Do not replace the complete content with a sentinel: event and request screens structurally consume shallow fields such as `listeners` and `middleware`. Do not add a recursive sanitizer, watcher/type map, storage ceiling override, or successful-path preflight. - `update()` decodes the retained content through Support before merging changes, then uses `encodeContent()`. Let `array_merge()` fail naturally if a corrupt stored value is not an array; do not replace it with an empty value. A depth-heavy changed top-level field is purged and later updates continue, while malformed retained JSON and non-depth encoding defects remain fail-loud. - For each exception chunk, group by family hash and `sortKeys()` before querying occurrence counts and collecting last UUIDs. Build and encode the complete insert-row array before any write. The deterministic family order is required because the transaction below retains locks across family updates; first-appearance order would let two concurrent multi-family chunks acquire the same locks in opposite orders and deadlock. -- On `DB::connection($this->connection)`, use `transaction(Closure)` with its default single attempt around only the existing sorted per-family visibility clears and the already-built chunk insert. Encoding stays outside the transaction. This makes clear-then-insert atomic: encoding, update, or insert failure leaves the prior visible row intact. Keep update-before-insert because insert-first writers can clear each other's newly inserted visible rows. The transaction preserves the existing possibility of duplicate visible rows during concurrent first writes; eliminating that cosmetic, self-repairing race would require unsupported cross-engine schema or family-lock machinery. +- On `DB::connection($this->connection)`, use `transaction(Closure)` with its default single attempt around only the existing sorted per-family visibility clears and the already-built chunk insert. Encoding stays outside the transaction. This makes clear-then-insert atomic: encoding, update, or insert failure leaves the prior visible row intact. Keep update-before-insert because insert-first writers can clear each other's newly inserted visible rows. The transaction preserves the existing possibility of duplicate visible rows during concurrent first writes and repeated or stale `occurrences` values during concurrent same-family writes. Later writes recompute the count from every persisted row. Moving the count into the transaction would not serialize writers because a plain count is a non-locking snapshot read; exact counts would require `SELECT ... FOR UPDATE`, which SQLite does not support, or a dedicated family-lock row. Eliminating either cosmetic, self-repairing race therefore requires unsupported cross-engine schema or family-lock machinery. - Keep the existing chunked and partial-batch model. Depth recovery changes only offending top-level field values and retains the row UUID, type, family state, and tags, so it requires no filtering or tag bookkeeping. A non-depth failure in a later exception chunk leaves prior chunks stored but prevents tags for every exception chunk and prevents every ordinary entry and its tags from being stored; `Telescope::executeStore()` reports the failure, skips later updates/hooks, and flushes the in-memory queues. Do not add a whole-batch transaction, whole-batch pre-encoding, duplicate validation, row filtering, or recovery for non-depth defects: they would widen locks, remove the memory bound, serialize twice, or hide an instrumentation defect. - The exception transaction adds one begin/commit pair per nonempty exception chunk and holds matched family-row locks plus its pooled connection through the update/insert pair. This can serialize same-family writers during an exception storm, but batches without exceptions pay nothing. If the configured connection already has an outer transaction, the normal savepoint behavior applies and Telescope retains its existing participation in the application's eventual commit or rollback. @@ -407,7 +407,7 @@ Run every changed/new test file immediately after editing it. Use small local de - Build the XML boundary fixture from 255 levels of repeated same-named siblings: SimpleXML projects each sibling pair through an array, producing 512 JSON containers within libxml's default element-depth limit. A plain element chain cannot reach this boundary. At 512 containers, the current null decode causes a return-type `TypeError`; the Support codec makes the document readable. Adding one innermost attribute produces 513 containers: the current encode `false` causes a nested `json_decode()` parameter `TypeError`, while the Support codec raises `JsonException`. Do not add `LIBXML_PARSEHUGE` or pin intermediate encoded bytes. - Inertia deliberately replaces the nested native-call `TypeError` on over-depth values with encode-side `JsonException`. Assert that Inertia lets this `JsonException` propagate rather than converting it to `Not a valid Inertia response.`; its assertion-only catch remains unchanged. - Pin the serialized-closure boundary exactly: 510 array containers in a public exception value become 511 in the named-parameter map and 512 in the complete envelope. The command must emit it, and the shared decoder must reconstruct the original exception class and exact value; 509 already works and 511 deliberately degrades before emission, so either neighboring value would miss the regression. -- Central decoder coverage owns malformed output/envelopes, gzip suffixes, remote exception reconstruction, binary/false results, and unserialization failures. The command's six test decoders use throwing Support decode. ProcessDriver and Testbench retain thin success/failure delegation tests plus their non-envelope branches; Testbench proves non-Closure raw output preserves gzip-marker bytes. +- Central decoder coverage owns malformed output/envelopes, gzip suffixes, remote exception reconstruction, binary/false results, and unserialization failures. Unavailable and non-Throwable transported classes assert the outer remote message and local `previous` diagnostic; the non-Throwable probe also proves its constructor is never invoked. The command's six test decoders use throwing Support decode. ProcessDriver and Testbench retain thin success/failure delegation tests plus their non-envelope branches; Testbench proves non-Closure raw output preserves gzip-marker bytes. - Request `array`, `object`, and `collection` casts accept valid JSON at the maximum and reject malformed, empty, and over-depth raw input with `JsonException`. - The default validated form-request path uses the `json` rule and the same maximum. - Public validator and compiled plan paths both accept the maximum, reject one level over, and return validation failure—not an exception—for empty/malformed fields. diff --git a/src/concurrency/src/SerializedClosureResult.php b/src/concurrency/src/SerializedClosureResult.php index 151b3d3a6..62acb0263 100644 --- a/src/concurrency/src/SerializedClosureResult.php +++ b/src/concurrency/src/SerializedClosureResult.php @@ -9,6 +9,13 @@ use Throwable; /** + * Decode serialized-closure responses produced by the framework subprocess command. + * + * The subprocess stream may also contain output from the application task, so the + * response envelope is validated before use. Task results may contain objects, so + * successful responses require unrestricted unserialization. Callers must never + * pass external input to this decoder. + * * @internal */ class SerializedClosureResult @@ -52,15 +59,15 @@ public static function decode(string $output): mixed $parameters = $payload['parameters'] ?? ['message' => $message]; try { + if (! is_a($exceptionClass, Throwable::class, true)) { + throw new RuntimeException("The transported exception class [{$exceptionClass}] is not an available Throwable."); + } + $exception = new $exceptionClass(...$parameters); } catch (Throwable $constructionException) { throw new RuntimeException($message, previous: $constructionException); } - if (! $exception instanceof Throwable) { - throw new RuntimeException($message); - } - throw $exception; } diff --git a/tests/Concurrency/ConcurrencyTest.php b/tests/Concurrency/ConcurrencyTest.php index 1381fed57..b078f4a04 100644 --- a/tests/Concurrency/ConcurrencyTest.php +++ b/tests/Concurrency/ConcurrencyTest.php @@ -107,6 +107,8 @@ public function testRunRethrowsCustomExceptionWithOriginalMessage() public function testRunRethrowsExceptionFromEarliestInputPositionWhenMultipleTasksFail() { + $caught = null; + try { $this->coroutineDriver->run([ function () { @@ -119,11 +121,12 @@ function () { throw new RuntimeException('second in input'); }, ]); - - $this->fail('Expected exception was not thrown'); } catch (RuntimeException $e) { - $this->assertSame('first in input', $e->getMessage()); + $caught = $e; } + + $this->assertNotNull($caught, 'Expected exception was not thrown'); + $this->assertSame('first in input', $caught->getMessage()); } public function testRunWithEmptyArrayReturnsEmptyArray() @@ -461,17 +464,20 @@ public function testProcessDriverPreservesPublicFalseyExceptionParameters(): voi 'detail' => null, ], ]); + $caught = null; try { $driver->run(static fn () => null); - $this->fail('Expected the transported exception to be thrown.'); } catch (Exception $exception) { - $this->assertSame(ConcurrentProcessExceptionFixtures::PUBLIC_FALSEY_EXCEPTION, $exception::class); - $this->assertSame(0, $exception->status); - $this->assertFalse($exception->retry); - $this->assertSame('', $exception->reason); - $this->assertNull($exception->detail); + $caught = $exception; } + + $this->assertNotNull($caught, 'Expected the transported exception to be thrown.'); + $this->assertSame(ConcurrentProcessExceptionFixtures::PUBLIC_FALSEY_EXCEPTION, $caught::class); + $this->assertSame(0, $caught->status); + $this->assertFalse($caught->retry); + $this->assertSame('', $caught->reason); + $this->assertNull($caught->detail); } public function testProcessDriverReportsFailedChildProcessesBeforeDecoding(): void diff --git a/tests/Concurrency/Console/InvokeSerializedClosureCommandTest.php b/tests/Concurrency/Console/InvokeSerializedClosureCommandTest.php index 724c64e53..af62159b5 100644 --- a/tests/Concurrency/Console/InvokeSerializedClosureCommandTest.php +++ b/tests/Concurrency/Console/InvokeSerializedClosureCommandTest.php @@ -227,14 +227,17 @@ public function testItTransportsTheMaximumReconstructibleExceptionParameterDepth $output = $this->invokeSerializedClosureOutput( static fn () => ConcurrentProcessExceptionFixtures::throwPublicValue($value) ); + $caught = null; try { SerializedClosureResult::decode($output); - $this->fail('Expected the transported exception to be thrown.'); } catch (RuntimeException $exception) { - $this->assertSame(ConcurrentProcessExceptionFixtures::PUBLIC_VALUE_EXCEPTION, $exception::class); - $this->assertSame($value, $exception->value); + $caught = $exception; } + + $this->assertNotNull($caught, 'Expected the transported exception to be thrown.'); + $this->assertSame(ConcurrentProcessExceptionFixtures::PUBLIC_VALUE_EXCEPTION, $caught::class); + $this->assertSame($value, $caught->value); } public function testItDegradesExceptionParametersBeyondTheTransportDepth(): void diff --git a/tests/Concurrency/SerializedClosureResultTest.php b/tests/Concurrency/SerializedClosureResultTest.php index 32ac2bf0a..27b5ad6f8 100644 --- a/tests/Concurrency/SerializedClosureResultTest.php +++ b/tests/Concurrency/SerializedClosureResultTest.php @@ -12,7 +12,6 @@ use Hypervel\Tests\TestCase; use JsonException; use RuntimeException; -use stdClass; use TypeError; class SerializedClosureResultTest extends TestCase @@ -56,12 +55,16 @@ public function testItRejectsInvalidResponseEnvelopes(): void ]; foreach ($payloads as $description => $payload) { + $caught = null; + try { SerializedClosureResult::decode(Json::encode($payload)); - $this->fail("Expected the {$description} response envelope to be rejected."); } catch (RuntimeException $exception) { - $this->assertSame('Invalid serialized closure response envelope.', $exception->getMessage()); + $caught = $exception; } + + $this->assertNotNull($caught, "Expected the {$description} response envelope to be rejected."); + $this->assertSame('Invalid serialized closure response envelope.', $caught->getMessage()); } } @@ -71,20 +74,26 @@ public function testItRejectsMalformedEncodedAndSerializedResults(): void 'base64' => '*not-base64*', 'serialized value' => base64_encode('not-serialized'), ] as $description => $result) { + $caught = null; + try { $this->decodePayload([ 'successful' => true, 'result' => $result, ]); - $this->fail("Expected the malformed {$description} to be rejected."); } catch (RuntimeException $exception) { - $this->assertSame('Unable to decode the serialized closure result.', $exception->getMessage()); + $caught = $exception; } + + $this->assertNotNull($caught, "Expected the malformed {$description} to be rejected."); + $this->assertSame('Unable to decode the serialized closure result.', $caught->getMessage()); } } public function testItPreservesPublicFalseyExceptionParameters(): void { + $caught = null; + try { $this->decodePayload([ 'successful' => false, @@ -97,14 +106,16 @@ public function testItPreservesPublicFalseyExceptionParameters(): void 'detail' => null, ], ]); - $this->fail('Expected the transported exception to be thrown.'); } catch (Exception $exception) { - $this->assertSame(ConcurrentProcessExceptionFixtures::PUBLIC_FALSEY_EXCEPTION, $exception::class); - $this->assertSame(0, $exception->status); - $this->assertFalse($exception->retry); - $this->assertSame('', $exception->reason); - $this->assertNull($exception->detail); + $caught = $exception; } + + $this->assertNotNull($caught, 'Expected the transported exception to be thrown.'); + $this->assertSame(ConcurrentProcessExceptionFixtures::PUBLIC_FALSEY_EXCEPTION, $caught::class); + $this->assertSame(0, $caught->status); + $this->assertFalse($caught->retry); + $this->assertSame('', $caught->reason); + $this->assertNull($caught->detail); } public function testItReconstructsOptionalVariadicAndInheritedParameters(): void @@ -131,13 +142,17 @@ public function testItReconstructsOptionalVariadicAndInheritedParameters(): void ]; foreach ($payloads as $payload) { + $caught = null; + try { $this->decodePayload(['successful' => false, ...$payload]); - $this->fail('Expected the transported exception to be thrown.'); } catch (Exception $exception) { - $this->assertSame($payload['exception'], $exception::class); - $this->assertSame($payload['expectedMessage'], $exception->getMessage()); + $caught = $exception; } + + $this->assertNotNull($caught, 'Expected the transported exception to be thrown.'); + $this->assertSame($payload['exception'], $caught::class); + $this->assertSame($payload['expectedMessage'], $caught->getMessage()); } } @@ -173,6 +188,8 @@ public function testItReconstructsZeroArgumentAndStoredVariadicExceptions(): voi ]; foreach ($payloads as $exceptionClass => $property) { + $caught = null; + try { $this->decodePayload([ 'successful' => false, @@ -180,16 +197,20 @@ public function testItReconstructsZeroArgumentAndStoredVariadicExceptions(): voi 'message' => 'remote failure', 'parameters' => [], ]); - $this->fail('Expected the transported exception to be thrown.'); } catch (Exception $exception) { - $this->assertSame($exceptionClass, $exception::class); - $this->assertSame($property === 'argumentCount' ? 0 : [], $exception->{$property}); + $caught = $exception; } + + $this->assertNotNull($caught, 'Expected the transported exception to be thrown.'); + $this->assertSame($exceptionClass, $caught::class); + $this->assertSame($property === 'argumentCount' ? 0 : [], $caught->{$property}); } } public function testItContainsConstructorFailuresDuringReconstruction(): void { + $caught = null; + try { $this->decodePayload([ 'successful' => false, @@ -197,15 +218,19 @@ public function testItContainsConstructorFailuresDuringReconstruction(): void 'message' => 'status=5', 'parameters' => ['status' => 'v5'], ]); - $this->fail('Expected exception reconstruction to fail.'); } catch (RuntimeException $exception) { - $this->assertSame('status=5', $exception->getMessage()); - $this->assertInstanceOf(TypeError::class, $exception->getPrevious()); + $caught = $exception; } + + $this->assertNotNull($caught, 'Expected exception reconstruction to fail.'); + $this->assertSame('status=5', $caught->getMessage()); + $this->assertInstanceOf(TypeError::class, $caught->getPrevious()); } public function testItContainsUnavailableExceptionClassesDuringReconstruction(): void { + $caught = null; + try { $this->decodePayload([ 'successful' => false, @@ -213,24 +238,43 @@ public function testItContainsUnavailableExceptionClassesDuringReconstruction(): 'message' => 'remote failure', 'parameters' => [], ]); - $this->fail('Expected exception reconstruction to fail.'); } catch (RuntimeException $exception) { - $this->assertSame('remote failure', $exception->getMessage()); - $this->assertNotNull($exception->getPrevious()); + $caught = $exception; } + + $this->assertNotNull($caught, 'Expected exception reconstruction to fail.'); + $this->assertSame('remote failure', $caught->getMessage()); + $this->assertInstanceOf(RuntimeException::class, $caught->getPrevious()); + $this->assertSame( + 'The transported exception class [Missing\SerializedClosureException] is not an available Throwable.', + $caught->getPrevious()->getMessage(), + ); } - public function testItRejectsNonThrowableExceptionClasses(): void + public function testItRejectsNonThrowableExceptionClassesBeforeConstruction(): void { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('remote failure'); + NonThrowableConstructorProbe::$constructed = false; + $caught = null; - $this->decodePayload([ - 'successful' => false, - 'exception' => stdClass::class, - 'message' => 'remote failure', - 'parameters' => [], - ]); + try { + $this->decodePayload([ + 'successful' => false, + 'exception' => NonThrowableConstructorProbe::class, + 'message' => 'remote failure', + 'parameters' => [], + ]); + } catch (RuntimeException $exception) { + $caught = $exception; + } + + $this->assertNotNull($caught, 'Expected exception reconstruction to fail.'); + $this->assertSame('remote failure', $caught->getMessage()); + $this->assertInstanceOf(RuntimeException::class, $caught->getPrevious()); + $this->assertSame( + 'The transported exception class [' . NonThrowableConstructorProbe::class . '] is not an available Throwable.', + $caught->getPrevious()->getMessage(), + ); + $this->assertFalse(NonThrowableConstructorProbe::$constructed); } public function testItUsesTheGenericFailureFallback(): void @@ -244,6 +288,7 @@ public function testItUsesTheGenericFailureFallback(): void public function testItReconstructsTheMaximumExceptionParameterDepth(): void { $value = $this->nestedValue(510); + $caught = null; try { $this->decodePayload([ @@ -252,11 +297,13 @@ public function testItReconstructsTheMaximumExceptionParameterDepth(): void 'message' => 'public value', 'parameters' => ['value' => $value], ]); - $this->fail('Expected the transported exception to be thrown.'); } catch (RuntimeException $exception) { - $this->assertSame(ConcurrentProcessExceptionFixtures::PUBLIC_VALUE_EXCEPTION, $exception::class); - $this->assertSame($value, $exception->value); + $caught = $exception; } + + $this->assertNotNull($caught, 'Expected the transported exception to be thrown.'); + $this->assertSame(ConcurrentProcessExceptionFixtures::PUBLIC_VALUE_EXCEPTION, $caught::class); + $this->assertSame($value, $caught->value); } /** @@ -294,3 +341,16 @@ private function nestedValue(int $containers): array return $value; } } + +class NonThrowableConstructorProbe +{ + public static bool $constructed = false; + + /** + * Create a new non-Throwable constructor probe. + */ + public function __construct() + { + self::$constructed = true; + } +} diff --git a/tests/Testbench/Foundation/PackageManifestPackageTesterTest.php b/tests/Testbench/Foundation/PackageManifestPackageTesterTest.php index a227dcb15..86e5b6469 100644 --- a/tests/Testbench/Foundation/PackageManifestPackageTesterTest.php +++ b/tests/Testbench/Foundation/PackageManifestPackageTesterTest.php @@ -185,7 +185,7 @@ private function runManifest(string $manifestName, array $env = [], array $argum */ private function processOutput(Process $process): string { - return $process->getOutput() . $process->getErrorOutput(); + return $process->getOutput() . "\n" . $process->getErrorOutput(); } /** diff --git a/tests/Testbench/Foundation/Process/ProcessResultTest.php b/tests/Testbench/Foundation/Process/ProcessResultTest.php index 431143f55..090c80cdc 100644 --- a/tests/Testbench/Foundation/Process/ProcessResultTest.php +++ b/tests/Testbench/Foundation/Process/ProcessResultTest.php @@ -36,17 +36,20 @@ public function testItPreservesPublicFalseyExceptionParameters(): void 'detail' => null, ], ]); + $caught = null; try { $result->output(); - $this->fail('Expected the transported exception to be thrown.'); } catch (Exception $exception) { - $this->assertSame(ConcurrentProcessExceptionFixtures::PUBLIC_FALSEY_EXCEPTION, $exception::class); - $this->assertSame(0, $exception->status); - $this->assertFalse($exception->retry); - $this->assertSame('', $exception->reason); - $this->assertNull($exception->detail); + $caught = $exception; } + + $this->assertNotNull($caught, 'Expected the transported exception to be thrown.'); + $this->assertSame(ConcurrentProcessExceptionFixtures::PUBLIC_FALSEY_EXCEPTION, $caught::class); + $this->assertSame(0, $caught->status); + $this->assertFalse($caught->retry); + $this->assertSame('', $caught->reason); + $this->assertNull($caught->detail); } public function testItReturnsRawNonClosureOutputWithoutInterpretingGzipMarkers(): void