diff --git a/README.md b/README.md index 3d1c70b..a544bb6 100644 --- a/README.md +++ b/README.md @@ -406,22 +406,83 @@ readonly class Dto } ``` +### Events + +Another way to intervene in the extract and hydrate process is through events. +There are two events: `PostExtract` and `PreHydrate`. +For this functionality we use the [symfony/event-dispatcher](https://symfony.com/doc/current/components/event_dispatcher.html). + +```php +use Patchlevel\Hydrator\Cryptography\PersonalDataPayloadCryptographer; +use Patchlevel\Hydrator\Cryptography\Store\CipherKeyStore; +use Patchlevel\Hydrator\Metadata\Event\EventMetadataFactory; +use Patchlevel\Hydrator\MetadataHydrator; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Patchlevel\Hydrator\Event\PostExtract; +use Patchlevel\Hydrator\Event\PreHydrate; + +$eventDispatcher = new EventDispatcher(); + +$eventDispatcher->addListener( + PostExtract::class, + static function (PostExtract $event): void { + // do something + } +); + +$eventDispatcher->addListener( + PreHydrate::class, + static function (PreHydrate $event): void { + // do something + } +); + +$hydrator = new MetadataHydrator(eventDispatcher: $eventDispatcher); +``` + ### Cryptography The library also offers the possibility to encrypt and decrypt personal data. +For this purpose, a key is created for each subject ID, which is used to encrypt the personal data. + +#### DataSubjectId + +First we need to define what the subject id is. + +```php +use Patchlevel\Hydrator\Attribute\DataSubjectId; + +final class EmailChanged +{ + public function __construct( + #[DataSubjectId] + public readonly string $profileId, + ) { + } +} +``` + +> [!WARNING] +> The `DataSubjectId` must be a string. You can use a normalizer to convert it to a string. +> The Subject ID cannot be personal data. #### PersonalData -First of all, we have to mark the fields that contain personal data. -For our example, we use events, but you can do the same with aggregates. +Next, we need to specify which fields we want to encrypt. ```php +use Patchlevel\Hydrator\Attribute\DataSubjectId; use Patchlevel\Hydrator\Attribute\PersonalData; final class DTO { - #[PersonalData] - public readonly string|null $email; + public function __construct( + #[DataSubjectId] + public readonly string $profileId, + #[PersonalData] + public readonly string|null $email, + ) { + } } ``` @@ -436,43 +497,40 @@ use Patchlevel\Hydrator\Attribute\PersonalData; final class DTO { public function __construct( + #[DataSubjectId] + public readonly string $profileId, #[PersonalData(fallback: 'unknown')] - public readonly string $email, + public readonly string $name, ) { } } ``` -> [!DANGER] -> You have to deal with this case in your business logic such as aggregates and subscriptions. - -> [!WARNING] -> You need to define a subject ID to use the personal data attribute. - -#### DataSubjectId - -In order for the correct key to be used, a subject ID must be defined. -Without Subject Id, no personal data can be encrypted or decrypted. +You can also use a callable as a fallback. ```php use Patchlevel\Hydrator\Attribute\DataSubjectId; use Patchlevel\Hydrator\Attribute\PersonalData; -final class EmailChanged +final class ProfileCreated { public function __construct( #[DataSubjectId] - public readonly string $personId, - #[PersonalData(fallback: 'unknown')] - public readonly string|null $email, + public readonly string $profileId, + #[PersonalData(fallback: 'deleted profile')] + public readonly string $name, + #[PersonalData(fallbackCallable: [self::class, 'anonymizedEmail'])] + public readonly string $email, ) { } + + public static function anonymizedEmail(string $subjectId): string + { + return sprintf('%s@anno.com', $subjectId); + } } ``` -> [!WARNING] -> A subject ID can not be a personal data. - #### Configure Cryptography Here we show you how to configure the cryptography. @@ -484,10 +542,14 @@ use Patchlevel\Hydrator\Metadata\Event\EventMetadataFactory; use Patchlevel\Hydrator\MetadataHydrator; $cipherKeyStore = new InMemoryCipherKeyStore(); -$cryptographer = PersonalDataPayloadCryptographer::createWithOpenssl($cipherKeyStore); +$cryptographer = PersonalDataPayloadCryptographer::createWithDefaultSettings($cipherKeyStore); $hydrator = new MetadataHydrator(cryptographer: $cryptographer); ``` +> [!WARNING] +> We recommend to use the `useEncryptedFieldName` option to recognize encrypted fields. +> This allows data to be encrypted later without big troubles. + #### Cipher Key Store The keys must be stored somewhere. For testing purposes, we offer an in-memory implementation. diff --git a/baseline.xml b/baseline.xml index b5a818d..7869dec 100644 --- a/baseline.xml +++ b/baseline.xml @@ -1,5 +1,5 @@ - + ivLength)]]> @@ -14,12 +14,14 @@ - fieldName()]]]> + fieldName()]]]> fieldName()]]]> fieldName()]]]> + + diff --git a/composer.json b/composer.json index 806e690..0c33c24 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ "require": { "php": "~8.2.0 || ~8.3.0 || ~8.4.0", "ext-openssl": "*", + "symfony/event-dispatcher": "^5.4.29|^6.4.0|^7.0.0", "symfony/type-info": "^7.2.4" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 2769cfe..5fed57d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "494504a9a9114a0867e7641e1b18f10c", + "content-hash": "a0e35a5f5bae3f912dd9256790a4aa52", "packages": [ { "name": "psr/container", @@ -59,18 +59,224 @@ }, "time": "2021-11-05T16:47:00+00:00" }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "910c5db85a5356d0fea57680defec4e99eb9c8c1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/910c5db85a5356d0fea57680defec4e99eb9c8c1", + "reference": "910c5db85a5356d0fea57680defec4e99eb9c8c1", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/error-handler": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.2.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.5.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/7642f5e970b672283b7823222ae8ef8bbc160b9f", + "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.5-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:20:29+00:00" + }, { "name": "symfony/type-info", - "version": "v7.2.4", + "version": "v7.2.5", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "269344575181c326781382ed53f7262feae3c6a4" + "reference": "c4824a6b658294c828e609d3d8dbb4e87f6a375d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/269344575181c326781382ed53f7262feae3c6a4", - "reference": "269344575181c326781382ed53f7262feae3c6a4", + "url": "https://api.github.com/repos/symfony/type-info/zipball/c4824a6b658294c828e609d3d8dbb4e87f6a375d", + "reference": "c4824a6b658294c828e609d3d8dbb4e87f6a375d", "shasum": "" }, "require": { @@ -116,7 +322,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v7.2.4" + "source": "https://github.com/symfony/type-info/tree/v7.2.5" }, "funding": [ { @@ -132,7 +338,7 @@ "type": "tidelift" } ], - "time": "2025-02-25T15:19:41+00:00" + "time": "2025-03-24T09:03:36+00:00" } ], "packages-dev": [ @@ -1032,79 +1238,6 @@ ], "time": "2024-02-09T13:06:12+00:00" }, - { - "name": "composer/package-versions-deprecated", - "version": "1.11.99.5", - "source": { - "type": "git", - "url": "https://github.com/composer/package-versions-deprecated.git", - "reference": "b4f54f74ef3453349c24a845d22392cd31e65f1d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/package-versions-deprecated/zipball/b4f54f74ef3453349c24a845d22392cd31e65f1d", - "reference": "b4f54f74ef3453349c24a845d22392cd31e65f1d", - "shasum": "" - }, - "require": { - "composer-plugin-api": "^1.1.0 || ^2.0", - "php": "^7 || ^8" - }, - "replace": { - "ocramius/package-versions": "1.11.99" - }, - "require-dev": { - "composer/composer": "^1.9.3 || ^2.0@dev", - "ext-zip": "^1.13", - "phpunit/phpunit": "^6.5 || ^7" - }, - "type": "composer-plugin", - "extra": { - "class": "PackageVersions\\Installer", - "branch-alias": { - "dev-master": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "PackageVersions\\": "src/PackageVersions" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com" - }, - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be" - } - ], - "description": "Composer plugin that provides efficient querying for installed package versions (no runtime IO)", - "support": { - "issues": "https://github.com/composer/package-versions-deprecated/issues", - "source": "https://github.com/composer/package-versions-deprecated/tree/1.11.99.5" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2022-01-17T14:14:24+00:00" - }, { "name": "composer/pcre", "version": "3.3.2", @@ -3980,35 +4113,32 @@ }, { "name": "psalm/plugin-phpunit", - "version": "0.19.3", + "version": "0.19.5", "source": { "type": "git", "url": "https://github.com/psalm/psalm-plugin-phpunit.git", - "reference": "07dbf9fec23a694f2c095d8e2d44ccd6992afe38" + "reference": "143f9d5e049fffcdbc0da3fbb99f6149f9d3e2dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/psalm/psalm-plugin-phpunit/zipball/07dbf9fec23a694f2c095d8e2d44ccd6992afe38", - "reference": "07dbf9fec23a694f2c095d8e2d44ccd6992afe38", + "url": "https://api.github.com/repos/psalm/psalm-plugin-phpunit/zipball/143f9d5e049fffcdbc0da3fbb99f6149f9d3e2dc", + "reference": "143f9d5e049fffcdbc0da3fbb99f6149f9d3e2dc", "shasum": "" }, "require": { - "composer/package-versions-deprecated": "^1.10", - "composer/semver": "^1.4 || ^2.0 || ^3.0", "ext-simplexml": "*", "php": ">=8.1", - "vimeo/psalm": "dev-master || ^6 || ^7" + "vimeo/psalm": "dev-master || ^6.10.0" }, "conflict": { - "phpunit/phpunit": "<7.5" + "phpspec/prophecy": "<1.20.0", + "phpspec/prophecy-phpunit": "<2.3.0", + "phpunit/phpunit": "<8.5.1" }, "require-dev": { - "behat/gherkin": "~4.11.0", - "codeception/codeception": "^4.0.3", "php": "^7.3 || ^8.0", - "phpunit/phpunit": "^7.5 || ^8.0 || ^9.0", + "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", "squizlabs/php_codesniffer": "^3.3.1", - "weirdan/codeception-psalm-module": "^0.11.0", "weirdan/prophecy-shim": "^1.0 || ^2.0" }, "type": "psalm-plugin", @@ -4035,9 +4165,9 @@ "description": "Psalm plugin for PHPUnit", "support": { "issues": "https://github.com/psalm/psalm-plugin-phpunit/issues", - "source": "https://github.com/psalm/psalm-plugin-phpunit/tree/0.19.3" + "source": "https://github.com/psalm/psalm-plugin-phpunit/tree/0.19.5" }, - "time": "2025-03-20T11:21:58+00:00" + "time": "2025-03-31T18:49:55+00:00" }, { "name": "psr/cache", @@ -5536,16 +5666,16 @@ }, { "name": "slevomat/coding-standard", - "version": "8.16.1", + "version": "8.16.2", "source": { "type": "git", "url": "https://github.com/slevomat/coding-standard.git", - "reference": "490023f23813483b5f75381c4ee07d26d9edced1" + "reference": "8bf0408a9cf30687d87957d364de9a3d5d00d948" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/490023f23813483b5f75381c4ee07d26d9edced1", - "reference": "490023f23813483b5f75381c4ee07d26d9edced1", + "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/8bf0408a9cf30687d87957d364de9a3d5d00d948", + "reference": "8bf0408a9cf30687d87957d364de9a3d5d00d948", "shasum": "" }, "require": { @@ -5557,11 +5687,11 @@ "require-dev": { "phing/phing": "3.0.1", "php-parallel-lint/php-parallel-lint": "1.4.0", - "phpstan/phpstan": "2.1.6", + "phpstan/phpstan": "2.1.11", "phpstan/phpstan-deprecation-rules": "2.0.1", - "phpstan/phpstan-phpunit": "2.0.4", - "phpstan/phpstan-strict-rules": "2.0.3", - "phpunit/phpunit": "9.6.8|10.5.45|11.4.4|11.5.9|12.0.4" + "phpstan/phpstan-phpunit": "2.0.6", + "phpstan/phpstan-strict-rules": "2.0.4", + "phpunit/phpunit": "9.6.8|10.5.45|11.4.4|11.5.15|12.0.10" }, "type": "phpcodesniffer-standard", "extra": { @@ -5585,7 +5715,7 @@ ], "support": { "issues": "https://github.com/slevomat/coding-standard/issues", - "source": "https://github.com/slevomat/coding-standard/tree/8.16.1" + "source": "https://github.com/slevomat/coding-standard/tree/8.16.2" }, "funding": [ { @@ -5597,7 +5727,7 @@ "type": "tidelift" } ], - "time": "2025-03-23T16:33:42+00:00" + "time": "2025-03-27T19:37:58+00:00" }, { "name": "spatie/array-to-xml", @@ -5753,16 +5883,16 @@ }, { "name": "symfony/console", - "version": "v7.2.1", + "version": "v7.2.5", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "fefcc18c0f5d0efe3ab3152f15857298868dc2c3" + "reference": "e51498ea18570c062e7df29d05a7003585b19b88" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/fefcc18c0f5d0efe3ab3152f15857298868dc2c3", - "reference": "fefcc18c0f5d0efe3ab3152f15857298868dc2c3", + "url": "https://api.github.com/repos/symfony/console/zipball/e51498ea18570c062e7df29d05a7003585b19b88", + "reference": "e51498ea18570c062e7df29d05a7003585b19b88", "shasum": "" }, "require": { @@ -5826,7 +5956,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.2.1" + "source": "https://github.com/symfony/console/tree/v7.2.5" }, "funding": [ { @@ -5842,7 +5972,7 @@ "type": "tidelift" } ], - "time": "2024-12-11T03:49:26+00:00" + "time": "2025-03-12T08:11:12+00:00" }, { "name": "symfony/deprecation-contracts", @@ -6504,16 +6634,16 @@ }, { "name": "symfony/process", - "version": "v7.2.4", + "version": "v7.2.5", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "d8f411ff3c7ddc4ae9166fb388d1190a2df5b5cf" + "reference": "87b7c93e57df9d8e39a093d32587702380ff045d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/d8f411ff3c7ddc4ae9166fb388d1190a2df5b5cf", - "reference": "d8f411ff3c7ddc4ae9166fb388d1190a2df5b5cf", + "url": "https://api.github.com/repos/symfony/process/zipball/87b7c93e57df9d8e39a093d32587702380ff045d", + "reference": "87b7c93e57df9d8e39a093d32587702380ff045d", "shasum": "" }, "require": { @@ -6545,7 +6675,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.2.4" + "source": "https://github.com/symfony/process/tree/v7.2.5" }, "funding": [ { @@ -6561,7 +6691,7 @@ "type": "tidelift" } ], - "time": "2025-02-05T08:33:46+00:00" + "time": "2025-03-13T12:21:46+00:00" }, { "name": "symfony/service-contracts", @@ -7007,16 +7137,16 @@ }, { "name": "vimeo/psalm", - "version": "6.9.4", + "version": "6.10.0", "source": { "type": "git", "url": "https://github.com/vimeo/psalm.git", - "reference": "e052de4275fb6cdbfedfef1d883a7e90732ea609" + "reference": "9c0add4eb88d4b169ac04acb7c679918cbb9c252" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vimeo/psalm/zipball/e052de4275fb6cdbfedfef1d883a7e90732ea609", - "reference": "e052de4275fb6cdbfedfef1d883a7e90732ea609", + "url": "https://api.github.com/repos/vimeo/psalm/zipball/9c0add4eb88d4b169ac04acb7c679918cbb9c252", + "reference": "9c0add4eb88d4b169ac04acb7c679918cbb9c252", "shasum": "" }, "require": { @@ -7121,7 +7251,7 @@ "issues": "https://github.com/vimeo/psalm/issues", "source": "https://github.com/vimeo/psalm" }, - "time": "2025-03-20T13:21:28+00:00" + "time": "2025-03-31T10:12:50+00:00" }, { "name": "webmozart/assert", diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 388af2f..e9a9e84 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -24,6 +24,12 @@ parameters: count: 1 path: src/Metadata/AttributeMetadataFactory.php + - + message: '#^Method Patchlevel\\Hydrator\\Metadata\\AttributeMetadataFactory\:\:getPersonalData\(\) should return array\{bool, mixed, \(callable\(string, mixed\)\: mixed\)\|null\} but returns array\{false, null\}\.$#' + identifier: return.type + count: 1 + path: src/Metadata/AttributeMetadataFactory.php + - message: '#^Parameter \#1 \$objectOrClass of class ReflectionClass constructor expects class\-string\\|T of object, string given\.$#' identifier: argument.type diff --git a/src/Attribute/PersonalData.php b/src/Attribute/PersonalData.php index 7ea6ff7..5397d43 100644 --- a/src/Attribute/PersonalData.php +++ b/src/Attribute/PersonalData.php @@ -5,12 +5,22 @@ namespace Patchlevel\Hydrator\Attribute; use Attribute; +use InvalidArgumentException; #[Attribute(Attribute::TARGET_PROPERTY)] final class PersonalData { + /** @var (callable(string, mixed):mixed)|null */ + public readonly mixed $fallbackCallable; + public function __construct( public readonly mixed $fallback = null, + callable|null $fallbackCallable = null, ) { + $this->fallbackCallable = $fallbackCallable; + + if ($this->fallbackCallable !== null && $this->fallback !== null) { + throw new InvalidArgumentException('You can only set one of fallback or fallbackCallable'); + } } } diff --git a/src/Cryptography/CryptographySubscriber.php b/src/Cryptography/CryptographySubscriber.php new file mode 100644 index 0000000..23e5f38 --- /dev/null +++ b/src/Cryptography/CryptographySubscriber.php @@ -0,0 +1,36 @@ +data = $this->cryptography->decrypt($event->metadata, $event->data); + } + + public function postExtract(PostExtract $event): void + { + $event->data = $this->cryptography->encrypt($event->metadata, $event->data); + } + + /** @return array> */ + public static function getSubscribedEvents(): array + { + return [ + PreHydrate::class => 'preHydrate', + PostExtract::class => 'postExtract', + ]; + } +} diff --git a/src/Cryptography/PersonalDataPayloadCryptographer.php b/src/Cryptography/PersonalDataPayloadCryptographer.php index ac16a52..d9f2151 100644 --- a/src/Cryptography/PersonalDataPayloadCryptographer.php +++ b/src/Cryptography/PersonalDataPayloadCryptographer.php @@ -12,6 +12,7 @@ use Patchlevel\Hydrator\Cryptography\Store\CipherKeyNotExists; use Patchlevel\Hydrator\Cryptography\Store\CipherKeyStore; use Patchlevel\Hydrator\Metadata\ClassMetadata; +use Patchlevel\Hydrator\Metadata\PropertyMetadata; use function array_key_exists; use function is_int; @@ -23,6 +24,8 @@ public function __construct( private readonly CipherKeyStore $cipherKeyStore, private readonly CipherKeyFactory $cipherKeyFactory, private readonly Cipher $cipher, + private readonly bool $useEncryptedFieldName = false, + private readonly bool $fallbackToFieldName = false, ) { } @@ -51,10 +54,20 @@ public function encrypt(ClassMetadata $metadata, array $data): array continue; } - $data[$propertyMetadata->fieldName()] = $this->cipher->encrypt( + $targetFieldName = $this->useEncryptedFieldName + ? $propertyMetadata->encryptedFieldName() + : $propertyMetadata->fieldName(); + + $data[$targetFieldName] = $this->cipher->encrypt( $cipherKey, $data[$propertyMetadata->fieldName()], ); + + if (!$this->useEncryptedFieldName) { + continue; + } + + unset($data[$propertyMetadata->fieldName()]); } return $data; @@ -84,18 +97,27 @@ public function decrypt(ClassMetadata $metadata, array $data): array continue; } + if ($this->useEncryptedFieldName && array_key_exists($propertyMetadata->encryptedFieldName(), $data)) { + $rawData = $data[$propertyMetadata->encryptedFieldName()]; + unset($data[$propertyMetadata->encryptedFieldName()]); + } elseif (!$this->useEncryptedFieldName || $this->fallbackToFieldName) { + $rawData = $data[$propertyMetadata->fieldName()]; + } else { + continue; + } + if (!$cipherKey) { - $data[$propertyMetadata->fieldName()] = $propertyMetadata->personalDataFallback(); + $data[$propertyMetadata->fieldName()] = $this->fallback($propertyMetadata, $subjectId, $rawData); continue; } try { $data[$propertyMetadata->fieldName()] = $this->cipher->decrypt( $cipherKey, - $data[$propertyMetadata->fieldName()], + $rawData, ); } catch (DecryptionFailed) { - $data[$propertyMetadata->fieldName()] = $propertyMetadata->personalDataFallback(); + $data[$propertyMetadata->fieldName()] = $this->fallback($propertyMetadata, $subjectId, $rawData); } } @@ -128,15 +150,43 @@ private function subjectId(ClassMetadata $metadata, array $data): string|null return $subjectId; } + private function fallback(PropertyMetadata $propertyMetadata, string $subjectId, mixed $rawData): mixed + { + $callback = $propertyMetadata->personalDataFallbackCallback(); + + if (!$callback) { + return $propertyMetadata->personalDataFallback(); + } + + return $callback($subjectId, $rawData); + } + /** @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/Event/PostExtract.php b/src/Event/PostExtract.php new file mode 100644 index 0000000..55a45b9 --- /dev/null +++ b/src/Event/PostExtract.php @@ -0,0 +1,17 @@ + $data */ + public function __construct( + public array $data, + public readonly ClassMetadata $metadata, + ) { + } +} diff --git a/src/Event/PreHydrate.php b/src/Event/PreHydrate.php new file mode 100644 index 0000000..f6f86b6 --- /dev/null +++ b/src/Event/PreHydrate.php @@ -0,0 +1,17 @@ + $data */ + public function __construct( + public array $data, + public readonly ClassMetadata $metadata, + ) { + } +} diff --git a/src/Metadata/AttributeMetadataFactory.php b/src/Metadata/AttributeMetadataFactory.php index d455226..c6b7458 100644 --- a/src/Metadata/AttributeMetadataFactory.php +++ b/src/Metadata/AttributeMetadataFactory.php @@ -153,14 +153,11 @@ private function getPropertyMetadataList(ReflectionClass $reflectionClass): arra ); } - [$isPersonalData, $personalDataFallback] = $this->getPersonalData($reflectionProperty); - $properties[$fieldName] = new PropertyMetadata( $reflectionProperty, $fieldName, $this->getNormalizer($reflectionProperty), - $isPersonalData, - $personalDataFallback, + ...$this->getPersonalData($reflectionProperty), ); } @@ -357,7 +354,7 @@ private function getSubjectIdField(ReflectionClass $reflectionClass): string|nul return $this->getFieldName($property); } - /** @return array{bool, mixed} */ + /** @return array{bool, mixed, (callable(string, mixed):mixed)|null} */ private function getPersonalData(ReflectionProperty $reflectionProperty): array { $attributeReflectionList = $reflectionProperty->getAttributes(PersonalData::class); @@ -368,7 +365,7 @@ private function getPersonalData(ReflectionProperty $reflectionProperty): array $attribute = $attributeReflectionList[0]->newInstance(); - return [true, $attribute->fallback]; + return [true, $attribute->fallback, $attribute->fallbackCallable]; } private function validate(ClassMetadata $metadata): void @@ -423,7 +420,7 @@ private function findNormalizer(ReflectionProperty $reflectionProperty, Type $ty $normalizer = $this->findNormalizerOnClass(new ReflectionClass($valueType->getClassName())); if ($normalizer === null) { - return $normalizer; + return null; } if ($normalizer instanceof TypeAwareNormalizer) { diff --git a/src/Metadata/PropertyMetadata.php b/src/Metadata/PropertyMetadata.php index 41e2bcc..b4b4544 100644 --- a/src/Metadata/PropertyMetadata.php +++ b/src/Metadata/PropertyMetadata.php @@ -4,9 +4,13 @@ namespace Patchlevel\Hydrator\Metadata; +use Closure; +use InvalidArgumentException; use Patchlevel\Hydrator\Normalizer\Normalizer; use ReflectionProperty; +use function str_starts_with; + /** * @psalm-type serialized = array{ * className: class-string, @@ -19,13 +23,20 @@ */ final class PropertyMetadata { + private const ENCRYPTED_PREFIX = '!'; + + /** @param (callable(string, mixed):mixed)|null $personalDataFallbackCallable */ public function __construct( private readonly ReflectionProperty $reflection, private readonly string $fieldName, private readonly Normalizer|null $normalizer = null, private readonly bool $isPersonalData = false, private readonly mixed $personalDataFallback = null, + private readonly mixed $personalDataFallbackCallable = null, ) { + if (str_starts_with($fieldName, self::ENCRYPTED_PREFIX)) { + throw new InvalidArgumentException('fieldName must not start with !'); + } } public function reflection(): ReflectionProperty @@ -43,6 +54,11 @@ public function fieldName(): string return $this->fieldName; } + public function encryptedFieldName(): string + { + return self::ENCRYPTED_PREFIX . $this->fieldName; + } + public function normalizer(): Normalizer|null { return $this->normalizer; @@ -68,6 +84,16 @@ public function personalDataFallback(): mixed return $this->personalDataFallback; } + /** @return (Closure(string, mixed):mixed)|null */ + public function personalDataFallbackCallback(): Closure|null + { + if ($this->personalDataFallbackCallable) { + return ($this->personalDataFallbackCallable)(...); + } + + return null; + } + /** @return serialized */ public function __serialize(): array { diff --git a/src/MetadataHydrator.php b/src/MetadataHydrator.php index c32d2fd..33443c0 100644 --- a/src/MetadataHydrator.php +++ b/src/MetadataHydrator.php @@ -4,13 +4,18 @@ namespace Patchlevel\Hydrator; +use Patchlevel\Hydrator\Cryptography\CryptographySubscriber; use Patchlevel\Hydrator\Cryptography\PayloadCryptographer; +use Patchlevel\Hydrator\Event\PostExtract; +use Patchlevel\Hydrator\Event\PreHydrate; use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\Metadata\ClassNotFound; use Patchlevel\Hydrator\Metadata\MetadataFactory; use Patchlevel\Hydrator\Normalizer\HydratorAwareNormalizer; use ReflectionParameter; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Throwable; use TypeError; @@ -26,8 +31,20 @@ final class MetadataHydrator implements Hydrator public function __construct( private readonly MetadataFactory $metadataFactory = new AttributeMetadataFactory(), - private readonly PayloadCryptographer|null $cryptographer = null, + PayloadCryptographer|null $cryptographer = null, + private EventDispatcherInterface|null $eventDispatcher = null, ) { + if (!$cryptographer) { + return; + } + + if (!$this->eventDispatcher) { + $this->eventDispatcher = new EventDispatcher(); + } + + $this->eventDispatcher->addSubscriber( + new CryptographySubscriber($cryptographer), + ); } /** @@ -46,8 +63,8 @@ public function hydrate(string $class, array $data): object throw new ClassNotSupported($class, $e); } - if ($this->cryptographer) { - $data = $this->cryptographer->decrypt($metadata, $data); + if ($this->eventDispatcher) { + $data = $this->eventDispatcher->dispatch(new PreHydrate($data, $metadata))->data; } $object = $metadata->newInstance(); @@ -173,8 +190,8 @@ public function extract(object $object): array $data[$propertyMetadata->fieldName()] = $value; } - if ($this->cryptographer) { - return $this->cryptographer->encrypt($metadata, $data); + if ($this->eventDispatcher) { + return $this->eventDispatcher->dispatch(new PostExtract($data, $metadata))->data; } return $data; diff --git a/tests/Benchmark/Fixture/PersonalDataProfileCreated.php b/tests/Benchmark/Fixture/PersonalDataProfileCreated.php deleted file mode 100644 index dc6666f..0000000 --- a/tests/Benchmark/Fixture/PersonalDataProfileCreated.php +++ /dev/null @@ -1,20 +0,0 @@ - $skills */ public function __construct( #[ProfileIdNormalizer] + #[DataSubjectId] public ProfileId $profileId, + #[PersonalData(fallback: 'unknown')] public string $name, - /** @var list */ public array $skills = [], ) { } diff --git a/tests/Benchmark/HydratorBench.php b/tests/Benchmark/HydratorBench.php index 43365b1..6ea2eb7 100644 --- a/tests/Benchmark/HydratorBench.php +++ b/tests/Benchmark/HydratorBench.php @@ -23,19 +23,22 @@ public function __construct() public function setUp(): void { - $object = $this->hydrator->hydrate(ProfileCreated::class, [ - 'profileId' => '1', - 'name' => 'foo', - 'skills' => [ - ['name' => 'php'], - ['name' => 'symfony'], + $object = $this->hydrator->hydrate( + ProfileCreated::class, + [ + 'profileId' => '1', + 'name' => 'foo', + 'skills' => [ + ['name' => 'php'], + ['name' => 'symfony'], + ], ], - ]); + ); $this->hydrator->extract($object); } - #[Bench\Revs(10)] + #[Bench\Revs(5)] public function benchHydrate1Object(): void { $this->hydrator->hydrate(ProfileCreated::class, [ @@ -48,7 +51,7 @@ public function benchHydrate1Object(): void ]); } - #[Bench\Revs(10)] + #[Bench\Revs(5)] public function benchExtract1Object(): void { $object = new ProfileCreated( diff --git a/tests/Benchmark/HydratorWithCryptographyBench.php b/tests/Benchmark/HydratorWithCryptographyBench.php index fc055f6..f3b076b 100644 --- a/tests/Benchmark/HydratorWithCryptographyBench.php +++ b/tests/Benchmark/HydratorWithCryptographyBench.php @@ -8,8 +8,9 @@ use Patchlevel\Hydrator\Cryptography\Store\InMemoryCipherKeyStore; use Patchlevel\Hydrator\Hydrator; use Patchlevel\Hydrator\MetadataHydrator; -use Patchlevel\Hydrator\Tests\Benchmark\Fixture\PersonalDataProfileCreated; +use Patchlevel\Hydrator\Tests\Benchmark\Fixture\ProfileCreated; use Patchlevel\Hydrator\Tests\Benchmark\Fixture\ProfileId; +use Patchlevel\Hydrator\Tests\Benchmark\Fixture\Skill; use PhpBench\Attributes as Bench; #[Bench\BeforeMethods('setUp')] @@ -24,7 +25,7 @@ public function __construct() $this->store = new InMemoryCipherKeyStore(); $this->hydrator = new MetadataHydrator( - cryptographer: PersonalDataPayloadCryptographer::createWithOpenssl($this->store), + cryptographer: PersonalDataPayloadCryptographer::createWithDefaultSettings($this->store), ); } @@ -32,67 +33,116 @@ public function setUp(): void { $this->store->clear(); - $object = $this->hydrator->hydrate(PersonalDataProfileCreated::class, [ - 'profileId' => '1', - 'name' => 'foo', - ]); + $object = $this->hydrator->hydrate( + ProfileCreated::class, + [ + 'profileId' => '1', + 'name' => 'foo', + 'skills' => [ + ['name' => 'php'], + ['name' => 'symfony'], + ], + ], + ); $this->hydrator->extract($object); } - #[Bench\Revs(10)] + #[Bench\Revs(5)] public function benchHydrate1Object(): void { - $this->hydrator->hydrate(PersonalDataProfileCreated::class, [ - 'profileId' => '1', - 'name' => 'foo', - ]); + $this->hydrator->hydrate( + ProfileCreated::class, + [ + 'profileId' => '1', + 'name' => 'foo', + 'skills' => [ + ['name' => 'php'], + ['name' => 'symfony'], + ], + ], + ); } - #[Bench\Revs(10)] + #[Bench\Revs(5)] public function benchExtract1Object(): void { - $object = new PersonalDataProfileCreated(ProfileId::fromString('1'), 'foo'); + $object = new ProfileCreated( + ProfileId::fromString('1'), + 'foo', + [ + new Skill('php'), + new Skill('symfony'), + ], + ); $this->hydrator->extract($object); } - #[Bench\Revs(10)] + #[Bench\Revs(5)] public function benchHydrate1000Objects(): void { for ($i = 0; $i < 1_000; $i++) { - $this->hydrator->hydrate(PersonalDataProfileCreated::class, [ - 'profileId' => '1', - 'name' => 'foo', - ]); + $this->hydrator->hydrate( + ProfileCreated::class, + [ + 'profileId' => '1', + 'name' => 'foo', + 'skills' => [ + ['name' => 'php'], + ['name' => 'symfony'], + ], + ], + ); } } - #[Bench\Revs(10)] + #[Bench\Revs(5)] public function benchExtract1000Objects(): void { - $object = new PersonalDataProfileCreated(ProfileId::fromString('1'), 'foo'); + $object = new ProfileCreated( + ProfileId::fromString('1'), + 'foo', + [ + new Skill('php'), + new Skill('symfony'), + ], + ); for ($i = 0; $i < 1_000; $i++) { $this->hydrator->extract($object); } } - #[Bench\Revs(10)] + #[Bench\Revs(5)] public function benchHydrate1000000Objects(): void { for ($i = 0; $i < 1_000_000; $i++) { - $this->hydrator->hydrate(PersonalDataProfileCreated::class, [ - 'profileId' => '1', - 'name' => 'foo', - ]); + $this->hydrator->hydrate( + ProfileCreated::class, + [ + 'profileId' => '1', + 'name' => 'foo', + 'skills' => [ + ['name' => 'php'], + ['name' => 'symfony'], + ], + ], + ); } } - #[Bench\Revs(10)] + #[Bench\Revs(5)] public function benchExtract1000000Objects(): void { - $object = new PersonalDataProfileCreated(ProfileId::fromString('1'), 'foo'); + $object = new ProfileCreated( + ProfileId::fromString('1'), + 'foo', + [ + new Skill('php'), + new Skill('symfony'), + ], + ); for ($i = 0; $i < 1_000_000; $i++) { $this->hydrator->extract($object); diff --git a/tests/Unit/Cryptography/CryptographySubscriberTest.php b/tests/Unit/Cryptography/CryptographySubscriberTest.php new file mode 100644 index 0000000..ba1af50 --- /dev/null +++ b/tests/Unit/Cryptography/CryptographySubscriberTest.php @@ -0,0 +1,76 @@ + 'preHydrate', + PostExtract::class => 'postExtract', + ], CryptographySubscriber::getSubscribedEvents()); + } + + public function testPreHydrate(): void + { + $metadata = new ClassMetadata( + new ReflectionClass(stdClass::class), + ); + + $event = new PreHydrate( + ['foo' => 'bar'], + $metadata, + ); + + $cryptographer = $this->prophesize(PayloadCryptographer::class); + $cryptographer->decrypt( + $metadata, + ['foo' => 'bar'], + )->willReturn(['foo' => 'baz'])->shouldBeCalledOnce(); + + $subscriber = new CryptographySubscriber($cryptographer->reveal()); + $subscriber->preHydrate($event); + + self::assertEquals(['foo' => 'baz'], $event->data); + } + + public function testPostExtract(): void + { + $metadata = new ClassMetadata( + new ReflectionClass(stdClass::class), + ); + + $event = new PostExtract( + ['foo' => 'bar'], + $metadata, + ); + + $cryptographer = $this->prophesize(PayloadCryptographer::class); + $cryptographer->encrypt( + $metadata, + ['foo' => 'bar'], + )->willReturn(['foo' => 'baz'])->shouldBeCalledOnce(); + + $subscriber = new CryptographySubscriber($cryptographer->reveal()); + $subscriber->postExtract($event); + + self::assertEquals(['foo' => 'baz'], $event->data); + } +} diff --git a/tests/Unit/Cryptography/PersonalDataPayloadCryptographerTest.php b/tests/Unit/Cryptography/PersonalDataPayloadCryptographerTest.php index 9f1df3c..fefbb2b 100644 --- a/tests/Unit/Cryptography/PersonalDataPayloadCryptographerTest.php +++ b/tests/Unit/Cryptography/PersonalDataPayloadCryptographerTest.php @@ -18,6 +18,7 @@ use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\Tests\Unit\Fixture\Email; use Patchlevel\Hydrator\Tests\Unit\Fixture\PersonalDataProfileCreated; +use Patchlevel\Hydrator\Tests\Unit\Fixture\PersonalDataProfileCreatedFallbackCallback; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -112,6 +113,39 @@ public function testEncryptWithExistingKey(): void self::assertEquals(['id' => 'foo', 'email' => 'encrypted'], $result); } + public function testEncryptWithExistingKeyEncryptedFieldName(): void + { + $cipherKey = new CipherKey( + 'foo', + 'bar', + 'baz', + ); + + $cipherKeyStore = $this->prophesize(CipherKeyStore::class); + $cipherKeyStore->get('foo')->willReturn($cipherKey); + $cipherKeyStore->store('foo', Argument::type(CipherKey::class))->shouldNotBeCalled(); + + $cipherKeyFactory = $this->prophesize(CipherKeyFactory::class); + $cipherKeyFactory->__invoke()->shouldNotBeCalled(); + + $cipher = $this->prophesize(Cipher::class); + $cipher + ->encrypt($cipherKey, 'info@patchlevel.de') + ->willReturn('encrypted') + ->shouldBeCalledOnce(); + + $cryptographer = new PersonalDataPayloadCryptographer( + $cipherKeyStore->reveal(), + $cipherKeyFactory->reveal(), + $cipher->reveal(), + true, + ); + + $result = $cryptographer->encrypt($this->metadata(PersonalDataProfileCreated::class), ['id' => 'foo', 'email' => 'info@patchlevel.de']); + + self::assertEquals(['id' => 'foo', '!email' => 'encrypted'], $result); + } + public function testSkipDecrypt(): void { $cipherKeyStore = $this->prophesize(CipherKeyStore::class); @@ -187,7 +221,134 @@ public function testDecryptWithInvalidKey(): void self::assertEquals(['id' => 'foo', 'email' => new Email('unknown')], $result); } - public function testDecryptWithExistingKey(): void + public function testDecryptWithInvalidKeyWithFallbackCallback(): void + { + $cipherKey = new CipherKey( + 'foo', + 'bar', + 'baz', + ); + + $cipherKeyStore = $this->prophesize(CipherKeyStore::class); + $cipherKeyStore->get('foo')->willReturn($cipherKey); + $cipherKeyStore->store('foo', Argument::type(CipherKey::class))->shouldNotBeCalled(); + + $cipherKeyFactory = $this->prophesize(CipherKeyFactory::class); + $cipherKeyFactory->__invoke()->shouldNotBeCalled(); + + $cipher = $this->prophesize(Cipher::class); + $cipher + ->decrypt($cipherKey, 'encrypted') + ->willThrow(new DecryptionFailed()) + ->shouldBeCalledOnce(); + + $cryptographer = new PersonalDataPayloadCryptographer( + $cipherKeyStore->reveal(), + $cipherKeyFactory->reveal(), + $cipher->reveal(), + ); + + $result = $cryptographer->decrypt($this->metadata(PersonalDataProfileCreatedFallbackCallback::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->prophesize(CipherKeyStore::class); + $cipherKeyStore->get('foo')->willReturn($cipherKey); + $cipherKeyStore->store('foo', Argument::type(CipherKey::class))->shouldNotBeCalled(); + + $cipherKeyFactory = $this->prophesize(CipherKeyFactory::class); + $cipherKeyFactory->__invoke()->shouldNotBeCalled(); + + $cipher = $this->prophesize(Cipher::class); + $cipher + ->decrypt($cipherKey, 'encrypted') + ->willReturn('info@patchlevel.de') + ->shouldBeCalledOnce(); + + $cryptographer = new PersonalDataPayloadCryptographer( + $cipherKeyStore->reveal(), + $cipherKeyFactory->reveal(), + $cipher->reveal(), + false, + ); + + $result = $cryptographer->decrypt($this->metadata(PersonalDataProfileCreated::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->prophesize(CipherKeyStore::class); + $cipherKeyStore->get('foo')->willReturn($cipherKey); + $cipherKeyStore->store('foo', Argument::type(CipherKey::class))->shouldNotBeCalled(); + + $cipherKeyFactory = $this->prophesize(CipherKeyFactory::class); + $cipherKeyFactory->__invoke()->shouldNotBeCalled(); + + $cipher = $this->prophesize(Cipher::class); + $cipher + ->decrypt($cipherKey, 'encrypted') + ->willReturn('info@patchlevel.de') + ->shouldBeCalledOnce(); + + $cryptographer = new PersonalDataPayloadCryptographer( + $cipherKeyStore->reveal(), + $cipherKeyFactory->reveal(), + $cipher->reveal(), + true, + ); + + $result = $cryptographer->decrypt($this->metadata(PersonalDataProfileCreated::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->prophesize(CipherKeyStore::class); + $cipherKeyStore->get('foo')->willReturn($cipherKey); + $cipherKeyStore->store('foo', Argument::type(CipherKey::class))->shouldNotBeCalled(); + + $cipherKeyFactory = $this->prophesize(CipherKeyFactory::class); + $cipherKeyFactory->__invoke()->shouldNotBeCalled(); + + $cipher = $this->prophesize(Cipher::class); + + $cryptographer = new PersonalDataPayloadCryptographer( + $cipherKeyStore->reveal(), + $cipherKeyFactory->reveal(), + $cipher->reveal(), + true, + ); + + $result = $cryptographer->decrypt($this->metadata(PersonalDataProfileCreated::class), ['id' => 'foo', 'email' => 'info@patchlevel.de']); + + self::assertEquals(['id' => 'foo', 'email' => 'info@patchlevel.de'], $result); + } + + public function testDecryptWithValidKeyAndEncryptedFieldNameAndFallbackFieldName(): void { $cipherKey = new CipherKey( 'foo', @@ -212,6 +373,8 @@ public function testDecryptWithExistingKey(): void $cipherKeyStore->reveal(), $cipherKeyFactory->reveal(), $cipher->reveal(), + true, + true, ); $result = $cryptographer->decrypt($this->metadata(PersonalDataProfileCreated::class), ['id' => 'foo', 'email' => 'encrypted']); diff --git a/tests/Unit/Fixture/PersonalDataProfileCreatedFallbackCallback.php b/tests/Unit/Fixture/PersonalDataProfileCreatedFallbackCallback.php new file mode 100644 index 0000000..4a21855 --- /dev/null +++ b/tests/Unit/Fixture/PersonalDataProfileCreatedFallbackCallback.php @@ -0,0 +1,28 @@ +postHydrateCallbacks()); } + public function testSameMetadata(): void + { + $object = new class { + }; + + $metadataFactory = new AttributeMetadataFactory(); + + self::assertSame($metadataFactory->metadata($object::class), $metadataFactory->metadata($object::class)); + } + public function testNotFoundProperty(): void { $this->expectException(PropertyMetadataNotFound::class); diff --git a/tests/Unit/MetadataHydratorTest.php b/tests/Unit/MetadataHydratorTest.php index 11f5e15..33f1a2c 100644 --- a/tests/Unit/MetadataHydratorTest.php +++ b/tests/Unit/MetadataHydratorTest.php @@ -11,6 +11,8 @@ use Patchlevel\Hydrator\ClassNotSupported; use Patchlevel\Hydrator\Cryptography\PayloadCryptographer; use Patchlevel\Hydrator\DenormalizationFailure; +use Patchlevel\Hydrator\Event\PostExtract; +use Patchlevel\Hydrator\Event\PreHydrate; use Patchlevel\Hydrator\Metadata\AttributeMetadataFactory; use Patchlevel\Hydrator\MetadataHydrator; use Patchlevel\Hydrator\NormalizationFailure; @@ -37,6 +39,7 @@ use Patchlevel\Hydrator\TypeMismatch; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; final class MetadataHydratorTest extends TestCase { @@ -298,6 +301,76 @@ public function testEncrypt(): void self::assertSame($encryptedPayload, $return); } + public function testPreHydrate(): 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(); + + $event = new PreHydrate( + $encryptedPayload, + $metadataFactory->metadata(ProfileCreated::class), + ); + + $eventReturn = new PreHydrate( + $payload, + $metadataFactory->metadata(ProfileCreated::class), + ); + + $eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $eventDispatcher + ->dispatch($event) + ->willReturn($eventReturn) + ->shouldBeCalledOnce(); + + $hydrator = new MetadataHydrator($metadataFactory, eventDispatcher: $eventDispatcher->reveal()); + + $return = $hydrator->hydrate(ProfileCreated::class, $encryptedPayload); + + self::assertEquals($object, $return); + } + + public function testPostExtract(): 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(); + + $event = new PostExtract( + $payload, + $metadataFactory->metadata(ProfileCreated::class), + ); + + $eventReturn = new PostExtract( + $encryptedPayload, + $metadataFactory->metadata(ProfileCreated::class), + ); + + $eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $eventDispatcher + ->dispatch($event) + ->willReturn($eventReturn) + ->shouldBeCalledOnce(); + + $hydrator = new MetadataHydrator($metadataFactory, eventDispatcher: $eventDispatcher->reveal()); + + $return = $hydrator->extract($object); + + self::assertSame($encryptedPayload, $return); + } + public function testHydrateWithNormalizerInBaseClass(): void { $expected = new NormalizerInBaseClassDefinedDto( diff --git a/tools/composer.lock b/tools/composer.lock index d304a5d..48bcfc2 100644 --- a/tools/composer.lock +++ b/tools/composer.lock @@ -1643,16 +1643,16 @@ }, { "name": "symfony/console", - "version": "v7.2.1", + "version": "v7.2.5", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "fefcc18c0f5d0efe3ab3152f15857298868dc2c3" + "reference": "e51498ea18570c062e7df29d05a7003585b19b88" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/fefcc18c0f5d0efe3ab3152f15857298868dc2c3", - "reference": "fefcc18c0f5d0efe3ab3152f15857298868dc2c3", + "url": "https://api.github.com/repos/symfony/console/zipball/e51498ea18570c062e7df29d05a7003585b19b88", + "reference": "e51498ea18570c062e7df29d05a7003585b19b88", "shasum": "" }, "require": { @@ -1716,7 +1716,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.2.1" + "source": "https://github.com/symfony/console/tree/v7.2.5" }, "funding": [ { @@ -1732,7 +1732,7 @@ "type": "tidelift" } ], - "time": "2024-12-11T03:49:26+00:00" + "time": "2025-03-12T08:11:12+00:00" }, { "name": "symfony/deprecation-contracts", @@ -2483,16 +2483,16 @@ }, { "name": "symfony/process", - "version": "v7.2.4", + "version": "v7.2.5", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "d8f411ff3c7ddc4ae9166fb388d1190a2df5b5cf" + "reference": "87b7c93e57df9d8e39a093d32587702380ff045d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/d8f411ff3c7ddc4ae9166fb388d1190a2df5b5cf", - "reference": "d8f411ff3c7ddc4ae9166fb388d1190a2df5b5cf", + "url": "https://api.github.com/repos/symfony/process/zipball/87b7c93e57df9d8e39a093d32587702380ff045d", + "reference": "87b7c93e57df9d8e39a093d32587702380ff045d", "shasum": "" }, "require": { @@ -2524,7 +2524,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.2.4" + "source": "https://github.com/symfony/process/tree/v7.2.5" }, "funding": [ { @@ -2540,7 +2540,7 @@ "type": "tidelift" } ], - "time": "2025-02-05T08:33:46+00:00" + "time": "2025-03-13T12:21:46+00:00" }, { "name": "symfony/service-contracts",