Files
stack/packages/db/src/hierarchy-audit.witness.test.ts
T
2026-08-28 02:22:53 +00:00

364 lines
13 KiB
TypeScript

/**
* Hierarchy audit event + outbox schema witnesses — contract 1 §5.2 and the
* schema-level half of §6.4.
*
* Witnesses the guarantees the tables themselves carry: verb/target-kind/
* transfer CHECK constraints, idempotency uniqueness (REQ-AUD-001 duplicate
* suppression at the database level), monotonic append order (`seq`),
* deletion-safe linkage (no foreign key from the events table into any class
* table — events survive the deletion of their target), the causation
* self-FK, and the outbox's FK/uniqueness/status shape. The repository-level
* half of §6.4 (same-transaction atomicity, rollback, replay) is witnessed in
* apps/gateway/src/hierarchy/hierarchy-audit.integration.test.ts.
*
* Two legs run the same witness body:
* - PGlite (WASM Postgres): always runs.
* - Real PostgreSQL (§6.8): runs when DATABASE_URL is set — the binding
* witness; CI migrates ci-postgres before `pnpm test`.
*/
import { randomUUID } from 'node:crypto';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { sql } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createDb } from './client.js';
import { createPgliteDb } from './client-pglite.js';
import { runPgliteMigrations } from './migrate.js';
import { companies, hierarchyAuditEvents, hierarchyOutbox } from './schema.js';
type AnyDb = {
db: {
insert: (t: unknown) => { values: (v: unknown) => Promise<unknown> };
execute: (q: unknown) => Promise<{ rows?: unknown[] } | unknown[]>;
};
close: () => Promise<void>;
};
/** Match a constraint failure anywhere along drizzle's cause chain. */
async function expectViolation(p: Promise<unknown>, re: RegExp, label = ''): Promise<void> {
let err: unknown;
try {
await p;
} catch (e) {
err = e;
}
expect(err, label || 'expected the statement to be refused').toBeDefined();
const messages: string[] = [];
let cur: unknown = err;
while (cur instanceof Error) {
messages.push(cur.message);
cur = (cur as { cause?: unknown }).cause;
}
expect(messages.join(' | '), label).toMatch(re);
}
function rows(res: { rows?: unknown[] } | unknown[]): Record<string, unknown>[] {
return (Array.isArray(res) ? res : (res.rows ?? [])) as Record<string, unknown>[];
}
/** Unique per-run prefix so real-PG runs never collide and clean up safely. */
const T = `hier-a-${randomUUID().slice(0, 8)}`;
/** Minimal valid event row; overrides compose the negative cases. */
type EventInsert = typeof hierarchyAuditEvents.$inferInsert;
function eventRow(overrides: Partial<EventInsert> = {}): EventInsert {
return {
actorId: `${T}-actor`,
verb: 'create',
targetKind: 'company',
targetId: randomUUID(),
targetSnapshot: { id: 'x', slug: 'x', name: 'x', parentChain: [] },
correlationId: `${T}-corr`,
idempotencyKey: `${T}-${randomUUID()}`,
...overrides,
};
}
function witnessSuite(getHandle: () => AnyDb): void {
const db = () => getHandle().db as unknown as ReturnType<typeof createDb>['db'];
afterAll(async () => {
const d = db();
await d.execute(sql`DELETE FROM hierarchy_outbox WHERE idempotency_key LIKE ${T + '%'}`);
// Caused events first: the causation self-FK is RESTRICT.
await d.execute(
sql`DELETE FROM hierarchy_audit_events WHERE idempotency_key LIKE ${T + '%'} AND causation_id IS NOT NULL`,
);
await d.execute(sql`DELETE FROM hierarchy_audit_events WHERE idempotency_key LIKE ${T + '%'}`);
await d.execute(sql`DELETE FROM companies WHERE slug LIKE ${T + '%'}`);
});
// ── CHECK constraints ──────────────────────────────────────────────────────
it('accepts every declared verb and refuses an undeclared one', async () => {
for (const verb of [
'create',
'rename',
'delete',
'grant_create',
'grant_change',
'grant_revoke',
]) {
await db().insert(hierarchyAuditEvents).values(eventRow({ verb }));
}
await expectViolation(
db()
.insert(hierarchyAuditEvents)
.values(eventRow({ verb: 'update' })),
/verb_check|violates check/i,
'undeclared verb must be refused',
);
});
it('refuses an undeclared target kind', async () => {
await expectViolation(
db()
.insert(hierarchyAuditEvents)
.values(eventRow({ targetKind: 'workspace' })),
/target_kind_check|violates check/i,
'workspace is not an audited target kind (workspace mutation is SOT-side)',
);
});
it('requires transfer snapshots exactly on transfers', async () => {
const parent = { kind: 'company', id: randomUUID(), slug: 'p' };
await db()
.insert(hierarchyAuditEvents)
.values(
eventRow({
verb: 'transfer',
targetKind: 'estate',
transferFrom: parent,
transferTo: { ...parent, id: randomUUID() },
}),
);
await expectViolation(
db()
.insert(hierarchyAuditEvents)
.values(eventRow({ verb: 'transfer' })),
/transfer_check|violates check/i,
'transfer without source/destination snapshots must be refused',
);
await expectViolation(
db()
.insert(hierarchyAuditEvents)
.values(eventRow({ verb: 'transfer', transferFrom: parent })),
/transfer_check|violates check/i,
'transfer with only the source snapshot must be refused',
);
await expectViolation(
db()
.insert(hierarchyAuditEvents)
.values(eventRow({ verb: 'create', transferFrom: parent, transferTo: parent })),
/transfer_check|violates check/i,
'non-transfer with transfer snapshots must be refused',
);
});
// ── Idempotency and ordering ───────────────────────────────────────────────
it('refuses a duplicate idempotency key', async () => {
const key = `${T}-dup-${randomUUID()}`;
await db()
.insert(hierarchyAuditEvents)
.values(eventRow({ idempotencyKey: key }));
await expectViolation(
db()
.insert(hierarchyAuditEvents)
.values(eventRow({ idempotencyKey: key })),
/duplicate key|unique/i,
);
});
it('assigns strictly increasing seq in insert order for one target', async () => {
const targetId = randomUUID();
const k1 = `${T}-seq-1-${randomUUID()}`;
const k2 = `${T}-seq-2-${randomUUID()}`;
await db()
.insert(hierarchyAuditEvents)
.values(eventRow({ targetId, idempotencyKey: k1 }));
await db()
.insert(hierarchyAuditEvents)
.values(eventRow({ targetId, verb: 'rename', idempotencyKey: k2 }));
const res = rows(
await db().execute(
sql`SELECT idempotency_key, seq FROM hierarchy_audit_events WHERE target_id = ${targetId} ORDER BY seq ASC`,
),
);
expect(res.map((r) => r['idempotency_key'])).toEqual([k1, k2]);
expect(Number(res[1]!['seq'])).toBeGreaterThan(Number(res[0]!['seq']));
});
// ── Deletion-safe linkage (§5.2) ───────────────────────────────────────────
it('has no foreign key into any class table, and events survive target deletion', async () => {
const fks = rows(
await db().execute(sql`
SELECT ccu.table_name AS referenced_table
FROM information_schema.table_constraints tc
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name AND ccu.constraint_schema = tc.constraint_schema
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_name = 'hierarchy_audit_events'
`),
);
// The causation self-FK is the ONLY foreign key on the events table.
expect([...new Set(fks.map((r) => r['referenced_table']))]).toEqual(['hierarchy_audit_events']);
const companyId = randomUUID();
await db()
.insert(companies)
.values({ id: companyId, name: 'Doomed', slug: `${T}-doomed` });
const key = `${T}-survive-${randomUUID()}`;
await db()
.insert(hierarchyAuditEvents)
.values(
eventRow({
verb: 'delete',
targetId: companyId,
targetSnapshot: { id: companyId, slug: `${T}-doomed`, name: 'Doomed', parentChain: [] },
idempotencyKey: key,
}),
);
await db().execute(sql`DELETE FROM companies WHERE id = ${companyId}`);
const after = rows(
await db().execute(
sql`SELECT target_snapshot FROM hierarchy_audit_events WHERE idempotency_key = ${key}`,
),
);
expect(after).toHaveLength(1);
expect((after[0]!['target_snapshot'] as { id: string }).id).toBe(companyId);
});
it('enforces the causation self-FK and RESTRICTs deleting a cause', async () => {
await expectViolation(
db()
.insert(hierarchyAuditEvents)
.values(eventRow({ causationId: randomUUID() })),
/foreign key/i,
'causation must reference an existing event',
);
const causeKey = `${T}-cause-${randomUUID()}`;
await db()
.insert(hierarchyAuditEvents)
.values(eventRow({ verb: 'delete', idempotencyKey: causeKey }));
const cause = rows(
await db().execute(
sql`SELECT id FROM hierarchy_audit_events WHERE idempotency_key = ${causeKey}`,
),
)[0]!;
await db()
.insert(hierarchyAuditEvents)
.values(
eventRow({ verb: 'grant_revoke', targetKind: 'grant', causationId: cause['id'] as string }),
);
await expectViolation(
db().execute(sql`DELETE FROM hierarchy_audit_events WHERE id = ${cause['id'] as string}`),
/foreign key/i,
'a cause with dependent events must not be deletable',
);
});
// ── Outbox shape ───────────────────────────────────────────────────────────
it('outbox rows require an existing event, one outbox row per event, unique idempotency', async () => {
await expectViolation(
db()
.insert(hierarchyOutbox)
.values({
eventId: randomUUID(),
idempotencyKey: `${T}-ob-${randomUUID()}`,
correlationId: `${T}-corr`,
}),
/foreign key/i,
'outbox must reference an existing event',
);
const key = `${T}-ob-${randomUUID()}`;
await db()
.insert(hierarchyAuditEvents)
.values(eventRow({ idempotencyKey: key }));
const event = rows(
await db().execute(sql`SELECT id FROM hierarchy_audit_events WHERE idempotency_key = ${key}`),
)[0]!;
const eventId = event['id'] as string;
await db()
.insert(hierarchyOutbox)
.values({ eventId, idempotencyKey: key, correlationId: `${T}-corr` });
await expectViolation(
db()
.insert(hierarchyOutbox)
.values({ eventId, idempotencyKey: `${T}-ob2-${randomUUID()}`, correlationId: `${T}-c` }),
/duplicate key|unique/i,
'one outbox record per event',
);
await expectViolation(
db().execute(
sql`INSERT INTO hierarchy_outbox (event_id, idempotency_key, correlation_id, status)
VALUES (${eventId}, ${`${T}-ob3-${randomUUID()}`}, 'c', 'failed')`,
),
/invalid input value for enum|22P02/i,
'status outside pending/processing/delivered must be refused',
);
});
it('outbox FK RESTRICTs event deletion while the outbox row exists', async () => {
const key = `${T}-obr-${randomUUID()}`;
await db()
.insert(hierarchyAuditEvents)
.values(eventRow({ idempotencyKey: key }));
const event = rows(
await db().execute(sql`SELECT id FROM hierarchy_audit_events WHERE idempotency_key = ${key}`),
)[0]!;
await db()
.insert(hierarchyOutbox)
.values({
eventId: event['id'] as string,
idempotencyKey: key,
correlationId: `${T}-corr`,
});
await expectViolation(
db().execute(sql`DELETE FROM hierarchy_audit_events WHERE id = ${event['id'] as string}`),
/foreign key/i,
);
});
}
// ── Leg 1: PGlite (always runs — local witness signal) ───────────────────────
describe('hierarchy audit witnesses — PGlite', () => {
let dir: string;
let handle: ReturnType<typeof createPgliteDb>;
beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), 'hier-audit-witness-'));
handle = createPgliteDb(dir);
await runPgliteMigrations(handle);
});
afterAll(async () => {
await handle.close();
rmSync(dir, { recursive: true, force: true });
});
witnessSuite(() => handle as unknown as AnyDb);
});
// ── Leg 2: real PostgreSQL (§6.8 — binding witness, ci-postgres in CI) ───────
const hasPostgres = Boolean(process.env['DATABASE_URL']);
describe.skipIf(!hasPostgres)('hierarchy audit witnesses — real PostgreSQL', () => {
let handle: ReturnType<typeof createDb>;
beforeAll(() => {
handle = createDb(process.env['DATABASE_URL']!);
});
afterAll(async () => {
await handle.close();
});
witnessSuite(() => handle as unknown as AnyDb);
});