Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 19 additions & 58 deletions packages/database/tests/Entity/Cast/EncryptedColumnTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>
*/
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
Expand Down Expand Up @@ -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;
}
Expand All @@ -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));

Expand All @@ -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');
});

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
40 changes: 38 additions & 2 deletions packages/docs-markdown/docs/packages/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -887,6 +909,20 @@ public function json(): mixed;
public function body(): string;
```

### FakeEncryptor

```php
public private(set) array $encrypted; // list<array{value: string, aad: string, encrypted: string}>
public private(set) array $decrypted; // list<array{encrypted: string, aad: string, value: string}>
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
Expand Down
21 changes: 2 additions & 19 deletions packages/security/tests/Unit/CsrfRotationOnAuthTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

/**
Expand Down
43 changes: 11 additions & 32 deletions packages/security/tests/Unit/CsrfTokenManagerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
17 changes: 16 additions & 1 deletion packages/testing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/testing/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading