Testing apps that use Sealcraft

This guide covers writing tests against application code that stores encrypted columns — factories, assertions, mocking providers, and the patterns that keep the test suite fast and deterministic.

Pick a test provider

Every test run needs a KEK provider. The choices:

Provider When to use
null Default for unit/feature tests. No external I/O, no keyfile, instant.
local When you explicitly want to exercise file-backed KEK versioning / rotation in a test.
aws_kms / gcp_kms / azure_key_vault / vault_transit Only in dedicated integration tests, typically behind a feature flag or CI-only suite. Mock them in unit/feature tests.

A sane default for phpunit.xml:

<php>
    <env name="SEALCRAFT_PROVIDER" value="null"/>
    <env name="SEALCRAFT_CIPHER" value="aes-256-gcm"/>
</php>

Or set it per test with config()->set('sealcraft.default_provider', 'null') in a beforeEach.

Flush the DEK cache between tests

Sealcraft caches plaintext DEKs in memory (DekCache). Between tests that touch different tenants (or the same tenant across schema resets), flush the cache so cached DEKs don't refer to rows that no longer exist:

beforeEach(function (): void {
    config()->set('sealcraft.default_provider', 'null');
    config()->set('sealcraft.default_cipher', 'aes-256-gcm');

    $this->app->make(\Crumbls\Sealcraft\Services\DekCache::class)->flush();
});

If you use RefreshDatabase, this is especially important — the sealcraft_data_keys table is reset between tests but the in-memory cache singleton survives if you are not using Testbench.

Factories work out of the box

class PatientFactory extends Factory
{
    protected $model = Patient::class;

    public function definition(): array
    {
        return [
            'tenant_id' => 1,
            'ssn' => fake()->numerify('###-##-####'),
            'dob' => fake()->date(),
            'diagnosis' => fake()->sentence(),
        ];
    }
}

$patient = Patient::factory()->create();
$patient->ssn; // decrypted plaintext

The cast encrypts on write and decrypts on read — factories don't need to know about Sealcraft at all. For per-row models, each factory-created row gets its own sealcraft_key (auto-minted by the creating hook).

Assert the column is actually encrypted on disk

Use getRawOriginal() to see the stored ciphertext and peekId() to confirm it's a sealcraft envelope:

use Crumbls\Sealcraft\Services\CipherRegistry;

it('ssn is stored encrypted', function (): void {
    $patient = Patient::factory()->create(['ssn' => '123-45-6789']);

    $ciphertext = $patient->getRawOriginal('ssn');

    expect($ciphertext)->not->toBe('123-45-6789');
    expect(app(CipherRegistry::class)->peekId($ciphertext))->toBe('ag1');
});

Pest custom expectation for readability:

// tests/Pest.php
expect()->extend('toBeEncryptedOnDisk', function (): \Pest\Expectation {
    $ciphertext = (string) $this->value;
    $id = app(\Crumbls\Sealcraft\Services\CipherRegistry::class)->peekId($ciphertext);

    expect($id)->not->toBeNull("Column value is not a sealcraft envelope: {$ciphertext}");

    return $this;
});

// In a test:
expect($patient->getRawOriginal('ssn'))->toBeEncryptedOnDisk();

Assert a cross-tenant read throws DecryptionFailed

use Crumbls\Sealcraft\Exceptions\DecryptionFailedException;
use Illuminate\Support\Facades\DB;

it('swapping ciphertext across tenants fails authentication', function (): void {
    $a = Patient::factory()->create(['tenant_id' => 1, 'ssn' => 'a-ssn']);
    $b = Patient::factory()->create(['tenant_id' => 2, 'ssn' => 'b-ssn']);

    DB::table('patients')->where('id', $b->id)->update([
        'ssn' => $a->getRawOriginal('ssn'),
    ]);

    app(\Crumbls\Sealcraft\Services\DekCache::class)->flush();

    expect(fn () => Patient::find($b->id)->ssn)->toThrow(DecryptionFailedException::class);
});

Shredding in tests

Shredding is permanent — after a test shreds a context, any later test trying to read the same context will raise ContextShreddedException. If you use RefreshDatabase the sealcraft_data_keys table resets between tests, so each test starts with a clean slate.

use Crumbls\Sealcraft\Services\KeyManager;

it('crypto-shreds a patient', function (): void {
    $patient = Patient::factory()->create(['ssn' => 'destroy-me']);

    app(KeyManager::class)->shredContext($patient->sealcraftContext());
    app(\Crumbls\Sealcraft\Services\DekCache::class)->flush();

    expect(fn () => $patient->fresh()->ssn)
        ->toThrow(\Crumbls\Sealcraft\Exceptions\ContextShreddedException::class);
});

Faking the KEK provider

For unit tests that don't need a real wrap/unwrap round-trip, register a custom driver via ProviderRegistry::extend() and point your test config at it:

use Crumbls\Sealcraft\Services\ProviderRegistry;
use Crumbls\Sealcraft\Providers\NullKekProvider;

beforeEach(function (): void {
    config()->set('sealcraft.providers.test_null', ['driver' => 'test_null']);
    config()->set('sealcraft.default_provider', 'test_null');

    app(ProviderRegistry::class)->extend('test_null', fn () => new NullKekProvider);
});

For cloud providers, use Laravel's Http::fake():

use Illuminate\Support\Facades\Http;

it('mocks GCP KMS', function (): void {
    Http::fake([
        'cloudkms.googleapis.com/*:encrypt' => Http::response([
            'ciphertext' => base64_encode('fake-wrapped-dek'),
            'name' => 'projects/p/locations/l/keyRings/r/cryptoKeys/k/cryptoKeyVersions/1',
        ]),
        'cloudkms.googleapis.com/*:decrypt' => Http::response([
            'plaintext' => base64_encode('fake-plaintext-dek'),
        ]),
    ]);

    // ... exercise your code ...
});

Listening for events in tests

use Crumbls\Sealcraft\Events\DekCreated;
use Illuminate\Support\Facades\Event;

it('fires DekCreated on first write for a new tenant', function (): void {
    Event::fake([DekCreated::class]);

    Patient::factory()->create(['tenant_id' => 99, 'ssn' => '111-22-3333']);

    Event::assertDispatched(DekCreated::class, fn ($e) => $e->context->contextId === 99);
});

Hiding encrypted columns from toArray() in API tests

Eloquent's toArray() runs through casts, so encrypted columns come out as plaintext. If your API should not leak them, add the columns to $hidden on the model or project them through an API Resource. In tests, assert the response:

it('api response does not expose ssn', function (): void {
    $patient = Patient::factory()->create();

    $this->getJson("/api/patients/{$patient->id}")
        ->assertJsonMissing(['ssn' => $patient->ssn]);
});

Running sealcraft:doctor in CI

Treat sealcraft:doctor --skip-roundtrip as a deploy-gate check in staging/CI:

# .github/workflows/deploy.yml
- run: php artisan sealcraft:doctor --skip-roundtrip --skip-models

Skip --skip-roundtrip on a staging pipeline that can reach your KMS to get the full end-to-end check.

Parallel test runners

Pest's parallel mode and PHPUnit's paratest both run tests in separate processes, each with its own DekCache singleton. No cross-process leakage to worry about. If a test uses RefreshDatabase the sealcraft_data_keys table is scoped per worker as well.


Contributors

Thank you to everyone who has contributed to this package. Every pull request, bug report, and idea makes a difference.