From 6a22fa2bcd8bc611a6bdffd371f9d1693ae6118b Mon Sep 17 00:00:00 2001 From: David Badura Date: Fri, 3 Apr 2026 14:18:53 +0200 Subject: [PATCH 1/3] legacy cryptography attribute metadata mapping --- phpstan-baseline.neon | 6 + .../Cryptography/CryptographyExtension.php | 5 + .../LegacyCryptographyMetadataEnricher.php | 82 ++++++++++ ...onalDataAndSensitiveDataOnSameProperty.php | 26 ++++ ...LegacyCryptographyMetadataEnricherTest.php | 142 ++++++++++++++++++ 5 files changed, 261 insertions(+) create mode 100644 src/Extension/Cryptography/LegacyCryptographyMetadataEnricher.php create mode 100644 src/Extension/Cryptography/PersonalDataAndSensitiveDataOnSameProperty.php create mode 100644 tests/Unit/Extension/Cryptography/LegacyCryptographyMetadataEnricherTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 34a1440..dabea00 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -66,6 +66,12 @@ parameters: count: 1 path: src/Extension/Cryptography/Cipher/OpensslCipherKeyFactory.php + - + message: '#^Cannot access property \$nameToField on mixed\.$#' + identifier: property.nonObject + count: 1 + path: src/Extension/Cryptography/LegacyCryptographyMetadataEnricher.php + - message: '#^Method Patchlevel\\Hydrator\\Guesser\\BuiltInGuesser\:\:guess\(\) has parameter \$type with generic class Symfony\\Component\\TypeInfo\\Type\\ObjectType but does not specify its types\: T$#' identifier: missingType.generics diff --git a/src/Extension/Cryptography/CryptographyExtension.php b/src/Extension/Cryptography/CryptographyExtension.php index 83963d1..5dbffa0 100644 --- a/src/Extension/Cryptography/CryptographyExtension.php +++ b/src/Extension/Cryptography/CryptographyExtension.php @@ -14,6 +14,7 @@ final class CryptographyExtension implements Extension public function __construct( private readonly Cryptographer $cryptography, private readonly PayloadCryptographer|null $legacyCryptographer = null, + private readonly bool $legacyMetadataMapping = false, ) { } @@ -22,6 +23,10 @@ public function configure(StackHydratorBuilder $builder): void $builder->addMetadataEnricher(new CryptographyMetadataEnricher(), 64); $builder->addMiddleware(new CryptographyMiddleware($this->cryptography), 64); + if ($this->legacyMetadataMapping) { + $builder->addMetadataEnricher(new LegacyCryptographyMetadataEnricher(), 63); + } + if ($this->legacyCryptographer === null) { return; } diff --git a/src/Extension/Cryptography/LegacyCryptographyMetadataEnricher.php b/src/Extension/Cryptography/LegacyCryptographyMetadataEnricher.php new file mode 100644 index 0000000..126dac1 --- /dev/null +++ b/src/Extension/Cryptography/LegacyCryptographyMetadataEnricher.php @@ -0,0 +1,82 @@ + $subjectIdMapping */ + $subjectIdMapping = isset($classMetadata->extras[SubjectIdFieldMapping::class]) + ? $classMetadata->extras[SubjectIdFieldMapping::class]->nameToField + : []; + + foreach ($classMetadata->properties as $property) { + $attributeReflectionList = $property->reflection->getAttributes(DataSubjectId::class); + + if ($attributeReflectionList) { + if (array_key_exists(self::SUBJECT_ID, $subjectIdMapping)) { + throw new DuplicateSubjectIdIdentifier( + $classMetadata->className, + $classMetadata->propertyForField($subjectIdMapping[self::SUBJECT_ID])->propertyName, + $property->propertyName, + self::SUBJECT_ID, + ); + } + + $subjectIdMapping[self::SUBJECT_ID] = $property->fieldName; + } + + $sensitiveDataInfo = $this->sensitiveDataInfo($property->reflection); + + if (!$sensitiveDataInfo) { + continue; + } + + if (in_array($property->fieldName, $subjectIdMapping, true)) { + throw new SubjectIdAndSensitiveDataConflict($classMetadata->className, $property->propertyName); + } + + if (isset($property->extras[SensitiveDataInfo::class])) { + throw new PersonalDataAndSensitiveDataOnSameProperty($classMetadata->className, $property->propertyName); + } + + $property->extras[SensitiveDataInfo::class] = $sensitiveDataInfo; + } + + if ($subjectIdMapping === []) { + return; + } + + $classMetadata->extras[SubjectIdFieldMapping::class] = new SubjectIdFieldMapping($subjectIdMapping); + } + + private function sensitiveDataInfo(ReflectionProperty $reflectionProperty): SensitiveDataInfo|null + { + $attributeReflectionList = $reflectionProperty->getAttributes(PersonalData::class); + + if ($attributeReflectionList === []) { + return null; + } + + $attribute = $attributeReflectionList[0]->newInstance(); + + return new SensitiveDataInfo( + self::SUBJECT_ID, + $attribute->fallbackCallable !== null ? ($attribute->fallbackCallable)(...) : $attribute->fallback, + ); + } +} diff --git a/src/Extension/Cryptography/PersonalDataAndSensitiveDataOnSameProperty.php b/src/Extension/Cryptography/PersonalDataAndSensitiveDataOnSameProperty.php new file mode 100644 index 0000000..4d3c7fc --- /dev/null +++ b/src/Extension/Cryptography/PersonalDataAndSensitiveDataOnSameProperty.php @@ -0,0 +1,26 @@ +metadata($event::class, new LegacyCryptographyMetadataEnricher()); + + self::assertArrayHasKey(SubjectIdFieldMapping::class, $metadata->extras); + $subjectIdFieldMapping = $metadata->extras[SubjectIdFieldMapping::class]; + self::assertInstanceOf(SubjectIdFieldMapping::class, $subjectIdFieldMapping); + self::assertSame(['legacy' => '_id'], $subjectIdFieldMapping->nameToField); + + $property = $metadata->propertyForField('_name'); + self::assertArrayHasKey(SensitiveDataInfo::class, $property->extras); + self::assertEquals(new SensitiveDataInfo('legacy', 'fallback'), $property->extras[SensitiveDataInfo::class]); + } + + public function testSubjectIdAndSensitiveDataConflict(): void + { + $event = new class ('legacy', 'id', 'name') { + public function __construct( + #[LegacyDataSubjectId] + public string $legacyId, + #[DataSubjectId] + #[PersonalData] + public string $id, + public string $name, + ) { + } + }; + + $this->expectException(SubjectIdAndSensitiveDataConflict::class); + + $this->metadata( + $event::class, + new CryptographyMetadataEnricher(), + new LegacyCryptographyMetadataEnricher(), + ); + } + + public function testMultipleDataSubjectIdWithSameIdentifier(): void + { + $event = new class ('legacyId', 'id', 'name') { + public function __construct( + #[LegacyDataSubjectId] + public string $legacyId, + #[DataSubjectId(name: 'legacy')] + public string $id, + public string $name, + ) { + } + }; + + $this->expectException(DuplicateSubjectIdIdentifier::class); + + $this->metadata( + $event::class, + new CryptographyMetadataEnricher(), + new LegacyCryptographyMetadataEnricher(), + ); + } + + public function testPersonalDataAndSensitiveDataOnSameProperty(): void + { + $event = new class ('id', 'name') { + public function __construct( + #[LegacyDataSubjectId] + public string $id, + #[SensitiveData] + #[PersonalData] + public string $name, + ) { + } + }; + + $this->expectException(PersonalDataAndSensitiveDataOnSameProperty::class); + + $this->metadata( + $event::class, + new CryptographyMetadataEnricher(), + new LegacyCryptographyMetadataEnricher(), + ); + } + + public function testNoLegacyAttributes(): void + { + $event = new class { + }; + + $metadata = $this->metadata($event::class, new LegacyCryptographyMetadataEnricher()); + + self::assertArrayNotHasKey(SubjectIdFieldMapping::class, $metadata->extras); + } + + /** @param class-string $class */ + private function metadata(string $class, MetadataEnricher ...$enricherList): ClassMetadata + { + $metadata = (new AttributeMetadataFactory())->metadata($class); + + foreach ($enricherList as $enricher) { + $enricher->enrich($metadata); + } + + return $metadata; + } +} From c5fb84c105f0cf441d5302fe76eec45d1d894fa4 Mon Sep 17 00:00:00 2001 From: David Badura Date: Fri, 3 Apr 2026 17:39:52 +0200 Subject: [PATCH 2/3] add some stack builder methods & cryptography extension tests --- src/StackHydratorBuilder.php | 39 ++++++++-- .../CryptographyExtensionTest.php | 71 +++++++++++++++++++ tests/Unit/StackHydratorBuilderTest.php | 4 ++ 3 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 tests/Unit/Extension/Cryptography/CryptographyExtensionTest.php diff --git a/src/StackHydratorBuilder.php b/src/StackHydratorBuilder.php index 4d5e72f..16ecf3b 100644 --- a/src/StackHydratorBuilder.php +++ b/src/StackHydratorBuilder.php @@ -81,15 +81,11 @@ public function setCache(CacheItemPoolInterface|CacheInterface|null $cache): sta public function build(): StackHydrator { - krsort($this->guessers); - krsort($this->metadataEnrichers); - krsort($this->middlewares); - $metadataFactory = new EnrichingMetadataFactory( new AttributeMetadataFactory( - guesser: new ChainGuesser(array_merge(...$this->guessers)), + guesser: new ChainGuesser($this->guessers()), ), - array_merge(...$this->metadataEnrichers), + $this->metadataEnrichers(), ); if ($this->cache instanceof CacheItemPoolInterface) { @@ -102,8 +98,37 @@ public function build(): StackHydrator return new StackHydrator( $metadataFactory, - array_merge(...$this->middlewares), + $this->middlewares(), $this->defaultLazy, ); } + + public function defaultLazy(): bool + { + return $this->defaultLazy; + } + + /** @return list */ + public function middlewares(): array + { + krsort($this->middlewares); + + return array_merge(...$this->middlewares); + } + + /** @return list */ + public function guessers(): array + { + krsort($this->guessers); + + return array_merge(...$this->guessers); + } + + /** @return list */ + public function metadataEnrichers(): array + { + krsort($this->metadataEnrichers); + + return array_merge(...$this->metadataEnrichers); + } } diff --git a/tests/Unit/Extension/Cryptography/CryptographyExtensionTest.php b/tests/Unit/Extension/Cryptography/CryptographyExtensionTest.php new file mode 100644 index 0000000..d526b7b --- /dev/null +++ b/tests/Unit/Extension/Cryptography/CryptographyExtensionTest.php @@ -0,0 +1,71 @@ +createMock(Cryptographer::class); + + $extension = new CryptographyExtension($cryptographer); + $extension->configure($builder); + + $middlewares = $builder->middlewares(); + self::assertCount(1, $middlewares); + self::assertInstanceOf(CryptographyMiddleware::class, $middlewares[0]); + + $metadataEnrichers = $builder->metadataEnrichers(); + self::assertCount(1, $metadataEnrichers); + self::assertInstanceOf(CryptographyMetadataEnricher::class, $metadataEnrichers[0]); + } + + public function testConfigureWithLegacyMetadataMapping(): void + { + $builder = new StackHydratorBuilder(); + $cryptographer = $this->createMock(Cryptographer::class); + + $extension = new CryptographyExtension($cryptographer, legacyMetadataMapping: true); + $extension->configure($builder); + + $metadataEnrichers = $builder->metadataEnrichers(); + self::assertCount(2, $metadataEnrichers); + self::assertInstanceOf(CryptographyMetadataEnricher::class, $metadataEnrichers[0]); + self::assertInstanceOf(LegacyCryptographyMetadataEnricher::class, $metadataEnrichers[1]); + } + + public function testConfigureWithLegacyCryptographerAndMetadataMapping(): void + { + $builder = new StackHydratorBuilder(); + $cryptographer = $this->createMock(Cryptographer::class); + $legacyCryptographer = $this->createMock(PayloadCryptographer::class); + + $extension = new CryptographyExtension($cryptographer, $legacyCryptographer, true); + $extension->configure($builder); + + $middlewares = $builder->middlewares(); + self::assertCount(2, $middlewares); + self::assertInstanceOf(LegacyCryptographyDecryptMiddleware::class, $middlewares[0]); + self::assertInstanceOf(CryptographyMiddleware::class, $middlewares[1]); + + $metadataEnrichers = $builder->metadataEnrichers(); + self::assertCount(2, $metadataEnrichers); + self::assertInstanceOf(CryptographyMetadataEnricher::class, $metadataEnrichers[0]); + self::assertInstanceOf(LegacyCryptographyMetadataEnricher::class, $metadataEnrichers[1]); + } +} diff --git a/tests/Unit/StackHydratorBuilderTest.php b/tests/Unit/StackHydratorBuilderTest.php index 774a86d..bb4b9e8 100644 --- a/tests/Unit/StackHydratorBuilderTest.php +++ b/tests/Unit/StackHydratorBuilderTest.php @@ -39,6 +39,7 @@ public function testAddMiddlewareWithPriority(): void $middlewares = $reflection->getValue($hydrator); self::assertSame([$middleware2, $middleware1], $middlewares); + self::assertSame([$middleware2, $middleware1], $builder->middlewares()); } public function testAddMetadataEnricherWithPriority(): void @@ -61,6 +62,7 @@ public function testAddMetadataEnricherWithPriority(): void $enrichers = $reflection->getValue($enrichingMetadataFactory); self::assertSame([$enricher2, $enricher1], $enrichers); + self::assertSame([$enricher2, $enricher1], $builder->metadataEnrichers()); } public function testAddGuesserWithPriority(): void @@ -93,6 +95,7 @@ public function testAddGuesserWithPriority(): void $guessers = $reflection->getValue($guesser); self::assertSame([$guesser2, $guesser1], $guessers); + self::assertSame([$guesser2, $guesser1], $builder->guessers()); } public function testEnableDefaultLazy(): void @@ -104,6 +107,7 @@ public function testEnableDefaultLazy(): void $reflection = new ReflectionProperty(StackHydrator::class, 'defaultLazy'); self::assertTrue($reflection->getValue($hydrator)); + self::assertTrue($builder->defaultLazy()); } public function testUseExtension(): void From 4b8f1007d56a47764057302c538241bc0b64b79f Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 8 Apr 2026 16:02:52 +0200 Subject: [PATCH 3/3] add innerNormalizer methods & rename className methods --- src/Normalizer/ArrayNormalizer.php | 5 +++++ src/Normalizer/ArrayShapeNormalizer.php | 6 ++++++ src/Normalizer/EnumNormalizer.php | 12 ++++++++++-- src/Normalizer/ObjectNormalizer.php | 12 ++++++++++-- tests/Unit/Normalizer/ArrayNormalizerTest.php | 8 ++++++++ tests/Unit/Normalizer/ArrayShapeNormalizerTest.php | 8 ++++++++ tests/Unit/Normalizer/EnumNormalizerTest.php | 8 ++++---- tests/Unit/Normalizer/ObjectNormalizerTest.php | 12 ++++++------ 8 files changed, 57 insertions(+), 14 deletions(-) diff --git a/src/Normalizer/ArrayNormalizer.php b/src/Normalizer/ArrayNormalizer.php index b61d9b2..02b9f87 100644 --- a/src/Normalizer/ArrayNormalizer.php +++ b/src/Normalizer/ArrayNormalizer.php @@ -101,4 +101,9 @@ public function handleType(Type|null $type): void $this->normalizer->handleType($type->getCollectionValueType()); } + + public function innerNormalizer(): Normalizer + { + return $this->normalizer; + } } diff --git a/src/Normalizer/ArrayShapeNormalizer.php b/src/Normalizer/ArrayShapeNormalizer.php index b86692f..ef517b6 100644 --- a/src/Normalizer/ArrayShapeNormalizer.php +++ b/src/Normalizer/ArrayShapeNormalizer.php @@ -120,4 +120,10 @@ public function handleType(Type|null $type): void $normalizer->handleType($shape[$field]['type']); } } + + /** @return array */ + public function innerNormalizers(): array + { + return $this->normalizerMap; + } } diff --git a/src/Normalizer/EnumNormalizer.php b/src/Normalizer/EnumNormalizer.php index 95bf1b6..af643d5 100644 --- a/src/Normalizer/EnumNormalizer.php +++ b/src/Normalizer/EnumNormalizer.php @@ -6,6 +6,7 @@ use Attribute; use BackedEnum; +use Deprecated; use ReflectionType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\BackedEnumType; @@ -30,7 +31,7 @@ public function normalize(mixed $value): mixed return null; } - $enum = $this->getEnum(); + $enum = $this->className(); if (!$value instanceof $enum) { throw InvalidArgument::withWrongType($enum . '|null', $value); @@ -49,7 +50,7 @@ public function denormalize(mixed $value): BackedEnum|null throw InvalidArgument::withWrongType('string|int|null', $value); } - $enum = $this->getEnum(); + $enum = $this->className(); try { return $enum::from($value); @@ -86,7 +87,14 @@ public function handleType(Type|null $type): void } /** @return class-string */ + #[Deprecated('Use `className()` method instead')] public function getEnum(): string + { + return $this->className(); + } + + /** @return class-string */ + public function className(): string { if ($this->enum === null) { throw InvalidType::missingType(); diff --git a/src/Normalizer/ObjectNormalizer.php b/src/Normalizer/ObjectNormalizer.php index 2aca4d7..cfdc9a1 100644 --- a/src/Normalizer/ObjectNormalizer.php +++ b/src/Normalizer/ObjectNormalizer.php @@ -5,6 +5,7 @@ namespace Patchlevel\Hydrator\Normalizer; use Attribute; +use Deprecated; use Patchlevel\Hydrator\Hydrator; use Patchlevel\Hydrator\HydratorWithContext; use ReflectionType; @@ -42,7 +43,7 @@ public function normalize(mixed $value, array $context = []): array|null return null; } - $className = $this->getClassName(); + $className = $this->className(); if (!$value instanceof $className) { throw InvalidArgument::withWrongType($className . '|null', $value); @@ -70,7 +71,7 @@ public function denormalize(mixed $value, array $context = []): object|null throw InvalidArgument::withWrongType('array|null', $value); } - $className = $this->getClassName(); + $className = $this->className(); if ($this->hydrator instanceof HydratorWithContext) { return $this->hydrator->hydrate($className, $value, $context); @@ -120,7 +121,14 @@ public function handleType(Type|null $type): void } /** @return class-string */ + #[Deprecated('Use `className()` method instead')] public function getClassName(): string + { + return $this->className(); + } + + /** @return class-string */ + public function className(): string { if ($this->className === null) { throw InvalidType::missingType(); diff --git a/tests/Unit/Normalizer/ArrayNormalizerTest.php b/tests/Unit/Normalizer/ArrayNormalizerTest.php index a617bfe..88fa1cf 100644 --- a/tests/Unit/Normalizer/ArrayNormalizerTest.php +++ b/tests/Unit/Normalizer/ArrayNormalizerTest.php @@ -161,4 +161,12 @@ public function testPassHydrator(): void $normalizer = new ArrayNormalizer($normalizer); $normalizer->setHydrator($hydrator); } + + public function testInnerNormalizer(): void + { + $innerNormalizer = $this->createMock(Normalizer::class); + $normalizer = new ArrayNormalizer($innerNormalizer); + + self::assertSame($innerNormalizer, $normalizer->innerNormalizer()); + } } diff --git a/tests/Unit/Normalizer/ArrayShapeNormalizerTest.php b/tests/Unit/Normalizer/ArrayShapeNormalizerTest.php index fc2369b..a85a6e4 100644 --- a/tests/Unit/Normalizer/ArrayShapeNormalizerTest.php +++ b/tests/Unit/Normalizer/ArrayShapeNormalizerTest.php @@ -161,4 +161,12 @@ public function testPassHydrator(): void $normalizer = new ArrayShapeNormalizer(['foo' => $normalizer]); $normalizer->setHydrator($hydrator); } + + public function testInnerNormalizers(): void + { + $innerNormalizer = $this->createMock(Normalizer::class); + $normalizer = new ArrayShapeNormalizer(['foo' => $innerNormalizer]); + + self::assertSame(['foo' => $innerNormalizer], $normalizer->innerNormalizers()); + } } diff --git a/tests/Unit/Normalizer/EnumNormalizerTest.php b/tests/Unit/Normalizer/EnumNormalizerTest.php index dcaf1b5..cdb7e73 100644 --- a/tests/Unit/Normalizer/EnumNormalizerTest.php +++ b/tests/Unit/Normalizer/EnumNormalizerTest.php @@ -69,7 +69,7 @@ public function testAutoDetect(): void $normalizer = new EnumNormalizer(); $normalizer->handleReflectionType($this->reflectionType(AutoTypeDto::class, 'status')); - self::assertEquals(Status::class, $normalizer->getEnum()); + self::assertEquals(Status::class, $normalizer->className()); } public function testAutoDetectOverrideNotPossible(): void @@ -77,7 +77,7 @@ public function testAutoDetectOverrideNotPossible(): void $normalizer = new EnumNormalizer(AnotherEnum::class); $normalizer->handleReflectionType($this->reflectionType(AutoTypeDto::class, 'status')); - self::assertEquals(AnotherEnum::class, $normalizer->getEnum()); + self::assertEquals(AnotherEnum::class, $normalizer->className()); } public function testAutoDetectMissingType(): void @@ -85,7 +85,7 @@ public function testAutoDetectMissingType(): void $this->expectException(InvalidType::class); $normalizer = new EnumNormalizer(); - $normalizer->getEnum(); + $normalizer->className(); } public function testAutoDetectMissingTypeBecauseNull(): void @@ -95,7 +95,7 @@ public function testAutoDetectMissingTypeBecauseNull(): void $normalizer = new EnumNormalizer(); $normalizer->handleReflectionType(null); - $normalizer->getEnum(); + $normalizer->className(); } /** @param class-string $class */ diff --git a/tests/Unit/Normalizer/ObjectNormalizerTest.php b/tests/Unit/Normalizer/ObjectNormalizerTest.php index 2f53a15..c1ab874 100644 --- a/tests/Unit/Normalizer/ObjectNormalizerTest.php +++ b/tests/Unit/Normalizer/ObjectNormalizerTest.php @@ -189,7 +189,7 @@ public function testAutoDetect(): void $normalizer->setHydrator($hydrator); $normalizer->handleReflectionType($this->reflectionType(AutoTypeDto::class, 'profileCreated')); - self::assertEquals(ProfileCreated::class, $normalizer->getClassName()); + self::assertEquals(ProfileCreated::class, $normalizer->className()); } public function testAutoDetectOverrideNotPossible(): void @@ -200,7 +200,7 @@ public function testAutoDetectOverrideNotPossible(): void $normalizer->setHydrator($hydrator); $normalizer->handleReflectionType($this->reflectionType(AutoTypeDto::class, 'profileCreated')); - self::assertEquals(AutoTypeDto::class, $normalizer->getClassName()); + self::assertEquals(AutoTypeDto::class, $normalizer->className()); } public function testAutoDetectMissingType(): void @@ -212,7 +212,7 @@ public function testAutoDetectMissingType(): void $normalizer = new ObjectNormalizer(); $normalizer->setHydrator($hydrator); - $normalizer->getClassName(); + $normalizer->className(); } public function testAutoDetectMissingTypeBecauseNull(): void @@ -225,7 +225,7 @@ public function testAutoDetectMissingTypeBecauseNull(): void $normalizer->setHydrator($hydrator); $normalizer->handleReflectionType(null); - $normalizer->getClassName(); + $normalizer->className(); } public function testGeneric(): void @@ -236,7 +236,7 @@ public function testGeneric(): void $normalizer->setHydrator($hydrator); $normalizer->handleType(Type::generic(Type::object(ProfileCreated::class))); - self::assertEquals(ProfileCreated::class, $normalizer->getClassName()); + self::assertEquals(ProfileCreated::class, $normalizer->className()); } public function testTemplate(): void @@ -247,7 +247,7 @@ public function testTemplate(): void $normalizer->setHydrator($hydrator); $normalizer->handleType(Type::template('T', Type::object(ProfileCreated::class))); - self::assertEquals(ProfileCreated::class, $normalizer->getClassName()); + self::assertEquals(ProfileCreated::class, $normalizer->className()); } public function testSerialize(): void