From 94002a77882d965c0816c478a45185235670ff00 Mon Sep 17 00:00:00 2001 From: David Badura Date: Tue, 9 Apr 2024 13:35:09 +0200 Subject: [PATCH] add payload cryptographer for snapshots too --- baseline.xml | 18 +- docs/pages/personal_data.md | 107 ++++++- phpstan-baseline.neon | 7 +- src/Cryptography/CryptographicHydrator.php | 2 +- .../EventPayloadCryptographer.php | 127 +++++++- src/Cryptography/PayloadCryptographer.php | 24 ++ ...r.php => SnapshotPayloadCryptographer.php} | 34 ++- src/Cryptography/UnsupportedClass.php | 17 ++ .../AggregateRoot/AggregateRootMetadata.php | 3 + .../AttributeAggregateRootMetadataFactory.php | 100 ++++++- .../AggregateRoot/MissingDataSubjectId.php | 20 ++ .../AggregateRoot/MultipleDataSubjectId.php | 23 ++ .../AggregateRoot/PropertyMetadata.php | 16 + .../SubjectIdAndPersonalDataConflict.php | 24 ++ .../Event/AttributeEventMetadataFactory.php | 2 +- ...p => SubjectIdAndPersonalDataConflict.php} | 2 +- src/Serializer/DefaultEventSerializer.php | 4 +- tests/Benchmark/PersonalDataBench.php | 4 +- .../PersonalData/PersonalDataTest.php | 6 +- .../CryptographicHydratorTest.php | 6 +- ....php => EventPayloadCryptographerTest.php} | 28 +- .../SnapshotPayloadCryptographerTest.php | 274 ++++++++++++++++++ tests/Unit/Fixture/ProfileWithSnapshot.php | 4 + .../AttributeAggregateMetadataFactoryTest.php | 99 +++++++ .../AttributeEventMetadataFactoryTest.php | 4 +- 25 files changed, 891 insertions(+), 64 deletions(-) create mode 100644 src/Cryptography/PayloadCryptographer.php rename src/Cryptography/{DefaultEventPayloadCryptographer.php => SnapshotPayloadCryptographer.php} (78%) create mode 100644 src/Cryptography/UnsupportedClass.php create mode 100644 src/Metadata/AggregateRoot/MissingDataSubjectId.php create mode 100644 src/Metadata/AggregateRoot/MultipleDataSubjectId.php create mode 100644 src/Metadata/AggregateRoot/PropertyMetadata.php create mode 100644 src/Metadata/AggregateRoot/SubjectIdAndPersonalDataConflict.php rename src/Metadata/Event/{DataSubjectIdIsPersonalData.php => SubjectIdAndPersonalDataConflict.php} (87%) rename tests/Unit/Cryptography/{DefaultEventPayloadCryptographerTest.php => EventPayloadCryptographerTest.php} (90%) create mode 100644 tests/Unit/Cryptography/SnapshotPayloadCryptographerTest.php diff --git a/baseline.xml b/baseline.xml index 396b14716..57fc47037 100644 --- a/baseline.xml +++ b/baseline.xml @@ -36,7 +36,17 @@ ]]> - + + + fieldName]]]> + + + fieldName]]]> + fieldName]]]> + fieldName]]]> + + + fieldName]]]> @@ -101,12 +111,6 @@ - - - - ]]> - - diff --git a/docs/pages/personal_data.md b/docs/pages/personal_data.md index 88a8ad0d8..cafdb219b 100644 --- a/docs/pages/personal_data.md +++ b/docs/pages/personal_data.md @@ -18,10 +18,12 @@ you can simply delete the key and the personal data can no longer be decrypted. Encrypting and decrypting is handled by the library. You just have to configure the events accordingly. +And if you use snapshots, you have to configure your aggregates too. ### PersonalData First of all, we have to mark the fields that contain personal data. +For our example, we use events, but you can do the same with aggregates. ```php use Patchlevel\EventSourcing\Attribute\PersonalData; @@ -35,6 +37,10 @@ final class EmailChanged } } ``` +!!! tip + + You can use the `PersonalData` in aggregates for snapshots too. + If the information could not be decrypted, then a fallback value is inserted. The default fallback value is `null`. You can change this by setting the `fallback` parameter. @@ -84,6 +90,10 @@ final class EmailChanged } } ``` +!!! tip + + You can use the `DataSubjectId` in aggregates for snapshots too. + !!! warning A subject ID can not be a personal data. @@ -94,7 +104,7 @@ In order for the system to work, a few things have to be done. !!! tip - You can use named constructor `DefaultEventPayloadCryptographer::createWithOpenssl` to skip some necessary setups. + You can use named constructor `EventPayloadCryptographer::createWithOpenssl` and `SnapshotPayloadCryptographer::createWithOpenssl` to skip some necessary setups. ### Cipher Key Factory @@ -135,6 +145,16 @@ To use the `DoctrineCipherKeyStore` you need to register this service in Doctrin Then the table will be added automatically. ```php +use Doctrine\DBAL\Connection; +use Patchlevel\EventSourcing\Cryptography\Store\DoctrineCipherKeyStore; +use Patchlevel\EventSourcing\Schema\ChainDoctrineSchemaConfigurator; +use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; + +/** + * @var Connection $dbalConnection + * @var DoctrineCipherKeyStore $cipherKeyStore + * @var Store $store + */ $schemaDirector = new DoctrineSchemaDirector( $dbalConnection, new ChainDoctrineSchemaConfigurator([ @@ -165,11 +185,43 @@ $value = $cipher->decrypt($cipherKey, $encrypted); Now we have to put the whole thing together in an Event Payload Cryptographer. ```php -use Patchlevel\EventSourcing\Cryptography\DefaultEventPayloadCryptographer; -$cryptographer = new DefaultEventPayloadCryptographer( +``` +You can also use the shortcut with openssl. + +```php +use Patchlevel\EventSourcing\Cryptography\EventPayloadCryptographer; +use Patchlevel\EventSourcing\Cryptography\Store\CipherKeyStore; + +/** + * @var EventMetadataFactory $aggregateRootMetadataFactory + * @var CipherKeyStore $cipherKeyStore + */ +$cryptographer = EventPayloadCryptographer::createWithOpenssl( $eventMetadataFactory, $cipherKeyStore, +); +``` +### Snapshot Payload Cryptographer + +You can also use the cryptographer for snapshots. + +```php +use Patchlevel\EventSourcing\Cryptography\Cipher\OpensslCipher; +use Patchlevel\EventSourcing\Cryptography\Cipher\OpensslCipherKeyFactory; +use Patchlevel\EventSourcing\Cryptography\SnapshotPayloadCryptographer; +use Patchlevel\EventSourcing\Cryptography\Store\CipherKeyStore; +use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootMetadataFactory; + +/** + * @var AggregateRootMetadataFactory $aggregateRootMetadataFactory + * @var CipherKeyStore $cipherKeyStore + * @var OpensslCipherKeyFactory $cipherKeyFactory + * @var OpensslCipher $cipher + */ +$cryptographer = new SnapshotPayloadCryptographer( + $aggregateRootMetadataFactory, + $cipherKeyStore, $cipherKeyFactory, $cipher, ); @@ -177,10 +229,16 @@ $cryptographer = new DefaultEventPayloadCryptographer( You can also use the shortcut with openssl. ```php -use Patchlevel\EventSourcing\Cryptography\DefaultEventPayloadCryptographer; - -$cryptographer = DefaultEventPayloadCryptographer::createWithOpenssl( - $eventMetadataFactory, +use Patchlevel\EventSourcing\Cryptography\SnapshotPayloadCryptographer; +use Patchlevel\EventSourcing\Cryptography\Store\CipherKeyStore; +use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootMetadataFactory; + +/** + * @var AggregateRootMetadataFactory $aggregateRootMetadataFactory + * @var CipherKeyStore $cipherKeyStore + */ +$cryptographer = SnapshotPayloadCryptographer::createWithOpenssl( + $aggregateRootMetadataFactory, $cipherKeyStore, ); ``` @@ -189,13 +247,48 @@ $cryptographer = DefaultEventPayloadCryptographer::createWithOpenssl( The last step is to integrate the cryptographer into the event store. ```php +use Patchlevel\EventSourcing\Cryptography\EventPayloadCryptographer; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; +/** + * @var Hydrator $hydrator + * @var EventPayloadCryptographer $cryptographer + */ DefaultEventSerializer::createFromPaths( [__DIR__ . '/Events'], cryptographer: $cryptographer, ); ``` +!!! note + + More information about the events can be found [here](./events.md). + +And for the snapshot store. + +```php +use Patchlevel\EventSourcing\Cryptography\CryptographicHydrator; +use Patchlevel\EventSourcing\Cryptography\SnapshotPayloadCryptographer; +use Patchlevel\EventSourcing\Snapshot\DefaultSnapshotStore; +use Patchlevel\Hydrator\Hydrator; + +/** + * @var Hydrator $hydrator + * @var SnapshotPayloadCryptographer $cryptographer + */ +$snapshotStore = new DefaultSnapshotStore( + [ + /* adapters... */ + ], + new CryptographicHydrator( + $hydrator, + $cryptographer, + ), +); +``` +!!! note + + More information about the snapshot store can be found [here](./snapshots.md). + !!! success Now you can save and read events with personal data. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 3cec5d190..3e7c689d0 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -18,7 +18,12 @@ parameters: - message: "#^Parameter \\#2 \\$data of method Patchlevel\\\\EventSourcing\\\\Cryptography\\\\Cipher\\\\Cipher\\:\\:decrypt\\(\\) expects string, mixed given\\.$#" count: 1 - path: src/Cryptography/DefaultEventPayloadCryptographer.php + path: src/Cryptography/EventPayloadCryptographer.php + + - + message: "#^Parameter \\#2 \\$data of method Patchlevel\\\\EventSourcing\\\\Cryptography\\\\Cipher\\\\Cipher\\:\\:decrypt\\(\\) expects string, mixed given\\.$#" + count: 1 + path: src/Cryptography/SnapshotPayloadCryptographer.php - message: "#^Parameter \\#1 \\$key of class Patchlevel\\\\EventSourcing\\\\Cryptography\\\\Cipher\\\\CipherKey constructor expects non\\-empty\\-string, string given\\.$#" diff --git a/src/Cryptography/CryptographicHydrator.php b/src/Cryptography/CryptographicHydrator.php index e85ccdc9a..b09dd4da4 100644 --- a/src/Cryptography/CryptographicHydrator.php +++ b/src/Cryptography/CryptographicHydrator.php @@ -10,7 +10,7 @@ final class CryptographicHydrator implements Hydrator { public function __construct( private readonly Hydrator $hydrator, - private readonly EventPayloadCryptographer $cryptographer, + private readonly PayloadCryptographer $cryptographer, ) { } diff --git a/src/Cryptography/EventPayloadCryptographer.php b/src/Cryptography/EventPayloadCryptographer.php index ea1aa6569..ddb020d24 100644 --- a/src/Cryptography/EventPayloadCryptographer.php +++ b/src/Cryptography/EventPayloadCryptographer.php @@ -4,15 +4,64 @@ namespace Patchlevel\EventSourcing\Cryptography; -interface EventPayloadCryptographer +use Patchlevel\EventSourcing\Cryptography\Cipher\Cipher; +use Patchlevel\EventSourcing\Cryptography\Cipher\CipherKeyFactory; +use Patchlevel\EventSourcing\Cryptography\Cipher\DecryptionFailed; +use Patchlevel\EventSourcing\Cryptography\Cipher\OpensslCipher; +use Patchlevel\EventSourcing\Cryptography\Cipher\OpensslCipherKeyFactory; +use Patchlevel\EventSourcing\Cryptography\Store\CipherKeyNotExists; +use Patchlevel\EventSourcing\Cryptography\Store\CipherKeyStore; +use Patchlevel\EventSourcing\Metadata\Event\EventMetadataFactory; + +use function array_key_exists; +use function is_string; + +final class EventPayloadCryptographer implements PayloadCryptographer { + public function __construct( + private readonly EventMetadataFactory $metadataFactory, + private readonly CipherKeyStore $cipherKeyStore, + private readonly CipherKeyFactory $cipherKeyFactory, + private readonly Cipher $cipher, + ) { + } + /** * @param class-string $class * @param array $data * * @return array */ - public function encrypt(string $class, array $data): array; + public function encrypt(string $class, array $data): array + { + $subjectId = $this->subjectId($class, $data); + + if ($subjectId === null) { + return $data; + } + + try { + $cipherKey = $this->cipherKeyStore->get($subjectId); + } catch (CipherKeyNotExists) { + $cipherKey = ($this->cipherKeyFactory)(); + $this->cipherKeyStore->store($subjectId, $cipherKey); + } + + $metadata = $this->metadataFactory->metadata($class); + + foreach ($metadata->propertyMetadata as $propertyMetadata) { + if (!$propertyMetadata->isPersonalData) { + continue; + } + + $data[$propertyMetadata->fieldName] = $this->cipher->encrypt( + $cipherKey, + $data[$propertyMetadata->fieldName], + ); + } + + return $data; + } /** * @param class-string $class @@ -20,5 +69,77 @@ public function encrypt(string $class, array $data): array; * * @return array */ - public function decrypt(string $class, array $data): array; + public function decrypt(string $class, array $data): array + { + $subjectId = $this->subjectId($class, $data); + + if ($subjectId === null) { + return $data; + } + + try { + $cipherKey = $this->cipherKeyStore->get($subjectId); + } catch (CipherKeyNotExists) { + $cipherKey = null; + } + + $metadata = $this->metadataFactory->metadata($class); + + foreach ($metadata->propertyMetadata as $propertyMetadata) { + if (!$propertyMetadata->isPersonalData) { + continue; + } + + if (!$cipherKey) { + $data[$propertyMetadata->fieldName] = $propertyMetadata->personalDataFallback; + continue; + } + + try { + $data[$propertyMetadata->fieldName] = $this->cipher->decrypt( + $cipherKey, + $data[$propertyMetadata->fieldName], + ); + } catch (DecryptionFailed) { + $data[$propertyMetadata->fieldName] = $propertyMetadata->personalDataFallback; + } + } + + return $data; + } + + /** + * @param class-string $class + * @param array $data + */ + private function subjectId(string $class, array $data): string|null + { + $metadata = $this->metadataFactory->metadata($class); + + if ($metadata->dataSubjectIdField === null) { + return null; + } + + if (!array_key_exists($metadata->dataSubjectIdField, $data)) { + throw new MissingSubjectId(); + } + + $subjectId = $data[$metadata->dataSubjectIdField]; + + if (!is_string($subjectId)) { + throw new UnsupportedSubjectId($subjectId); + } + + return $subjectId; + } + + public static function createWithOpenssl(EventMetadataFactory $metadataFactory, CipherKeyStore $cryptoStore): static + { + return new self( + $metadataFactory, + $cryptoStore, + new OpensslCipherKeyFactory(), + new OpensslCipher(), + ); + } } diff --git a/src/Cryptography/PayloadCryptographer.php b/src/Cryptography/PayloadCryptographer.php new file mode 100644 index 000000000..467903a62 --- /dev/null +++ b/src/Cryptography/PayloadCryptographer.php @@ -0,0 +1,24 @@ + $data + * + * @return array + */ + public function encrypt(string $class, array $data): array; + + /** + * @param class-string $class + * @param array $data + * + * @return array + */ + public function decrypt(string $class, array $data): array; +} diff --git a/src/Cryptography/DefaultEventPayloadCryptographer.php b/src/Cryptography/SnapshotPayloadCryptographer.php similarity index 78% rename from src/Cryptography/DefaultEventPayloadCryptographer.php rename to src/Cryptography/SnapshotPayloadCryptographer.php index 6f20ad7ab..61a6a1401 100644 --- a/src/Cryptography/DefaultEventPayloadCryptographer.php +++ b/src/Cryptography/SnapshotPayloadCryptographer.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Cryptography; +use Patchlevel\EventSourcing\Aggregate\AggregateRoot; use Patchlevel\EventSourcing\Cryptography\Cipher\Cipher; use Patchlevel\EventSourcing\Cryptography\Cipher\CipherKeyFactory; use Patchlevel\EventSourcing\Cryptography\Cipher\DecryptionFailed; @@ -11,15 +12,16 @@ use Patchlevel\EventSourcing\Cryptography\Cipher\OpensslCipherKeyFactory; use Patchlevel\EventSourcing\Cryptography\Store\CipherKeyNotExists; use Patchlevel\EventSourcing\Cryptography\Store\CipherKeyStore; -use Patchlevel\EventSourcing\Metadata\Event\EventMetadataFactory; +use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootMetadataFactory; use function array_key_exists; +use function is_a; use function is_string; -final class DefaultEventPayloadCryptographer implements EventPayloadCryptographer +final class SnapshotPayloadCryptographer implements PayloadCryptographer { public function __construct( - private readonly EventMetadataFactory $eventMetadataFactory, + private readonly AggregateRootMetadataFactory $metadataFactory, private readonly CipherKeyStore $cipherKeyStore, private readonly CipherKeyFactory $cipherKeyFactory, private readonly Cipher $cipher, @@ -34,6 +36,10 @@ public function __construct( */ public function encrypt(string $class, array $data): array { + if (!is_a($class, AggregateRoot::class, true)) { + throw UnsupportedClass::fromClass($class); + } + $subjectId = $this->subjectId($class, $data); if ($subjectId === null) { @@ -47,7 +53,7 @@ public function encrypt(string $class, array $data): array $this->cipherKeyStore->store($subjectId, $cipherKey); } - $metadata = $this->eventMetadataFactory->metadata($class); + $metadata = $this->metadataFactory->metadata($class); foreach ($metadata->propertyMetadata as $propertyMetadata) { if (!$propertyMetadata->isPersonalData) { @@ -71,6 +77,10 @@ public function encrypt(string $class, array $data): array */ public function decrypt(string $class, array $data): array { + if (!is_a($class, AggregateRoot::class, true)) { + throw UnsupportedClass::fromClass($class); + } + $subjectId = $this->subjectId($class, $data); if ($subjectId === null) { @@ -83,7 +93,7 @@ public function decrypt(string $class, array $data): array $cipherKey = null; } - $metadata = $this->eventMetadataFactory->metadata($class); + $metadata = $this->metadataFactory->metadata($class); foreach ($metadata->propertyMetadata as $propertyMetadata) { if (!$propertyMetadata->isPersonalData) { @@ -109,12 +119,12 @@ public function decrypt(string $class, array $data): array } /** - * @param class-string $class - * @param array $data + * @param class-string $class + * @param array $data */ private function subjectId(string $class, array $data): string|null { - $metadata = $this->eventMetadataFactory->metadata($class); + $metadata = $this->metadataFactory->metadata($class); if ($metadata->dataSubjectIdField === null) { return null; @@ -133,10 +143,12 @@ private function subjectId(string $class, array $data): string|null return $subjectId; } - public static function createWithOpenssl(EventMetadataFactory $eventMetadataFactory, CipherKeyStore $cryptoStore): static - { + public static function createWithOpenssl( + AggregateRootMetadataFactory $metadataFactory, + CipherKeyStore $cryptoStore, + ): static { return new self( - $eventMetadataFactory, + $metadataFactory, $cryptoStore, new OpensslCipherKeyFactory(), new OpensslCipher(), diff --git a/src/Cryptography/UnsupportedClass.php b/src/Cryptography/UnsupportedClass.php new file mode 100644 index 000000000..0b30bee67 --- /dev/null +++ b/src/Cryptography/UnsupportedClass.php @@ -0,0 +1,17 @@ + */ + public readonly array $propertyMetadata = [], ) { } } diff --git a/src/Metadata/AggregateRoot/AttributeAggregateRootMetadataFactory.php b/src/Metadata/AggregateRoot/AttributeAggregateRootMetadataFactory.php index e2c351c08..91a5942e3 100644 --- a/src/Metadata/AggregateRoot/AttributeAggregateRootMetadataFactory.php +++ b/src/Metadata/AggregateRoot/AttributeAggregateRootMetadataFactory.php @@ -7,13 +7,17 @@ use Patchlevel\EventSourcing\Aggregate\AggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Apply; +use Patchlevel\EventSourcing\Attribute\DataSubjectId; use Patchlevel\EventSourcing\Attribute\Id; +use Patchlevel\EventSourcing\Attribute\PersonalData; use Patchlevel\EventSourcing\Attribute\Snapshot as AttributeSnapshot; use Patchlevel\EventSourcing\Attribute\SuppressMissingApply; +use Patchlevel\Hydrator\Attribute\NormalizedName; use ReflectionClass; use ReflectionIntersectionType; use ReflectionMethod; use ReflectionNamedType; +use ReflectionProperty; use ReflectionUnionType; use function array_key_exists; @@ -39,13 +43,36 @@ public function metadata(string $aggregate): AggregateRootMetadata return $this->aggregateMetadata[$aggregate]; } - $reflector = new ReflectionClass($aggregate); + $reflectionClass = new ReflectionClass($aggregate); - $aggregateName = $this->findAggregateName($reflector); - $idProperty = $this->findIdProperty($reflector); - [$suppressEvents, $suppressAll] = $this->findSuppressMissingApply($reflector); - $applyMethods = $this->findApplyMethods($reflector, $aggregate); - $snapshot = $this->findSnapshot($reflector); + $aggregateName = $this->findAggregateName($reflectionClass); + $idProperty = $this->findIdProperty($reflectionClass); + [$suppressEvents, $suppressAll] = $this->findSuppressMissingApply($reflectionClass); + $applyMethods = $this->findApplyMethods($reflectionClass, $aggregate); + $snapshot = $this->findSnapshot($reflectionClass); + + $propertyMetadataList = []; + $hasPersonalData = false; + + $subjectId = $this->subjectIdField($reflectionClass); + + foreach ($reflectionClass->getProperties() as $reflectionProperty) { + $propertyMetadata = $this->propertyMetadata($reflectionProperty); + + if ($propertyMetadata->isPersonalData) { + if ($subjectId === $propertyMetadata->fieldName) { + throw new SubjectIdAndPersonalDataConflict($aggregate, $propertyMetadata->fieldName); + } + + $hasPersonalData = true; + } + + $propertyMetadataList[$reflectionProperty->getName()] = $propertyMetadata; + } + + if ($hasPersonalData && $subjectId === null) { + throw new MissingDataSubjectId($aggregate); + } $metadata = new AggregateRootMetadata( $aggregate, @@ -55,6 +82,8 @@ public function metadata(string $aggregate): AggregateRootMetadata $suppressEvents, $suppressAll, $snapshot, + $subjectId, + $propertyMetadataList, ); $this->aggregateMetadata[$aggregate] = $metadata; @@ -234,4 +263,63 @@ static function (ReflectionNamedType|ReflectionIntersectionType $reflectionType) return []; } + + private function propertyMetadata(ReflectionProperty $reflectionProperty): PropertyMetadata + { + $attributeReflectionList = $reflectionProperty->getAttributes(PersonalData::class); + + if (!$attributeReflectionList) { + return new PropertyMetadata( + $reflectionProperty->getName(), + $this->fieldName($reflectionProperty), + ); + } + + $attribute = $attributeReflectionList[0]->newInstance(); + + return new PropertyMetadata( + $reflectionProperty->getName(), + $this->fieldName($reflectionProperty), + true, + $attribute->fallback, + ); + } + + private function subjectIdField(ReflectionClass $reflectionClass): string|null + { + $property = null; + + foreach ($reflectionClass->getProperties() as $reflectionProperty) { + $attributeReflectionList = $reflectionProperty->getAttributes(DataSubjectId::class); + + if (!$attributeReflectionList) { + continue; + } + + if ($property !== null) { + throw new MultipleDataSubjectId($property->getName(), $reflectionProperty->getName()); + } + + $property = $reflectionProperty; + } + + if ($property === null) { + return null; + } + + return $this->fieldName($property); + } + + private function fieldName(ReflectionProperty $reflectionProperty): string + { + $attributeReflectionList = $reflectionProperty->getAttributes(NormalizedName::class); + + if (!$attributeReflectionList) { + return $reflectionProperty->getName(); + } + + $attribute = $attributeReflectionList[0]->newInstance(); + + return $attribute->name(); + } } diff --git a/src/Metadata/AggregateRoot/MissingDataSubjectId.php b/src/Metadata/AggregateRoot/MissingDataSubjectId.php new file mode 100644 index 000000000..196b11c67 --- /dev/null +++ b/src/Metadata/AggregateRoot/MissingDataSubjectId.php @@ -0,0 +1,20 @@ +isPersonalData) { if ($subjectId === $propertyMetadata->fieldName) { - throw new DataSubjectIdIsPersonalData($event, $propertyMetadata->fieldName); + throw new SubjectIdAndPersonalDataConflict($event, $propertyMetadata->fieldName); } $hasPersonalData = true; diff --git a/src/Metadata/Event/DataSubjectIdIsPersonalData.php b/src/Metadata/Event/SubjectIdAndPersonalDataConflict.php similarity index 87% rename from src/Metadata/Event/DataSubjectIdIsPersonalData.php rename to src/Metadata/Event/SubjectIdAndPersonalDataConflict.php index 9ff1b2957..f48a7402f 100644 --- a/src/Metadata/Event/DataSubjectIdIsPersonalData.php +++ b/src/Metadata/Event/SubjectIdAndPersonalDataConflict.php @@ -8,7 +8,7 @@ use function sprintf; -final class DataSubjectIdIsPersonalData extends MetadataException +final class SubjectIdAndPersonalDataConflict extends MetadataException { /** @param class-string $class */ public function __construct(string $class, string $property) diff --git a/src/Serializer/DefaultEventSerializer.php b/src/Serializer/DefaultEventSerializer.php index df5eada3b..3512655cb 100644 --- a/src/Serializer/DefaultEventSerializer.php +++ b/src/Serializer/DefaultEventSerializer.php @@ -5,7 +5,7 @@ namespace Patchlevel\EventSourcing\Serializer; use Patchlevel\EventSourcing\Cryptography\CryptographicHydrator; -use Patchlevel\EventSourcing\Cryptography\EventPayloadCryptographer; +use Patchlevel\EventSourcing\Cryptography\PayloadCryptographer; use Patchlevel\EventSourcing\Metadata\Event\AttributeEventRegistryFactory; use Patchlevel\EventSourcing\Metadata\Event\EventRegistry; use Patchlevel\EventSourcing\Serializer\Encoder\Encoder; @@ -58,7 +58,7 @@ public function deserialize(SerializedEvent $data, array $options = []): object public static function createFromPaths( array $paths, Upcaster|null $upcaster = null, - EventPayloadCryptographer|null $cryptographer = null, + PayloadCryptographer|null $cryptographer = null, ): static { $hydrator = new MetadataHydrator(); diff --git a/tests/Benchmark/PersonalDataBench.php b/tests/Benchmark/PersonalDataBench.php index 7cd530e78..39d8275eb 100644 --- a/tests/Benchmark/PersonalDataBench.php +++ b/tests/Benchmark/PersonalDataBench.php @@ -5,7 +5,7 @@ namespace Patchlevel\EventSourcing\Tests\Benchmark; use Patchlevel\EventSourcing\Aggregate\AggregateRootId; -use Patchlevel\EventSourcing\Cryptography\DefaultEventPayloadCryptographer; +use Patchlevel\EventSourcing\Cryptography\EventPayloadCryptographer; use Patchlevel\EventSourcing\Cryptography\Store\DoctrineCipherKeyStore; use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer; use Patchlevel\EventSourcing\Metadata\Event\AttributeEventMetadataFactory; @@ -35,7 +35,7 @@ public function setUp(): void $cipherKeyStore = new DoctrineCipherKeyStore($connection); - $cryptographer = DefaultEventPayloadCryptographer::createWithOpenssl( + $cryptographer = EventPayloadCryptographer::createWithOpenssl( new AttributeEventMetadataFactory(), $cipherKeyStore, ); diff --git a/tests/Integration/PersonalData/PersonalDataTest.php b/tests/Integration/PersonalData/PersonalDataTest.php index 68d4ef347..0bf704248 100644 --- a/tests/Integration/PersonalData/PersonalDataTest.php +++ b/tests/Integration/PersonalData/PersonalDataTest.php @@ -5,7 +5,7 @@ namespace Patchlevel\EventSourcing\Tests\Integration\PersonalData; use Doctrine\DBAL\Connection; -use Patchlevel\EventSourcing\Cryptography\DefaultEventPayloadCryptographer; +use Patchlevel\EventSourcing\Cryptography\EventPayloadCryptographer; use Patchlevel\EventSourcing\Cryptography\Store\DoctrineCipherKeyStore; use Patchlevel\EventSourcing\EventBus\DefaultEventBus; use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer; @@ -42,7 +42,7 @@ public function testSuccessful(): void { $cipherKeyStore = new DoctrineCipherKeyStore($this->connection); - $cryptographer = DefaultEventPayloadCryptographer::createWithOpenssl( + $cryptographer = EventPayloadCryptographer::createWithOpenssl( new AttributeEventMetadataFactory(), $cipherKeyStore, ); @@ -103,7 +103,7 @@ public function testRemoveKey(): void { $cipherKeyStore = new DoctrineCipherKeyStore($this->connection); - $cryptographer = DefaultEventPayloadCryptographer::createWithOpenssl( + $cryptographer = EventPayloadCryptographer::createWithOpenssl( new AttributeEventMetadataFactory(), $cipherKeyStore, ); diff --git a/tests/Unit/Cryptography/CryptographicHydratorTest.php b/tests/Unit/Cryptography/CryptographicHydratorTest.php index 15f31fc64..51ea7b969 100644 --- a/tests/Unit/Cryptography/CryptographicHydratorTest.php +++ b/tests/Unit/Cryptography/CryptographicHydratorTest.php @@ -5,7 +5,7 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Cryptography; use Patchlevel\EventSourcing\Cryptography\CryptographicHydrator; -use Patchlevel\EventSourcing\Cryptography\EventPayloadCryptographer; +use Patchlevel\EventSourcing\Cryptography\PayloadCryptographer; use Patchlevel\Hydrator\Hydrator; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; @@ -28,7 +28,7 @@ public function testHydrate(): void ->willReturn($object) ->shouldBeCalledOnce(); - $cryptographer = $this->prophesize(EventPayloadCryptographer::class); + $cryptographer = $this->prophesize(PayloadCryptographer::class); $cryptographer ->decrypt(stdClass::class, $encryptedPayload) ->willReturn($payload) @@ -56,7 +56,7 @@ public function testExtract(): void ->willReturn($payload) ->shouldBeCalledOnce(); - $cryptographer = $this->prophesize(EventPayloadCryptographer::class); + $cryptographer = $this->prophesize(PayloadCryptographer::class); $cryptographer ->encrypt(stdClass::class, $payload) ->willReturn($encryptedPayload) diff --git a/tests/Unit/Cryptography/DefaultEventPayloadCryptographerTest.php b/tests/Unit/Cryptography/EventPayloadCryptographerTest.php similarity index 90% rename from tests/Unit/Cryptography/DefaultEventPayloadCryptographerTest.php rename to tests/Unit/Cryptography/EventPayloadCryptographerTest.php index a739c4e37..6e3a947d5 100644 --- a/tests/Unit/Cryptography/DefaultEventPayloadCryptographerTest.php +++ b/tests/Unit/Cryptography/EventPayloadCryptographerTest.php @@ -8,7 +8,7 @@ use Patchlevel\EventSourcing\Cryptography\Cipher\CipherKey; use Patchlevel\EventSourcing\Cryptography\Cipher\CipherKeyFactory; use Patchlevel\EventSourcing\Cryptography\Cipher\DecryptionFailed; -use Patchlevel\EventSourcing\Cryptography\DefaultEventPayloadCryptographer; +use Patchlevel\EventSourcing\Cryptography\EventPayloadCryptographer; use Patchlevel\EventSourcing\Cryptography\MissingSubjectId; use Patchlevel\EventSourcing\Cryptography\Store\CipherKeyNotExists; use Patchlevel\EventSourcing\Cryptography\Store\CipherKeyStore; @@ -20,8 +20,8 @@ use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; -/** @covers \Patchlevel\EventSourcing\Cryptography\DefaultEventPayloadCryptographer */ -final class DefaultEventPayloadCryptographerTest extends TestCase +/** @covers \Patchlevel\EventSourcing\Cryptography\EventPayloadCryptographer */ +final class EventPayloadCryptographerTest extends TestCase { use ProphecyTrait; @@ -33,7 +33,7 @@ public function testSkipEncrypt(): void $cipherKeyFactory = $this->prophesize(CipherKeyFactory::class); $cipher = $this->prophesize(Cipher::class); - $cryptographer = new DefaultEventPayloadCryptographer( + $cryptographer = new EventPayloadCryptographer( new AttributeEventMetadataFactory(), $cipherKeyStore->reveal(), $cipherKeyFactory->reveal(), @@ -68,7 +68,7 @@ public function testEncryptWithMissingKey(): void ->willReturn('encrypted') ->shouldBeCalledOnce(); - $cryptographer = new DefaultEventPayloadCryptographer( + $cryptographer = new EventPayloadCryptographer( new AttributeEventMetadataFactory(), $cipherKeyStore->reveal(), $cipherKeyFactory->reveal(), @@ -101,7 +101,7 @@ public function testEncryptWithExistingKey(): void ->willReturn('encrypted') ->shouldBeCalledOnce(); - $cryptographer = new DefaultEventPayloadCryptographer( + $cryptographer = new EventPayloadCryptographer( new AttributeEventMetadataFactory(), $cipherKeyStore->reveal(), $cipherKeyFactory->reveal(), @@ -121,7 +121,7 @@ public function testSkipDecrypt(): void $cipherKeyFactory = $this->prophesize(CipherKeyFactory::class); $cipher = $this->prophesize(Cipher::class); - $cryptographer = new DefaultEventPayloadCryptographer( + $cryptographer = new EventPayloadCryptographer( new AttributeEventMetadataFactory(), $cipherKeyStore->reveal(), $cipherKeyFactory->reveal(), @@ -146,7 +146,7 @@ public function testDecryptWithMissingKey(): void $cipher = $this->prophesize(Cipher::class); $cipher->decrypt()->shouldNotBeCalled(); - $cryptographer = new DefaultEventPayloadCryptographer( + $cryptographer = new EventPayloadCryptographer( new AttributeEventMetadataFactory(), $cipherKeyStore->reveal(), $cipherKeyFactory->reveal(), @@ -179,7 +179,7 @@ public function testDecryptWithInvalidKey(): void ->willThrow(new DecryptionFailed()) ->shouldBeCalledOnce(); - $cryptographer = new DefaultEventPayloadCryptographer( + $cryptographer = new EventPayloadCryptographer( new AttributeEventMetadataFactory(), $cipherKeyStore->reveal(), $cipherKeyFactory->reveal(), @@ -212,7 +212,7 @@ public function testDecryptWithExistingKey(): void ->willReturn('info@patchlevel.de') ->shouldBeCalledOnce(); - $cryptographer = new DefaultEventPayloadCryptographer( + $cryptographer = new EventPayloadCryptographer( new AttributeEventMetadataFactory(), $cipherKeyStore->reveal(), $cipherKeyFactory->reveal(), @@ -232,7 +232,7 @@ public function testUnsupportedSubjectId(): void $cipherKeyFactory = $this->prophesize(CipherKeyFactory::class); $cipher = $this->prophesize(Cipher::class); - $cryptographer = new DefaultEventPayloadCryptographer( + $cryptographer = new EventPayloadCryptographer( new AttributeEventMetadataFactory(), $cipherKeyStore->reveal(), $cipherKeyFactory->reveal(), @@ -250,7 +250,7 @@ public function testMissingSubjectId(): void $cipherKeyFactory = $this->prophesize(CipherKeyFactory::class); $cipher = $this->prophesize(Cipher::class); - $cryptographer = new DefaultEventPayloadCryptographer( + $cryptographer = new EventPayloadCryptographer( new AttributeEventMetadataFactory(), $cipherKeyStore->reveal(), $cipherKeyFactory->reveal(), @@ -264,11 +264,11 @@ public function testCreateWithOpenssl(): void { $cipherKeyStore = $this->prophesize(CipherKeyStore::class); - $cryptographer = DefaultEventPayloadCryptographer::createWithOpenssl( + $cryptographer = EventPayloadCryptographer::createWithOpenssl( new AttributeEventMetadataFactory(), $cipherKeyStore->reveal(), ); - self::assertInstanceOf(DefaultEventPayloadCryptographer::class, $cryptographer); + self::assertInstanceOf(EventPayloadCryptographer::class, $cryptographer); } } diff --git a/tests/Unit/Cryptography/SnapshotPayloadCryptographerTest.php b/tests/Unit/Cryptography/SnapshotPayloadCryptographerTest.php new file mode 100644 index 000000000..cef4274aa --- /dev/null +++ b/tests/Unit/Cryptography/SnapshotPayloadCryptographerTest.php @@ -0,0 +1,274 @@ +prophesize(CipherKeyStore::class); + $cipherKeyStore->get(Argument::any())->shouldNotBeCalled(); + + $cipherKeyFactory = $this->prophesize(CipherKeyFactory::class); + $cipher = $this->prophesize(Cipher::class); + + $cryptographer = new SnapshotPayloadCryptographer( + new AttributeAggregateRootMetadataFactory(), + $cipherKeyStore->reveal(), + $cipherKeyFactory->reveal(), + $cipher->reveal(), + ); + + $payload = ['id' => 'foo', 'email' => 'info@patchlevel.de']; + + $result = $cryptographer->encrypt(Profile::class, ['id' => 'foo', 'email' => 'info@patchlevel.de']); + + self::assertSame($payload, $result); + } + + public function testEncryptWithMissingKey(): void + { + $cipherKey = new CipherKey( + 'foo', + 'bar', + 'baz', + ); + + $cipherKeyStore = $this->prophesize(CipherKeyStore::class); + $cipherKeyStore->get('foo')->willThrow(new CipherKeyNotExists('foo')); + $cipherKeyStore->store('foo', $cipherKey)->shouldBeCalled(); + + $cipherKeyFactory = $this->prophesize(CipherKeyFactory::class); + $cipherKeyFactory->__invoke()->willReturn($cipherKey)->shouldBeCalledOnce(); + + $cipher = $this->prophesize(Cipher::class); + $cipher + ->encrypt($cipherKey, 'info@patchlevel.de') + ->willReturn('encrypted') + ->shouldBeCalledOnce(); + + $cryptographer = new SnapshotPayloadCryptographer( + new AttributeAggregateRootMetadataFactory(), + $cipherKeyStore->reveal(), + $cipherKeyFactory->reveal(), + $cipher->reveal(), + ); + + $result = $cryptographer->encrypt(ProfileWithSnapshot::class, ['id' => 'foo', 'email' => 'info@patchlevel.de']); + + self::assertEquals(['id' => 'foo', 'email' => 'encrypted'], $result); + } + + public function testEncryptWithExistingKey(): void + { + $cipherKey = new CipherKey( + 'foo', + 'bar', + 'baz', + ); + + $cipherKeyStore = $this->prophesize(CipherKeyStore::class); + $cipherKeyStore->get('foo')->willReturn($cipherKey); + $cipherKeyStore->store('foo', Argument::type(CipherKey::class))->shouldNotBeCalled(); + + $cipherKeyFactory = $this->prophesize(CipherKeyFactory::class); + $cipherKeyFactory->__invoke()->shouldNotBeCalled(); + + $cipher = $this->prophesize(Cipher::class); + $cipher + ->encrypt($cipherKey, 'info@patchlevel.de') + ->willReturn('encrypted') + ->shouldBeCalledOnce(); + + $cryptographer = new SnapshotPayloadCryptographer( + new AttributeAggregateRootMetadataFactory(), + $cipherKeyStore->reveal(), + $cipherKeyFactory->reveal(), + $cipher->reveal(), + ); + + $result = $cryptographer->encrypt(ProfileWithSnapshot::class, ['id' => 'foo', 'email' => 'info@patchlevel.de']); + + self::assertEquals(['id' => 'foo', 'email' => 'encrypted'], $result); + } + + public function testSkipDecrypt(): void + { + $cipherKeyStore = $this->prophesize(CipherKeyStore::class); + $cipherKeyStore->get(Argument::any())->shouldNotBeCalled(); + + $cipherKeyFactory = $this->prophesize(CipherKeyFactory::class); + $cipher = $this->prophesize(Cipher::class); + + $cryptographer = new SnapshotPayloadCryptographer( + new AttributeAggregateRootMetadataFactory(), + $cipherKeyStore->reveal(), + $cipherKeyFactory->reveal(), + $cipher->reveal(), + ); + + $payload = ['id' => 'foo', 'email' => 'info@patchlevel.de']; + + $result = $cryptographer->decrypt(Profile::class, ['id' => 'foo', 'email' => 'info@patchlevel.de']); + + self::assertSame($payload, $result); + } + + public function testDecryptWithMissingKey(): void + { + $cipherKeyStore = $this->prophesize(CipherKeyStore::class); + $cipherKeyStore->get('foo')->willThrow(new CipherKeyNotExists('foo')); + + $cipherKeyFactory = $this->prophesize(CipherKeyFactory::class); + $cipherKeyFactory->__invoke()->shouldNotBeCalled(); + + $cipher = $this->prophesize(Cipher::class); + $cipher->decrypt()->shouldNotBeCalled(); + + $cryptographer = new SnapshotPayloadCryptographer( + new AttributeAggregateRootMetadataFactory(), + $cipherKeyStore->reveal(), + $cipherKeyFactory->reveal(), + $cipher->reveal(), + ); + + $result = $cryptographer->decrypt(ProfileWithSnapshot::class, ['id' => 'foo', 'email' => 'encrypted']); + + self::assertEquals(['id' => 'foo', 'email' => 'fallback'], $result); + } + + public function testDecryptWithInvalidKey(): void + { + $cipherKey = new CipherKey( + 'foo', + 'bar', + 'baz', + ); + + $cipherKeyStore = $this->prophesize(CipherKeyStore::class); + $cipherKeyStore->get('foo')->willReturn($cipherKey); + $cipherKeyStore->store('foo', Argument::type(CipherKey::class))->shouldNotBeCalled(); + + $cipherKeyFactory = $this->prophesize(CipherKeyFactory::class); + $cipherKeyFactory->__invoke()->shouldNotBeCalled(); + + $cipher = $this->prophesize(Cipher::class); + $cipher + ->decrypt($cipherKey, 'encrypted') + ->willThrow(new DecryptionFailed()) + ->shouldBeCalledOnce(); + + $cryptographer = new SnapshotPayloadCryptographer( + new AttributeAggregateRootMetadataFactory(), + $cipherKeyStore->reveal(), + $cipherKeyFactory->reveal(), + $cipher->reveal(), + ); + + $result = $cryptographer->decrypt(ProfileWithSnapshot::class, ['id' => 'foo', 'email' => 'encrypted']); + + self::assertEquals(['id' => 'foo', 'email' => 'fallback'], $result); + } + + public function testDecryptWithExistingKey(): void + { + $cipherKey = new CipherKey( + 'foo', + 'bar', + 'baz', + ); + + $cipherKeyStore = $this->prophesize(CipherKeyStore::class); + $cipherKeyStore->get('foo')->willReturn($cipherKey); + $cipherKeyStore->store('foo', Argument::type(CipherKey::class))->shouldNotBeCalled(); + + $cipherKeyFactory = $this->prophesize(CipherKeyFactory::class); + $cipherKeyFactory->__invoke()->shouldNotBeCalled(); + + $cipher = $this->prophesize(Cipher::class); + $cipher + ->decrypt($cipherKey, 'encrypted') + ->willReturn('info@patchlevel.de') + ->shouldBeCalledOnce(); + + $cryptographer = new SnapshotPayloadCryptographer( + new AttributeAggregateRootMetadataFactory(), + $cipherKeyStore->reveal(), + $cipherKeyFactory->reveal(), + $cipher->reveal(), + ); + + $result = $cryptographer->decrypt(ProfileWithSnapshot::class, ['id' => 'foo', 'email' => 'encrypted']); + + self::assertEquals(['id' => 'foo', 'email' => 'info@patchlevel.de'], $result); + } + + public function testUnsupportedSubjectId(): void + { + $this->expectException(UnsupportedSubjectId::class); + + $cipherKeyStore = $this->prophesize(CipherKeyStore::class); + $cipherKeyFactory = $this->prophesize(CipherKeyFactory::class); + $cipher = $this->prophesize(Cipher::class); + + $cryptographer = new SnapshotPayloadCryptographer( + new AttributeAggregateRootMetadataFactory(), + $cipherKeyStore->reveal(), + $cipherKeyFactory->reveal(), + $cipher->reveal(), + ); + + $cryptographer->decrypt(ProfileWithSnapshot::class, ['id' => null, 'email' => 'encrypted']); + } + + public function testMissingSubjectId(): void + { + $this->expectException(MissingSubjectId::class); + + $cipherKeyStore = $this->prophesize(CipherKeyStore::class); + $cipherKeyFactory = $this->prophesize(CipherKeyFactory::class); + $cipher = $this->prophesize(Cipher::class); + + $cryptographer = new SnapshotPayloadCryptographer( + new AttributeAggregateRootMetadataFactory(), + $cipherKeyStore->reveal(), + $cipherKeyFactory->reveal(), + $cipher->reveal(), + ); + + $cryptographer->decrypt(ProfileWithSnapshot::class, ['email' => 'encrypted']); + } + + public function testCreateWithOpenssl(): void + { + $cipherKeyStore = $this->prophesize(CipherKeyStore::class); + + $cryptographer = SnapshotPayloadCryptographer::createWithOpenssl( + new AttributeAggregateRootMetadataFactory(), + $cipherKeyStore->reveal(), + ); + + self::assertInstanceOf(SnapshotPayloadCryptographer::class, $cryptographer); + } +} diff --git a/tests/Unit/Fixture/ProfileWithSnapshot.php b/tests/Unit/Fixture/ProfileWithSnapshot.php index d215c5f66..5a4aa52fe 100644 --- a/tests/Unit/Fixture/ProfileWithSnapshot.php +++ b/tests/Unit/Fixture/ProfileWithSnapshot.php @@ -7,7 +7,9 @@ use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Apply; +use Patchlevel\EventSourcing\Attribute\DataSubjectId; use Patchlevel\EventSourcing\Attribute\Id; +use Patchlevel\EventSourcing\Attribute\PersonalData; use Patchlevel\EventSourcing\Attribute\Snapshot; use Patchlevel\EventSourcing\Attribute\SuppressMissingApply; use Patchlevel\EventSourcing\Serializer\Normalizer\IdNormalizer; @@ -20,8 +22,10 @@ final class ProfileWithSnapshot extends BasicAggregateRoot { #[Id] #[IdNormalizer] + #[DataSubjectId] private ProfileId $id; #[EmailNormalizer] + #[PersonalData(fallback: 'fallback')] private Email $email; /** @var array */ #[ArrayNormalizer(new MessageNormalizer())] diff --git a/tests/Unit/Metadata/Aggregate/AttributeAggregateMetadataFactoryTest.php b/tests/Unit/Metadata/Aggregate/AttributeAggregateMetadataFactoryTest.php index 7bf5f6402..e42346029 100644 --- a/tests/Unit/Metadata/Aggregate/AttributeAggregateMetadataFactoryTest.php +++ b/tests/Unit/Metadata/Aggregate/AttributeAggregateMetadataFactoryTest.php @@ -4,10 +4,17 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Metadata\Aggregate; +use Patchlevel\EventSourcing\Attribute\Aggregate; +use Patchlevel\EventSourcing\Attribute\DataSubjectId; +use Patchlevel\EventSourcing\Attribute\Id; +use Patchlevel\EventSourcing\Attribute\PersonalData; use Patchlevel\EventSourcing\Metadata\AggregateRoot\ArgumentTypeIsMissing; use Patchlevel\EventSourcing\Metadata\AggregateRoot\AttributeAggregateRootMetadataFactory; use Patchlevel\EventSourcing\Metadata\AggregateRoot\DuplicateEmptyApplyAttribute; +use Patchlevel\EventSourcing\Metadata\AggregateRoot\MissingDataSubjectId; use Patchlevel\EventSourcing\Metadata\AggregateRoot\MixedApplyAttributeUsage; +use Patchlevel\EventSourcing\Metadata\AggregateRoot\MultipleDataSubjectId; +use Patchlevel\EventSourcing\Metadata\AggregateRoot\SubjectIdAndPersonalDataConflict; use Patchlevel\EventSourcing\Tests\Unit\Fixture\MessageDeleted; use Patchlevel\EventSourcing\Tests\Unit\Fixture\NameChanged; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Profile; @@ -19,6 +26,7 @@ use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithBrokenApplyNoType; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileWithEmptyApply; use Patchlevel\EventSourcing\Tests\Unit\Fixture\SplittingEvent; +use Patchlevel\Hydrator\Attribute\NormalizedName; use PHPUnit\Framework\TestCase; /** @covers \Patchlevel\EventSourcing\Metadata\AggregateRoot\AttributeAggregateRootMetadataFactory */ @@ -93,4 +101,95 @@ public function testBrokenApplyWithBothUsages(): void $metadataFactory->metadata(ProfileWithBrokenApplyBothUsage::class); } + + public function testPersonalData(): void + { + $event = new #[Aggregate('profile')] + class ('id', 'name') { + public function __construct( + #[Id] + #[DataSubjectId] + #[NormalizedName('_id')] + public string $id, + #[PersonalData('fallback')] + #[NormalizedName('_name')] + public string $name, + ) { + } + }; + + $metadataFactory = new AttributeAggregateRootMetadataFactory(); + $metadata = $metadataFactory->metadata($event::class); + + self::assertSame('profile', $metadata->name); + self::assertSame('_id', $metadata->dataSubjectIdField); + self::assertCount(2, $metadata->propertyMetadata); + + self::assertSame('id', $metadata->propertyMetadata['id']->propertyName); + self::assertSame(false, $metadata->propertyMetadata['id']->isPersonalData); + self::assertSame('_id', $metadata->propertyMetadata['id']->fieldName); + self::assertSame(null, $metadata->propertyMetadata['id']->personalDataFallback); + + self::assertSame('name', $metadata->propertyMetadata['name']->propertyName); + self::assertSame(true, $metadata->propertyMetadata['name']->isPersonalData); + self::assertSame('_name', $metadata->propertyMetadata['name']->fieldName); + self::assertSame('fallback', $metadata->propertyMetadata['name']->personalDataFallback); + } + + public function testMissingDataSubjectId(): void + { + $event = new #[Aggregate('profile')] + class ('name') { + public function __construct( + #[Id] + #[PersonalData] + public string $name, + ) { + } + }; + + $this->expectException(MissingDataSubjectId::class); + + $metadataFactory = new AttributeAggregateRootMetadataFactory(); + $metadataFactory->metadata($event::class); + } + + public function testDataSubjectIdIsPersonalData(): void + { + $event = new #[Aggregate('profile')] + class ('name') { + public function __construct( + #[Id] + #[DataSubjectId] + #[PersonalData] + public string $name, + ) { + } + }; + + $this->expectException(SubjectIdAndPersonalDataConflict::class); + + $metadataFactory = new AttributeAggregateRootMetadataFactory(); + $metadataFactory->metadata($event::class); + } + + public function testMultipleDataSubjectId(): void + { + $aggregate = new #[Aggregate('profile')] + class ('id', 'name') { + public function __construct( + #[Id] + #[DataSubjectId] + public string $id, + #[DataSubjectId] + public string $name, + ) { + } + }; + + $this->expectException(MultipleDataSubjectId::class); + + $metadataFactory = new AttributeAggregateRootMetadataFactory(); + $metadataFactory->metadata($aggregate::class); + } } diff --git a/tests/Unit/Metadata/Event/AttributeEventMetadataFactoryTest.php b/tests/Unit/Metadata/Event/AttributeEventMetadataFactoryTest.php index 05fbd3829..0a1949af3 100644 --- a/tests/Unit/Metadata/Event/AttributeEventMetadataFactoryTest.php +++ b/tests/Unit/Metadata/Event/AttributeEventMetadataFactoryTest.php @@ -10,9 +10,9 @@ use Patchlevel\EventSourcing\Attribute\SplitStream; use Patchlevel\EventSourcing\Metadata\Event\AttributeEventMetadataFactory; use Patchlevel\EventSourcing\Metadata\Event\ClassIsNotAnEvent; -use Patchlevel\EventSourcing\Metadata\Event\DataSubjectIdIsPersonalData; use Patchlevel\EventSourcing\Metadata\Event\MissingDataSubjectId; use Patchlevel\EventSourcing\Metadata\Event\MultipleDataSubjectId; +use Patchlevel\EventSourcing\Metadata\Event\SubjectIdAndPersonalDataConflict; use Patchlevel\Hydrator\Attribute\NormalizedName; use PHPUnit\Framework\TestCase; @@ -124,7 +124,7 @@ public function __construct( } }; - $this->expectException(DataSubjectIdIsPersonalData::class); + $this->expectException(SubjectIdAndPersonalDataConflict::class); $metadataFactory = new AttributeEventMetadataFactory(); $metadataFactory->metadata($event::class);