From 3886a53a4d21f4b7aa38b77aaea7fedba34978f6 Mon Sep 17 00:00:00 2001 From: David Badura Date: Sat, 17 Jan 2026 12:30:12 +0100 Subject: [PATCH] rewrite cryptography --- phpstan.neon.dist | 4 + src/Attribute/SensitiveData.php | 2 +- src/Cryptography/BaseCryptographer.php | 91 +++++ src/Cryptography/Cipher/Cipher.php | 6 +- .../Cipher/CreateCipherKeyFailed.php | 3 +- src/Cryptography/Cipher/DecryptionFailed.php | 3 +- src/Cryptography/Cipher/EncryptionFailed.php | 3 +- .../Cipher/MethodNotSupported.php | 3 +- src/Cryptography/Cipher/OpensslCipher.php | 3 + src/Cryptography/CryptoNormalizer.php | 73 ++++ src/Cryptography/Cryptographer.php | 22 ++ .../CryptographyMetadataFactory.php | 31 +- src/Cryptography/CryptographyMiddleware.php | 79 ++++- src/Cryptography/MissingSubjectId.php | 8 +- src/Cryptography/MissingSubjectIdField.php | 19 ++ src/Cryptography/PayloadCryptographer.php | 24 -- src/Cryptography/SensitiveDataInfo.php | 14 - .../SensitiveDataPayloadCryptographer.php | 221 ------------ src/Cryptography/Store/CipherKeyNotExists.php | 3 +- src/Cryptography/SubjectIds.php | 22 ++ src/Cryptography/UnsupportedSubjectId.php | 3 +- src/Metadata/PropertyMetadata.php | 2 +- ...ceptionImplementsHydratorExceptionTest.php | 25 ++ .../HydratorWithCryptographyBench.php | 9 +- ...rapherTest.php => CryptographerV1Test.php} | 322 ++++++++++++------ .../CryptographyMetadataFactoryTest.php | 130 ++++--- .../CryptographyMiddlewareTest.php | 94 +++-- .../Cryptography/MissingSubjectIdTest.php | 5 +- tests/Unit/MetadataHydratorTest.php | 67 ---- 29 files changed, 752 insertions(+), 539 deletions(-) create mode 100644 src/Cryptography/BaseCryptographer.php create mode 100644 src/Cryptography/CryptoNormalizer.php create mode 100644 src/Cryptography/Cryptographer.php create mode 100644 src/Cryptography/MissingSubjectIdField.php delete mode 100644 src/Cryptography/PayloadCryptographer.php delete mode 100644 src/Cryptography/SensitiveDataInfo.php delete mode 100644 src/Cryptography/SensitiveDataPayloadCryptographer.php create mode 100644 src/Cryptography/SubjectIds.php create mode 100644 tests/Architecture/ExceptionImplementsHydratorExceptionTest.php rename tests/Unit/Cryptography/{SensitiveDataPayloadCryptographerTest.php => CryptographerV1Test.php} (63%) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index b2f33b2..81e9ded 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -15,3 +15,7 @@ services: class: Patchlevel\Hydrator\Tests\Architecture\FinalClassesTest tags: - phpat.test + - + class: Patchlevel\Hydrator\Tests\Architecture\ExceptionImplementsHydratorExceptionTest + tags: + - phpat.test diff --git a/src/Attribute/SensitiveData.php b/src/Attribute/SensitiveData.php index 3c80e00..2d8285a 100644 --- a/src/Attribute/SensitiveData.php +++ b/src/Attribute/SensitiveData.php @@ -10,7 +10,7 @@ #[Attribute(Attribute::TARGET_PROPERTY)] final class SensitiveData { - /** @var (callable(string, mixed):mixed)|null */ + /** @var (callable(string):mixed)|null */ public readonly mixed $fallbackCallable; public function __construct( diff --git a/src/Cryptography/BaseCryptographer.php b/src/Cryptography/BaseCryptographer.php new file mode 100644 index 0000000..698de4f --- /dev/null +++ b/src/Cryptography/BaseCryptographer.php @@ -0,0 +1,91 @@ +cipherKeyStore->get($subjectId); + } catch (CipherKeyNotExists) { + $cipherKey = ($this->cipherKeyFactory)(); + $this->cipherKeyStore->store($subjectId, $cipherKey); + } + + return [ + '__enc' => 'v1', + 'data' => $this->cipher->encrypt($cipherKey, $value), + 'method' => $cipherKey->method, + 'iv' => $cipherKey->iv, + ]; + } + + /** + * @param EncryptedDataV1 $encryptedData + * + * @throws CipherKeyNotExists + * @throws DecryptionFailed + */ + public function decrypt(string $subjectId, mixed $encryptedData): mixed + { + $cipherKey = $this->cipherKeyStore->get($subjectId); + + return $this->cipher->decrypt( + new CipherKey( + $cipherKey->key, + $encryptedData['method'] ?? $cipherKey->method, + $encryptedData['iv'] ?? $cipherKey->iv + ), + $encryptedData['data'] + ); + } + + public function supports(mixed $value): bool + { + return is_array($value) && array_key_exists('__enc', $value) && $value['__enc'] === 'v1'; + } + + /** @param non-empty-string $method */ + public static function createWithOpenssl( + CipherKeyStore $cryptoStore, + string $method = OpensslCipherKeyFactory::DEFAULT_METHOD, + ): static { + return new self( + new OpensslCipher(), + $cryptoStore, + new OpensslCipherKeyFactory($method), + ); + } +} \ No newline at end of file diff --git a/src/Cryptography/Cipher/Cipher.php b/src/Cryptography/Cipher/Cipher.php index 8da0db1..21e976c 100644 --- a/src/Cryptography/Cipher/Cipher.php +++ b/src/Cryptography/Cipher/Cipher.php @@ -6,7 +6,11 @@ interface Cipher { - /** @throws EncryptionFailed */ + /** + * @return non-empty-string + * + * @throws EncryptionFailed + */ public function encrypt(CipherKey $key, mixed $data): string; /** @throws DecryptionFailed */ diff --git a/src/Cryptography/Cipher/CreateCipherKeyFailed.php b/src/Cryptography/Cipher/CreateCipherKeyFailed.php index 65f1fb3..8f1c092 100644 --- a/src/Cryptography/Cipher/CreateCipherKeyFailed.php +++ b/src/Cryptography/Cipher/CreateCipherKeyFailed.php @@ -4,9 +4,10 @@ namespace Patchlevel\Hydrator\Cryptography\Cipher; +use Patchlevel\Hydrator\HydratorException; use RuntimeException; -final class CreateCipherKeyFailed extends RuntimeException +final class CreateCipherKeyFailed extends RuntimeException implements HydratorException { public function __construct() { diff --git a/src/Cryptography/Cipher/DecryptionFailed.php b/src/Cryptography/Cipher/DecryptionFailed.php index 8c29a1d..d95a674 100644 --- a/src/Cryptography/Cipher/DecryptionFailed.php +++ b/src/Cryptography/Cipher/DecryptionFailed.php @@ -4,9 +4,10 @@ namespace Patchlevel\Hydrator\Cryptography\Cipher; +use Patchlevel\Hydrator\HydratorException; use RuntimeException; -final class DecryptionFailed extends RuntimeException +final class DecryptionFailed extends RuntimeException implements HydratorException { public function __construct() { diff --git a/src/Cryptography/Cipher/EncryptionFailed.php b/src/Cryptography/Cipher/EncryptionFailed.php index dfb7af9..6406572 100644 --- a/src/Cryptography/Cipher/EncryptionFailed.php +++ b/src/Cryptography/Cipher/EncryptionFailed.php @@ -4,9 +4,10 @@ namespace Patchlevel\Hydrator\Cryptography\Cipher; +use Patchlevel\Hydrator\HydratorException; use RuntimeException; -final class EncryptionFailed extends RuntimeException +final class EncryptionFailed extends RuntimeException implements HydratorException { public function __construct() { diff --git a/src/Cryptography/Cipher/MethodNotSupported.php b/src/Cryptography/Cipher/MethodNotSupported.php index 9596b11..57219d9 100644 --- a/src/Cryptography/Cipher/MethodNotSupported.php +++ b/src/Cryptography/Cipher/MethodNotSupported.php @@ -4,11 +4,12 @@ namespace Patchlevel\Hydrator\Cryptography\Cipher; +use Patchlevel\Hydrator\HydratorException; use RuntimeException; use function sprintf; -final class MethodNotSupported extends RuntimeException +final class MethodNotSupported extends RuntimeException implements HydratorException { public function __construct(string $method) { diff --git a/src/Cryptography/Cipher/OpensslCipher.php b/src/Cryptography/Cipher/OpensslCipher.php index c2947cb..b17811c 100644 --- a/src/Cryptography/Cipher/OpensslCipher.php +++ b/src/Cryptography/Cipher/OpensslCipher.php @@ -17,6 +17,9 @@ final class OpensslCipher implements Cipher { + /** + * @return non-empty-string + */ public function encrypt(CipherKey $key, mixed $data): string { $encryptedData = @openssl_encrypt( diff --git a/src/Cryptography/CryptoNormalizer.php b/src/Cryptography/CryptoNormalizer.php new file mode 100644 index 0000000..a32abbe --- /dev/null +++ b/src/Cryptography/CryptoNormalizer.php @@ -0,0 +1,73 @@ + $context + * + * @throws InvalidArgument + */ + public function normalize(mixed $value, array $context = []): mixed + { + if ($this->normalizer !== null) { + $value = $this->normalizer->normalize($value, $context); + } + + if ($value === null) { + return null; + } + + return $this->cryptographer->encrypt($context[SubjectIds::class]->get($this->subjectIdName), $value); + } + + /** + * @param array $context + * + * @throws InvalidArgument + */ + public function denormalize(mixed $value, array $context = []): mixed + { + if (!$this->cryptographer->supports($value)) { + if ($this->normalizer === null) { + return $value; + } + + return $this->normalizer->denormalize($value, $context); + } + + $subjectId = $context[SubjectIds::class]->get($this->subjectIdName); + + try { + $data = $this->cryptographer->decrypt($subjectId, $value); + } catch (DecryptionFailed|CipherKeyNotExists) { + if ($this->fallback instanceof Closure) { + return ($this->fallback)($subjectId); + } + + return $this->fallback; + } + + if ($this->normalizer === null) { + return $data; + } + + return $this->normalizer->denormalize($data, $context); + } +} \ No newline at end of file diff --git a/src/Cryptography/Cryptographer.php b/src/Cryptography/Cryptographer.php new file mode 100644 index 0000000..becdce0 --- /dev/null +++ b/src/Cryptography/Cryptographer.php @@ -0,0 +1,22 @@ +sensitiveDataInfo($property->reflection); + $attributeReflectionList = $property->reflection->getAttributes(SensitiveData::class); - if (!$sensitiveDataInfo) { + if ($attributeReflectionList === []) { continue; } @@ -56,7 +56,14 @@ public function metadata(string $class): ClassMetadata throw new SubjectIdAndSensitiveDataConflict($metadata->className, $property->propertyName); } - $property->extras[SensitiveDataInfo::class] = $sensitiveDataInfo; + $attribute = $attributeReflectionList[0]->newInstance(); + + $property->normalizer = new CryptoNormalizer( + $this->cryptographer, + $attribute->subjectIdName, + $attribute->fallbackCallable !== null ? ($attribute->fallbackCallable)(...) : $attribute->fallback, + $property->normalizer, + ); } if ($subjectIdMapping !== []) { @@ -65,20 +72,4 @@ public function metadata(string $class): ClassMetadata return $metadata; } - - private function sensitiveDataInfo(ReflectionProperty $reflectionProperty): SensitiveDataInfo|null - { - $attributeReflectionList = $reflectionProperty->getAttributes(SensitiveData::class); - - if ($attributeReflectionList === []) { - return null; - } - - $attribute = $attributeReflectionList[0]->newInstance(); - - return new SensitiveDataInfo( - $attribute->subjectIdName, - $attribute->fallbackCallable !== null ? ($attribute->fallbackCallable)(...) : $attribute->fallback, - ); - } } diff --git a/src/Cryptography/CryptographyMiddleware.php b/src/Cryptography/CryptographyMiddleware.php index cd78542..d384ff8 100644 --- a/src/Cryptography/CryptographyMiddleware.php +++ b/src/Cryptography/CryptographyMiddleware.php @@ -7,14 +7,16 @@ use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\Middleware\Middleware; use Patchlevel\Hydrator\Middleware\Stack; +use Stringable; + +use function array_key_exists; +use function assert; +use function is_array; +use function is_int; +use function is_string; final class CryptographyMiddleware implements Middleware { - public function __construct( - private readonly PayloadCryptographer $cryptography, - ) { - } - /** * @param ClassMetadata $metadata * @param array $data @@ -26,9 +28,11 @@ public function __construct( */ public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object { + $context[SubjectIds::class] = $this->resolveSubjectIds($metadata, $data, $context); + return $stack->next()->hydrate( $metadata, - $this->cryptography->decrypt($metadata, $data), + $data, $context, $stack, ); @@ -45,9 +49,64 @@ public function hydrate(ClassMetadata $metadata, array $data, array $context, St */ public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array { - return $this->cryptography->encrypt( - $metadata, - $stack->next()->extract($metadata, $object, $context, $stack), - ); + $context[SubjectIds::class] = $this->resolveSubjectIds($metadata, $object, $context); + + return $stack->next()->extract($metadata, $object, $context, $stack); + } + + /** + * @param array|object $data + * @param array $context + */ + private function resolveSubjectIds( + ClassMetadata $metadata, + array|object $data, + array $context, + ): SubjectIds { + $subjectIds = $context[SubjectIds::class] ?? new SubjectIds(); + + assert($subjectIds instanceof SubjectIds); + + $mapping = $metadata->extras[SubjectIdFieldMapping::class] ?? null; + + if (!$mapping instanceof SubjectIdFieldMapping) { + return $subjectIds; + } + + $result = []; + + foreach ($mapping->nameToField as $name => $fieldName) { + if (is_array($data)) { + if (!array_key_exists($fieldName, $data)) { + throw new MissingSubjectIdField($metadata->className, $fieldName); + } + + $subjectId = $data[$fieldName]; + } else { + $property = $metadata->propertyForField($fieldName); + + if ($property->normalizer) { + $subjectId = $property->normalizer->normalize($property->getValue($data)); + } else { + $subjectId = $property->getValue($data); + } + } + + if (is_int($subjectId)) { + $subjectId = (string)$subjectId; + } + + if ($subjectId instanceof Stringable) { + $subjectId = $subjectId->__toString(); + } + + if (!is_string($subjectId)) { + throw new UnsupportedSubjectId($metadata->className, $fieldName, $subjectId); + } + + $result[$name] = $subjectId; + } + + return $subjectIds->merge(new SubjectIds($result)); } } diff --git a/src/Cryptography/MissingSubjectId.php b/src/Cryptography/MissingSubjectId.php index b2b670c..56035a3 100644 --- a/src/Cryptography/MissingSubjectId.php +++ b/src/Cryptography/MissingSubjectId.php @@ -4,15 +4,15 @@ namespace Patchlevel\Hydrator\Cryptography; +use Patchlevel\Hydrator\HydratorException; use RuntimeException; use function sprintf; -final class MissingSubjectId extends RuntimeException +final class MissingSubjectId extends RuntimeException implements HydratorException { - /** @param class-string $class */ - public function __construct(string $class, string $fieldName) + public function __construct(string $name) { - parent::__construct(sprintf('Missing subject id for %s in field %s.', $class, $fieldName)); + parent::__construct(sprintf('Missing subject id %s.', $name)); } } diff --git a/src/Cryptography/MissingSubjectIdField.php b/src/Cryptography/MissingSubjectIdField.php new file mode 100644 index 0000000..05ad5cc --- /dev/null +++ b/src/Cryptography/MissingSubjectIdField.php @@ -0,0 +1,19 @@ + $data - * - * @return array - */ - public function encrypt(ClassMetadata $metadata, array $data): array; - - /** - * @param array $data - * - * @return array - */ - public function decrypt(ClassMetadata $metadata, array $data): array; -} diff --git a/src/Cryptography/SensitiveDataInfo.php b/src/Cryptography/SensitiveDataInfo.php deleted file mode 100644 index baddde4..0000000 --- a/src/Cryptography/SensitiveDataInfo.php +++ /dev/null @@ -1,14 +0,0 @@ - $data - * - * @return array - */ - public function encrypt(ClassMetadata $metadata, array $data): array - { - $mapping = $metadata->extras[SubjectIdFieldMapping::class] ?? null; - - if (!$mapping instanceof SubjectIdFieldMapping) { - return $data; - } - - $subjectIds = $this->getSubjectIds($metadata, $mapping, $data); - - foreach ($metadata->properties as $propertyMetadata) { - $sensitiveDataInfo = $propertyMetadata->extras[SensitiveDataInfo::class] ?? null; - - if (!$sensitiveDataInfo instanceof SensitiveDataInfo) { - continue; - } - - $subjectId = $subjectIds[$sensitiveDataInfo->subjectIdName] ?? null; - - if ($subjectId === null) { - throw new MissingSubjectId($metadata->className, $sensitiveDataInfo->subjectIdName); - } - - try { - $cipherKey = $this->cipherKeyStore->get($subjectId); - } catch (CipherKeyNotExists) { - $cipherKey = ($this->cipherKeyFactory)(); - $this->cipherKeyStore->store($subjectId, $cipherKey); - } - - $targetFieldName = $this->useEncryptedFieldName - ? self::ENCRYPTED_PREFIX . $propertyMetadata->fieldName - : $propertyMetadata->fieldName; - - $data[$targetFieldName] = $this->cipher->encrypt( - $cipherKey, - $data[$propertyMetadata->fieldName], - ); - - if (!$this->useEncryptedFieldName) { - continue; - } - - unset($data[$propertyMetadata->fieldName]); - } - - return $data; - } - - /** - * @param array $data - * - * @return array - */ - public function decrypt(ClassMetadata $metadata, array $data): array - { - $mapping = $metadata->extras[SubjectIdFieldMapping::class] ?? null; - - if (!$mapping instanceof SubjectIdFieldMapping) { - return $data; - } - - $subjectIds = $this->getSubjectIds($metadata, $mapping, $data); - - foreach ($metadata->properties as $propertyMetadata) { - $sensitiveDataInfo = $propertyMetadata->extras[SensitiveDataInfo::class] ?? null; - - if (!$sensitiveDataInfo instanceof SensitiveDataInfo) { - continue; - } - - $subjectId = $subjectIds[$sensitiveDataInfo->subjectIdName] ?? null; - - if ($subjectId === null) { - throw new MissingSubjectId($metadata->className, $sensitiveDataInfo->subjectIdName); - } - - try { - $cipherKey = $this->cipherKeyStore->get($subjectId); - } catch (CipherKeyNotExists) { - $cipherKey = null; - } - - if ($this->useEncryptedFieldName && array_key_exists(self::ENCRYPTED_PREFIX . $propertyMetadata->fieldName, $data)) { - $rawData = $data[self::ENCRYPTED_PREFIX . $propertyMetadata->fieldName]; - unset($data[self::ENCRYPTED_PREFIX . $propertyMetadata->fieldName]); - } elseif (!$this->useEncryptedFieldName || $this->fallbackToFieldName) { - $rawData = $data[$propertyMetadata->fieldName]; - } else { - continue; - } - - if (!$cipherKey) { - $data[$propertyMetadata->fieldName] = $this->fallback($sensitiveDataInfo, $subjectId, $rawData); - continue; - } - - try { - $data[$propertyMetadata->fieldName] = $this->cipher->decrypt( - $cipherKey, - $rawData, - ); - } catch (DecryptionFailed) { - $data[$propertyMetadata->fieldName] = $this->fallback($sensitiveDataInfo, $subjectId, $rawData); - } - } - - return $data; - } - - /** - * @param array $data - * - * @return array - */ - private function getSubjectIds(ClassMetadata $metadata, SubjectIdFieldMapping $mapping, array $data): array - { - $result = []; - - foreach ($mapping->nameToField as $name => $fieldName) { - if (!array_key_exists($fieldName, $data)) { - throw new MissingSubjectId($metadata->className, $fieldName); - } - - $subjectId = $data[$fieldName]; - - if (is_int($subjectId)) { - $subjectId = (string)$subjectId; - } - - if ($subjectId instanceof Stringable) { - $subjectId = $subjectId->__toString(); - } - - if (!is_string($subjectId)) { - throw new UnsupportedSubjectId($metadata->className, $fieldName, $subjectId); - } - - $result[$name] = $subjectId; - } - - return $result; - } - - private function fallback(SensitiveDataInfo $sensitiveDataInfo, string $subjectId, mixed $rawData): mixed - { - if ($sensitiveDataInfo->fallback instanceof Closure) { - return ($sensitiveDataInfo->fallback)($subjectId, $rawData); - } - - return $sensitiveDataInfo->fallback; - } - - /** @param non-empty-string $method */ - public static function createWithOpenssl( - CipherKeyStore $cryptoStore, - string $method = OpensslCipherKeyFactory::DEFAULT_METHOD, - bool $useEncryptedFieldName = false, - bool $fallbackToFieldName = false, - ): static { - return new self( - $cryptoStore, - new OpensslCipherKeyFactory($method), - new OpensslCipher(), - $useEncryptedFieldName, - $fallbackToFieldName, - ); - } - - /** @param non-empty-string $method */ - public static function createWithDefaultSettings( - CipherKeyStore $cryptoStore, - string $method = OpensslCipherKeyFactory::DEFAULT_METHOD, - ): static { - return new self( - $cryptoStore, - new OpensslCipherKeyFactory($method), - new OpensslCipher(), - true, - ); - } -} diff --git a/src/Cryptography/Store/CipherKeyNotExists.php b/src/Cryptography/Store/CipherKeyNotExists.php index 700185c..def236d 100644 --- a/src/Cryptography/Store/CipherKeyNotExists.php +++ b/src/Cryptography/Store/CipherKeyNotExists.php @@ -4,11 +4,12 @@ namespace Patchlevel\Hydrator\Cryptography\Store; +use Patchlevel\Hydrator\HydratorException; use RuntimeException; use function sprintf; -final class CipherKeyNotExists extends RuntimeException +final class CipherKeyNotExists extends RuntimeException implements HydratorException { public function __construct(string $id) { diff --git a/src/Cryptography/SubjectIds.php b/src/Cryptography/SubjectIds.php new file mode 100644 index 0000000..6eb2e3a --- /dev/null +++ b/src/Cryptography/SubjectIds.php @@ -0,0 +1,22 @@ + $subjectIds */ + public function __construct( + public readonly array $subjectIds = [], + ) { + } + + public function merge(self $other): self + { + return new self(array_merge($this->subjectIds, $other->subjectIds)); + } + + public function get(string $name): string + { + return $this->subjectIds[$name] ?? throw new MissingSubjectId($name); + } +} \ No newline at end of file diff --git a/src/Cryptography/UnsupportedSubjectId.php b/src/Cryptography/UnsupportedSubjectId.php index 95394ee..d1f517c 100644 --- a/src/Cryptography/UnsupportedSubjectId.php +++ b/src/Cryptography/UnsupportedSubjectId.php @@ -4,12 +4,13 @@ namespace Patchlevel\Hydrator\Cryptography; +use Patchlevel\Hydrator\HydratorException; use RuntimeException; use function get_debug_type; use function sprintf; -final class UnsupportedSubjectId extends RuntimeException +final class UnsupportedSubjectId extends RuntimeException implements HydratorException { public function __construct(string $class, string $fieldName, mixed $subjectId) { diff --git a/src/Metadata/PropertyMetadata.php b/src/Metadata/PropertyMetadata.php index 2bf6be1..3762323 100644 --- a/src/Metadata/PropertyMetadata.php +++ b/src/Metadata/PropertyMetadata.php @@ -24,7 +24,7 @@ final class PropertyMetadata public function __construct( public readonly ReflectionProperty $reflection, public readonly string $fieldName, - public readonly Normalizer|null $normalizer = null, + public Normalizer|null $normalizer = null, public array $extras = [], ) { $this->propertyName = $reflection->getName(); diff --git a/tests/Architecture/ExceptionImplementsHydratorExceptionTest.php b/tests/Architecture/ExceptionImplementsHydratorExceptionTest.php new file mode 100644 index 0000000..30175f1 --- /dev/null +++ b/tests/Architecture/ExceptionImplementsHydratorExceptionTest.php @@ -0,0 +1,25 @@ +classes( + Selector::AllOf( + Selector::inNamespace('Patchlevel\Hydrator'), + Selector::isException(), + ), + ) + ->shouldImplement()->classes(Selector::classname(HydratorException::class)); + } +} diff --git a/tests/Benchmark/HydratorWithCryptographyBench.php b/tests/Benchmark/HydratorWithCryptographyBench.php index 4e4af49..e686ae9 100644 --- a/tests/Benchmark/HydratorWithCryptographyBench.php +++ b/tests/Benchmark/HydratorWithCryptographyBench.php @@ -4,9 +4,9 @@ namespace Patchlevel\Hydrator\Tests\Benchmark; +use Patchlevel\Hydrator\Cryptography\Cryptographer; use Patchlevel\Hydrator\Cryptography\CryptographyMetadataFactory; use Patchlevel\Hydrator\Cryptography\CryptographyMiddleware; -use Patchlevel\Hydrator\Cryptography\SensitiveDataPayloadCryptographer; use Patchlevel\Hydrator\Cryptography\Store\InMemoryCipherKeyStore; use Patchlevel\Hydrator\Hydrator; use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; @@ -29,9 +29,12 @@ public function __construct() $this->store = new InMemoryCipherKeyStore(); $this->hydrator = new MetadataHydrator( - new CryptographyMetadataFactory(new AttributeMetadataFactory()), + new CryptographyMetadataFactory( + Cryptographer::createWithOpenssl($this->store), + new AttributeMetadataFactory(), + ), [ - new CryptographyMiddleware(SensitiveDataPayloadCryptographer::createWithDefaultSettings($this->store)), + new CryptographyMiddleware(), new TransformMiddleware(), ], ); diff --git a/tests/Unit/Cryptography/SensitiveDataPayloadCryptographerTest.php b/tests/Unit/Cryptography/CryptographerV1Test.php similarity index 63% rename from tests/Unit/Cryptography/SensitiveDataPayloadCryptographerTest.php rename to tests/Unit/Cryptography/CryptographerV1Test.php index b34c6f0..d596dc3 100644 --- a/tests/Unit/Cryptography/SensitiveDataPayloadCryptographerTest.php +++ b/tests/Unit/Cryptography/CryptographerV1Test.php @@ -1,23 +1,27 @@ createMock(CipherKeyFactory::class); $cipher = $this->createMock(Cipher::class); - $cryptographer = new SensitiveDataPayloadCryptographer( + $middleware = new CryptographyMiddleware( + $cipher, $cipherKeyStore, $cipherKeyFactory, - $cipher, ); - $payload = ['id' => 'foo', 'email' => 'info@patchlevel.de']; + $object = new ProfileCreated( + ProfileId::fromString('foo'), + Email::fromString('info@patchlevel.de'), + ); + + $expected = ['profileId' => 'foo', 'email' => 'info@patchlevel.de']; + + $metadata = $this->metadata(ProfileCreated::class); + + $otherMiddleware = $this->createMock(Middleware::class); + $stack = new Stack([$otherMiddleware]); - $result = $cryptographer->encrypt($this->metadata(SensitiveData::class), ['id' => 'foo', 'email' => 'info@patchlevel.de']); + $otherMiddleware + ->expects($this->once()) + ->method('extract') + ->with($metadata, $object, [SubjectIds::class => new SubjectIds()], $stack) + ->willReturn($expected); + + $result = $middleware->extract( + $metadata, + $object, + [], + $stack, + ); - self::assertSame($payload, $result); + self::assertSame($expected, $result); } public function testEncryptWithMissingKey(): void @@ -68,15 +93,36 @@ public function testEncryptWithMissingKey(): void $cipher->expects($this->once())->method('encrypt')->with($cipherKey, 'info@patchlevel.de') ->willReturn('encrypted'); - $cryptographer = new SensitiveDataPayloadCryptographer( + $object = new SensitiveDataProfileCreated( + ProfileId::fromString('foo'), + Email::fromString('info@patchlevel.de'), + ); + + $metadata = $this->metadata(SensitiveDataProfileCreated::class); + + $otherMiddleware = $this->createMock(Middleware::class); + $stack = new Stack([$otherMiddleware]); + + $otherMiddleware + ->expects($this->once()) + ->method('extract') + ->with($metadata, $object, [SubjectIds::class => new SubjectIds(['default' => 'foo'])], $stack) + ->willReturn(['id' => 'foo', 'email' => 'info@patchlevel.de']); + + $middleware = new CryptographyMiddleware( + $cipher, $cipherKeyStore, $cipherKeyFactory, - $cipher, ); - $result = $cryptographer->encrypt($this->metadata(SensitiveDataProfileCreated::class), ['id' => 'foo', 'email' => 'info@patchlevel.de']); + $result = $middleware->extract( + $metadata, + $object, + [], + $stack, + ); - self::assertEquals(['id' => 'foo', 'email' => 'encrypted'], $result); + self::assertEquals(['id' => 'foo', '!email' => 'encrypted'], $result); } public function testEncryptWithExistingKey(): void @@ -101,18 +147,39 @@ public function testEncryptWithExistingKey(): void $cipher->expects($this->once())->method('encrypt')->with($cipherKey, 'info@patchlevel.de') ->willReturn('encrypted'); - $cryptographer = new SensitiveDataPayloadCryptographer( + $object = new SensitiveDataProfileCreated( + ProfileId::fromString('foo'), + Email::fromString('info@patchlevel.de'), + ); + + $metadata = $this->metadata(SensitiveDataProfileCreated::class); + + $otherMiddleware = $this->createMock(Middleware::class); + $stack = new Stack([$otherMiddleware]); + + $otherMiddleware + ->expects($this->once()) + ->method('extract') + ->with($metadata, $object, [SubjectIds::class => new SubjectIds(['default' => 'foo'])], $stack) + ->willReturn(['id' => 'foo', 'email' => 'info@patchlevel.de']); + + $middleware = new CryptographyMiddleware( + $cipher, $cipherKeyStore, $cipherKeyFactory, - $cipher, ); - $result = $cryptographer->encrypt($this->metadata(SensitiveDataProfileCreated::class), ['id' => 'foo', 'email' => 'info@patchlevel.de']); + $result = $middleware->extract( + $metadata, + $object, + [], + $stack, + ); - self::assertEquals(['id' => 'foo', 'email' => 'encrypted'], $result); + self::assertEquals(['id' => 'foo', '!email' => 'encrypted'], $result); } - public function testEncryptWithExistingKeyEncryptedFieldName(): void + public function testEncryptWithoutEncryptedFieldPrefix(): void { $cipherKey = new CipherKey( 'foo', @@ -134,16 +201,37 @@ public function testEncryptWithExistingKeyEncryptedFieldName(): void $cipher->expects($this->once())->method('encrypt')->with($cipherKey, 'info@patchlevel.de') ->willReturn('encrypted'); - $cryptographer = new SensitiveDataPayloadCryptographer( + $object = new SensitiveDataProfileCreated( + ProfileId::fromString('foo'), + Email::fromString('info@patchlevel.de'), + ); + + $metadata = $this->metadata(SensitiveDataProfileCreated::class); + + $otherMiddleware = $this->createMock(Middleware::class); + $stack = new Stack([$otherMiddleware]); + + $otherMiddleware + ->expects($this->once()) + ->method('extract') + ->with($metadata, $object, [SubjectIds::class => new SubjectIds(['default' => 'foo'])], $stack) + ->willReturn(['id' => 'foo', 'email' => 'info@patchlevel.de']); + + $middleware = new CryptographyMiddleware( + $cipher, $cipherKeyStore, $cipherKeyFactory, - $cipher, - true, + null, ); - $result = $cryptographer->encrypt($this->metadata(SensitiveDataProfileCreated::class), ['id' => 'foo', 'email' => 'info@patchlevel.de']); + $result = $middleware->extract( + $metadata, + $object, + [], + $stack, + ); - self::assertEquals(['id' => 'foo', '!email' => 'encrypted'], $result); + self::assertEquals(['id' => 'foo', 'email' => 'encrypted'], $result); } public function testSkipDecrypt(): void @@ -154,17 +242,38 @@ public function testSkipDecrypt(): void $cipherKeyFactory = $this->createMock(CipherKeyFactory::class); $cipher = $this->createMock(Cipher::class); - $cryptographer = new SensitiveDataPayloadCryptographer( + $data = ['profileId' => 'foo', 'email' => 'info@patchlevel.de']; + + $expected = new ProfileCreated( + ProfileId::fromString('foo'), + Email::fromString('info@patchlevel.de'), + ); + + $metadata = $this->metadata(ProfileCreated::class); + + $otherMiddleware = $this->createMock(Middleware::class); + $stack = new Stack([$otherMiddleware]); + + $otherMiddleware + ->expects($this->once()) + ->method('hydrate') + ->with($metadata, $data, [SubjectIds::class => new SubjectIds()], $stack) + ->willReturn($expected); + + $middleware = new CryptographyMiddleware( + $cipher, $cipherKeyStore, $cipherKeyFactory, - $cipher, ); - $payload = ['id' => 'foo', 'email' => 'info@patchlevel.de']; - - $result = $cryptographer->decrypt($this->metadata(SensitiveData::class), ['id' => 'foo', 'email' => 'info@patchlevel.de']); + $result = $middleware->hydrate( + $metadata, + $data, + [], + $stack, + ); - self::assertSame($payload, $result); + self::assertSame($expected, $result); } public function testDecryptWithMissingKey(): void @@ -178,13 +287,18 @@ public function testDecryptWithMissingKey(): void $cipher = $this->createMock(Cipher::class); $cipher->expects($this->never())->method('decrypt'); - $cryptographer = new SensitiveDataPayloadCryptographer( + $middleware = new CryptographyMiddleware( + $cipher, $cipherKeyStore, $cipherKeyFactory, - $cipher, ); - $result = $cryptographer->decrypt($this->metadata(SensitiveDataProfileCreated::class), ['id' => 'foo', 'email' => 'encrypted']); + $result = $middleware->hydrate( + $this->metadata(SensitiveDataProfileCreated::class), + ['id' => 'foo', 'email' => 'encrypted'], + [], + new Stack([new TransformMiddleware()]), + ); self::assertEquals(['id' => 'foo', 'email' => new Email('unknown')], $result); } @@ -211,13 +325,18 @@ public function testDecryptWithInvalidKey(): void $cipher->expects($this->once())->method('decrypt')->with($cipherKey, 'encrypted') ->willThrowException(new DecryptionFailed()); - $cryptographer = new SensitiveDataPayloadCryptographer( + $middleware = new CryptographyMiddleware( + $cipher, $cipherKeyStore, $cipherKeyFactory, - $cipher, ); - $result = $cryptographer->decrypt($this->metadata(SensitiveDataProfileCreated::class), ['id' => 'foo', 'email' => 'encrypted']); + $result = $middleware->hydrate( + $this->metadata(SensitiveDataProfileCreated::class), + ['id' => 'foo', 'email' => 'encrypted'], + [], + new Stack([new TransformMiddleware()]), + ); self::assertEquals(['id' => 'foo', 'email' => new Email('unknown')], $result); } @@ -241,13 +360,18 @@ public function testDecryptWithInvalidKeyWithFallbackCallback(): void $cipher->expects($this->once())->method('decrypt')->with($cipherKey, 'encrypted') ->willThrowException(new DecryptionFailed()); - $cryptographer = new SensitiveDataPayloadCryptographer( + $middleware = new CryptographyMiddleware( + $cipher, $cipherKeyStore, $cipherKeyFactory, - $cipher, ); - $result = $cryptographer->decrypt($this->metadata(SensitiveDataProfileCreatedFallbackCallback::class), ['id' => 'foo', 'email' => 'encrypted']); + $result = $middleware->hydrate( + $this->metadata(SensitiveDataProfileCreatedFallbackCallback::class), + ['id' => 'foo', 'email' => 'encrypted'], + [], + new Stack([new TransformMiddleware()]), + ); self::assertEquals(['id' => 'foo', 'email' => new Email('foo@example.com')], $result); } @@ -271,14 +395,19 @@ public function testDecryptWithValidKey(): void $cipher->expects($this->once())->method('decrypt')->with($cipherKey, 'encrypted') ->willReturn('info@patchlevel.de'); - $cryptographer = new SensitiveDataPayloadCryptographer( + $middleware = new CryptographyMiddleware( + $cipher, $cipherKeyStore, $cipherKeyFactory, - $cipher, - false, + null, ); - $result = $cryptographer->decrypt($this->metadata(SensitiveDataProfileCreated::class), ['id' => 'foo', 'email' => 'encrypted']); + $result = $middleware->hydrate( + $this->metadata(SensitiveDataProfileCreated::class), + ['id' => 'foo', 'email' => 'encrypted'], + [], + new Stack([new TransformMiddleware()]), + ); self::assertEquals(['id' => 'foo', 'email' => 'info@patchlevel.de'], $result); } @@ -305,14 +434,18 @@ public function testDecryptWithValidKeyAndEncryptedFieldName(): void ->with($cipherKey, 'encrypted') ->willReturn('info@patchlevel.de'); - $cryptographer = new SensitiveDataPayloadCryptographer( + $middleware = new CryptographyMiddleware( + $cipher, $cipherKeyStore, $cipherKeyFactory, - $cipher, - true, ); - $result = $cryptographer->decrypt($this->metadata(SensitiveDataProfileCreated::class), ['id' => 'foo', '!email' => 'encrypted']); + $result = $middleware->hydrate( + $this->metadata(SensitiveDataProfileCreated::class), + ['id' => 'foo', '!email' => 'encrypted'], + [], + new Stack([new TransformMiddleware()]), + ); self::assertEquals(['id' => 'foo', 'email' => 'info@patchlevel.de'], $result); } @@ -334,46 +467,18 @@ public function testDecryptWithValidKeyAndEncryptedFieldNameWithoutEncryptedData $cipher = $this->createMock(Cipher::class); - $cryptographer = new SensitiveDataPayloadCryptographer( - $cipherKeyStore, - $cipherKeyFactory, + $middleware = new CryptographyMiddleware( $cipher, - true, - ); - - $result = $cryptographer->decrypt($this->metadata(SensitiveDataProfileCreated::class), ['id' => 'foo', 'email' => 'info@patchlevel.de']); - - self::assertEquals(['id' => 'foo', 'email' => 'info@patchlevel.de'], $result); - } - - public function testDecryptWithValidKeyAndEncryptedFieldNameAndFallbackFieldName(): void - { - $cipherKey = new CipherKey( - 'foo', - 'bar', - 'baz', - ); - - $cipherKeyStore = $this->createMock(CipherKeyStore::class); - $cipherKeyStore->method('get')->with('foo')->willReturn($cipherKey); - $cipherKeyStore->expects($this->never())->method('store')->with('foo', $this->isInstanceOf(CipherKey::class)); - - $cipherKeyFactory = $this->createMock(CipherKeyFactory::class); - $cipherKeyFactory->expects($this->never())->method('__invoke'); - - $cipher = $this->createMock(Cipher::class); - $cipher->expects($this->once())->method('decrypt')->with($cipherKey, 'encrypted') - ->willReturn('info@patchlevel.de'); - - $cryptographer = new SensitiveDataPayloadCryptographer( $cipherKeyStore, $cipherKeyFactory, - $cipher, - true, - true, ); - $result = $cryptographer->decrypt($this->metadata(SensitiveDataProfileCreated::class), ['id' => 'foo', 'email' => 'encrypted']); + $result = $middleware->hydrate( + $this->metadata(SensitiveDataProfileCreated::class), + ['id' => 'foo', 'email' => 'info@patchlevel.de'], + [], + new Stack([new TransformMiddleware()]), + ); self::assertEquals(['id' => 'foo', 'email' => 'info@patchlevel.de'], $result); } @@ -386,13 +491,18 @@ public function testUnsupportedSubjectId(): void $cipherKeyFactory = $this->createMock(CipherKeyFactory::class); $cipher = $this->createMock(Cipher::class); - $cryptographer = new SensitiveDataPayloadCryptographer( + $middleware = new CryptographyMiddleware( + $cipher, $cipherKeyStore, $cipherKeyFactory, - $cipher, ); - $cryptographer->decrypt($this->metadata(SensitiveDataProfileCreated::class), ['id' => null, 'email' => 'encrypted']); + $middleware->hydrate( + $this->metadata(SensitiveDataProfileCreated::class), + ['id' => null, 'email' => 'encrypted'], + [], + new Stack([new TransformMiddleware()]), + ); } public function testMissingSubjectId(): void @@ -403,13 +513,18 @@ public function testMissingSubjectId(): void $cipherKeyFactory = $this->createMock(CipherKeyFactory::class); $cipher = $this->createMock(Cipher::class); - $cryptographer = new SensitiveDataPayloadCryptographer( + $middleware = new CryptographyMiddleware( + $cipher, $cipherKeyStore, $cipherKeyFactory, - $cipher, ); - $cryptographer->decrypt($this->metadata(SensitiveDataProfileCreated::class), ['email' => 'encrypted']); + $middleware->hydrate( + $this->metadata(SensitiveDataProfileCreated::class), + ['email' => 'encrypted'], + [], + new Stack([new TransformMiddleware()]), + ); } public function testStringableSubjectId(): void @@ -431,17 +546,19 @@ public function testStringableSubjectId(): void $cipher->expects($this->once())->method('encrypt')->with($cipherKey, 'John Doe') ->willReturn('encrypted'); - $cryptographer = new SensitiveDataPayloadCryptographer( + $middleware = new CryptographyMiddleware( + $cipher, $cipherKeyStore, $cipherKeyFactory, - $cipher, ); $subjectId = new StringableSubjectId('user-123'); - $result = $cryptographer->encrypt( + $result = $middleware->extract( $this->metadata(SensitiveDataWithStringableSubjectId::class), ['subjectId' => $subjectId, 'name' => 'John Doe'], + [], + new Stack([new TransformMiddleware()]), ); self::assertEquals(['subjectId' => $subjectId, 'name' => 'encrypted'], $result); @@ -458,15 +575,26 @@ public function testCreateWithOpenssl(): void ->method('store') ->with('foo', $this->isInstanceOf(CipherKey::class)); - $cryptographer = SensitiveDataPayloadCryptographer::createWithOpenssl($cipherKeyStore); + $middleware = CryptographyMiddleware::createWithOpenssl($cipherKeyStore); $data = ['id' => 'foo', 'email' => 'info@patchlevel.de']; - $enrcyptedData = $cryptographer->encrypt($this->metadata(SensitiveDataProfileCreated::class), $data); + + $enrcyptedData = $middleware->extract( + $this->metadata(SensitiveDataProfileCreated::class), + $data, + [], + new Stack([new TransformMiddleware()]), + ); self::assertNotSame('info@patchlevel.de', $enrcyptedData['email']); self::assertSame('aUYxMzQ2bm80cUNCcE1wOUsveitUSmdGaHpYYjNoQWp1VGxTQXVITXRDVT0=', $enrcyptedData['email']); - $decryptedData = $cryptographer->decrypt($this->metadata(SensitiveDataProfileCreated::class), $enrcyptedData); + $decryptedData = $middleware->hydrate( + $this->metadata(SensitiveDataProfileCreated::class), + $enrcyptedData, + [], + new Stack([new TransformMiddleware()]) + ); self::assertSame($data, $decryptedData); } @@ -480,4 +608,4 @@ private function metadata(string $class): ClassMetadata return $factory->metadata($class); } -} +} \ No newline at end of file diff --git a/tests/Unit/Cryptography/CryptographyMetadataFactoryTest.php b/tests/Unit/Cryptography/CryptographyMetadataFactoryTest.php index 7a07c30..b7dead2 100644 --- a/tests/Unit/Cryptography/CryptographyMetadataFactoryTest.php +++ b/tests/Unit/Cryptography/CryptographyMetadataFactoryTest.php @@ -7,12 +7,14 @@ use Patchlevel\Hydrator\Attribute\DataSubjectId; use Patchlevel\Hydrator\Attribute\NormalizedName; use Patchlevel\Hydrator\Attribute\SensitiveData; +use Patchlevel\Hydrator\Cryptography\Cryptographer; use Patchlevel\Hydrator\Cryptography\CryptographyMetadataFactory; +use Patchlevel\Hydrator\Cryptography\CryptoNormalizer; use Patchlevel\Hydrator\Cryptography\DuplicateSubjectIdIdentifier; -use Patchlevel\Hydrator\Cryptography\SensitiveDataInfo; use Patchlevel\Hydrator\Cryptography\SubjectIdAndSensitiveDataConflict; use Patchlevel\Hydrator\Cryptography\SubjectIdFieldMapping; use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; +use Patchlevel\Hydrator\Tests\Unit\Fixture\EmailNormalizer; use Patchlevel\Hydrator\Tests\Unit\Fixture\ParentWithSensitiveDataDto; use Patchlevel\Hydrator\Tests\Unit\Fixture\ParentWithSensitiveDataWithIdentifierDto; use PHPUnit\Framework\Attributes\CoversClass; @@ -35,7 +37,13 @@ public function __construct( } }; - $metadataFactory = new CryptographyMetadataFactory(new AttributeMetadataFactory()); + $cryptographer = $this->createMock(Cryptographer::class); + + $metadataFactory = new CryptographyMetadataFactory( + $cryptographer, + new AttributeMetadataFactory(), + ); + $metadata = $metadataFactory->metadata($event::class); self::assertArrayHasKey(SubjectIdFieldMapping::class, $metadata->extras); @@ -45,12 +53,15 @@ public function __construct( $property = $metadata->propertyForField('_name'); - self::assertArrayHasKey(SensitiveDataInfo::class, $property->extras); - $sensitiveDataInfo = $property->extras[SensitiveDataInfo::class]; - self::assertInstanceOf(SensitiveDataInfo::class, $sensitiveDataInfo); - - self::assertSame('default', $sensitiveDataInfo->subjectIdName); - self::assertSame('fallback', $sensitiveDataInfo->fallback); + self::assertInstanceOf(CryptoNormalizer::class, $property->normalizer); + self::assertEquals( + new CryptoNormalizer( + $cryptographer, + 'default', + 'fallback', + ), + $property->normalizer, + ); } public function testSubjectIdAndSensitiveDataConflict(): void @@ -66,7 +77,11 @@ public function __construct( $this->expectException(SubjectIdAndSensitiveDataConflict::class); - $metadataFactory = new CryptographyMetadataFactory(new AttributeMetadataFactory()); + $metadataFactory = new CryptographyMetadataFactory( + $this->createMock(Cryptographer::class), + new AttributeMetadataFactory(), + ); + $metadataFactory->metadata($event::class); } @@ -84,7 +99,11 @@ public function __construct( $this->expectException(DuplicateSubjectIdIdentifier::class); - $metadataFactory = new CryptographyMetadataFactory(new AttributeMetadataFactory()); + $metadataFactory = new CryptographyMetadataFactory( + $this->createMock(Cryptographer::class), + new AttributeMetadataFactory(), + ); + $metadataFactory->metadata($event::class); } @@ -108,7 +127,13 @@ public function __construct( } }; - $metadataFactory = new CryptographyMetadataFactory(new AttributeMetadataFactory()); + $cryptographer = $this->createMock(Cryptographer::class); + + $metadataFactory = new CryptographyMetadataFactory( + $cryptographer, + new AttributeMetadataFactory(), + ); + $metadata = $metadataFactory->metadata($event::class); self::assertArrayHasKey(SubjectIdFieldMapping::class, $metadata->extras); @@ -118,21 +143,27 @@ public function __construct( $property = $metadata->propertyForField('_fooName'); - self::assertArrayHasKey(SensitiveDataInfo::class, $property->extras); - $sensitiveDataInfo = $property->extras[SensitiveDataInfo::class]; - self::assertInstanceOf(SensitiveDataInfo::class, $sensitiveDataInfo); - - self::assertSame('foo', $sensitiveDataInfo->subjectIdName); - self::assertSame('fallback', $sensitiveDataInfo->fallback); + self::assertInstanceOf(CryptoNormalizer::class, $property->normalizer); + self::assertEquals( + new CryptoNormalizer( + $cryptographer, + 'foo', + 'fallback', + ), + $property->normalizer, + ); $property = $metadata->propertyForField('_barName'); - self::assertArrayHasKey(SensitiveDataInfo::class, $property->extras); - $sensitiveDataInfo = $property->extras[SensitiveDataInfo::class]; - self::assertInstanceOf(SensitiveDataInfo::class, $sensitiveDataInfo); - - self::assertSame('bar', $sensitiveDataInfo->subjectIdName); - self::assertSame('fallback', $sensitiveDataInfo->fallback); + self::assertInstanceOf(CryptoNormalizer::class, $property->normalizer); + self::assertEquals( + new CryptoNormalizer( + $cryptographer, + 'bar', + 'fallback', + ), + $property->normalizer, + ); } public function testDuplicateSubjectIdIdentifiers(): void @@ -155,7 +186,10 @@ public function __construct( } }; - $metadataFactory = new CryptographyMetadataFactory(new AttributeMetadataFactory()); + $metadataFactory = new CryptographyMetadataFactory( + $this->createMock(Cryptographer::class), + new AttributeMetadataFactory(), + ); $this->expectException(DuplicateSubjectIdIdentifier::class); $this->expectExceptionMessageMatches('/Duplicate subject id identifier found\. Used foo for .*::fooId and .*::barId\./'); @@ -164,7 +198,13 @@ public function __construct( public function testExtendsWithSensitiveData(): void { - $metadataFactory = new CryptographyMetadataFactory(new AttributeMetadataFactory()); + $cryptographer = $this->createMock(Cryptographer::class); + + $metadataFactory = new CryptographyMetadataFactory( + $cryptographer, + new AttributeMetadataFactory(), + ); + $metadata = $metadataFactory->metadata(ParentWithSensitiveDataDto::class); self::assertCount(2, $metadata->properties); @@ -176,17 +216,27 @@ public function testExtendsWithSensitiveData(): void $property = $metadata->propertyForField('email'); - self::assertArrayHasKey(SensitiveDataInfo::class, $property->extras); - $sensitiveDataInfo = $property->extras[SensitiveDataInfo::class]; - self::assertInstanceOf(SensitiveDataInfo::class, $sensitiveDataInfo); - - self::assertSame('default', $sensitiveDataInfo->subjectIdName); - self::assertSame(null, $sensitiveDataInfo->fallback); + self::assertInstanceOf(CryptoNormalizer::class, $property->normalizer); + self::assertEquals( + new CryptoNormalizer( + $cryptographer, + 'default', + null, + new EmailNormalizer(), + ), + $property->normalizer, + ); } public function testExtendsWithSensitiveDataWithName(): void { - $metadataFactory = new CryptographyMetadataFactory(new AttributeMetadataFactory()); + $cryptographer = $this->createMock(Cryptographer::class); + + $metadataFactory = new CryptographyMetadataFactory( + $cryptographer, + new AttributeMetadataFactory(), + ); + $metadata = $metadataFactory->metadata(ParentWithSensitiveDataWithIdentifierDto::class); self::assertCount(2, $metadata->properties); @@ -198,11 +248,15 @@ public function testExtendsWithSensitiveDataWithName(): void $property = $metadata->propertyForField('email'); - self::assertArrayHasKey(SensitiveDataInfo::class, $property->extras); - $sensitiveDataInfo = $property->extras[SensitiveDataInfo::class]; - self::assertInstanceOf(SensitiveDataInfo::class, $sensitiveDataInfo); - - self::assertSame('profile', $sensitiveDataInfo->subjectIdName); - self::assertSame(null, $sensitiveDataInfo->fallback); + self::assertInstanceOf(CryptoNormalizer::class, $property->normalizer); + self::assertEquals( + new CryptoNormalizer( + $cryptographer, + 'profile', + null, + new EmailNormalizer(), + ), + $property->normalizer, + ); } } diff --git a/tests/Unit/Cryptography/CryptographyMiddlewareTest.php b/tests/Unit/Cryptography/CryptographyMiddlewareTest.php index 5e675af..270f828 100644 --- a/tests/Unit/Cryptography/CryptographyMiddlewareTest.php +++ b/tests/Unit/Cryptography/CryptographyMiddlewareTest.php @@ -4,60 +4,96 @@ namespace Patchlevel\Hydrator\Tests\Unit\Cryptography; +use Patchlevel\Hydrator\Cryptography\Cipher\Cipher; +use Patchlevel\Hydrator\Cryptography\Cipher\CipherKey; +use Patchlevel\Hydrator\Cryptography\Cipher\CipherKeyFactory; +use Patchlevel\Hydrator\Cryptography\CryptographyMetadataFactory; use Patchlevel\Hydrator\Cryptography\CryptographyMiddleware; -use Patchlevel\Hydrator\Cryptography\PayloadCryptographer; +use Patchlevel\Hydrator\Cryptography\MissingSubjectId; +use Patchlevel\Hydrator\Cryptography\Store\CipherKeyNotExists; +use Patchlevel\Hydrator\Cryptography\Store\CipherKeyStore; +use Patchlevel\Hydrator\Cryptography\UnsupportedSubjectId; +use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\Metadata\ClassMetadata; -use Patchlevel\Hydrator\Middleware\Middleware; use Patchlevel\Hydrator\Middleware\Stack; +use Patchlevel\Hydrator\Middleware\TransformMiddleware; +use Patchlevel\Hydrator\Tests\Unit\Fixture\SensitiveDataProfileCreated; +use Patchlevel\Hydrator\Tests\Unit\Fixture\SensitiveDataWithStringableSubjectId; +use Patchlevel\Hydrator\Tests\Unit\Fixture\StringableSubjectId; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use ReflectionClass; -use stdClass; #[CoversClass(CryptographyMiddleware::class)] final class CryptographyMiddlewareTest extends TestCase { - public function testHydrate(): void + public function testUnsupportedSubjectId(): void { - $metadata = new ClassMetadata(new ReflectionClass(stdClass::class)); + $this->expectException(UnsupportedSubjectId::class); - $payloadCryptographer = $this->createMock(PayloadCryptographer::class); - $payloadCryptographer->expects($this->once())->method('decrypt')->with($metadata, ['name' => 'foo'])->willReturn(['name' => 'bar']); + $middleware = new CryptographyMiddleware(); - $object = new stdClass(); - - $cryptographyMiddleware = new CryptographyMiddleware($payloadCryptographer); - - $otherMiddleware = $this->createMock(Middleware::class); - - $stack = new Stack([$otherMiddleware]); + $middleware->hydrate( + $this->metadata(SensitiveDataProfileCreated::class), + ['id' => null, 'email' => 'encrypted'], + [], + new Stack([new TransformMiddleware()]), + ); + } - $otherMiddleware->expects($this->once())->method('hydrate')->with($metadata, ['name' => 'bar'], [], $stack)->willReturn($object); + public function testMissingSubjectId(): void + { + $this->expectException(MissingSubjectId::class); - $result = $cryptographyMiddleware->hydrate($metadata, ['name' => 'foo'], [], $stack); + $middleware = new CryptographyMiddleware(); - self::assertSame($object, $result); + $middleware->hydrate( + $this->metadata(SensitiveDataProfileCreated::class), + ['email' => 'encrypted'], + [], + new Stack([new TransformMiddleware()]), + ); } - public function testExtract(): void + public function testStringableSubjectId(): void { - $metadata = new ClassMetadata(new ReflectionClass(stdClass::class)); + $cipherKey = new CipherKey( + 'user-123', + 'bar', + 'baz', + ); + + $cipherKeyStore = $this->createMock(CipherKeyStore::class); + $cipherKeyStore->method('get')->willThrowException(new CipherKeyNotExists('user-123')); + $cipherKeyStore->expects($this->once())->method('store')->with('user-123', $cipherKey); - $payloadCryptographer = $this->createMock(PayloadCryptographer::class); - $payloadCryptographer->expects($this->once())->method('encrypt')->with($metadata, ['name' => 'foo'])->willReturn(['name' => 'bar']); + $cipherKeyFactory = $this->createMock(CipherKeyFactory::class); + $cipherKeyFactory->expects($this->once())->method('__invoke')->willReturn($cipherKey); - $object = new stdClass(); + $cipher = $this->createMock(Cipher::class); + $cipher->expects($this->once())->method('encrypt')->with($cipherKey, 'John Doe') + ->willReturn('encrypted'); - $cryptographyMiddleware = new CryptographyMiddleware($payloadCryptographer); + $middleware = new CryptographyMiddleware(); - $otherMiddleware = $this->createMock(Middleware::class); + $subjectId = new StringableSubjectId('user-123'); - $stack = new Stack([$otherMiddleware]); + $result = $middleware->extract( + $this->metadata(SensitiveDataWithStringableSubjectId::class), + ['subjectId' => $subjectId, 'name' => 'John Doe'], + [], + new Stack([new TransformMiddleware()]), + ); - $otherMiddleware->expects($this->once())->method('extract')->with($metadata, $object, [], $stack)->willReturn(['name' => 'foo']); + self::assertEquals(['subjectId' => $subjectId, 'name' => 'encrypted'], $result); + } - $result = $cryptographyMiddleware->extract($metadata, $object, [], $stack); + /** @param class-string $class */ + private function metadata(string $class): ClassMetadata + { + $factory = new CryptographyMetadataFactory( + new AttributeMetadataFactory(), + ); - self::assertSame(['name' => 'bar'], $result); + return $factory->metadata($class); } } diff --git a/tests/Unit/Cryptography/MissingSubjectIdTest.php b/tests/Unit/Cryptography/MissingSubjectIdTest.php index 6d23236..37df03e 100644 --- a/tests/Unit/Cryptography/MissingSubjectIdTest.php +++ b/tests/Unit/Cryptography/MissingSubjectIdTest.php @@ -5,7 +5,6 @@ namespace Patchlevel\Hydrator\Tests\Unit\Cryptography; use Patchlevel\Hydrator\Cryptography\MissingSubjectId; -use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -14,8 +13,8 @@ final class MissingSubjectIdTest extends TestCase { public function testCreation(): void { - $exception = new MissingSubjectId(ProfileCreated::class, 'profile_id'); + $exception = new MissingSubjectId('default'); - self::assertSame('Missing subject id for Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated in field profile_id.', $exception->getMessage()); + self::assertSame('Missing subject id default.', $exception->getMessage()); } } diff --git a/tests/Unit/MetadataHydratorTest.php b/tests/Unit/MetadataHydratorTest.php index e0355f5..eeced93 100644 --- a/tests/Unit/MetadataHydratorTest.php +++ b/tests/Unit/MetadataHydratorTest.php @@ -9,11 +9,8 @@ use DateTimeZone; use Patchlevel\Hydrator\CircularReference; use Patchlevel\Hydrator\ClassNotSupported; -use Patchlevel\Hydrator\Cryptography\CryptographyMiddleware; -use Patchlevel\Hydrator\Cryptography\PayloadCryptographer; use Patchlevel\Hydrator\DenormalizationFailure; use Patchlevel\Hydrator\Guesser\Guesser; -use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\MetadataHydrator; use Patchlevel\Hydrator\Middleware\Middleware; @@ -309,70 +306,6 @@ public function testNormalizationFailure(): void ); } - public function testDecrypt(): void - { - $object = new ProfileCreated( - ProfileId::fromString('1'), - Email::fromString('info@patchlevel.de'), - ); - - $payload = ['profileId' => '1', 'email' => 'info@patchlevel.de']; - $encryptedPayload = ['profileId' => '1', 'email' => 'encrypted']; - - $metadataFactory = new AttributeMetadataFactory(); - - $cryptographer = $this->createMock(PayloadCryptographer::class); - $cryptographer - ->expects($this->once()) - ->method('decrypt') - ->with($metadataFactory->metadata(ProfileCreated::class), $encryptedPayload) - ->willReturn($payload); - - $hydrator = new MetadataHydrator( - $metadataFactory, - [ - new CryptographyMiddleware($cryptographer), - new TransformMiddleware(), - ], - ); - - $return = $hydrator->hydrate(ProfileCreated::class, $encryptedPayload); - - self::assertEquals($object, $return); - } - - public function testEncrypt(): void - { - $object = new ProfileCreated( - ProfileId::fromString('1'), - Email::fromString('info@patchlevel.de'), - ); - - $payload = ['profileId' => '1', 'email' => 'info@patchlevel.de']; - $encryptedPayload = ['profileId' => '1', 'email' => 'encrypted']; - - $metadataFactory = new AttributeMetadataFactory(); - - $cryptographer = $this->createMock(PayloadCryptographer::class); - $cryptographer - ->expects($this->once()) - ->method('encrypt') - ->with($metadataFactory->metadata(ProfileCreated::class), $payload) - ->willReturn($encryptedPayload); - - $hydrator = new MetadataHydrator( - $metadataFactory, - [ - new CryptographyMiddleware($cryptographer), - new TransformMiddleware(), - ], - ); - - $return = $hydrator->extract($object); - - self::assertSame($encryptedPayload, $return); - } - public function testHydrateWithNormalizerInBaseClass(): void { $expected = new NormalizerInBaseClassDefinedDto(