diff --git a/baseline.xml b/baseline.xml index 617141b91..0d4322928 100644 --- a/baseline.xml +++ b/baseline.xml @@ -1,5 +1,5 @@ - + @@ -15,6 +15,28 @@ + + + + length)]]> + + + + + fieldName]]]> + + + fieldName]]]> + fieldName]]]> + fieldName]]]> + + + + + + + + getName()]]> @@ -88,12 +110,21 @@ - + + + + + + + + + + @@ -145,6 +176,17 @@ + + + + + + + + + + + diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e97903bba..3cec5d190 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -5,6 +5,31 @@ parameters: count: 1 path: src/Console/DoctrineHelper.php + - + message: "#^Parameter \\#1 \\$key of class Patchlevel\\\\EventSourcing\\\\Cryptography\\\\Cipher\\\\CipherKey constructor expects non\\-empty\\-string, string given\\.$#" + count: 1 + path: src/Cryptography/Cipher/OpensslCipherKeyFactory.php + + - + message: "#^Parameter \\#3 \\$iv of class Patchlevel\\\\EventSourcing\\\\Cryptography\\\\Cipher\\\\CipherKey constructor expects non\\-empty\\-string, string given\\.$#" + count: 1 + path: src/Cryptography/Cipher/OpensslCipherKeyFactory.php + + - + message: "#^Parameter \\#2 \\$data of method Patchlevel\\\\EventSourcing\\\\Cryptography\\\\Cipher\\\\Cipher\\:\\:decrypt\\(\\) expects string, mixed given\\.$#" + count: 1 + path: src/Cryptography/DefaultEventPayloadCryptographer.php + + - + message: "#^Parameter \\#1 \\$key of class Patchlevel\\\\EventSourcing\\\\Cryptography\\\\Cipher\\\\CipherKey constructor expects non\\-empty\\-string, string given\\.$#" + count: 1 + path: src/Cryptography/Store/DoctrineCipherKeyStore.php + + - + message: "#^Parameter \\#3 \\$iv of class Patchlevel\\\\EventSourcing\\\\Cryptography\\\\Cipher\\\\CipherKey constructor expects non\\-empty\\-string, string given\\.$#" + count: 1 + path: src/Cryptography/Store/DoctrineCipherKeyStore.php + - message: "#^Parameter \\#2 \\$data of method Patchlevel\\\\Hydrator\\\\Hydrator\\:\\:hydrate\\(\\) expects array\\, mixed given\\.$#" count: 1 diff --git a/src/Attribute/DataSubjectId.php b/src/Attribute/DataSubjectId.php new file mode 100644 index 000000000..e41005217 --- /dev/null +++ b/src/Attribute/DataSubjectId.php @@ -0,0 +1,12 @@ +dataEncode($data), + $key->method, + $key->key, + 0, + $key->iv, + ); + + if ($encryptedData === false) { + throw new EncryptionFailed(); + } + + return base64_encode($encryptedData); + } + + public function decrypt(CipherKey $key, string $data): mixed + { + $data = openssl_decrypt( + base64_decode($data), + $key->method, + $key->key, + 0, + $key->iv, + ); + + if ($data === false) { + throw new DecryptionFailed(); + } + + return $this->dataDecode($data); + } + + private function dataEncode(mixed $data): string + { + return json_encode($data, JSON_THROW_ON_ERROR); + } + + private function dataDecode(string $data): mixed + { + return json_decode($data, true, 512, JSON_THROW_ON_ERROR); + } +} diff --git a/src/Cryptography/Cipher/OpensslCipherKeyFactory.php b/src/Cryptography/Cipher/OpensslCipherKeyFactory.php new file mode 100644 index 000000000..b2cf2e7cb --- /dev/null +++ b/src/Cryptography/Cipher/OpensslCipherKeyFactory.php @@ -0,0 +1,40 @@ +method); + + if ($ivLength === false) { + throw new CreateCipherKeyFailed(); + } + + return new CipherKey( + openssl_random_pseudo_bytes($this->length), + $this->method, + openssl_random_pseudo_bytes($ivLength), + ); + } +} diff --git a/src/Cryptography/DefaultEventPayloadCryptographer.php b/src/Cryptography/DefaultEventPayloadCryptographer.php new file mode 100644 index 000000000..6f20ad7ab --- /dev/null +++ b/src/Cryptography/DefaultEventPayloadCryptographer.php @@ -0,0 +1,145 @@ + $data + * + * @return 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->eventMetadataFactory->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 + * @param array $data + * + * @return 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->eventMetadataFactory->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->eventMetadataFactory->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 $eventMetadataFactory, CipherKeyStore $cryptoStore): static + { + return new self( + $eventMetadataFactory, + $cryptoStore, + new OpensslCipherKeyFactory(), + new OpensslCipher(), + ); + } +} diff --git a/src/Cryptography/EventPayloadCryptographer.php b/src/Cryptography/EventPayloadCryptographer.php new file mode 100644 index 000000000..ea1aa6569 --- /dev/null +++ b/src/Cryptography/EventPayloadCryptographer.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/MissingSubjectId.php b/src/Cryptography/MissingSubjectId.php new file mode 100644 index 000000000..7ee85d2e1 --- /dev/null +++ b/src/Cryptography/MissingSubjectId.php @@ -0,0 +1,15 @@ + */ + private array $keys = []; + + public function __construct( + private readonly Connection $connection, + private readonly string $tableName = 'crypto_keys', + ) { + } + + public function get(string $id): CipherKey + { + if (array_key_exists($id, $this->keys)) { + return $this->keys[$id]; + } + + /** @var Row|false $result */ + $result = $this->connection->fetchAssociative( + "SELECT * FROM {$this->tableName} WHERE subject_id = :subject_id", + ['subject_id' => $id], + ); + + if ($result === false) { + throw new CipherKeyNotExists($id); + } + + $this->keys[$id] = new CipherKey( + base64_decode($result['crypto_key']), + $result['crypto_method'], + base64_decode($result['crypto_iv']), + ); + + return $this->keys[$id]; + } + + public function store(string $id, CipherKey $key): void + { + $this->connection->insert($this->tableName, [ + 'subject_id' => $id, + 'crypto_key' => base64_encode($key->key), + 'crypto_method' => $key->method, + 'crypto_iv' => base64_encode($key->iv), + ]); + + $this->keys[$id] = $key; + } + + public function remove(string $id): void + { + $this->connection->delete($this->tableName, ['subject_id' => $id]); + + unset($this->keys[$id]); + } + + public function configureSchema(Schema $schema, Connection $connection): void + { + if ($connection !== $this->connection) { + return; + } + + $table = $schema->createTable($this->tableName); + $table->addColumn('subject_id', 'string') + ->setNotnull(true) + ->setLength(255); + $table->addColumn('crypto_key', 'string') + ->setNotnull(true) + ->setLength(255); + $table->addColumn('crypto_method', 'string') + ->setNotnull(true) + ->setLength(255); + $table->addColumn('crypto_iv', 'string') + ->setNotnull(true) + ->setLength(255); + $table->setPrimaryKey(['subject_id']); + } + + public function clear(): void + { + $this->keys = []; + } +} diff --git a/src/Cryptography/Store/InMemoryCipherKeyStore.php b/src/Cryptography/Store/InMemoryCipherKeyStore.php new file mode 100644 index 000000000..7bf8c5f3e --- /dev/null +++ b/src/Cryptography/Store/InMemoryCipherKeyStore.php @@ -0,0 +1,33 @@ + */ + private array $keys = []; + + public function get(string $id): CipherKey + { + return $this->keys[$id] ?? throw new CipherKeyNotExists($id); + } + + public function store(string $id, CipherKey $key): void + { + $this->keys[$id] = $key; + } + + public function remove(string $id): void + { + unset($this->keys[$id]); + } + + public function clear(): void + { + $this->keys = []; + } +} diff --git a/src/Cryptography/UnsupportedSubjectId.php b/src/Cryptography/UnsupportedSubjectId.php new file mode 100644 index 000000000..ea0d6f8a4 --- /dev/null +++ b/src/Cryptography/UnsupportedSubjectId.php @@ -0,0 +1,18 @@ +getProperties() as $reflectionProperty) { + $propertyMetadata[$reflectionProperty->getName()] = $this->propertyMetadata($reflectionProperty); + } + $eventAttribute = $attributeReflectionList[0]->newInstance(); $this->eventMetadata[$event] = new EventMetadata( $eventAttribute->name, $this->splitStream($reflectionClass), + $this->subjectIdField($reflectionClass), + $propertyMetadata, ); return $this->eventMetadata[$event]; @@ -45,4 +57,63 @@ private function splitStream(ReflectionClass $reflectionClass): bool { return count($reflectionClass->getAttributes(SplitStream::class)) !== 0; } + + 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/Event/EventMetadata.php b/src/Metadata/Event/EventMetadata.php index e5064109b..4c2018b59 100644 --- a/src/Metadata/Event/EventMetadata.php +++ b/src/Metadata/Event/EventMetadata.php @@ -9,6 +9,9 @@ final class EventMetadata public function __construct( public readonly string $name, public readonly bool $splitStream = false, + public readonly string|null $dataSubjectIdField = null, + /** @var array */ + public readonly array $propertyMetadata = [], ) { } } diff --git a/src/Metadata/Event/MultipleDataSubjectId.php b/src/Metadata/Event/MultipleDataSubjectId.php new file mode 100644 index 000000000..2df69b180 --- /dev/null +++ b/src/Metadata/Event/MultipleDataSubjectId.php @@ -0,0 +1,23 @@ + $class + * @param array $data + * + * @return T + * + * @template T of object + */ + public function hydrate(string $class, array $data): object + { + $data = $this->cryptographer->decrypt($class, $data); + + return $this->hydrator->hydrate($class, $data); + } + + /** @return array */ + public function extract(object $object): array + { + $data = $this->hydrator->extract($object); + + return $this->cryptographer->encrypt($object::class, $data); + } +} diff --git a/src/Serializer/DefaultEventSerializer.php b/src/Serializer/DefaultEventSerializer.php index a1d3f2a4c..e3e0fdbc8 100644 --- a/src/Serializer/DefaultEventSerializer.php +++ b/src/Serializer/DefaultEventSerializer.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Serializer; +use Patchlevel\EventSourcing\Cryptography\EventPayloadCryptographer; use Patchlevel\EventSourcing\Metadata\Event\AttributeEventRegistryFactory; use Patchlevel\EventSourcing\Metadata\Event\EventRegistry; use Patchlevel\EventSourcing\Serializer\Encoder\Encoder; @@ -53,11 +54,20 @@ public function deserialize(SerializedEvent $data, array $options = []): object } /** @param list $paths */ - public static function createFromPaths(array $paths, Upcaster|null $upcaster = null): static - { + public static function createFromPaths( + array $paths, + Upcaster|null $upcaster = null, + EventPayloadCryptographer|null $cryptographer = null, + ): static { + $hydrator = new MetadataHydrator(); + + if ($cryptographer) { + $hydrator = new CryptographicHydrator($hydrator, $cryptographer); + } + return new self( (new AttributeEventRegistryFactory())->create($paths), - new MetadataHydrator(), + $hydrator, new JsonEncoder(), $upcaster, ); diff --git a/tests/Benchmark/BasicImplementation/Events/EmailChanged.php b/tests/Benchmark/BasicImplementation/Events/EmailChanged.php new file mode 100644 index 000000000..d0f642cf8 --- /dev/null +++ b/tests/Benchmark/BasicImplementation/Events/EmailChanged.php @@ -0,0 +1,24 @@ +recordThat(new ProfileCreated($id, $name)); + $self->recordThat(new ProfileCreated($id, $name, $email)); return $self; } @@ -37,6 +38,11 @@ public function changeName(string $name): void $this->recordThat(new NameChanged($name)); } + public function changeEmail(string $email): void + { + $this->recordThat(new EmailChanged($this->id, $email)); + } + public function reborn(): void { $this->recordThat(new Reborn( @@ -50,6 +56,7 @@ protected function applyProfileCreated(ProfileCreated $event): void { $this->id = $event->profileId; $this->name = $event->name; + $this->email = $event->email; } #[Apply] @@ -58,15 +65,27 @@ protected function applyNameChanged(NameChanged $event): void $this->name = $event->name; } + #[Apply] + protected function applyEmailChanged(EmailChanged $event): void + { + $this->email = $event->email; + } + #[Apply] protected function applyReborn(Reborn $event): void { $this->id = $event->profileId; $this->name = $event->name; + $this->email = null; } public function name(): string { return $this->name; } + + public function email(): string|null + { + return $this->email; + } } diff --git a/tests/Benchmark/PersonalDataBench.php b/tests/Benchmark/PersonalDataBench.php new file mode 100644 index 000000000..390dac4cf --- /dev/null +++ b/tests/Benchmark/PersonalDataBench.php @@ -0,0 +1,128 @@ +bus = DefaultEventBus::create(); + + $this->store = new DoctrineDbalStore( + $connection, + DefaultEventSerializer::createFromPaths( + [__DIR__ . '/BasicImplementation/Events'], + cryptographer: $cryptographer, + ), + DefaultHeadersSerializer::createFromPaths([ + __DIR__ . '/../../src', + __DIR__ . '/BasicImplementation/Events', + ]), + 'eventstore', + ); + + $this->repository = new DefaultRepository($this->store, $this->bus, Profile::metadata()); + + $schemaDirector = new DoctrineSchemaDirector( + $connection, + new ChainDoctrineSchemaConfigurator([ + $this->store, + $cipherKeyStore, + ]), + ); + + $schemaDirector->create(); + + $this->id = ProfileId::v7(); + + $profile = Profile::create($this->id, 'Peter', 'info@patchlevel.de'); + + for ($i = 0; $i < 10_000; $i++) { + $profile->changeEmail('info@patchlevel.de'); + } + + $this->repository->save($profile); + } + + #[Bench\Revs(10)] + public function benchLoad10000Events(): void + { + $this->repository->load($this->id); + } + + #[Bench\Revs(10)] + public function benchSave1Event(): void + { + $profile = Profile::create(ProfileId::v7(), 'Peter', 'info@patchlevel.de'); + $this->repository->save($profile); + } + + #[Bench\Revs(10)] + public function benchSave10000Events(): void + { + $profile = Profile::create(ProfileId::v7(), 'Peter', 'info@patchlevel.de'); + + for ($i = 1; $i < 10_000; $i++) { + $profile->changeEmail('info@patchlevel.de'); + } + + $this->repository->save($profile); + } + + #[Bench\Revs(1)] + public function benchSave10000Aggregates(): void + { + for ($i = 1; $i < 10_000; $i++) { + $profile = Profile::create(ProfileId::v7(), 'Peter', 'info@patchlevel.de'); + $this->repository->save($profile); + } + } + + #[Bench\Revs(10)] + public function benchSave10000AggregatesTransaction(): void + { + $this->store->transactional(function (): void { + for ($i = 1; $i < 10_000; $i++) { + $profile = Profile::create(ProfileId::v7(), 'Peter', 'info@patchlevel.de'); + $this->repository->save($profile); + } + }); + } +} diff --git a/tests/Benchmark/SimpleSetupBench.php b/tests/Benchmark/SimpleSetupBench.php index e6a13c7f5..e6af30560 100644 --- a/tests/Benchmark/SimpleSetupBench.php +++ b/tests/Benchmark/SimpleSetupBench.php @@ -14,7 +14,7 @@ use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\DoctrineDbalStore; use Patchlevel\EventSourcing\Store\Store; -use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Aggregate\Profile; +use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Profile; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\ProfileId; use Patchlevel\EventSourcing\Tests\DbalManager; use PhpBench\Attributes as Bench; diff --git a/tests/Benchmark/SnapshotsBench.php b/tests/Benchmark/SnapshotsBench.php index a8c898b31..135d36750 100644 --- a/tests/Benchmark/SnapshotsBench.php +++ b/tests/Benchmark/SnapshotsBench.php @@ -17,7 +17,7 @@ use Patchlevel\EventSourcing\Snapshot\SnapshotStore; use Patchlevel\EventSourcing\Store\DoctrineDbalStore; use Patchlevel\EventSourcing\Store\Store; -use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Aggregate\Profile; +use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Profile; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\ProfileId; use Patchlevel\EventSourcing\Tests\DbalManager; use PhpBench\Attributes as Bench; diff --git a/tests/Benchmark/SplitStreamBench.php b/tests/Benchmark/SplitStreamBench.php index c0b1ced5f..c319fd198 100644 --- a/tests/Benchmark/SplitStreamBench.php +++ b/tests/Benchmark/SplitStreamBench.php @@ -16,7 +16,7 @@ use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\DoctrineDbalStore; use Patchlevel\EventSourcing\Store\Store; -use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Aggregate\Profile; +use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Profile; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\ProfileId; use Patchlevel\EventSourcing\Tests\DbalManager; use PhpBench\Attributes as Bench; diff --git a/tests/Benchmark/SubscriptionEngineBench.php b/tests/Benchmark/SubscriptionEngineBench.php index c30a0567d..cb494de1e 100644 --- a/tests/Benchmark/SubscriptionEngineBench.php +++ b/tests/Benchmark/SubscriptionEngineBench.php @@ -19,8 +19,8 @@ use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Store\DoctrineSubscriptionStore; use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; -use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Aggregate\Profile; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Processor\SendEmailProcessor; +use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Profile; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\ProfileId; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Projection\ProfileProjector; use Patchlevel\EventSourcing\Tests\DbalManager; diff --git a/tests/Benchmark/blackfire.php b/tests/Benchmark/blackfire.php index aa785c9dc..347aa6f8e 100644 --- a/tests/Benchmark/blackfire.php +++ b/tests/Benchmark/blackfire.php @@ -10,7 +10,7 @@ use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\DoctrineDbalStore; -use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Aggregate\Profile; +use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Profile; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\ProfileId; require_once __DIR__ . '/../../vendor/autoload.php'; diff --git a/tests/Integration/PersonalData/Events/NameChanged.php b/tests/Integration/PersonalData/Events/NameChanged.php new file mode 100644 index 000000000..741515412 --- /dev/null +++ b/tests/Integration/PersonalData/Events/NameChanged.php @@ -0,0 +1,24 @@ +connection = DbalManager::createConnection(); + } + + public function tearDown(): void + { + $this->connection->close(); + } + + public function testSuccessful(): void + { + $cipherKeyStore = new DoctrineCipherKeyStore($this->connection); + + $cryptographer = DefaultEventPayloadCryptographer::createWithOpenssl( + new AttributeEventMetadataFactory(), + $cipherKeyStore, + ); + + $store = new DoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events'], cryptographer: $cryptographer), + DefaultHeadersSerializer::createFromPaths([ + __DIR__ . '/../../../src', + __DIR__, + ]), + 'eventstore', + ); + + $eventBus = DefaultEventBus::create(); + + $manager = new DefaultRepositoryManager( + new AggregateRootRegistry(['profile' => Profile::class]), + $store, + $eventBus, + ); + + $repository = $manager->get(Profile::class); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + new ChainDoctrineSchemaConfigurator([ + $store, + $cipherKeyStore, + ]), + ); + + $schemaDirector->create(); + + $profileId = ProfileId::fromString('1'); + $profile = Profile::create($profileId, 'John'); + + $repository->save($profile); + + $profile = $repository->load($profileId); + + self::assertInstanceOf(Profile::class, $profile); + self::assertEquals($profileId, $profile->aggregateRootId()); + self::assertSame(1, $profile->playhead()); + self::assertSame('John', $profile->name()); + + $result = $this->connection->fetchAllAssociative('SELECT * FROM eventstore'); + + self::assertCount(1, $result); + self::assertArrayHasKey(0, $result); + + $row = $result[0]; + + self::assertStringNotContainsString('John', $row['payload']); + } + + public function testRemoveKey(): void + { + $cipherKeyStore = new DoctrineCipherKeyStore($this->connection); + + $cryptographer = DefaultEventPayloadCryptographer::createWithOpenssl( + new AttributeEventMetadataFactory(), + $cipherKeyStore, + ); + + $subscriptionStore = new DoctrineSubscriptionStore( + $this->connection, + ); + + $store = new DoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events'], cryptographer: $cryptographer), + DefaultHeadersSerializer::createFromPaths([ + __DIR__ . '/../../../src', + __DIR__, + ]), + 'eventstore', + ); + + $eventBus = DefaultEventBus::create(); + + $manager = new DefaultRepositoryManager( + new AggregateRootRegistry(['profile' => Profile::class]), + $store, + $eventBus, + ); + + $repository = $manager->get(Profile::class); + + $schemaDirector = new DoctrineSchemaDirector( + $this->connection, + new ChainDoctrineSchemaConfigurator([ + $store, + $cipherKeyStore, + $subscriptionStore, + ]), + ); + + $schemaDirector->create(); + + $engine = new DefaultSubscriptionEngine( + $store, + $subscriptionStore, + new MetadataSubscriberAccessorRepository([new DeletePersonalDataProcessor($cipherKeyStore)]), + ); + + $engine->setup(skipBooting: true); + + $profileId = ProfileId::fromString('1'); + $profile = Profile::create($profileId, 'John'); + + $repository->save($profile); + $engine->run(); + + $profile = $repository->load($profileId); + + self::assertInstanceOf(Profile::class, $profile); + self::assertEquals($profileId, $profile->aggregateRootId()); + self::assertSame(1, $profile->playhead()); + self::assertSame('John', $profile->name()); + + $profile->removePersonalData(); + $repository->save($profile); + $engine->run(); + + $profile = $repository->load($profileId); + + self::assertInstanceOf(Profile::class, $profile); + self::assertEquals($profileId, $profile->aggregateRootId()); + self::assertSame(2, $profile->playhead()); + self::assertSame('unknown', $profile->name()); + + $profile->changeName('hallo'); + $repository->save($profile); + + $profile = $repository->load($profileId); + + self::assertInstanceOf(Profile::class, $profile); + self::assertEquals($profileId, $profile->aggregateRootId()); + self::assertSame(3, $profile->playhead()); + self::assertSame('hallo', $profile->name()); + } +} diff --git a/tests/Integration/PersonalData/Processor/DeletePersonalDataProcessor.php b/tests/Integration/PersonalData/Processor/DeletePersonalDataProcessor.php new file mode 100644 index 000000000..6b661a5c1 --- /dev/null +++ b/tests/Integration/PersonalData/Processor/DeletePersonalDataProcessor.php @@ -0,0 +1,29 @@ +header(AggregateHeader::class)->aggregateId; + + $this->cipherKeyStore->remove($aggregateId); + } +} diff --git a/tests/Integration/PersonalData/Profile.php b/tests/Integration/PersonalData/Profile.php new file mode 100644 index 000000000..f3d8905b5 --- /dev/null +++ b/tests/Integration/PersonalData/Profile.php @@ -0,0 +1,68 @@ +recordThat(new ProfileCreated($id, $name)); + + return $self; + } + + public function removePersonalData(): void + { + $this->recordThat(new PersonalDataRemoved()); + } + + public function changeName(string $name): void + { + $this->recordThat(new NameChanged($this->id, $name)); + } + + #[Apply(ProfileCreated::class)] + protected function applyProfileCreated(ProfileCreated $event): void + { + $this->id = $event->profileId; + $this->name = $event->name; + } + + #[Apply(PersonalDataRemoved::class)] + protected function applyPersonalDataRemoved(): void + { + $this->name = 'unknown'; + } + + #[Apply(NameChanged::class)] + protected function applyNameChanged(NameChanged $event): void + { + $this->name = $event->name; + } + + public function name(): string + { + return $this->name; + } +} diff --git a/tests/Integration/PersonalData/ProfileId.php b/tests/Integration/PersonalData/ProfileId.php new file mode 100644 index 000000000..6b2f1f6ff --- /dev/null +++ b/tests/Integration/PersonalData/ProfileId.php @@ -0,0 +1,25 @@ +id; + } +}