mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
test(mana-auth): vault service integration tests against real postgres
Closes backlog #1 from the Phase 9 audit. Adds 28 integration tests for the EncryptionVaultService against a real Postgres so the RLS policies, CHECK constraints and audit-row writes are exercised as the production app actually sees them. The pure-crypto KEK tests in kek.test.ts already covered the wrap/unwrap primitives — this new file fills in the service-shaped gaps that need a real DB. Test infrastructure ------------------- - Reads TEST_DATABASE_URL from env. Whole suite is SKIPPED via describe.skip if unset, so unrelated CI runs and `bun test` from a fresh checkout don't fail on missing connection. The encryption-vault sub-job has to provision a Postgres explicitly. - Schema is assumed already migrated (run `pnpm db:push` or apply sql/002 + sql/003 manually before invoking the suite). Tests insert a fresh test user per case via beforeEach so cross-test pollution is impossible despite the FK to auth.users. - afterAll cleans up the user (CASCADE wipes vault + audit) and closes the postgres pool so bun test exits cleanly. Coverage -------- init (3): - Mints a fresh vault, wrapped_mk + wrap_iv populated, ZK off - Idempotent (returns same key) - Audit rows are written getStatus (5): - vaultExists=false for unconfigured user - vaultExists=true after init, no recovery wrap - hasRecoveryWrap=true after setRecoveryWrap - zeroKnowledge=true after enableZK - Does NOT write an audit row (cheap metadata read) setRecoveryWrap (4): - Stores wrap on existing vault - VaultNotFoundError on missing vault - Idempotent (replaces previous wrap) - Writes recovery_set audit row clearRecoveryWrap (3): - Removes the wrap - ZeroKnowledgeActiveError when ZK is on - VaultNotFoundError on missing vault enableZeroKnowledge (4): - Flips zero_knowledge=true and NULLs out wrapped_mk + wrap_iv - RecoveryWrapMissingError if no recovery wrap is set - Idempotent (already-on is no-op) - VaultNotFoundError on missing vault disableZeroKnowledge (2): - Restores wrapped_mk from a client-supplied master key, verifies the round-trip via getMasterKey returns the same bytes - No-op when ZK is already off getMasterKey (3): - Returns unwrapped MK in standard mode - Returns recovery blob with requiresRecoveryCode=true in ZK mode - VaultNotFoundError on missing vault rotate (2): - Mints fresh MK and wipes any existing recovery wrap - ZeroKnowledgeRotateForbidden in ZK mode DB-level invariants (2): - Setting wrapped_mk back while ZK active is rejected by encryption_vaults_zk_consistency - Setting wrap_iv to NULL while wrapped_mk is set is rejected by encryption_vaults_wrap_iv_pair Both wrap the Drizzle update in an arrow IIFE so expect(...).rejects.toThrow() sees a real Promise (Drizzle's chainable update() only executes on await/then). Run results ----------- With TEST_DATABASE_URL set + schema migrated: 28 pass, 0 fail, 64 expect() calls Without TEST_DATABASE_URL set (default): 0 pass, 30 skip (full suite cleanly skipped) KEK tests in kek.test.ts still run unaffected. Drive-by: kek.test.ts header comment updated to point at the new sibling file instead of saying "tests will live alongside mana-sync" (which was outdated speculation from Phase 2). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ea165c8b46
commit
c2c960121e
2 changed files with 501 additions and 5 deletions
497
services/mana-auth/src/services/encryption-vault/index.test.ts
Normal file
497
services/mana-auth/src/services/encryption-vault/index.test.ts
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
/**
|
||||
* EncryptionVaultService integration tests (Phase 9 backlog #1).
|
||||
*
|
||||
* Exercises the full service surface against a real Postgres so the
|
||||
* row-level-security policies, CHECK constraints and audit-row writes
|
||||
* are tested as the production app actually sees them. Pure-crypto
|
||||
* tests live in `kek.test.ts` and don't need this scaffolding.
|
||||
*
|
||||
* Test database
|
||||
* -------------
|
||||
* Reads `TEST_DATABASE_URL` from the environment. The whole suite is
|
||||
* SKIPPED if the variable is not set, so unrelated CI runs (and the
|
||||
* default `bun test` from a fresh checkout) don't fail with "no
|
||||
* connection" — only the encryption-vault sub-job has to provision a
|
||||
* Postgres.
|
||||
*
|
||||
* Schema is assumed to already exist (run `pnpm db:push` against the
|
||||
* test DB before invoking the suite). The tests TRUNCATE the relevant
|
||||
* tables before each case so they're independent.
|
||||
*
|
||||
* Note on the user FK: encryption_vaults.user_id references auth.users
|
||||
* via ON DELETE CASCADE. We seed a single test user in beforeAll and
|
||||
* tear down in afterAll. Each test uses a fresh per-test sub-id stored
|
||||
* as a row in users — this avoids cross-test pollution from a single
|
||||
* shared user_id while still respecting the FK.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'bun:test';
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
import * as schema from '../../db/schema';
|
||||
import { users } from '../../db/schema/auth';
|
||||
import { encryptionVaults, encryptionVaultAudit } from '../../db/schema/encryption-vaults';
|
||||
import {
|
||||
EncryptionVaultService,
|
||||
VaultNotFoundError,
|
||||
RecoveryWrapMissingError,
|
||||
ZeroKnowledgeActiveError,
|
||||
ZeroKnowledgeRotateForbidden,
|
||||
} from './index';
|
||||
import { loadKek, _resetForTesting as resetKek } from './kek';
|
||||
|
||||
const TEST_KEK_BASE64 = 'AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=';
|
||||
const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL ?? '';
|
||||
|
||||
// Skip the entire suite if no test DB is configured. The describe.skip
|
||||
// pattern keeps the file importable so type-checking still runs against
|
||||
// production code.
|
||||
const maybeDescribe = TEST_DATABASE_URL ? describe : describe.skip;
|
||||
|
||||
maybeDescribe('EncryptionVaultService (integration)', () => {
|
||||
let client: ReturnType<typeof postgres>;
|
||||
let db: ReturnType<typeof drizzle<typeof schema>>;
|
||||
let service: EncryptionVaultService;
|
||||
let testUserId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Connect to the test database. `max: 5` keeps the connection
|
||||
// pool small — we don't run anything in parallel inside one test
|
||||
// suite, and CI runners are usually limited.
|
||||
client = postgres(TEST_DATABASE_URL, { max: 5 });
|
||||
db = drizzle(client, { schema });
|
||||
|
||||
resetKek();
|
||||
await loadKek(TEST_KEK_BASE64);
|
||||
|
||||
service = new EncryptionVaultService(db);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Drop the test user (CASCADE wipes the vault row + audit
|
||||
// entries via FK). Then close the pool so bun test exits cleanly.
|
||||
if (testUserId) {
|
||||
await db.delete(users).where(eq(users.id, testUserId));
|
||||
}
|
||||
await client.end();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Fresh user per test so the unique-email constraint doesn't bite
|
||||
// and so each test starts from a clean vault state.
|
||||
testUserId = `test-user-${nanoid(8)}`;
|
||||
await db.insert(users).values({
|
||||
id: testUserId,
|
||||
name: 'Vault Integration Test',
|
||||
email: `${testUserId}@test.local`,
|
||||
emailVerified: true,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── init() ────────────────────────────────────────────────
|
||||
|
||||
describe('init', () => {
|
||||
it('mints a fresh vault when none exists', async () => {
|
||||
const result = await service.init(testUserId);
|
||||
|
||||
expect(result.masterKey).toBeInstanceOf(Uint8Array);
|
||||
expect(result.masterKey!.length).toBe(32);
|
||||
expect(result.formatVersion).toBe(1);
|
||||
expect(result.kekId).toBe('env-v1');
|
||||
expect(result.requiresRecoveryCode).toBeUndefined();
|
||||
|
||||
// Verify the row was actually inserted
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(encryptionVaults)
|
||||
.where(eq(encryptionVaults.userId, testUserId));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].wrappedMk).not.toBeNull();
|
||||
expect(rows[0].wrapIv).not.toBeNull();
|
||||
expect(rows[0].zeroKnowledge).toBe(false);
|
||||
expect(rows[0].recoveryWrappedMk).toBeNull();
|
||||
});
|
||||
|
||||
it('is idempotent — second call returns the same key', async () => {
|
||||
const a = await service.init(testUserId);
|
||||
const b = await service.init(testUserId);
|
||||
|
||||
expect(Buffer.from(a.masterKey!).toString('hex')).toBe(
|
||||
Buffer.from(b.masterKey!).toString('hex')
|
||||
);
|
||||
});
|
||||
|
||||
it('writes init audit rows', async () => {
|
||||
await service.init(testUserId);
|
||||
await service.init(testUserId);
|
||||
|
||||
const audit = await db
|
||||
.select()
|
||||
.from(encryptionVaultAudit)
|
||||
.where(eq(encryptionVaultAudit.userId, testUserId));
|
||||
|
||||
expect(audit.length).toBeGreaterThanOrEqual(2);
|
||||
const actions = audit.map((a) => a.action);
|
||||
expect(actions).toContain('init');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getStatus() ───────────────────────────────────────────
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('returns vaultExists=false for a user with no vault', async () => {
|
||||
const status = await service.getStatus(testUserId);
|
||||
expect(status.vaultExists).toBe(false);
|
||||
expect(status.hasRecoveryWrap).toBe(false);
|
||||
expect(status.zeroKnowledge).toBe(false);
|
||||
expect(status.recoverySetAt).toBeNull();
|
||||
});
|
||||
|
||||
it('reports vaultExists=true after init, no recovery yet', async () => {
|
||||
await service.init(testUserId);
|
||||
const status = await service.getStatus(testUserId);
|
||||
expect(status.vaultExists).toBe(true);
|
||||
expect(status.hasRecoveryWrap).toBe(false);
|
||||
expect(status.zeroKnowledge).toBe(false);
|
||||
});
|
||||
|
||||
it('reports hasRecoveryWrap=true after setRecoveryWrap', async () => {
|
||||
await service.init(testUserId);
|
||||
await service.setRecoveryWrap(testUserId, {
|
||||
recoveryWrappedMk: 'AAAA',
|
||||
recoveryIv: 'BBBB',
|
||||
});
|
||||
const status = await service.getStatus(testUserId);
|
||||
expect(status.hasRecoveryWrap).toBe(true);
|
||||
expect(status.zeroKnowledge).toBe(false);
|
||||
expect(status.recoverySetAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('reports zeroKnowledge=true after enableZeroKnowledge', async () => {
|
||||
await service.init(testUserId);
|
||||
await service.setRecoveryWrap(testUserId, {
|
||||
recoveryWrappedMk: 'AAAA',
|
||||
recoveryIv: 'BBBB',
|
||||
});
|
||||
await service.enableZeroKnowledge(testUserId);
|
||||
const status = await service.getStatus(testUserId);
|
||||
expect(status.zeroKnowledge).toBe(true);
|
||||
expect(status.hasRecoveryWrap).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT write an audit row (cheap metadata read)', async () => {
|
||||
await service.init(testUserId);
|
||||
// Clear audit rows from init
|
||||
await db.execute(sql`DELETE FROM auth.encryption_vault_audit WHERE user_id = ${testUserId}`);
|
||||
await service.getStatus(testUserId);
|
||||
const audit = await db
|
||||
.select()
|
||||
.from(encryptionVaultAudit)
|
||||
.where(eq(encryptionVaultAudit.userId, testUserId));
|
||||
expect(audit).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── setRecoveryWrap() ─────────────────────────────────────
|
||||
|
||||
describe('setRecoveryWrap', () => {
|
||||
it('stores the recovery wrap on an existing vault', async () => {
|
||||
await service.init(testUserId);
|
||||
await service.setRecoveryWrap(testUserId, {
|
||||
recoveryWrappedMk: 'AAAA',
|
||||
recoveryIv: 'BBBB',
|
||||
});
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(encryptionVaults)
|
||||
.where(eq(encryptionVaults.userId, testUserId));
|
||||
expect(rows[0].recoveryWrappedMk).toBe('AAAA');
|
||||
expect(rows[0].recoveryIv).toBe('BBBB');
|
||||
expect(rows[0].recoverySetAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('throws VaultNotFoundError when no vault exists', async () => {
|
||||
await expect(
|
||||
service.setRecoveryWrap(testUserId, {
|
||||
recoveryWrappedMk: 'AAAA',
|
||||
recoveryIv: 'BBBB',
|
||||
})
|
||||
).rejects.toThrow(VaultNotFoundError);
|
||||
});
|
||||
|
||||
it('is idempotent — replaces the previous wrap', async () => {
|
||||
await service.init(testUserId);
|
||||
await service.setRecoveryWrap(testUserId, {
|
||||
recoveryWrappedMk: 'AAAA',
|
||||
recoveryIv: 'BBBB',
|
||||
});
|
||||
await service.setRecoveryWrap(testUserId, {
|
||||
recoveryWrappedMk: 'CCCC',
|
||||
recoveryIv: 'DDDD',
|
||||
});
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(encryptionVaults)
|
||||
.where(eq(encryptionVaults.userId, testUserId));
|
||||
expect(rows[0].recoveryWrappedMk).toBe('CCCC');
|
||||
expect(rows[0].recoveryIv).toBe('DDDD');
|
||||
});
|
||||
|
||||
it('writes a recovery_set audit row', async () => {
|
||||
await service.init(testUserId);
|
||||
await service.setRecoveryWrap(testUserId, {
|
||||
recoveryWrappedMk: 'AAAA',
|
||||
recoveryIv: 'BBBB',
|
||||
});
|
||||
|
||||
const audit = await db
|
||||
.select()
|
||||
.from(encryptionVaultAudit)
|
||||
.where(eq(encryptionVaultAudit.userId, testUserId));
|
||||
const actions = audit.map((a) => a.action);
|
||||
expect(actions).toContain('recovery_set');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── clearRecoveryWrap() ───────────────────────────────────
|
||||
|
||||
describe('clearRecoveryWrap', () => {
|
||||
it('removes the recovery wrap', async () => {
|
||||
await service.init(testUserId);
|
||||
await service.setRecoveryWrap(testUserId, {
|
||||
recoveryWrappedMk: 'AAAA',
|
||||
recoveryIv: 'BBBB',
|
||||
});
|
||||
await service.clearRecoveryWrap(testUserId);
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(encryptionVaults)
|
||||
.where(eq(encryptionVaults.userId, testUserId));
|
||||
expect(rows[0].recoveryWrappedMk).toBeNull();
|
||||
expect(rows[0].recoveryIv).toBeNull();
|
||||
expect(rows[0].recoverySetAt).toBeNull();
|
||||
});
|
||||
|
||||
it('throws ZeroKnowledgeActiveError when ZK is on', async () => {
|
||||
await service.init(testUserId);
|
||||
await service.setRecoveryWrap(testUserId, {
|
||||
recoveryWrappedMk: 'AAAA',
|
||||
recoveryIv: 'BBBB',
|
||||
});
|
||||
await service.enableZeroKnowledge(testUserId);
|
||||
|
||||
await expect(service.clearRecoveryWrap(testUserId)).rejects.toThrow(ZeroKnowledgeActiveError);
|
||||
});
|
||||
|
||||
it('throws VaultNotFoundError when no vault exists', async () => {
|
||||
await expect(service.clearRecoveryWrap(testUserId)).rejects.toThrow(VaultNotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── enableZeroKnowledge() ─────────────────────────────────
|
||||
|
||||
describe('enableZeroKnowledge', () => {
|
||||
it('flips zero_knowledge=true and NULLs out wrapped_mk', async () => {
|
||||
await service.init(testUserId);
|
||||
await service.setRecoveryWrap(testUserId, {
|
||||
recoveryWrappedMk: 'AAAA',
|
||||
recoveryIv: 'BBBB',
|
||||
});
|
||||
await service.enableZeroKnowledge(testUserId);
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(encryptionVaults)
|
||||
.where(eq(encryptionVaults.userId, testUserId));
|
||||
expect(rows[0].zeroKnowledge).toBe(true);
|
||||
expect(rows[0].wrappedMk).toBeNull();
|
||||
expect(rows[0].wrapIv).toBeNull();
|
||||
expect(rows[0].recoveryWrappedMk).not.toBeNull();
|
||||
});
|
||||
|
||||
it('throws RecoveryWrapMissingError if no recovery wrap is set', async () => {
|
||||
await service.init(testUserId);
|
||||
await expect(service.enableZeroKnowledge(testUserId)).rejects.toThrow(
|
||||
RecoveryWrapMissingError
|
||||
);
|
||||
});
|
||||
|
||||
it('is idempotent — second call is a no-op', async () => {
|
||||
await service.init(testUserId);
|
||||
await service.setRecoveryWrap(testUserId, {
|
||||
recoveryWrappedMk: 'AAAA',
|
||||
recoveryIv: 'BBBB',
|
||||
});
|
||||
await service.enableZeroKnowledge(testUserId);
|
||||
// Should not throw
|
||||
await service.enableZeroKnowledge(testUserId);
|
||||
});
|
||||
|
||||
it('throws VaultNotFoundError when no vault exists', async () => {
|
||||
await expect(service.enableZeroKnowledge(testUserId)).rejects.toThrow(VaultNotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── disableZeroKnowledge() ────────────────────────────────
|
||||
|
||||
describe('disableZeroKnowledge', () => {
|
||||
it('restores wrapped_mk from a client-supplied master key', async () => {
|
||||
await service.init(testUserId);
|
||||
await service.setRecoveryWrap(testUserId, {
|
||||
recoveryWrappedMk: 'AAAA',
|
||||
recoveryIv: 'BBBB',
|
||||
});
|
||||
await service.enableZeroKnowledge(testUserId);
|
||||
|
||||
// Verify wrapped_mk is gone
|
||||
let rows = await db
|
||||
.select()
|
||||
.from(encryptionVaults)
|
||||
.where(eq(encryptionVaults.userId, testUserId));
|
||||
expect(rows[0].wrappedMk).toBeNull();
|
||||
|
||||
// Hand back a fresh 32-byte MK and disable
|
||||
const freshMk = new Uint8Array(32).fill(0x42);
|
||||
await service.disableZeroKnowledge(testUserId, freshMk);
|
||||
|
||||
rows = await db
|
||||
.select()
|
||||
.from(encryptionVaults)
|
||||
.where(eq(encryptionVaults.userId, testUserId));
|
||||
expect(rows[0].zeroKnowledge).toBe(false);
|
||||
expect(rows[0].wrappedMk).not.toBeNull();
|
||||
expect(rows[0].wrapIv).not.toBeNull();
|
||||
|
||||
// Verify the round-trip: getMasterKey should now unwrap to
|
||||
// the same 32 bytes we handed in
|
||||
const fetched = await service.getMasterKey(testUserId);
|
||||
expect(fetched.masterKey).not.toBeNull();
|
||||
expect(Buffer.from(fetched.masterKey!).toString('hex')).toBe(
|
||||
Buffer.from(freshMk).toString('hex')
|
||||
);
|
||||
});
|
||||
|
||||
it('is a no-op when ZK is already off', async () => {
|
||||
await service.init(testUserId);
|
||||
const fresh = new Uint8Array(32).fill(0x99);
|
||||
// Should not throw
|
||||
await service.disableZeroKnowledge(testUserId, fresh);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getMasterKey() ────────────────────────────────────────
|
||||
|
||||
describe('getMasterKey', () => {
|
||||
it('returns the unwrapped MK in standard mode', async () => {
|
||||
const init = await service.init(testUserId);
|
||||
const fetch = await service.getMasterKey(testUserId);
|
||||
expect(fetch.masterKey).not.toBeNull();
|
||||
expect(Buffer.from(fetch.masterKey!).toString('hex')).toBe(
|
||||
Buffer.from(init.masterKey!).toString('hex')
|
||||
);
|
||||
expect(fetch.requiresRecoveryCode).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns recovery blob with requiresRecoveryCode=true in ZK mode', async () => {
|
||||
await service.init(testUserId);
|
||||
await service.setRecoveryWrap(testUserId, {
|
||||
recoveryWrappedMk: 'WRAPPED-CT',
|
||||
recoveryIv: 'WRAPPED-IV',
|
||||
});
|
||||
await service.enableZeroKnowledge(testUserId);
|
||||
|
||||
const result = await service.getMasterKey(testUserId);
|
||||
expect(result.masterKey).toBeNull();
|
||||
expect(result.requiresRecoveryCode).toBe(true);
|
||||
expect(result.recoveryWrappedMk).toBe('WRAPPED-CT');
|
||||
expect(result.recoveryIv).toBe('WRAPPED-IV');
|
||||
});
|
||||
|
||||
it('throws VaultNotFoundError when uninitialised', async () => {
|
||||
await expect(service.getMasterKey(testUserId)).rejects.toThrow(VaultNotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── rotate() ──────────────────────────────────────────────
|
||||
|
||||
describe('rotate', () => {
|
||||
it('mints a fresh master key and wipes any existing recovery wrap', async () => {
|
||||
const init = await service.init(testUserId);
|
||||
await service.setRecoveryWrap(testUserId, {
|
||||
recoveryWrappedMk: 'OLD-WRAP',
|
||||
recoveryIv: 'OLD-IV',
|
||||
});
|
||||
|
||||
const rotated = await service.rotate(testUserId);
|
||||
expect(Buffer.from(rotated.masterKey!).toString('hex')).not.toBe(
|
||||
Buffer.from(init.masterKey!).toString('hex')
|
||||
);
|
||||
|
||||
// The old recovery wrap was for the old MK and is now invalid —
|
||||
// the service wipes it on rotate to prevent confusion.
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(encryptionVaults)
|
||||
.where(eq(encryptionVaults.userId, testUserId));
|
||||
expect(rows[0].recoveryWrappedMk).toBeNull();
|
||||
expect(rows[0].recoveryIv).toBeNull();
|
||||
});
|
||||
|
||||
it('throws ZeroKnowledgeRotateForbidden in ZK mode', async () => {
|
||||
await service.init(testUserId);
|
||||
await service.setRecoveryWrap(testUserId, {
|
||||
recoveryWrappedMk: 'AAAA',
|
||||
recoveryIv: 'BBBB',
|
||||
});
|
||||
await service.enableZeroKnowledge(testUserId);
|
||||
|
||||
await expect(service.rotate(testUserId)).rejects.toThrow(ZeroKnowledgeRotateForbidden);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DB CHECK constraint enforcement ───────────────────────
|
||||
|
||||
describe('DB-level invariants', () => {
|
||||
// Drizzle's chainable update() object isn't a real Promise — it
|
||||
// only executes when you await it (or call .then). For these
|
||||
// constraint-violation tests we wrap the call in an arrow so
|
||||
// expect(...).rejects.toThrow() sees a real Promise.
|
||||
|
||||
it('enforces zk_consistency: setting wrapped_mk back while ZK active is rejected', async () => {
|
||||
await service.init(testUserId);
|
||||
await service.setRecoveryWrap(testUserId, {
|
||||
recoveryWrappedMk: 'AAAA',
|
||||
recoveryIv: 'BBBB',
|
||||
});
|
||||
await service.enableZeroKnowledge(testUserId);
|
||||
|
||||
// Try to set wrapped_mk back manually — should be rejected by
|
||||
// the encryption_vaults_zk_consistency constraint.
|
||||
await expect(
|
||||
(async () => {
|
||||
await db
|
||||
.update(encryptionVaults)
|
||||
.set({ wrappedMk: 'BAD', wrapIv: 'BAD' })
|
||||
.where(eq(encryptionVaults.userId, testUserId));
|
||||
})()
|
||||
).rejects.toThrow(/encryption_vaults_zk_consistency/);
|
||||
});
|
||||
|
||||
it('enforces wrap_iv_pair: setting wrap_iv to NULL while wrapped_mk is set is rejected', async () => {
|
||||
await service.init(testUserId);
|
||||
await expect(
|
||||
(async () => {
|
||||
await db
|
||||
.update(encryptionVaults)
|
||||
.set({ wrapIv: null })
|
||||
.where(eq(encryptionVaults.userId, testUserId));
|
||||
})()
|
||||
).rejects.toThrow(/encryption_vaults_wrap_iv_pair/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -3,11 +3,10 @@
|
|||
*
|
||||
* Pure crypto — no Postgres or Drizzle dependency. Run with `bun test`.
|
||||
*
|
||||
* The EncryptionVaultService itself is tested via integration tests
|
||||
* against a real Postgres instance because the row-level-security
|
||||
* behaviour cannot be faithfully reproduced with pg-mem or sqlite.
|
||||
* Those integration tests live alongside the existing mana-sync test
|
||||
* pattern (separate test database, set up in CI before the run).
|
||||
* Service-level tests for EncryptionVaultService live in `index.test.ts`
|
||||
* and require a real Postgres (RLS + CHECK constraints can't be
|
||||
* faithfully reproduced with pg-mem). They auto-skip when
|
||||
* TEST_DATABASE_URL is unset, so this kek.test.ts always runs.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'bun:test';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue