diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index e293f65..21ab980 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,22 +1,22 @@ parameters: ignoreErrors: - - message: '#^Parameter \#1 \$key of class Patchlevel\\Hydrator\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#' - identifier: argument.type + message: '#^Method Patchlevel\\Hydrator\\Cryptography\\Cipher\\OpensslCipher\:\:encrypt\(\) should return non\-empty\-string but returns string\.$#' + identifier: return.type count: 1 - path: src/Cryptography/Cipher/OpensslCipherKeyFactory.php + path: src/Cryptography/Cipher/OpensslCipher.php - - message: '#^Parameter \#3 \$iv of class Patchlevel\\Hydrator\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#' + message: '#^Parameter \#1 \$key of class Patchlevel\\Hydrator\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#' identifier: argument.type count: 1 path: src/Cryptography/Cipher/OpensslCipherKeyFactory.php - - message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\Cryptography\\Cipher\\Cipher\:\:decrypt\(\) expects string, mixed given\.$#' + message: '#^Parameter \#3 \$iv of class Patchlevel\\Hydrator\\Cryptography\\Cipher\\CipherKey constructor expects non\-empty\-string, string given\.$#' identifier: argument.type count: 1 - path: src/Cryptography/SensitiveDataPayloadCryptographer.php + path: src/Cryptography/Cipher/OpensslCipherKeyFactory.php - message: '#^Method Patchlevel\\Hydrator\\Guesser\\BuiltInGuesser\:\:guess\(\) has parameter \$type with generic class Symfony\\Component\\TypeInfo\\Type\\ObjectType but does not specify its types\: T$#' @@ -120,12 +120,6 @@ parameters: count: 2 path: tests/Unit/Normalizer/ArrayNormalizerTest.php - - - message: '#^Parameter \#1 \$normalizer of class Patchlevel\\Hydrator\\Normalizer\\ArrayNormalizer constructor expects Patchlevel\\Hydrator\\Normalizer\\Normalizer, PHPUnit\\Framework\\MockObject\\MockObject given\.$#' - identifier: argument.type - count: 1 - path: tests/Unit/Normalizer/ArrayNormalizerTest.php - - message: '#^Cannot cast mixed to int\.$#' identifier: cast.int @@ -137,9 +131,3 @@ parameters: identifier: cast.string count: 2 path: tests/Unit/Normalizer/ArrayShapeNormalizerTest.php - - - - message: '#^Parameter \#1 \$normalizerMap of class Patchlevel\\Hydrator\\Normalizer\\ArrayShapeNormalizer constructor expects array\, array\ given\.$#' - identifier: argument.type - count: 1 - path: tests/Unit/Normalizer/ArrayShapeNormalizerTest.php 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..b43989e --- /dev/null +++ b/src/Cryptography/BaseCryptographer.php @@ -0,0 +1,95 @@ +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), + ); + } +} 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..40ccc97 100644 --- a/src/Cryptography/Cipher/OpensslCipher.php +++ b/src/Cryptography/Cipher/OpensslCipher.php @@ -17,6 +17,7 @@ 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/Cryptographer.php b/src/Cryptography/Cryptographer.php new file mode 100644 index 0000000..3077d47 --- /dev/null +++ b/src/Cryptography/Cryptographer.php @@ -0,0 +1,23 @@ +resolveSubjectIds($metadata, $data, $context); + + foreach ($metadata->properties as $propertyMetadata) { + $info = $propertyMetadata->extras[SensitiveDataInfo::class] ?? null; + + if (!$info instanceof SensitiveDataInfo) { + continue; + } + + $value = $data[$propertyMetadata->fieldName] ?? null; + + if ($value === null) { + continue; + } + + if (!$this->cryptographer->supports($value)) { + continue; + } + + $subjectId = $subjectIds->get($info->subjectIdName); + + try { + $data[$propertyMetadata->fieldName] = $this->cryptographer->decrypt($subjectId, $value); + } catch (DecryptionFailed | CipherKeyNotExists) { + $fallback = $info->fallback instanceof Closure + ? ($info->fallback)($subjectId) + : $info->fallback; + + if ($propertyMetadata->normalizer) { + $fallback = $propertyMetadata->normalizer->normalize($fallback, $context); + } + + $data[$propertyMetadata->fieldName] = $fallback; + } + } + return $stack->next()->hydrate( $metadata, - $this->cryptography->decrypt($metadata, $data), + $data, $context, $stack, ); @@ -45,9 +91,83 @@ 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] = $subjectIds = $this->resolveSubjectIds($metadata, $object, $context); + + $data = $stack->next()->extract($metadata, $object, $context, $stack); + + foreach ($metadata->properties as $propertyMetadata) { + $info = $propertyMetadata->extras[SensitiveDataInfo::class] ?? null; + + if (!$info instanceof SensitiveDataInfo) { + continue; + } + + $value = $data[$propertyMetadata->fieldName] ?? null; + + if ($value === null) { + continue; + } + + $data[$propertyMetadata->fieldName] = $this->cryptographer->encrypt( + $subjectIds->get($info->subjectIdName), + $value, + ); + } + + return $data; + } + + /** + * @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); + $subjectId = $property->getValue($data); + + if ($property->normalizer) { + $subjectId = $property->normalizer->normalize($subjectId, $context); + } + } + + 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/SensitiveDataPayloadCryptographer.php b/src/Cryptography/SensitiveDataPayloadCryptographer.php deleted file mode 100644 index 6470c2c..0000000 --- a/src/Cryptography/SensitiveDataPayloadCryptographer.php +++ /dev/null @@ -1,221 +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..9e1dbfd --- /dev/null +++ b/src/Cryptography/SubjectIds.php @@ -0,0 +1,26 @@ + $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); + } +} 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/src/Normalizer/DateIntervalNormalizer.php b/src/Normalizer/DateIntervalNormalizer.php index 3914a51..6f7260b 100644 --- a/src/Normalizer/DateIntervalNormalizer.php +++ b/src/Normalizer/DateIntervalNormalizer.php @@ -19,7 +19,12 @@ public function __construct(private string $format = self::DEFAULT_FORMAT) { } - public function normalize(mixed $value): string|null + /** + * @param array $context + * + * @throws InvalidArgument + */ + public function normalize(mixed $value, array $context): string|null { if ($value === null) { return null; @@ -32,7 +37,12 @@ public function normalize(mixed $value): string|null return $value->format($this->format); } - public function denormalize(mixed $value): DateInterval|null + /** + * @param array $context + * + * @throws InvalidArgument + */ + public function denormalize(mixed $value, array $context): DateInterval|null { if ($value === null) { return null; 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 6f864c1..936cbb3 100644 --- a/tests/Benchmark/HydratorWithCryptographyBench.php +++ b/tests/Benchmark/HydratorWithCryptographyBench.php @@ -5,8 +5,8 @@ namespace Patchlevel\Hydrator\Tests\Benchmark; use Patchlevel\Hydrator\CoreExtension; +use Patchlevel\Hydrator\Cryptography\BaseCryptographer; use Patchlevel\Hydrator\Cryptography\CryptographyExtension; -use Patchlevel\Hydrator\Cryptography\SensitiveDataPayloadCryptographer; use Patchlevel\Hydrator\Cryptography\Store\InMemoryCipherKeyStore; use Patchlevel\Hydrator\Hydrator; use Patchlevel\Hydrator\HydratorBuilder; @@ -28,7 +28,7 @@ public function __construct() $this->hydrator = (new HydratorBuilder()) ->useExtension(new CoreExtension()) - ->useExtension(new CryptographyExtension(SensitiveDataPayloadCryptographer::createWithDefaultSettings($this->store))) + ->useExtension(new CryptographyExtension(BaseCryptographer::createWithOpenssl($this->store))) ->build(); } diff --git a/tests/Unit/Cryptography/BaseCryptographerTest.php b/tests/Unit/Cryptography/BaseCryptographerTest.php new file mode 100644 index 0000000..529b34a --- /dev/null +++ b/tests/Unit/Cryptography/BaseCryptographerTest.php @@ -0,0 +1,177 @@ +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('encrypt')->with($cipherKey, 'info@patchlevel.de') + ->willReturn('encrypted'); + + $cryptographer = new BaseCryptographer( + $cipher, + $cipherKeyStore, + $cipherKeyFactory, + ); + + $expected = [ + '__enc' => 'v1', + 'data' => 'encrypted', + 'method' => 'methodA', + 'iv' => 'random', + ]; + + self::assertEquals($expected, $cryptographer->encrypt('foo', 'info@patchlevel.de')); + } + + public function testEncryptWithoutKey(): void + { + $cipherKey = new CipherKey('foo', 'methodA', 'random'); + + $cipherKeyStore = $this->createMock(CipherKeyStore::class); + $cipherKeyStore->method('get')->with('foo')->willThrowException(new CipherKeyNotExists('foo')); + + $cipherKeyFactory = $this->createMock(CipherKeyFactory::class); + $cipherKeyFactory->expects($this->once())->method('__invoke')->willReturn($cipherKey); + + $cipherKeyStore + ->expects($this->once()) + ->method('store') + ->with('foo', $cipherKey); + + $cipher = $this->createMock(Cipher::class); + $cipher->expects($this->once())->method('encrypt')->with($cipherKey, 'info@patchlevel.de') + ->willReturn('encrypted'); + + $cryptographer = new BaseCryptographer( + $cipher, + $cipherKeyStore, + $cipherKeyFactory, + ); + + $expected = [ + '__enc' => 'v1', + 'data' => 'encrypted', + 'method' => 'methodA', + 'iv' => 'random', + ]; + + self::assertEquals($expected, $cryptographer->encrypt('foo', 'info@patchlevel.de')); + } + + public function testDecrypt(): void + { + $cipherKey = new CipherKey('foo', 'methodA', 'random'); + + $cipherKeyStore = $this->createMock(CipherKeyStore::class); + $cipherKeyStore->method('get')->with('foo')->willReturn($cipherKey); + + $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 BaseCryptographer( + $cipher, + $cipherKeyStore, + $cipherKeyFactory, + ); + + self::assertEquals( + 'info@patchlevel.de', + $cryptographer->decrypt( + 'foo', + [ + '__enc' => 'v1', + 'data' => 'encrypted', + 'method' => 'methodA', + 'iv' => 'random', + ], + ), + ); + } + + public function testDecryptWithFallback(): void + { + $cipherKey = new CipherKey('foo', 'methodA', 'random'); + + $cipherKeyStore = $this->createMock(CipherKeyStore::class); + $cipherKeyStore->method('get')->with('foo')->willReturn($cipherKey); + + $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 BaseCryptographer( + $cipher, + $cipherKeyStore, + $cipherKeyFactory, + ); + + self::assertEquals( + 'info@patchlevel.de', + $cryptographer->decrypt( + 'foo', + [ + '__enc' => 'v1', + 'data' => 'encrypted', + ], + ), + ); + } + + #[DataProvider('dataProviderSupports')] + public function testSupports(mixed $value, bool $supported): void + { + $cryptographer = new BaseCryptographer( + $this->createMock(Cipher::class), + $this->createMock(CipherKeyStore::class), + $this->createMock(CipherKeyFactory::class), + ); + + self::assertEquals($supported, $cryptographer->supports($value)); + } + + /** @return iterable */ + public static function dataProviderSupports(): iterable + { + yield ['foo', false]; + yield [[], false]; + yield [null, false]; + yield [['__enc' => 'foo'], false]; + yield [['__enc' => 'v1'], true]; + } +} diff --git a/tests/Unit/Cryptography/CryptographyMetadataEnricherTest.php b/tests/Unit/Cryptography/CryptographyMetadataEnricherTest.php index b030daf..777025f 100644 --- a/tests/Unit/Cryptography/CryptographyMetadataEnricherTest.php +++ b/tests/Unit/Cryptography/CryptographyMetadataEnricherTest.php @@ -46,11 +46,7 @@ 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::assertEquals(new SensitiveDataInfo('default', 'fallback'), $property->extras[SensitiveDataInfo::class]); } public function testSubjectIdAndSensitiveDataConflict(): void @@ -116,20 +112,12 @@ 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::assertEquals(new SensitiveDataInfo('foo', 'fallback'), $property->extras[SensitiveDataInfo::class]); $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::assertEquals(new SensitiveDataInfo('bar', 'fallback'), $property->extras[SensitiveDataInfo::class]); } public function testDuplicateSubjectIdIdentifiers(): void @@ -172,11 +160,7 @@ 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::assertEquals(new SensitiveDataInfo('default', null), $property->extras[SensitiveDataInfo::class]); } public function testExtendsWithSensitiveDataWithName(): void @@ -193,11 +177,7 @@ 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::assertEquals(new SensitiveDataInfo('profile', null), $property->extras[SensitiveDataInfo::class]); } /** @param class-string $class */ diff --git a/tests/Unit/Cryptography/CryptographyMiddlewareTest.php b/tests/Unit/Cryptography/CryptographyMiddlewareTest.php index 5e675af..1c9e0e1 100644 --- a/tests/Unit/Cryptography/CryptographyMiddlewareTest.php +++ b/tests/Unit/Cryptography/CryptographyMiddlewareTest.php @@ -4,60 +4,250 @@ namespace Patchlevel\Hydrator\Tests\Unit\Cryptography; +use Patchlevel\Hydrator\Cryptography\Cipher\DecryptionFailed; +use Patchlevel\Hydrator\Cryptography\Cryptographer; +use Patchlevel\Hydrator\Cryptography\CryptographyMetadataEnricher; use Patchlevel\Hydrator\Cryptography\CryptographyMiddleware; -use Patchlevel\Hydrator\Cryptography\PayloadCryptographer; +use Patchlevel\Hydrator\Cryptography\MissingSubjectIdField; +use Patchlevel\Hydrator\Cryptography\Store\CipherKeyNotExists; +use Patchlevel\Hydrator\Cryptography\SubjectIds; +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\Email; +use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated; +use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileId; +use Patchlevel\Hydrator\Tests\Unit\Fixture\SensitiveDataProfileCreated; +use Patchlevel\Hydrator\Tests\Unit\Fixture\SensitiveDataProfileCreatedFallbackCallback; 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); + + $middleware = new CryptographyMiddleware( + $this->createMock(Cryptographer::class), + ); + + $middleware->hydrate( + $this->metadata(SensitiveDataProfileCreated::class), + ['id' => null, 'email' => 'encrypted'], + [], + new Stack([new TransformMiddleware()]), + ); + } - $payloadCryptographer = $this->createMock(PayloadCryptographer::class); - $payloadCryptographer->expects($this->once())->method('decrypt')->with($metadata, ['name' => 'foo'])->willReturn(['name' => 'bar']); + public function testMissingSubjectId(): void + { + $this->expectException(MissingSubjectIdField::class); + + $middleware = new CryptographyMiddleware( + $this->createMock(Cryptographer::class), + ); + + $middleware->hydrate( + $this->metadata(SensitiveDataProfileCreated::class), + ['email' => 'encrypted'], + [], + new Stack([new TransformMiddleware()]), + ); + } + + public function testSkipEncrypt(): void + { + $middleware = new CryptographyMiddleware( + $this->createMock(Cryptographer::class), + ); - $object = new stdClass(); + $object = new ProfileCreated( + ProfileId::fromString('foo'), + Email::fromString('info@patchlevel.de'), + ); - $cryptographyMiddleware = new CryptographyMiddleware($payloadCryptographer); + $expected = ['profileId' => 'foo', 'email' => 'info@patchlevel.de']; + + $metadata = $this->metadata(ProfileCreated::class); $otherMiddleware = $this->createMock(Middleware::class); + $stack = new Stack([$otherMiddleware]); + + $otherMiddleware + ->expects($this->once()) + ->method('extract') + ->with($metadata, $object, [SubjectIds::class => new SubjectIds()], $stack) + ->willReturn($expected); + + $result = $middleware->extract( + $metadata, + $object, + [], + $stack, + ); + self::assertSame($expected, $result); + } + + public function testEncrypt(): void + { + $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('hydrate')->with($metadata, ['name' => 'bar'], [], $stack)->willReturn($object); + $otherMiddleware + ->expects($this->once()) + ->method('extract') + ->with($metadata, $object, [SubjectIds::class => new SubjectIds(['default' => 'foo'])], $stack) + ->willReturn(['id' => 'foo', 'email' => 'info@patchlevel.de']); + + $cryptographer = $this->createMock(Cryptographer::class); + $cryptographer->method('encrypt')->willReturn('encrypted'); - $result = $cryptographyMiddleware->hydrate($metadata, ['name' => 'foo'], [], $stack); + $middleware = new CryptographyMiddleware($cryptographer); - self::assertSame($object, $result); + $result = $middleware->extract( + $metadata, + $object, + [], + $stack, + ); + + self::assertEquals(['id' => 'foo', 'email' => 'encrypted'], $result); } - public function testExtract(): void + public function testSkipDecrypt(): void { - $metadata = new ClassMetadata(new ReflectionClass(stdClass::class)); + $cryptographer = $this->createMock(Cryptographer::class); + $cryptographer->expects($this->never())->method('decrypt'); - $payloadCryptographer = $this->createMock(PayloadCryptographer::class); - $payloadCryptographer->expects($this->once())->method('encrypt')->with($metadata, ['name' => 'foo'])->willReturn(['name' => 'bar']); + $data = ['profileId' => 'foo', 'email' => 'info@patchlevel.de']; - $object = new stdClass(); + $expected = new ProfileCreated( + ProfileId::fromString('foo'), + Email::fromString('info@patchlevel.de'), + ); - $cryptographyMiddleware = new CryptographyMiddleware($payloadCryptographer); + $metadata = $this->metadata(ProfileCreated::class); $otherMiddleware = $this->createMock(Middleware::class); - $stack = new Stack([$otherMiddleware]); - $otherMiddleware->expects($this->once())->method('extract')->with($metadata, $object, [], $stack)->willReturn(['name' => 'foo']); + $otherMiddleware + ->expects($this->once()) + ->method('hydrate') + ->with($metadata, $data, [SubjectIds::class => new SubjectIds()], $stack) + ->willReturn($expected); - $result = $cryptographyMiddleware->extract($metadata, $object, [], $stack); + $middleware = new CryptographyMiddleware($cryptographer); + + $result = $middleware->hydrate( + $metadata, + $data, + [], + $stack, + ); + + self::assertSame($expected, $result); + } + + public function testDecryptWithCipherKeyNotExists(): void + { + $cryptographer = $this->createMock(Cryptographer::class); + $cryptographer->method('supports')->willReturn(true); + $cryptographer->method('decrypt')->willThrowException(new CipherKeyNotExists('foo')); + + $middleware = new CryptographyMiddleware($cryptographer); + + $result = $middleware->hydrate( + $this->metadata(SensitiveDataProfileCreated::class), + ['id' => 'foo', 'email' => 'encrypted'], + [], + new Stack([new TransformMiddleware()]), + ); + + self::assertInstanceOf(SensitiveDataProfileCreated::class, $result); + self::assertEquals(ProfileId::fromString('foo'), $result->profileId); + self::assertEquals(new Email('unknown'), $result->email); + } + + public function testDecryptWithDecryptionFailed(): void + { + $cryptographer = $this->createMock(Cryptographer::class); + $cryptographer->method('supports')->willReturn(true); + $cryptographer->method('decrypt')->willThrowException(new DecryptionFailed()); + + $middleware = new CryptographyMiddleware($cryptographer); + + $result = $middleware->hydrate( + $this->metadata(SensitiveDataProfileCreated::class), + ['id' => 'foo', 'email' => 'encrypted'], + [], + new Stack([new TransformMiddleware()]), + ); + + self::assertInstanceOf(SensitiveDataProfileCreated::class, $result); + self::assertEquals(ProfileId::fromString('foo'), $result->profileId); + self::assertEquals(new Email('unknown'), $result->email); + } + + public function testDecryptWithFallbackCallback(): void + { + $cryptographer = $this->createMock(Cryptographer::class); + $cryptographer->method('supports')->willReturn(true); + $cryptographer->method('decrypt')->willThrowException(new DecryptionFailed()); + + $middleware = new CryptographyMiddleware($cryptographer); + + $result = $middleware->hydrate( + $this->metadata(SensitiveDataProfileCreatedFallbackCallback::class), + ['id' => 'foo', 'email' => 'encrypted'], + [], + new Stack([new TransformMiddleware()]), + ); + + self::assertInstanceOf(SensitiveDataProfileCreatedFallbackCallback::class, $result); + self::assertEquals(ProfileId::fromString('foo'), $result->profileId); + self::assertEquals(new Email('foo@example.com'), $result->email); + } + + public function testDecrypt(): void + { + $cryptographer = $this->createMock(Cryptographer::class); + $cryptographer->method('supports')->willReturn(true); + $cryptographer->method('decrypt')->willReturn('info@patchlevel.de'); + + $middleware = new CryptographyMiddleware($cryptographer); + + $result = $middleware->hydrate( + $this->metadata(SensitiveDataProfileCreated::class), + ['id' => 'foo', 'email' => 'encrypted'], + [], + new Stack([new TransformMiddleware()]), + ); + + self::assertInstanceOf(SensitiveDataProfileCreated::class, $result); + self::assertEquals(ProfileId::fromString('foo'), $result->profileId); + self::assertEquals(Email::fromString('info@patchlevel.de'), $result->email); + } + + /** @param class-string $class */ + private function metadata(string $class): ClassMetadata + { + $metadata = (new AttributeMetadataFactory())->metadata($class); + (new CryptographyMetadataEnricher())->enrich($metadata); - self::assertSame(['name' => 'bar'], $result); + return $metadata; } } 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/Cryptography/SensitiveDataPayloadCryptographerTest.php b/tests/Unit/Cryptography/SensitiveDataPayloadCryptographerTest.php deleted file mode 100644 index 17d2f0c..0000000 --- a/tests/Unit/Cryptography/SensitiveDataPayloadCryptographerTest.php +++ /dev/null @@ -1,482 +0,0 @@ -createMock(CipherKeyStore::class); - $cipherKeyStore->expects($this->never())->method('get'); - - $cipherKeyFactory = $this->createMock(CipherKeyFactory::class); - $cipher = $this->createMock(Cipher::class); - - $cryptographer = new SensitiveDataPayloadCryptographer( - $cipherKeyStore, - $cipherKeyFactory, - $cipher, - ); - - $payload = ['id' => 'foo', 'email' => 'info@patchlevel.de']; - - $result = $cryptographer->encrypt($this->metadata(SensitiveData::class), ['id' => 'foo', 'email' => 'info@patchlevel.de']); - - self::assertSame($payload, $result); - } - - public function testEncryptWithMissingKey(): void - { - $cipherKey = new CipherKey( - 'foo', - 'bar', - 'baz', - ); - - $cipherKeyStore = $this->createMock(CipherKeyStore::class); - $cipherKeyStore->method('get')->with('foo')->willThrowException(new CipherKeyNotExists('foo')); - $cipherKeyStore->expects($this->once())->method('store')->with('foo', $cipherKey); - - $cipherKeyFactory = $this->createMock(CipherKeyFactory::class); - $cipherKeyFactory->expects($this->once())->method('__invoke')->willReturn($cipherKey); - - $cipher = $this->createMock(Cipher::class); - $cipher->expects($this->once())->method('encrypt')->with($cipherKey, 'info@patchlevel.de') - ->willReturn('encrypted'); - - $cryptographer = new SensitiveDataPayloadCryptographer( - $cipherKeyStore, - $cipherKeyFactory, - $cipher, - ); - - $result = $cryptographer->encrypt($this->metadata(SensitiveDataProfileCreated::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->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('encrypt')->with($cipherKey, 'info@patchlevel.de') - ->willReturn('encrypted'); - - $cryptographer = new SensitiveDataPayloadCryptographer( - $cipherKeyStore, - $cipherKeyFactory, - $cipher, - ); - - $result = $cryptographer->encrypt($this->metadata(SensitiveDataProfileCreated::class), ['id' => 'foo', 'email' => 'info@patchlevel.de']); - - self::assertEquals(['id' => 'foo', 'email' => 'encrypted'], $result); - } - - public function testEncryptWithExistingKeyEncryptedFieldName(): 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('encrypt')->with($cipherKey, 'info@patchlevel.de') - ->willReturn('encrypted'); - - $cryptographer = new SensitiveDataPayloadCryptographer( - $cipherKeyStore, - $cipherKeyFactory, - $cipher, - true, - ); - - $result = $cryptographer->encrypt($this->metadata(SensitiveDataProfileCreated::class), ['id' => 'foo', 'email' => 'info@patchlevel.de']); - - self::assertEquals(['id' => 'foo', '!email' => 'encrypted'], $result); - } - - public function testSkipDecrypt(): void - { - $cipherKeyStore = $this->createMock(CipherKeyStore::class); - $cipherKeyStore->expects($this->never())->method('get'); - - $cipherKeyFactory = $this->createMock(CipherKeyFactory::class); - $cipher = $this->createMock(Cipher::class); - - $cryptographer = new SensitiveDataPayloadCryptographer( - $cipherKeyStore, - $cipherKeyFactory, - $cipher, - ); - - $payload = ['id' => 'foo', 'email' => 'info@patchlevel.de']; - - $result = $cryptographer->decrypt($this->metadata(SensitiveData::class), ['id' => 'foo', 'email' => 'info@patchlevel.de']); - - self::assertSame($payload, $result); - } - - public function testDecryptWithMissingKey(): void - { - $cipherKeyStore = $this->createMock(CipherKeyStore::class); - $cipherKeyStore->method('get')->with('foo')->willThrowException(new CipherKeyNotExists('foo')); - - $cipherKeyFactory = $this->createMock(CipherKeyFactory::class); - $cipherKeyFactory->expects($this->never())->method('__invoke'); - - $cipher = $this->createMock(Cipher::class); - $cipher->expects($this->never())->method('decrypt'); - - $cryptographer = new SensitiveDataPayloadCryptographer( - $cipherKeyStore, - $cipherKeyFactory, - $cipher, - ); - - $result = $cryptographer->decrypt($this->metadata(SensitiveDataProfileCreated::class), ['id' => 'foo', 'email' => 'encrypted']); - - self::assertEquals(['id' => 'foo', 'email' => new Email('unknown')], $result); - } - - public function testDecryptWithInvalidKey(): 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') - ->willThrowException(new DecryptionFailed()); - - $cryptographer = new SensitiveDataPayloadCryptographer( - $cipherKeyStore, - $cipherKeyFactory, - $cipher, - ); - - $result = $cryptographer->decrypt($this->metadata(SensitiveDataProfileCreated::class), ['id' => 'foo', 'email' => 'encrypted']); - - self::assertEquals(['id' => 'foo', 'email' => new Email('unknown')], $result); - } - - public function testDecryptWithInvalidKeyWithFallbackCallback(): 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') - ->willThrowException(new DecryptionFailed()); - - $cryptographer = new SensitiveDataPayloadCryptographer( - $cipherKeyStore, - $cipherKeyFactory, - $cipher, - ); - - $result = $cryptographer->decrypt($this->metadata(SensitiveDataProfileCreatedFallbackCallback::class), ['id' => 'foo', 'email' => 'encrypted']); - - self::assertEquals(['id' => 'foo', 'email' => new Email('foo@example.com')], $result); - } - - public function testDecryptWithValidKey(): 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, - false, - ); - - $result = $cryptographer->decrypt($this->metadata(SensitiveDataProfileCreated::class), ['id' => 'foo', 'email' => 'encrypted']); - - self::assertEquals(['id' => 'foo', 'email' => 'info@patchlevel.de'], $result); - } - - public function testDecryptWithValidKeyAndEncryptedFieldName(): 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, - ); - - $result = $cryptographer->decrypt($this->metadata(SensitiveDataProfileCreated::class), ['id' => 'foo', '!email' => 'encrypted']); - - self::assertEquals(['id' => 'foo', 'email' => 'info@patchlevel.de'], $result); - } - - public function testDecryptWithValidKeyAndEncryptedFieldNameWithoutEncryptedData(): 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); - - $cryptographer = new SensitiveDataPayloadCryptographer( - $cipherKeyStore, - $cipherKeyFactory, - $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']); - - self::assertEquals(['id' => 'foo', 'email' => 'info@patchlevel.de'], $result); - } - - public function testUnsupportedSubjectId(): void - { - $this->expectException(UnsupportedSubjectId::class); - - $cipherKeyStore = $this->createMock(CipherKeyStore::class); - $cipherKeyFactory = $this->createMock(CipherKeyFactory::class); - $cipher = $this->createMock(Cipher::class); - - $cryptographer = new SensitiveDataPayloadCryptographer( - $cipherKeyStore, - $cipherKeyFactory, - $cipher, - ); - - $cryptographer->decrypt($this->metadata(SensitiveDataProfileCreated::class), ['id' => null, 'email' => 'encrypted']); - } - - public function testMissingSubjectId(): void - { - $this->expectException(MissingSubjectId::class); - - $cipherKeyStore = $this->createMock(CipherKeyStore::class); - $cipherKeyFactory = $this->createMock(CipherKeyFactory::class); - $cipher = $this->createMock(Cipher::class); - - $cryptographer = new SensitiveDataPayloadCryptographer( - $cipherKeyStore, - $cipherKeyFactory, - $cipher, - ); - - $cryptographer->decrypt($this->metadata(SensitiveDataProfileCreated::class), ['email' => 'encrypted']); - } - - public function testStringableSubjectId(): void - { - $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); - - $cipherKeyFactory = $this->createMock(CipherKeyFactory::class); - $cipherKeyFactory->expects($this->once())->method('__invoke')->willReturn($cipherKey); - - $cipher = $this->createMock(Cipher::class); - $cipher->expects($this->once())->method('encrypt')->with($cipherKey, 'John Doe') - ->willReturn('encrypted'); - - $cryptographer = new SensitiveDataPayloadCryptographer( - $cipherKeyStore, - $cipherKeyFactory, - $cipher, - ); - - $subjectId = new StringableSubjectId('user-123'); - - $result = $cryptographer->encrypt( - $this->metadata(SensitiveDataWithStringableSubjectId::class), - ['subjectId' => $subjectId, 'name' => 'John Doe'], - ); - - self::assertEquals(['subjectId' => $subjectId, 'name' => 'encrypted'], $result); - } - - public function testCreateWithOpenssl(): void - { - $cipherKey = new CipherKey('foo', 'aes128', '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)); - - $cryptographer = SensitiveDataPayloadCryptographer::createWithOpenssl($cipherKeyStore); - - $data = ['id' => 'foo', 'email' => 'info@patchlevel.de']; - $enrcyptedData = $cryptographer->encrypt($this->metadata(SensitiveDataProfileCreated::class), $data); - - self::assertNotSame('info@patchlevel.de', $enrcyptedData['email']); - self::assertSame('aUYxMzQ2bm80cUNCcE1wOUsveitUSmdGaHpYYjNoQWp1VGxTQXVITXRDVT0=', $enrcyptedData['email']); - - $decryptedData = $cryptographer->decrypt($this->metadata(SensitiveDataProfileCreated::class), $enrcyptedData); - - self::assertSame($data, $decryptedData); - } - - /** @param class-string $class */ - private function metadata(string $class): ClassMetadata - { - $metadata = (new AttributeMetadataFactory())->metadata($class); - (new CryptographyMetadataEnricher())->enrich($metadata); - - return $metadata; - } -} diff --git a/tests/Unit/Cryptography/SubjectIdsTest.php b/tests/Unit/Cryptography/SubjectIdsTest.php new file mode 100644 index 0000000..014b17b --- /dev/null +++ b/tests/Unit/Cryptography/SubjectIdsTest.php @@ -0,0 +1,59 @@ + 'bar']); + + self::assertSame(['foo' => 'bar'], $subjectIds->subjectIds); + } + + public function testGet(): void + { + $subjectIds = new SubjectIds(['foo' => 'bar']); + + self::assertSame('bar', $subjectIds->get('foo')); + } + + public function testGetMissing(): void + { + $this->expectException(MissingSubjectId::class); + $this->expectExceptionMessage('Missing subject id foo.'); + + $subjectIds = new SubjectIds(); + $subjectIds->get('foo'); + } + + public function testMerge(): void + { + $subjectIds1 = new SubjectIds(['foo' => 'bar']); + $subjectIds2 = new SubjectIds(['baz' => 'qux']); + + $merged = $subjectIds1->merge($subjectIds2); + + self::assertSame(['foo' => 'bar', 'baz' => 'qux'], $merged->subjectIds); + self::assertNotSame($subjectIds1, $merged); + self::assertNotSame($subjectIds2, $merged); + } + + public function testMergeOverwrite(): void + { + $subjectIds1 = new SubjectIds(['foo' => 'bar']); + $subjectIds2 = new SubjectIds(['foo' => 'baz']); + + $merged = $subjectIds1->merge($subjectIds2); + + self::assertSame(['foo' => 'baz'], $merged->subjectIds); + } +} diff --git a/tests/Unit/MetadataHydratorTest.php b/tests/Unit/MetadataHydratorTest.php index 00220fb..d803f9b 100644 --- a/tests/Unit/MetadataHydratorTest.php +++ b/tests/Unit/MetadataHydratorTest.php @@ -10,12 +10,9 @@ use Patchlevel\Hydrator\CircularReference; use Patchlevel\Hydrator\ClassNotSupported; use Patchlevel\Hydrator\CoreExtension; -use Patchlevel\Hydrator\Cryptography\CryptographyExtension; -use Patchlevel\Hydrator\Cryptography\PayloadCryptographer; use Patchlevel\Hydrator\DenormalizationFailure; use Patchlevel\Hydrator\Hydrator; use Patchlevel\Hydrator\HydratorBuilder; -use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\MetadataHydrator; use Patchlevel\Hydrator\Middleware\Middleware; @@ -316,64 +313,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 HydratorBuilder()) - ->useExtension(new CoreExtension()) - ->useExtension(new CryptographyExtension($cryptographer)) - ->build(); - - $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 HydratorBuilder()) - ->useExtension(new CoreExtension()) - ->useExtension(new CryptographyExtension($cryptographer)) - ->build(); - - $return = $hydrator->extract($object); - - self::assertSame($encryptedPayload, $return); - } - public function testHydrateWithNormalizerInBaseClass(): void { $expected = new NormalizerInBaseClassDefinedDto( diff --git a/tests/Unit/Normalizer/DateIntervalNormalizerTest.php b/tests/Unit/Normalizer/DateIntervalNormalizerTest.php index 824363a..142704b 100644 --- a/tests/Unit/Normalizer/DateIntervalNormalizerTest.php +++ b/tests/Unit/Normalizer/DateIntervalNormalizerTest.php @@ -14,13 +14,13 @@ final class DateIntervalNormalizerTest extends TestCase public function testNormalizeWithNull(): void { $normalizer = new DateIntervalNormalizer(); - self::assertNull($normalizer->normalize(null)); + self::assertNull($normalizer->normalize(null, [])); } public function testDenormalizeWithNull(): void { $normalizer = new DateIntervalNormalizer(); - self::assertNull($normalizer->denormalize(null)); + self::assertNull($normalizer->denormalize(null, [])); } public function testNormalizeWithInvalidArgument(): void @@ -29,7 +29,7 @@ public function testNormalizeWithInvalidArgument(): void $this->expectExceptionCode(0); $normalizer = new DateIntervalNormalizer(); - $normalizer->normalize(123); + $normalizer->normalize(123, []); } public function testDenormalizeWithInvalidArgument(): void @@ -38,25 +38,25 @@ public function testDenormalizeWithInvalidArgument(): void $this->expectExceptionCode(0); $normalizer = new DateIntervalNormalizer(); - $normalizer->denormalize(123); + $normalizer->denormalize(123, []); } public function testNormalizeWithValue(): void { $normalizer = new DateIntervalNormalizer(); - self::assertSame('P02Y02M25DT06H07M08S', $normalizer->normalize(new DateInterval('P2Y2M3W4DT6H7M8S'))); + self::assertSame('P02Y02M25DT06H07M08S', $normalizer->normalize(new DateInterval('P2Y2M3W4DT6H7M8S'), [])); } public function testNormalizeWithChangeFormat(): void { $normalizer = new DateIntervalNormalizer(format: 'P%YY%MM'); - self::assertSame('P02Y02M', $normalizer->normalize(new DateInterval('P2Y2M3W4DT6H7M8S'))); + self::assertSame('P02Y02M', $normalizer->normalize(new DateInterval('P2Y2M3W4DT6H7M8S'), [])); } public function testDenormalizeWithValue(): void { $normalizer = new DateIntervalNormalizer(); - $denormalized = $normalizer->denormalize('P00Y00M35DT00H00M00S'); + $denormalized = $normalizer->denormalize('P00Y00M35DT00H00M00S', []); self::assertNotNull($denormalized); $this->assertEqualInterval( @@ -68,7 +68,7 @@ public function testDenormalizeWithValue(): void public function testDenormalizeWithChangeFormat(): void { $normalizer = new DateIntervalNormalizer(format: 'P%YY'); - $denormalized = $normalizer->denormalize('P5Y'); + $denormalized = $normalizer->denormalize('P5Y', []); self::assertNotNull($denormalized); $this->assertEqualInterval( @@ -83,7 +83,7 @@ public function testDateIntervalErrorsAreCaughtAndReThrown(): void $this->expectExceptionMessage('Invalid serialized date interval string'); $this->expectExceptionCode(0); - (new DateIntervalNormalizer())->denormalize('Kermit'); + (new DateIntervalNormalizer())->denormalize('Kermit', []); } private function assertEqualInterval(DateInterval $a, DateInterval $b): void