From 471f8bbd961f359226cc39094331341e4475deef Mon Sep 17 00:00:00 2001 From: Mark Shust Date: Tue, 6 Oct 2026 11:09:01 -0400 Subject: [PATCH] feat: ship a shared FakeEncryptor in marko/testing Packages that needed an encryptor in tests each wrote their own anonymous EncryptorInterface fake, so the $aad signature change in #502 broke every one of them. Marko\Testing\Fake\FakeEncryptor replaces them: a reversible encoding that records encrypt()/decrypt() calls, differs per call like a real cipher, and binds the ciphertext to its associated data so decrypt() with a different $aad throws a DecryptionException naming both values. assertEncrypted()/assertDecrypted() (value and AAD both optional) and assertNothingEncrypted()/assertNothingDecrypted() follow the other fakes. The security CSRF tests and the database encrypted-column tests now use it. marko/testing requires marko/encryption (contracts only). Fixes #515 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KyjR4cJTfro54iFZ9CWkv1 --- .../tests/Entity/Cast/EncryptedColumnTest.php | 77 ++----- .../docs-markdown/docs/packages/testing.md | 40 +++- .../tests/Unit/CsrfRotationOnAuthTest.php | 21 +- .../tests/Unit/CsrfTokenManagerTest.php | 43 +--- packages/testing/README.md | 17 +- packages/testing/composer.json | 1 + packages/testing/src/Fake/FakeEncryptor.php | 168 ++++++++++++++++ .../tests/Unit/Fake/FakeEncryptorTest.php | 189 ++++++++++++++++++ 8 files changed, 444 insertions(+), 112 deletions(-) create mode 100644 packages/testing/src/Fake/FakeEncryptor.php create mode 100644 packages/testing/tests/Unit/Fake/FakeEncryptorTest.php diff --git a/packages/database/tests/Entity/Cast/EncryptedColumnTest.php b/packages/database/tests/Entity/Cast/EncryptedColumnTest.php index 565189bdc..76c7747c5 100644 --- a/packages/database/tests/Entity/Cast/EncryptedColumnTest.php +++ b/packages/database/tests/Entity/Cast/EncryptedColumnTest.php @@ -27,50 +27,7 @@ use Marko\Encryption\Contracts\EncryptorInterface; use Marko\Encryption\Exceptions\DecryptionException; use Marko\Encryption\Exceptions\EncryptionException; - -/** - * Non-deterministic fake that, like a real AEAD cipher, only decrypts with the - * associated data the value was encrypted with. Records the AAD of every call. - */ -class FakeRandomEncryptor implements EncryptorInterface -{ - /** - * @var list - */ - public static array $aads = []; - - public function encrypt( - string $value, - string $aad = '', - ): string { - self::$aads[] = $aad; - - return 'enc:' . base64_encode(bin2hex(random_bytes(8)) . '|' . bin2hex($aad) . '|' . $value); - } - - public function decrypt( - string $encrypted, - string $aad = '', - ): string { - self::$aads[] = $aad; - - if (!str_starts_with($encrypted, 'enc:')) { - throw DecryptionException::invalidPayload(); - } - - $parts = explode('|', (string) base64_decode(substr($encrypted, 4)), 3); - - if (count($parts) !== 3) { - throw DecryptionException::invalidPayload(); - } - - if ($parts[1] !== bin2hex($aad)) { - throw DecryptionException::invalidKey(); - } - - return $parts[2]; - } -} +use Marko\Testing\Fake\FakeEncryptor; #[Table('secret_records')] class SecretRecord extends Entity @@ -165,10 +122,11 @@ class SchemaTypesEntity extends Entity public ?DateTimeImmutable $inferred = null; } -function encryptedContainer(): Container -{ +function encryptedContainer( + FakeEncryptor $encryptor = new FakeEncryptor(), +): Container { $container = new Container(); - $container->bind(EncryptorInterface::class, FakeRandomEncryptor::class); + $container->instance(EncryptorInterface::class, $encryptor); return $container; } @@ -184,9 +142,11 @@ function encryptedConnection(): RecordingSqliteConnection return $connection; } -function encryptedRepository(RecordingSqliteConnection $connection): SecretRecordRepository -{ - $container = encryptedContainer(); +function encryptedRepository( + RecordingSqliteConnection $connection, + FakeEncryptor $encryptor = new FakeEncryptor(), +): SecretRecordRepository { + $container = encryptedContainer($encryptor); $metadataFactory = new EntityMetadataFactory($container); $hydrator = new EntityHydrator($metadataFactory, new CastResolver($container)); @@ -205,7 +165,7 @@ function encryptedRepository(RecordingSqliteConnection $connection): SecretRecor $stored = $connection->lastInsertValues(); expect($stored['secret'])->not->toBe('top-secret') - ->and($stored['secret'])->toStartWith('enc:') + ->and($stored['secret'])->toStartWith('fake-encrypted:') ->and($stored['label'])->toBe('visible'); }); @@ -236,7 +196,7 @@ function encryptedRepository(RecordingSqliteConnection $connection): SecretRecor it('accepts an encryptor registered as an instance', function (): void { $container = new Container(); - $container->instance(EncryptorInterface::class, new FakeRandomEncryptor()); + $container->instance(EncryptorInterface::class, new FakeEncryptor()); $metadata = new EntityMetadataFactory($container)->parse(SecretRecord::class); @@ -356,16 +316,17 @@ function encryptedRepository(RecordingSqliteConnection $connection): SecretRecor }); it('binds each encrypted value to its table and column as associated data', function (): void { - FakeRandomEncryptor::$aads = []; - $repository = encryptedRepository(encryptedConnection()); + $encryptor = new FakeEncryptor(); + $repository = encryptedRepository(encryptedConnection(), $encryptor); $record = new SecretRecord(); $record->secret = 'top-secret'; $repository->save($record); - expect(FakeRandomEncryptor::$aads)->toContain('secret_records.secret') - ->and(FakeRandomEncryptor::$aads)->toContain('secret_records.hint') - ->and(FakeRandomEncryptor::$aads)->not->toContain(''); + $encryptor->assertEncrypted('top-secret', 'secret_records.secret'); + $encryptor->assertEncrypted(aad: 'secret_records.hint'); + + expect(array_column($encryptor->encrypted, 'aad'))->not->toContain(''); }); it('refuses to hydrate ciphertext copied from another encrypted column', function (): void { @@ -395,7 +356,7 @@ function encryptedRepository(RecordingSqliteConnection $connection): SecretRecor }); it('throws when encrypting a property whose metadata has no table name', function (): void { - $cast = new EncryptedCast(new FakeRandomEncryptor()); + $cast = new EncryptedCast(new FakeEncryptor()); $cast->toDatabase('value', new PropertyMetadata(name: 'secret', columnName: 'secret', type: 'string')); })->throws(EncryptionException::class, "Cannot encrypt property 'secret' without a table name"); diff --git a/packages/docs-markdown/docs/packages/testing.md b/packages/docs-markdown/docs/packages/testing.md index d518fb0ac..24e00c4eb 100644 --- a/packages/docs-markdown/docs/packages/testing.md +++ b/packages/docs-markdown/docs/packages/testing.md @@ -3,9 +3,9 @@ title: marko/testing description: Reusable fakes with built-in assertions that eliminate test boilerplate. --- -Testing utilities for Marko — reusable fakes with built-in assertions that eliminate test boilerplate. This package provides in-memory fakes for the core Marko contracts: events, broadcasting, mail, queues, sessions, cookies, logging, config, authentication, guards, HTTP clients, the clock, and console confirmations. Each fake records interactions and exposes assertion methods so your tests stay focused on behavior rather than mock setup. An in-process HTTP test client (`TestClient`) sends requests through your real routes, middleware and controllers for feature tests. Pest expectation extensions (`toHaveDispatched`, `toHaveBroadcast`, `toHaveSent`, `toHavePushed`, `toHaveLogged`, `toHaveAttempted`, `toBeAuthenticated`, `toHaveStatus`, `toHaveJsonPath`) are included for fluent assertions. +Testing utilities for Marko — reusable fakes with built-in assertions that eliminate test boilerplate. This package provides in-memory fakes for the core Marko contracts: events, broadcasting, mail, queues, sessions, cookies, logging, config, authentication, guards, HTTP clients, encryption, the clock, and console confirmations. Each fake records interactions and exposes assertion methods so your tests stay focused on behavior rather than mock setup. An in-process HTTP test client (`TestClient`) sends requests through your real routes, middleware and controllers for feature tests. Pest expectation extensions (`toHaveDispatched`, `toHaveBroadcast`, `toHaveSent`, `toHavePushed`, `toHaveLogged`, `toHaveAttempted`, `toBeAuthenticated`, `toHaveStatus`, `toHaveJsonPath`) are included for fluent assertions. -Available fakes: `FakeEventDispatcher`, `FakeBroadcaster`, `FakeMailer`, `FakeQueue`, `FakeSession`, `FakeCookieJar`, `FakeLogger`, `FakeConfigRepository`, `FakeAuthenticatable`, `FakeUserProvider`, `FakeGuard`, `FakeHttpClient`, `FakeClock`, `FakeSleeper`, `FakeConfirmationPrompter`. +Available fakes: `FakeEventDispatcher`, `FakeBroadcaster`, `FakeMailer`, `FakeQueue`, `FakeSession`, `FakeCookieJar`, `FakeLogger`, `FakeConfigRepository`, `FakeAuthenticatable`, `FakeUserProvider`, `FakeGuard`, `FakeHttpClient`, `FakeEncryptor`, `FakeClock`, `FakeSleeper`, `FakeConfirmationPrompter`. ## Installation @@ -187,6 +187,28 @@ $guard->assertLoggedOut(); $guard->assertGuest(); ``` +### FakeEncryptor + +`FakeEncryptor` implements `EncryptorInterface` from [`marko/encryption`](/docs/packages/encryption/), so code that encrypts values can be tested without a key. Encryption is a reversible encoding: the output starts with `fake-encrypted:`, never contains the plain text, and differs on every call as a real cipher's would. Any `FakeEncryptor` instance can decrypt another's output. + +Like a real AEAD encryptor, the ciphertext is bound to the associated data (`$aad`) it was encrypted with. `decrypt()` with different associated data throws a `DecryptionException` naming both values, so a missing or wrong `$aad` fails the test instead of passing silently. A value it did not produce throws `DecryptionException::invalidPayload()`. + +```php +use Marko\Testing\Fake\FakeEncryptor; + +$encryptor = new FakeEncryptor(); +$encrypted = $encryptor->encrypt('123-45-6789', 'users.ssn'); + +$encryptor->decrypt($encrypted, 'users.ssn'); // '123-45-6789' +$encryptor->decrypt($encrypted, 'users.email'); // throws DecryptionException + +$encryptor->assertEncrypted('123-45-6789', 'users.ssn'); +$encryptor->assertDecrypted(aad: 'users.ssn'); +$encryptor->encrypted; // [['value' => '123-45-6789', 'aad' => 'users.ssn', 'encrypted' => 'fake-encrypted:...']] +``` + +Both arguments of `assertEncrypted()` and `assertDecrypted()` are optional; pass only what the test cares about. Only successful `decrypt()` calls are recorded. Like every fake, it is never bound automatically: pass it to the class under test, or register it with `$container->instance(EncryptorInterface::class, $encryptor)`. + ### FakeClock `FakeClock` implements the PSR-20 `Psr\Clock\ClockInterface`, so it drops into anything that takes the injected clock from [`marko/clock`](/docs/packages/clock/). It is frozen: time never moves on its own, so expiry, TTL and freshness checks can be tested at exact boundaries without `sleep()` or loose time ranges. @@ -887,6 +909,20 @@ public function json(): mixed; public function body(): string; ``` +### FakeEncryptor + +```php +public private(set) array $encrypted; // list +public private(set) array $decrypted; // list +public function encrypt(string $value, string $aad = ''): string; +public function decrypt(string $encrypted, string $aad = ''): string; // throws DecryptionException on an AAD mismatch +public function assertEncrypted(?string $value = null, ?string $aad = null): void; +public function assertDecrypted(?string $value = null, ?string $aad = null): void; +public function assertNothingEncrypted(): void; +public function assertNothingDecrypted(): void; +public function clear(): void; +``` + ### FakeClock ```php diff --git a/packages/security/tests/Unit/CsrfRotationOnAuthTest.php b/packages/security/tests/Unit/CsrfRotationOnAuthTest.php index 032a971fa..7238d5764 100644 --- a/packages/security/tests/Unit/CsrfRotationOnAuthTest.php +++ b/packages/security/tests/Unit/CsrfRotationOnAuthTest.php @@ -10,36 +10,19 @@ use Marko\Core\Event\EventDispatcher; use Marko\Core\Event\ObserverDefinition; use Marko\Core\Event\ObserverRegistry; -use Marko\Encryption\Contracts\EncryptorInterface; use Marko\Security\Contracts\CsrfTokenManagerInterface; use Marko\Security\CsrfTokenManager; use Marko\Security\Observer\RotateCsrfTokenOnLogin; use Marko\Security\Observer\RotateCsrfTokenOnLogout; use Marko\Testing\Fake\FakeAuthenticatable; +use Marko\Testing\Fake\FakeEncryptor; use Marko\Testing\Fake\FakeSession; use Marko\Testing\Fake\FakeUserProvider; function csrfRotationManager( FakeSession $session, ): CsrfTokenManager { - $encryptor = new class () implements EncryptorInterface - { - public function encrypt( - string $value, - string $aad = '', - ): string { - return 'encrypted_' . bin2hex($value); - } - - public function decrypt( - string $encrypted, - string $aad = '', - ): string { - return $encrypted; - } - }; - - return new CsrfTokenManager($session, $encryptor); + return new CsrfTokenManager($session, new FakeEncryptor()); } /** diff --git a/packages/security/tests/Unit/CsrfTokenManagerTest.php b/packages/security/tests/Unit/CsrfTokenManagerTest.php index 2588bd31f..2c93ed407 100644 --- a/packages/security/tests/Unit/CsrfTokenManagerTest.php +++ b/packages/security/tests/Unit/CsrfTokenManagerTest.php @@ -2,37 +2,13 @@ declare(strict_types=1); -use Marko\Encryption\Contracts\EncryptorInterface; use Marko\Security\Contracts\CsrfTokenManagerInterface; use Marko\Security\CsrfTokenManager; use Marko\Security\Exceptions\CsrfTokenMismatchException; use Marko\Security\Exceptions\SecurityException; +use Marko\Testing\Fake\FakeEncryptor; use Marko\Testing\Fake\FakeSession; -function createStubEncryptor(): EncryptorInterface -{ - return new class () implements EncryptorInterface - { - private int $counter = 0; - - public function encrypt( - string $value, - string $aad = '', - ): string { - $this->counter++; - - return 'encrypted_' . $this->counter . '_' . bin2hex($value); - } - - public function decrypt( - string $encrypted, - string $aad = '', - ): string { - return 'decrypted_' . $encrypted; - } - }; -} - describe('CsrfTokenManagerInterface', function (): void { it('defines CsrfTokenManagerInterface with get validate and regenerate methods', function (): void { $reflection = new ReflectionClass(CsrfTokenManagerInterface::class); @@ -57,7 +33,7 @@ public function decrypt( describe('CsrfTokenManager', function (): void { it('generates a token and stores it in session', function (): void { $session = new FakeSession(); - $encryptor = createStubEncryptor(); + $encryptor = new FakeEncryptor(); $manager = new CsrfTokenManager( session: $session, @@ -66,15 +42,18 @@ public function decrypt( $token = $manager->get(); + $encryptor->assertEncrypted(); + expect($token)->toBeString() ->and($token)->not->toBeEmpty() + ->and($token)->toBe($encryptor->encrypted[0]['encrypted']) ->and($session->all())->toHaveKey('_csrf_token') ->and($session->get('_csrf_token'))->toBe($token); }); it('returns existing token from session on subsequent calls', function (): void { $session = new FakeSession(); - $encryptor = createStubEncryptor(); + $encryptor = new FakeEncryptor(); $manager = new CsrfTokenManager( session: $session, @@ -89,7 +68,7 @@ public function decrypt( it('validates correct token successfully', function (): void { $session = new FakeSession(); - $encryptor = createStubEncryptor(); + $encryptor = new FakeEncryptor(); $manager = new CsrfTokenManager( session: $session, @@ -103,7 +82,7 @@ public function decrypt( it('rejects invalid token', function (): void { $session = new FakeSession(); - $encryptor = createStubEncryptor(); + $encryptor = new FakeEncryptor(); $manager = new CsrfTokenManager( session: $session, @@ -117,7 +96,7 @@ public function decrypt( it('regenerates token replacing the previous one', function (): void { $session = new FakeSession(); - $encryptor = createStubEncryptor(); + $encryptor = new FakeEncryptor(); $manager = new CsrfTokenManager( session: $session, @@ -134,7 +113,7 @@ public function decrypt( it('implements CsrfTokenManagerInterface', function (): void { $session = new FakeSession(); - $encryptor = createStubEncryptor(); + $encryptor = new FakeEncryptor(); $manager = new CsrfTokenManager( session: $session, @@ -146,7 +125,7 @@ public function decrypt( it('uses FakeSession instead of inline session stub in CsrfTokenManagerTest', function (): void { $session = new FakeSession(); - $encryptor = createStubEncryptor(); + $encryptor = new FakeEncryptor(); $manager = new CsrfTokenManager( session: $session, diff --git a/packages/testing/README.md b/packages/testing/README.md index 827191f49..14eb1a5f2 100644 --- a/packages/testing/README.md +++ b/packages/testing/README.md @@ -66,7 +66,7 @@ afterEach(fn () => $this->refresh->rollback()); ## Available Fakes -`FakeEventDispatcher`, `FakeBroadcaster`, `FakeMailer`, `FakeQueue`, `FakeSession`, `FakeCookieJar`, `FakeLogger`, `FakeConfigRepository`, `FakeAuthenticatable`, `FakeUserProvider`, `FakeGuard`, `FakeHttpClient`, `FakeClock`, `FakeSleeper`, `FakeConfirmationPrompter` +`FakeEventDispatcher`, `FakeBroadcaster`, `FakeMailer`, `FakeQueue`, `FakeSession`, `FakeCookieJar`, `FakeLogger`, `FakeConfigRepository`, `FakeAuthenticatable`, `FakeUserProvider`, `FakeGuard`, `FakeHttpClient`, `FakeEncryptor`, `FakeClock`, `FakeSleeper`, `FakeConfirmationPrompter` ## Usage @@ -223,6 +223,21 @@ $http->assertSent(fn (RecordedRequest $r) => $r->method === 'POST'); $http->assertSentCount(1); ``` +### FakeEncryptor + +```php +use Marko\Testing\Fake\FakeEncryptor; + +$encryptor = new FakeEncryptor(); +$encrypted = $encryptor->encrypt('123-45-6789', 'users.ssn'); + +$encryptor->decrypt($encrypted, 'users.ssn'); // '123-45-6789' +$encryptor->decrypt($encrypted, 'users.email'); // throws DecryptionException: AAD mismatch + +$encryptor->assertEncrypted('123-45-6789', 'users.ssn'); +$encryptor->assertDecrypted(aad: 'users.ssn'); +``` + ### KnownDriversValidator ```php diff --git a/packages/testing/composer.json b/packages/testing/composer.json index 751e7ccfc..ff3570454 100644 --- a/packages/testing/composer.json +++ b/packages/testing/composer.json @@ -9,6 +9,7 @@ "marko/clock": "self.version", "marko/broadcasting": "self.version", "marko/config": "self.version", + "marko/encryption": "self.version", "marko/mail": "self.version", "marko/queue": "self.version", "marko/session": "self.version", diff --git a/packages/testing/src/Fake/FakeEncryptor.php b/packages/testing/src/Fake/FakeEncryptor.php new file mode 100644 index 000000000..132f35c11 --- /dev/null +++ b/packages/testing/src/Fake/FakeEncryptor.php @@ -0,0 +1,168 @@ + */ + public private(set) array $encrypted = []; + + /** @var list */ + public private(set) array $decrypted = []; + + private int $counter = 0; + + public function encrypt( + string $value, + string $aad = '', + ): string { + $this->counter++; + + // The counter makes each ciphertext unique, as a real cipher's random nonce does. + $encrypted = self::PREFIX . $this->counter . ':' . base64_encode($aad) . ':' . base64_encode($value); + + $this->encrypted[] = ['value' => $value, 'aad' => $aad, 'encrypted' => $encrypted]; + + return $encrypted; + } + + /** + * @throws DecryptionException + */ + public function decrypt( + string $encrypted, + string $aad = '', + ): string { + if (!str_starts_with($encrypted, self::PREFIX)) { + throw DecryptionException::invalidPayload(); + } + + $parts = explode(':', substr($encrypted, strlen(self::PREFIX))); + + if (count($parts) !== 3) { + throw DecryptionException::invalidPayload(); + } + + $encryptedAad = base64_decode($parts[1], true); + $value = base64_decode($parts[2], true); + + if ($encryptedAad === false || $value === false) { + throw DecryptionException::invalidPayload(); + } + + if ($encryptedAad !== $aad) { + throw new DecryptionException( + message: "FakeEncryptor: decrypt() was given associated data '$aad', " + . "but the value was encrypted with '$encryptedAad'", + context: 'Decrypting with different associated data than was used for encryption; ' + . 'a real AEAD encryptor rejects this too', + suggestion: 'Pass the same $aad to decrypt() that was passed to encrypt() for this value', + ); + } + + $this->decrypted[] = ['encrypted' => $encrypted, 'aad' => $aad, 'value' => $value]; + + return $value; + } + + /** + * Assert encrypt() was called, optionally with this value and/or associated data. + * + * @throws AssertionFailedException + */ + public function assertEncrypted( + ?string $value = null, + ?string $aad = null, + ): void { + $this->assertRecorded($this->encrypted, 'encrypted value', $value, $aad); + } + + /** + * Assert decrypt() succeeded, optionally returning this value and/or with this associated data. + * + * @throws AssertionFailedException + */ + public function assertDecrypted( + ?string $value = null, + ?string $aad = null, + ): void { + $this->assertRecorded($this->decrypted, 'decrypted value', $value, $aad); + } + + /** + * @throws AssertionFailedException + */ + public function assertNothingEncrypted(): void + { + if ($this->encrypted !== []) { + throw AssertionFailedException::expectedEmpty('encrypted values'); + } + } + + /** + * @throws AssertionFailedException + */ + public function assertNothingDecrypted(): void + { + if ($this->decrypted !== []) { + throw AssertionFailedException::expectedEmpty('decrypted values'); + } + } + + public function clear(): void + { + $this->encrypted = []; + $this->decrypted = []; + } + + /** + * @param list $calls + * + * @throws AssertionFailedException + */ + private function assertRecorded( + array $calls, + string $type, + ?string $value, + ?string $aad, + ): void { + if ($calls === []) { + throw AssertionFailedException::unexpectedEmpty($type); + } + + $found = array_any( + $calls, + fn (array $call): bool => ($value === null || $call['value'] === $value) + && ($aad === null || $call['aad'] === $aad), + ); + + if ($found) { + return; + } + + $criteria = []; + + if ($value !== null) { + $criteria[] = "value '$value'"; + } + + if ($aad !== null) { + $criteria[] = "associated data '$aad'"; + } + + throw AssertionFailedException::expectedContains($type, implode(' with ', $criteria)); + } +} diff --git a/packages/testing/tests/Unit/Fake/FakeEncryptorTest.php b/packages/testing/tests/Unit/Fake/FakeEncryptorTest.php new file mode 100644 index 000000000..bc655e4a3 --- /dev/null +++ b/packages/testing/tests/Unit/Fake/FakeEncryptorTest.php @@ -0,0 +1,189 @@ +toBeInstanceOf(EncryptorInterface::class); +}); + +it('round-trips a value through encrypt and decrypt', function () { + $encryptor = new FakeEncryptor(); + + $encrypted = $encryptor->encrypt('top-secret'); + + expect($encrypted)->toStartWith('fake-encrypted:') + ->and($encrypted)->not->toContain('top-secret') + ->and($encryptor->decrypt($encrypted))->toBe('top-secret'); +}); + +it('round-trips binary values', function () { + $encryptor = new FakeEncryptor(); + $bytes = random_bytes(32); + + expect($encryptor->decrypt($encryptor->encrypt($bytes)))->toBe($bytes); +}); + +it('produces different ciphertext for each call, like a real encryptor', function () { + $encryptor = new FakeEncryptor(); + + expect($encryptor->encrypt('same'))->not->toBe($encryptor->encrypt('same')); +}); + +it('decrypts values encrypted by another instance', function () { + $encrypted = new FakeEncryptor()->encrypt('shared', 'users.ssn'); + + expect(new FakeEncryptor()->decrypt($encrypted, 'users.ssn'))->toBe('shared'); +}); + +it('round-trips a value with matching associated data', function () { + $encryptor = new FakeEncryptor(); + + $encrypted = $encryptor->encrypt('123-45-6789', 'users.ssn'); + + expect($encryptor->decrypt($encrypted, 'users.ssn'))->toBe('123-45-6789'); +}); + +it('throws when decrypt is given different associated data than encrypt', function () { + $encryptor = new FakeEncryptor(); + $encrypted = $encryptor->encrypt('123-45-6789', 'users.ssn'); + + expect(fn () => $encryptor->decrypt($encrypted, 'users.email')) + ->toThrow( + DecryptionException::class, + "FakeEncryptor: decrypt() was given associated data 'users.email', " + . "but the value was encrypted with 'users.ssn'", + ); +}); + +it('throws when decrypt omits associated data that encrypt was given', function () { + $encryptor = new FakeEncryptor(); + $encrypted = $encryptor->encrypt('value', 'users.ssn'); + + expect(fn () => $encryptor->decrypt($encrypted)) + ->toThrow( + DecryptionException::class, + "was given associated data '', but the value was encrypted with 'users.ssn'", + ); +}); + +it('throws an invalid payload exception for values it did not encrypt', function () { + $encryptor = new FakeEncryptor(); + + expect(fn () => $encryptor->decrypt('not-valid-ciphertext')) + ->toThrow(DecryptionException::class, 'The encrypted payload is invalid') + ->and(fn () => $encryptor->decrypt('fake-encrypted:garbage')) + ->toThrow(DecryptionException::class, 'The encrypted payload is invalid'); +}); + +it('records encrypt calls with the value, associated data and result', function () { + $encryptor = new FakeEncryptor(); + + $encrypted = $encryptor->encrypt('secret', 'users.ssn'); + + expect($encryptor->encrypted)->toBe([ + ['value' => 'secret', 'aad' => 'users.ssn', 'encrypted' => $encrypted], + ]); +}); + +it('records decrypt calls with the payload, associated data and result', function () { + $encryptor = new FakeEncryptor(); + $encrypted = $encryptor->encrypt('secret', 'users.ssn'); + + $encryptor->decrypt($encrypted, 'users.ssn'); + + expect($encryptor->decrypted)->toBe([ + ['encrypted' => $encrypted, 'aad' => 'users.ssn', 'value' => 'secret'], + ]); +}); + +it('does not record a decrypt call that fails', function () { + $encryptor = new FakeEncryptor(); + + try { + $encryptor->decrypt('not-valid-ciphertext'); + } catch (DecryptionException) { + } + + expect($encryptor->decrypted)->toBe([]); +}); + +it('passes assertEncrypted when a matching value was encrypted', function () { + $encryptor = new FakeEncryptor(); + $encryptor->encrypt('secret', 'users.ssn'); + + $encryptor->assertEncrypted(); + $encryptor->assertEncrypted('secret'); + $encryptor->assertEncrypted(aad: 'users.ssn'); + $encryptor->assertEncrypted('secret', 'users.ssn'); + + expect($encryptor->encrypted)->toHaveCount(1); +}); + +it('throws from assertEncrypted when nothing was encrypted', function () { + new FakeEncryptor()->assertEncrypted(); +})->throws(AssertionFailedException::class, 'Expected at least one encrypted value'); + +it('throws from assertEncrypted when no call matches the value or associated data', function () { + $encryptor = new FakeEncryptor(); + $encryptor->encrypt('secret', 'users.ssn'); + + expect(fn () => $encryptor->assertEncrypted('other')) + ->toThrow(AssertionFailedException::class) + ->and(fn () => $encryptor->assertEncrypted('secret', 'users.email')) + ->toThrow(AssertionFailedException::class, "associated data 'users.email'"); +}); + +it('passes assertDecrypted when a matching value was decrypted', function () { + $encryptor = new FakeEncryptor(); + $encryptor->decrypt($encryptor->encrypt('secret', 'users.ssn'), 'users.ssn'); + + $encryptor->assertDecrypted(); + $encryptor->assertDecrypted('secret'); + $encryptor->assertDecrypted(aad: 'users.ssn'); + $encryptor->assertDecrypted('secret', 'users.ssn'); + + expect($encryptor->decrypted)->toHaveCount(1); +}); + +it('throws from assertDecrypted when no call matches', function () { + $encryptor = new FakeEncryptor(); + $encryptor->encrypt('secret', 'users.ssn'); + + expect(fn () => $encryptor->assertDecrypted()) + ->toThrow(AssertionFailedException::class, 'Expected at least one decrypted value') + ->and(fn () => $encryptor->assertDecrypted('secret')) + ->toThrow(AssertionFailedException::class); +}); + +it('asserts nothing was encrypted or decrypted', function () { + $encryptor = new FakeEncryptor(); + + $encryptor->assertNothingEncrypted(); + $encryptor->assertNothingDecrypted(); + + $encrypted = $encryptor->encrypt('secret'); + + expect(fn () => $encryptor->assertNothingEncrypted()) + ->toThrow(AssertionFailedException::class, 'Expected no encrypted values') + ->and(fn () => $encryptor->assertNothingDecrypted())->not->toThrow(AssertionFailedException::class); + + $encryptor->decrypt($encrypted); + + expect(fn () => $encryptor->assertNothingDecrypted()) + ->toThrow(AssertionFailedException::class, 'Expected no decrypted values'); +}); + +it('clears recorded calls', function () { + $encryptor = new FakeEncryptor(); + $encryptor->decrypt($encryptor->encrypt('secret')); + + $encryptor->clear(); + + expect($encryptor->encrypted)->toBe([]) + ->and($encryptor->decrypted)->toBe([]); +});