diff --git a/CHANGELOG.md b/CHANGELOG.md index 406313a..37291f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +1.5.2 (unreleased) +===== + +* (improvement) Reduce normalization overhead by tracking the debug stack internally across recursion instead of mutating it in context on each nested value. +* (improvement) Cache Doctrine-normalized class names in `SimpleNormalizer` to avoid repeated metadata lookups for the same object type. +* (improvement) Add test coverage to ensure class-name normalization is cached across repeated normalization calls. +* (improvement) Optimize `ValidJsonVerifier` by reusing a mutable path stack during traversal instead of allocating a new path array for each nested element. +* (improvement) Add dedicated verifier tests for deep-path reporting and first-invalid-element detection. +* (improvement) Optimize empty `stdClass` detection by using an `(array)` cast check instead of `get_object_vars()`. +* (improvement) Add a default max-depth guard (128) for normalization and JSON verification to mitigate deep-nesting DoS risk. + + 1.5.1 ===== diff --git a/composer.json b/composer.json index 74c7f9c..45afd22 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,9 @@ "phpunit/phpunit": "^13.0.5", "roave/security-advisories": "dev-latest" }, + "suggest": { + "doctrine/orm": "Can be used to normalize Doctrine entity class names (for example proxy classes to their mapped entity class)." + }, "autoload": { "psr-4": { "Torr\\SimpleNormalizer\\": "src/" diff --git a/src/Exception/InvalidMaxDepthException.php b/src/Exception/InvalidMaxDepthException.php new file mode 100644 index 0000000..285cf97 --- /dev/null +++ b/src/Exception/InvalidMaxDepthException.php @@ -0,0 +1,7 @@ + */ + private array $normalizedClassNames = []; /** * @param ServiceLocator $objectNormalizers @@ -35,8 +37,14 @@ public function __construct ( private readonly bool $isDebug = false, private readonly ?ValidJsonVerifier $validJsonVerifier = null, ?EntityManagerInterface $entityManager = null, + private readonly int $maxDepth = self::DEFAULT_MAX_DEPTH, ) { + if ($this->maxDepth < 1) + { + throw new InvalidMaxDepthException("The max depth must be at least 1."); + } + $this->doctrineMetadata = $entityManager?->getMetadataFactory(); } @@ -44,11 +52,12 @@ public function __construct ( */ public function normalize (mixed $value, array $context = []) : mixed { - $normalizedValue = $this->recursiveNormalize($value, $context); + $stack = []; + $normalizedValue = $this->recursiveNormalize($value, $context, $stack); if ($this->isDebug) { - $this->validJsonVerifier?->ensureValidOnlyJsonTypes($normalizedValue); + $this->validJsonVerifier?->ensureValidOnlyJsonTypes($normalizedValue, $this->maxDepth); } return $normalizedValue; @@ -58,11 +67,12 @@ public function normalize (mixed $value, array $context = []) : mixed */ public function normalizeArray (array $array, array $context = []) : array { - $normalizedValue = $this->recursiveNormalizeArray($array, $context); + $stack = []; + $normalizedValue = $this->recursiveNormalizeArray($array, $context, $stack); if ($this->isDebug) { - $this->validJsonVerifier?->ensureValidOnlyJsonTypes($normalizedValue); + $this->validJsonVerifier?->ensureValidOnlyJsonTypes($normalizedValue, $this->maxDepth); } return $normalizedValue; @@ -75,11 +85,12 @@ public function normalizeArray (array $array, array $context = []) : array public function normalizeMap (array $array, array $context = []) : array|\stdClass { // return stdClass if the array is empty here, as it will be automatically normalized to `{}` in JSON. - $normalizedValue = $this->recursiveNormalizeArray($array, $context) ?: new \stdClass(); + $stack = []; + $normalizedValue = $this->recursiveNormalizeArray($array, $context, $stack) ?: new \stdClass(); if ($this->isDebug) { - $this->validJsonVerifier?->ensureValidOnlyJsonTypes($normalizedValue); + $this->validJsonVerifier?->ensureValidOnlyJsonTypes($normalizedValue, $this->maxDepth); } return $normalizedValue; @@ -89,58 +100,71 @@ public function normalizeMap (array $array, array $context = []) : array|\stdCla * The actual normalize logic, that recursively normalizes the value. * It must never call one of the public methods above and just normalizes the value. */ - private function recursiveNormalize (mixed $value, array $context = []) : mixed + private function recursiveNormalize (mixed $value, array $context, array &$stack) : mixed { if (null === $value || \is_scalar($value)) { return $value; } - if (!isset($context[self::STACK_CONTEXT]) || !\is_array($context[self::STACK_CONTEXT])) + if (\count($stack) >= $this->maxDepth) { - $context[self::STACK_CONTEXT] = []; + $extendedStack = [...$stack, get_debug_type($value)]; + + throw new UnsupportedTypeException(\sprintf( + "Maximum normalization depth of %d exceeded when normalizing type %s in stack %s", + $this->maxDepth, + get_debug_type($value), + implode(" > ", array_reverse($extendedStack)), + )); } - $context[self::STACK_CONTEXT][] = get_debug_type($value); + $stack[] = get_debug_type($value); - if (\is_array($value)) + try { - return $this->recursiveNormalizeArray($value, $context); - } - - if (\is_object($value)) - { - // Allow empty stdClass as a way to force a JSON {} instead of an - // array which would encode to [] - if ($value instanceof \stdClass && [] === get_object_vars($value)) + if (\is_array($value)) { - return $value; + return $this->recursiveNormalizeArray($value, $context, $stack); } - try + if (\is_object($value)) { - $className = $this->normalizeClassName($value::class); - - $normalizer = $this->objectNormalizers->get($className); - \assert($normalizer instanceof SimpleObjectNormalizerInterface); - - return $normalizer->normalize($value, $context, $this); + // Allow empty stdClass as a way to force a JSON {} instead of an + // array which would encode to [] + if ($value instanceof \stdClass && [] === (array) $value) + { + return $value; + } + + try + { + $className = $this->normalizeClassName($value::class); + $normalizer = $this->objectNormalizers->get($className); + \assert($normalizer instanceof SimpleObjectNormalizerInterface); + + return $normalizer->normalize($value, $context, $this); + } + catch (ServiceNotFoundException $exception) + { + throw new ObjectTypeNotSupportedException(\sprintf( + "Can't normalize type '%s' in stack %s", + get_debug_type($value), + implode(" > ", array_reverse($stack)), + ), 0, $exception); + } } - catch (ServiceNotFoundException $exception) - { - throw new ObjectTypeNotSupportedException(\sprintf( - "Can't normalize type '%s' in stack %s", - get_debug_type($value), - implode(" > ", array_reverse($context[self::STACK_CONTEXT])), - ), 0, $exception); - } - } - throw new UnsupportedTypeException(\sprintf( - "Can't normalize type %s in stack %s", - get_debug_type($value), - implode(" > ", array_reverse($context[self::STACK_CONTEXT])), - )); + throw new UnsupportedTypeException(\sprintf( + "Can't normalize type %s in stack %s", + get_debug_type($value), + implode(" > ", array_reverse($stack)), + )); + } + finally + { + array_pop($stack); + } } /** @@ -158,7 +182,12 @@ private function normalizeClassName (string $className) : string return $className; } - return $this->doctrineMetadata->hasMetadataFor($className) + if (isset($this->normalizedClassNames[$className])) + { + return $this->normalizedClassNames[$className]; + } + + return $this->normalizedClassNames[$className] = $this->doctrineMetadata->hasMetadataFor($className) ? $this->doctrineMetadata->getMetadataFor($className)->getName() : $className; } @@ -167,14 +196,14 @@ private function normalizeClassName (string $className) : string * The actual customized normalization logic for arrays, that recursively normalizes the value. * It must never call one of the public methods above and just normalizes the value. */ - private function recursiveNormalizeArray (array $array, array $context = []) : array + private function recursiveNormalizeArray (array $array, array $context, array &$stack) : array { $result = []; $isList = array_is_list($array); foreach ($array as $key => $value) { - $normalized = $this->recursiveNormalize($value, $context); + $normalized = $this->recursiveNormalize($value, $context, $stack); // if the array was a list and the normalized value is null, just filter it out if ($isList && null === $normalized) diff --git a/src/Normalizer/Validator/ValidJsonVerifier.php b/src/Normalizer/Validator/ValidJsonVerifier.php index 9fcc0bd..01d86d5 100644 --- a/src/Normalizer/Validator/ValidJsonVerifier.php +++ b/src/Normalizer/Validator/ValidJsonVerifier.php @@ -12,11 +12,14 @@ class ValidJsonVerifier { /** - * Ensures that only valid JSON types are present in the value, that means scalars, arrays and empty objects. + * Ensures that only valid JSON types are present in the value that means scalars, arrays, and empty objects. + * + * @param positive-int $maxDepth */ - public function ensureValidOnlyJsonTypes (mixed $value) : void + public function ensureValidOnlyJsonTypes (mixed $value, int $maxDepth) : void { - $invalidElement = $this->findInvalidJsonElement($value); + $path = ["$"]; + $invalidElement = $this->findInvalidJsonElement($value, $path, $maxDepth); if (null !== $invalidElement) { @@ -34,10 +37,21 @@ public function ensureValidOnlyJsonTypes (mixed $value) : void * Searches through the value and looks for anything that isn't valid JSON * (scalars, arrays or empty objects). * + * @param positive-int $maxDepth + * * @return InvalidJsonElement|null returns null if everything is valid, otherwise the invalid value */ - private function findInvalidJsonElement (mixed $value, array $path = ["$"]) : ?InvalidJsonElement + private function findInvalidJsonElement (mixed $value, array &$path, int $maxDepth) : ?InvalidJsonElement { + if (\count($path) - 1 > $maxDepth) + { + throw new IncompleteNormalizationException(\sprintf( + "Maximum JSON verification depth of %d exceeded at path '%s'.", + $maxDepth, + implode(".", $path), + )); + } + // scalars are always valid if (null === $value || \is_scalar($value)) { @@ -47,19 +61,18 @@ private function findInvalidJsonElement (mixed $value, array $path = ["$"]) : ?I // only empty stdClass objects are allowed (as they are used to serialize to `{}`) if (\is_object($value)) { - return $value instanceof \stdClass && [] === get_object_vars($value) + return $value instanceof \stdClass && [] === (array) $value ? null - : new InvalidJsonElement($value, $path); + : new InvalidJsonElement($value, [...$path]); } if (\is_array($value)) { foreach ($value as $key => $item) { - $invalidItem = $this->findInvalidJsonElement( - $item, - [...$path, $key], - ); + $path[] = $key; + $invalidItem = $this->findInvalidJsonElement($item, $path, $maxDepth); + array_pop($path); if (null !== $invalidItem) { @@ -70,6 +83,6 @@ private function findInvalidJsonElement (mixed $value, array $path = ["$"]) : ?I return null; } - return new InvalidJsonElement($value, $path); + return new InvalidJsonElement($value, [...$path]); } } diff --git a/tests/DependencyInjection/BundleWiringTest.php b/tests/DependencyInjection/BundleWiringTest.php new file mode 100644 index 0000000..ec17a6f --- /dev/null +++ b/tests/DependencyInjection/BundleWiringTest.php @@ -0,0 +1,82 @@ +build($container); + + $container + ->register(BundleWiringDummyNormalizer::class, BundleWiringDummyNormalizer::class) + ->setAutoconfigured(true) + ->setAutowired(true); + + $container + ->register(SimpleNormalizer::class, SimpleNormalizer::class) + ->setPublic(true) + ->setArguments([ + new ServiceLocatorArgument( + new TaggedIteratorArgument( + "torr.normalizer.simple-object-normalizer", + defaultIndexMethod: "getNormalizedType", + needsIndexes: true, + ), + ), + false, + null, + null, + ]); + + $container->compile(); + + $normalizer = $container->get(SimpleNormalizer::class); + \assert($normalizer instanceof SimpleNormalizer); + + self::assertSame( + ["id" => 42], + $normalizer->normalize(new DummyVO(42)), + ); + } +} + +final class BundleWiringDummyNormalizer implements SimpleObjectNormalizerInterface +{ + /** + * @inheritDoc + */ + public function normalize (object $value, array $context, SimpleNormalizer $normalizer) : mixed + { + \assert($value instanceof DummyVO); + + return [ + "id" => $value->id, + ]; + } + + /** + * @inheritDoc + */ + public static function getNormalizedType () : string + { + return DummyVO::class; + } +} diff --git a/tests/Normalizer/ArrayNormalizerTest.php b/tests/Normalizer/ArrayNormalizerTest.php index e51b58d..4b9474c 100644 --- a/tests/Normalizer/ArrayNormalizerTest.php +++ b/tests/Normalizer/ArrayNormalizerTest.php @@ -45,6 +45,9 @@ public static function provideAssociativeArray () : iterable yield [["a" => 1, "b" => 2, "c" => 3, "d" => 4], ["a" => 1, "b" => 2, "c" => 3, "d" => 4]]; yield [["a" => 1, "b" => null, "c" => 3, "d" => null], ["a" => 1, "b" => null, "c" => 3, "d" => null]]; yield [["empty" => null], ["empty" => null]]; + yield [[0 => 1, 1 => null, 3 => 4], [0 => 1, 1 => null, 3 => 4]]; + yield [[1 => 1, 2 => null], [1 => 1, 2 => null]]; + yield [["01" => 1, 1 => 2], ["01" => 1, 1 => 2]]; } /** diff --git a/tests/Normalizer/ObjectNormalizationTest.php b/tests/Normalizer/ObjectNormalizationTest.php index d4ab1f8..9496315 100644 --- a/tests/Normalizer/ObjectNormalizationTest.php +++ b/tests/Normalizer/ObjectNormalizationTest.php @@ -3,6 +3,8 @@ namespace Tests\Torr\SimpleNormalizer\Normalizer; use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; +use Symfony\Component\DependencyInjection\ServiceLocator; use Tests\Torr\SimpleNormalizer\Fixture\DummyVO; use Torr\SimpleNormalizer\Exception\ObjectTypeNotSupportedException; use Torr\SimpleNormalizer\Normalizer\SimpleNormalizer; @@ -81,4 +83,46 @@ public function testMissingNormalizer () : void $normalizer = $this->createNormalizer(); $normalizer->normalize(new DummyVO(11)); } + + /** + */ + public function testMissingNormalizerUsesGet () : void + { + $locator = $this->createMock(ServiceLocator::class); + $locator->expects(self::once()) + ->method("get") + ->with(DummyVO::class) + ->willThrowException(new ServiceNotFoundException(DummyVO::class)); + + $normalizer = new SimpleNormalizer($locator); + + $this->expectException(ObjectTypeNotSupportedException::class); + $this->expectExceptionMessage("Can't normalize type 'Tests\Torr\SimpleNormalizer\Fixture\DummyVO' in stack Tests\Torr\SimpleNormalizer\Fixture\DummyVO"); + $normalizer->normalize(new DummyVO(11)); + } + + /** + */ + public function testMissingNormalizerKeepsPreviousException () : void + { + $previous = new ServiceNotFoundException(DummyVO::class); + + $locator = $this->createMock(ServiceLocator::class); + $locator->expects(self::once()) + ->method("get") + ->with(DummyVO::class) + ->willThrowException($previous); + + $normalizer = new SimpleNormalizer($locator); + + try + { + $normalizer->normalize(new DummyVO(11)); + self::fail("Expected ObjectTypeNotSupportedException to be thrown."); + } + catch (ObjectTypeNotSupportedException $exception) + { + self::assertSame($previous, $exception->getPrevious()); + } + } } diff --git a/tests/Normalizer/ObjectNormalizer/ValueWithContextNormalizerTest.php b/tests/Normalizer/ObjectNormalizer/ValueWithContextNormalizerTest.php index 123cab3..2c3ca60 100644 --- a/tests/Normalizer/ObjectNormalizer/ValueWithContextNormalizerTest.php +++ b/tests/Normalizer/ObjectNormalizer/ValueWithContextNormalizerTest.php @@ -36,10 +36,6 @@ public function testContextPassing () : void [ "test" => 123, "o" => "hai", - "simple-normalizer.debug-stack" => [ - get_debug_type($value), - DummyVO::class, - ], ], ); @@ -53,4 +49,58 @@ public function testContextPassing () : void "o" => 5, ]); } + + /** + */ + public function testEmptyWrappedContextKeepsIncomingContext () : void + { + $value = new ValueWithContext(new DummyVO(5), []); + + $dummyNormalizer = $this->createMock(SimpleObjectNormalizerInterface::class); + $dummyNormalizer + ->expects(self::once()) + ->method("normalize") + ->with( + $value->value, + [ + "test" => 123, + ], + ); + + $normalizer = new SimpleNormalizer(new ServiceLocator([ + ValueWithContext::class => static fn () => new ValueWithContextNormalizer(), + DummyVO::class => static fn () => $dummyNormalizer, + ])); + + $normalizer->normalize($value, [ + "test" => 123, + ]); + } + + /** + */ + public function testEmptyIncomingContextUsesWrappedContext () : void + { + $value = new ValueWithContext(new DummyVO(5), [ + "o" => "hai", + ]); + + $dummyNormalizer = $this->createMock(SimpleObjectNormalizerInterface::class); + $dummyNormalizer + ->expects(self::once()) + ->method("normalize") + ->with( + $value->value, + [ + "o" => "hai", + ], + ); + + $normalizer = new SimpleNormalizer(new ServiceLocator([ + ValueWithContext::class => static fn () => new ValueWithContextNormalizer(), + DummyVO::class => static fn () => $dummyNormalizer, + ])); + + $normalizer->normalize($value); + } } diff --git a/tests/Normalizer/SimpleNormalizerTest.php b/tests/Normalizer/SimpleNormalizerTest.php index 5986dd5..89404ff 100644 --- a/tests/Normalizer/SimpleNormalizerTest.php +++ b/tests/Normalizer/SimpleNormalizerTest.php @@ -11,6 +11,8 @@ use Tests\Torr\SimpleNormalizer\Fixture\DummyVO; use Tests\Torr\SimpleNormalizer\Fixture\DummyVONormalizer; use Torr\SimpleNormalizer\Exception\IncompleteNormalizationException; +use Torr\SimpleNormalizer\Exception\InvalidMaxDepthException; +use Torr\SimpleNormalizer\Exception\UnsupportedTypeException; use Torr\SimpleNormalizer\Normalizer\SimpleNormalizer; use Torr\SimpleNormalizer\Normalizer\Validator\ValidJsonVerifier; @@ -88,6 +90,26 @@ public function testJsonVerifierDisabled () : void self::assertTrue(true); // Just to ensure the test runs without exceptions } + /** + */ + public function testMaxDepthIsPassedToJsonVerifier () : void + { + $verifier = $this->createMock(ValidJsonVerifier::class); + $verifier + ->expects(self::once()) + ->method("ensureValidOnlyJsonTypes") + ->with("ok", 7); + + $normalizer = new SimpleNormalizer( + objectNormalizers: $this->createNormalizerObjectNormalizers("ok"), + isDebug: true, + validJsonVerifier: $verifier, + maxDepth: 7, + ); + + $normalizer->normalize(new DummyVO(1)); + } + /** * */ @@ -158,7 +180,7 @@ public function testWithEntityManagerButNoMapping () : void ->with(DummyVO::class) ->willReturn(false); - $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager = self::createStub(EntityManagerInterface::class); $entityManager->method("getMetadataFactory")->willReturn($metadataFactory); $locator = $this->createMock(ServiceLocator::class); @@ -197,7 +219,7 @@ public function testWithEntityManagerWithMapping () : void ->with(DummyVO::class) ->willReturn($classMetaData); - $entityManager = $this->createMock(EntityManagerInterface::class); + $entityManager = self::createStub(EntityManagerInterface::class); $entityManager->method("getMetadataFactory")->willReturn($metadataFactory); $locator = $this->createMock(ServiceLocator::class); @@ -217,6 +239,114 @@ public function testWithEntityManagerWithMapping () : void $normalizer->normalize(new DummyVO(5)); } + /** + */ + public function testWithEntityManagerCachesNormalizedClassName () : void + { + $classMetaData = new ClassMetadata("SomeClass"); + + $metadataFactory = $this->createMock(ClassMetadataFactory::class); + $metadataFactory + ->expects(self::once()) + ->method("hasMetadataFor") + ->with(DummyVO::class) + ->willReturn(true); + + $metadataFactory + ->expects(self::once()) + ->method("getMetadataFor") + ->with(DummyVO::class) + ->willReturn($classMetaData); + + $entityManager = self::createStub(EntityManagerInterface::class); + $entityManager->method("getMetadataFactory")->willReturn($metadataFactory); + + $locator = $this->createMock(ServiceLocator::class); + + $locator->expects(self::exactly(2)) + ->method("get") + ->with("SomeClass") + ->willReturn(new DummyVONormalizer(5)); + + $normalizer = new SimpleNormalizer( + objectNormalizers: $locator, + isDebug: true, + validJsonVerifier: new ValidJsonVerifier(), + entityManager: $entityManager, + ); + + $normalizer->normalize(new DummyVO(5)); + $normalizer->normalize(new DummyVO(5)); + } + + /** + */ + public function testWithEntityManagerCachesUnmappedClassName () : void + { + $metadataFactory = $this->createMock(ClassMetadataFactory::class); + $metadataFactory + ->expects(self::once()) + ->method("hasMetadataFor") + ->with(DummyVO::class) + ->willReturn(false); + $metadataFactory + ->expects(self::never()) + ->method("getMetadataFor"); + + $entityManager = self::createStub(EntityManagerInterface::class); + $entityManager->method("getMetadataFactory")->willReturn($metadataFactory); + + $locator = $this->createMock(ServiceLocator::class); + $locator->expects(self::exactly(2)) + ->method("get") + ->with(DummyVO::class) + ->willReturn(new DummyVONormalizer(5)); + + $normalizer = new SimpleNormalizer( + objectNormalizers: $locator, + isDebug: true, + validJsonVerifier: new ValidJsonVerifier(), + entityManager: $entityManager, + ); + + $normalizer->normalize(new DummyVO(5)); + $normalizer->normalize(new DummyVO(5)); + } + + /** + */ + public function testMaxDepthExceeded () : void + { + $normalizer = new SimpleNormalizer( + objectNormalizers: new ServiceLocator([]), + maxDepth: 2, + ); + + $this->expectException(UnsupportedTypeException::class); + $this->expectExceptionMessage("Maximum normalization depth of 2 exceeded"); + + $normalizer->normalize([ + [ + [ + "tooDeep" => true, + ], + ], + ]); + } + + /** + */ + public function testInvalidMaxDepth () : void + { + $this->expectException(InvalidMaxDepthException::class); + $this->expectExceptionMessage("The max depth must be at least 1."); + + new SimpleNormalizer( + objectNormalizers: new ServiceLocator([]), + maxDepth: 0, + ); + } + /** * @return ServiceLocator */ diff --git a/tests/Normalizer/Validator/ValidJsonVerifierTest.php b/tests/Normalizer/Validator/ValidJsonVerifierTest.php new file mode 100644 index 0000000..1c4f7c4 --- /dev/null +++ b/tests/Normalizer/Validator/ValidJsonVerifierTest.php @@ -0,0 +1,139 @@ + 42, + "bool" => true, + "nested" => [ + "null" => null, + "list" => [1, 2, 3], + ], + "emptyObject" => new \stdClass(), + ]; + + $verifier = new ValidJsonVerifier(); + $verifier->ensureValidOnlyJsonTypes($value, 128); + self::assertTrue(true); + } + + /** + */ + public function testReportsDeepInvalidPath () : void + { + $verifier = new ValidJsonVerifier(); + + $this->expectException(IncompleteNormalizationException::class); + $this->expectExceptionMessage("Found a JSON-incompatible value when normalizing. Found 'Tests\Torr\SimpleNormalizer\Fixture\DummyVO' at path '$.nested.deeply.0', but expected only scalars, arrays and empty objects."); + + $verifier->ensureValidOnlyJsonTypes([ + "nested" => [ + "deeply" => [ + new DummyVO(42), + ], + ], + ], 128); + } + + /** + */ + public function testReportsFirstInvalidElement () : void + { + $verifier = new ValidJsonVerifier(); + + $this->expectException(IncompleteNormalizationException::class); + $this->expectExceptionMessage("Found a JSON-incompatible value when normalizing. Found 'Tests\Torr\SimpleNormalizer\Fixture\DummyVO' at path '$.first', but expected only scalars, arrays and empty objects."); + + $invalidStdClass = new \stdClass(); + $invalidStdClass->prop = "x"; + + $verifier->ensureValidOnlyJsonTypes([ + "first" => new DummyVO(5), + "second" => $invalidStdClass, + ], 128); + } + + /** + */ + public function testReportsMixedNumericAndStringKeyPath () : void + { + $verifier = new ValidJsonVerifier(); + + $this->expectException(IncompleteNormalizationException::class); + $this->expectExceptionMessage("Found a JSON-incompatible value when normalizing. Found 'Tests\Torr\SimpleNormalizer\Fixture\DummyVO' at path '$.outer.01.2.inner', but expected only scalars, arrays and empty objects."); + + $verifier->ensureValidOnlyJsonTypes([ + "outer" => [ + "01" => [ + 2 => [ + "inner" => new DummyVO(6), + ], + ], + ], + ], 128); + } + + /** + */ + public function testReportsVeryDeepPath () : void + { + $verifier = new ValidJsonVerifier(); + + $this->expectException(IncompleteNormalizationException::class); + $this->expectExceptionMessage("Found a JSON-incompatible value when normalizing. Found 'Tests\\Torr\\SimpleNormalizer\\Fixture\\DummyVO' at path '$.root.lvl1.lvl2.lvl3.lvl4.lvl5.lvl6.target', but expected only scalars, arrays and empty objects."); + + $verifier->ensureValidOnlyJsonTypes([ + "root" => [ + "lvl1" => [ + "lvl2" => [ + "lvl3" => [ + "lvl4" => [ + "lvl5" => [ + "lvl6" => [ + "target" => new DummyVO(9), + ], + ], + ], + ], + ], + ], + ], + ], 128); + } + + /** + */ + public function testMaxDepthExceeded () : void + { + $verifier = new ValidJsonVerifier(); + + $this->expectException(IncompleteNormalizationException::class); + $this->expectExceptionMessage("Maximum JSON verification depth of 3 exceeded at path '$.a.b.c.d'."); + + $verifier->ensureValidOnlyJsonTypes([ + "a" => [ + "b" => [ + "c" => [ + "d" => [ + "e" => 1, + ], + ], + ], + ], + ], 3); + } +}