/** * FED-M2-01 — Integration test: federation DB schema (peers / grants / audit_log). * * Prereq: docker compose -f docker-compose.federated.yml --profile federated up -d * (or any postgres with the mosaic schema already applied) * Run: FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/db test src/federation.integration.test.ts * * Skipped when FEDERATED_INTEGRATION !== '1'. * * Strategy: * - Applies the federation migration SQL directly (idempotent: CREATE TYPE/TABLE * with IF NOT EXISTS guards applied via inline SQL before the migration DDL). * - Assumes the base schema (users table etc.) already exists in the target DB. * - All test rows use the `fed-m2-01-` prefix; cleanup in afterAll. * * Coverage: * 1. Federation tables + enums apply cleanly against the existing schema. * 2. Insert a sample user + peer + grant + audit row; verify round-trip. * 3. FK cascade: deleting the user cascades to federation_grants. * 4. FK set-null: deleting the peer sets federation_audit_log.peer_id to NULL. * 5. Enum constraint: inserting an invalid status/state value throws a DB error. * 6. Unique constraint: duplicate cert_serial throws a DB error. */ import postgres from 'postgres'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; const run = process.env['FEDERATED_INTEGRATION'] === '1'; const PG_URL = process.env['DATABASE_URL'] ?? 'postgresql://mosaic:mosaic@localhost:5433/mosaic'; /** Recognisable test-row prefix for safe cleanup without full-table truncation. */ const T = 'fed-m2-01'; // Deterministic IDs (UUID format required for uuid PK columns: 8-4-4-4-12 hex digits). const PEER1_ID = `f2000001-0000-4000-8000-000000000001`; const PEER2_ID = `f2000002-0000-4000-8000-000000000002`; const USER1_ID = `${T}-user-1`; let sql: ReturnType | undefined; beforeAll(async () => { if (!run) return; sql = postgres(PG_URL, { max: 1, connect_timeout: 10, idle_timeout: 10 }); // Apply the federation enums and tables idempotently. // This mirrors the migration file but uses IF NOT EXISTS guards so it can run // against a DB that may not have had drizzle migrations tracked. await sql` DO $$ BEGIN CREATE TYPE peer_state AS ENUM ('pending', 'active', 'suspended', 'revoked'); EXCEPTION WHEN duplicate_object THEN NULL; END $$ `; await sql` DO $$ BEGIN CREATE TYPE grant_status AS ENUM ('active', 'revoked', 'expired'); EXCEPTION WHEN duplicate_object THEN NULL; END $$ `; await sql` CREATE TABLE IF NOT EXISTS federation_peers ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), common_name text NOT NULL, display_name text NOT NULL, cert_pem text NOT NULL, cert_serial text NOT NULL, cert_not_after timestamp with time zone NOT NULL, client_key_pem text, state peer_state NOT NULL DEFAULT 'pending', endpoint_url text, last_seen_at timestamp with time zone, created_at timestamp with time zone NOT NULL DEFAULT now(), revoked_at timestamp with time zone, CONSTRAINT federation_peers_common_name_unique UNIQUE (common_name), CONSTRAINT federation_peers_cert_serial_unique UNIQUE (cert_serial) ) `; await sql` CREATE INDEX IF NOT EXISTS federation_peers_cert_serial_idx ON federation_peers (cert_serial) `; await sql` CREATE INDEX IF NOT EXISTS federation_peers_state_idx ON federation_peers (state) `; await sql` CREATE TABLE IF NOT EXISTS federation_grants ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), subject_user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE, peer_id uuid NOT NULL REFERENCES federation_peers(id) ON DELETE CASCADE, scope jsonb NOT NULL, status grant_status NOT NULL DEFAULT 'active', expires_at timestamp with time zone, created_at timestamp with time zone NOT NULL DEFAULT now(), revoked_at timestamp with time zone, revoked_reason text ) `; await sql` CREATE INDEX IF NOT EXISTS federation_grants_subject_status_idx ON federation_grants (subject_user_id, status) `; await sql` CREATE INDEX IF NOT EXISTS federation_grants_peer_status_idx ON federation_grants (peer_id, status) `; await sql` CREATE TABLE IF NOT EXISTS federation_audit_log ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), request_id text NOT NULL, peer_id uuid REFERENCES federation_peers(id) ON DELETE SET NULL, subject_user_id text REFERENCES users(id) ON DELETE SET NULL, grant_id uuid REFERENCES federation_grants(id) ON DELETE SET NULL, verb text NOT NULL, resource text NOT NULL, status_code integer NOT NULL, result_count integer, denied_reason text, latency_ms integer, created_at timestamp with time zone NOT NULL DEFAULT now(), query_hash text, outcome text, bytes_out integer ) `; await sql` CREATE INDEX IF NOT EXISTS federation_audit_log_peer_created_at_idx ON federation_audit_log (peer_id, created_at DESC NULLS LAST) `; await sql` CREATE INDEX IF NOT EXISTS federation_audit_log_subject_created_at_idx ON federation_audit_log (subject_user_id, created_at DESC NULLS LAST) `; await sql` CREATE INDEX IF NOT EXISTS federation_audit_log_created_at_idx ON federation_audit_log (created_at DESC NULLS LAST) `; }); afterAll(async () => { if (!sql) return; // Cleanup in FK-safe order (children before parents). await sql`DELETE FROM federation_audit_log WHERE request_id LIKE ${T + '%'}`.catch(() => {}); await sql` DELETE FROM federation_grants WHERE subject_user_id LIKE ${T + '%'} OR revoked_reason LIKE ${T + '%'} `.catch(() => {}); await sql`DELETE FROM federation_peers WHERE common_name LIKE ${T + '%'}`.catch(() => {}); await sql`DELETE FROM users WHERE id LIKE ${T + '%'}`.catch(() => {}); await sql.end({ timeout: 3 }).catch(() => {}); }); describe.skipIf(!run)('federation schema — integration', () => { // ── 1. Insert sample rows ────────────────────────────────────────────────── it('inserts a user, peer, grant, and audit row without constraint violation', async () => { const certPem = '-----BEGIN CERTIFICATE-----\nMIItest\n-----END CERTIFICATE-----'; // User — BetterAuth users.id is text (any string, not uuid). await sql!` INSERT INTO users (id, name, email, email_verified, created_at, updated_at) VALUES (${USER1_ID}, ${'M2-01 Test User'}, ${USER1_ID + '@example.com'}, false, now(), now()) ON CONFLICT (id) DO NOTHING `; // Peer await sql!` INSERT INTO federation_peers (id, common_name, display_name, cert_pem, cert_serial, cert_not_after, state, created_at) VALUES ( ${PEER1_ID}, ${T + '-gateway-example-com'}, ${'Test Peer'}, ${certPem}, ${T + '-serial-001'}, now() + interval '1 year', ${'active'}, now() ) ON CONFLICT (id) DO NOTHING `; // Grant — scope is jsonb; pass as JSON string and cast server-side. const scopeJson = JSON.stringify({ resources: ['tasks', 'notes'], operations: ['list', 'get'], }); const grants = await sql!` INSERT INTO federation_grants (subject_user_id, peer_id, scope, status, created_at) VALUES ( ${USER1_ID}, ${PEER1_ID}, ${scopeJson}::jsonb, ${'active'}, now() ) RETURNING id `; expect(grants).toHaveLength(1); const grantId = grants[0]!['id'] as string; // Audit log row await sql!` INSERT INTO federation_audit_log (request_id, peer_id, subject_user_id, grant_id, verb, resource, status_code, created_at) VALUES ( ${T + '-req-001'}, ${PEER1_ID}, ${USER1_ID}, ${grantId}, ${'list'}, ${'tasks'}, ${200}, now() ) `; // Verify the audit row is present and has correct data. const auditRows = await sql!` SELECT * FROM federation_audit_log WHERE request_id = ${T + '-req-001'} `; expect(auditRows).toHaveLength(1); expect(auditRows[0]!['status_code']).toBe(200); expect(auditRows[0]!['verb']).toBe('list'); expect(auditRows[0]!['resource']).toBe('tasks'); }, 30_000); // ── 2. FK cascade: user delete cascades grants ───────────────────────────── it('cascade-deletes federation_grants when the subject user is deleted', async () => { const cascadeUserId = `${T}-cascade-user`; await sql!` INSERT INTO users (id, name, email, email_verified, created_at, updated_at) VALUES (${cascadeUserId}, ${'Cascade User'}, ${cascadeUserId + '@example.com'}, false, now(), now()) ON CONFLICT (id) DO NOTHING `; const scopeJson = JSON.stringify({ resources: ['tasks'] }); await sql!` INSERT INTO federation_grants (subject_user_id, peer_id, scope, status, revoked_reason, created_at) VALUES ( ${cascadeUserId}, ${PEER1_ID}, ${scopeJson}::jsonb, ${'active'}, ${T + '-cascade-test'}, now() ) `; const before = await sql!` SELECT count(*)::int AS cnt FROM federation_grants WHERE subject_user_id = ${cascadeUserId} `; expect(before[0]!['cnt']).toBe(1); // Delete user → grants should cascade-delete. await sql!`DELETE FROM users WHERE id = ${cascadeUserId}`; const after = await sql!` SELECT count(*)::int AS cnt FROM federation_grants WHERE subject_user_id = ${cascadeUserId} `; expect(after[0]!['cnt']).toBe(0); }, 15_000); // ── 3. FK set-null: peer delete sets audit_log.peer_id to NULL ──────────── it('sets federation_audit_log.peer_id to NULL when the peer is deleted', async () => { // Insert a throwaway peer for this specific cascade test. await sql!` INSERT INTO federation_peers (id, common_name, display_name, cert_pem, cert_serial, cert_not_after, state, created_at) VALUES ( ${PEER2_ID}, ${T + '-gateway-throwaway-com'}, ${'Throwaway Peer'}, ${'cert-pem-placeholder'}, ${T + '-serial-002'}, now() + interval '1 year', ${'active'}, now() ) ON CONFLICT (id) DO NOTHING `; const reqId = `${T}-req-setnull`; await sql!` INSERT INTO federation_audit_log (request_id, peer_id, subject_user_id, verb, resource, status_code, created_at) VALUES ( ${reqId}, ${PEER2_ID}, ${USER1_ID}, ${'get'}, ${'tasks'}, ${200}, now() ) `; await sql!`DELETE FROM federation_peers WHERE id = ${PEER2_ID}`; const rows = await sql!` SELECT peer_id FROM federation_audit_log WHERE request_id = ${reqId} `; expect(rows).toHaveLength(1); expect(rows[0]!['peer_id']).toBeNull(); }, 15_000); // ── 4. Enum constraint: invalid grant_status rejected ───────────────────── it('rejects an invalid grant_status value with a DB error', async () => { const scopeJson = JSON.stringify({ resources: ['tasks'] }); await expect( sql!` INSERT INTO federation_grants (subject_user_id, peer_id, scope, status, created_at) VALUES ( ${USER1_ID}, ${PEER1_ID}, ${scopeJson}::jsonb, ${'invalid_status'}, now() ) `, ).rejects.toThrow(); }, 10_000); // ── 5. Enum constraint: invalid peer_state rejected ─────────────────────── it('rejects an invalid peer_state value with a DB error', async () => { await expect( sql!` INSERT INTO federation_peers (common_name, display_name, cert_pem, cert_serial, cert_not_after, state, created_at) VALUES ( ${'bad-state-peer'}, ${'Bad State'}, ${'pem'}, ${'bad-serial-999'}, now() + interval '1 year', ${'invalid_state'}, now() ) `, ).rejects.toThrow(); }, 10_000); // ── 6. Unique constraint: duplicate cert_serial rejected ────────────────── it('rejects a duplicate cert_serial with a unique constraint violation', async () => { await expect( sql!` INSERT INTO federation_peers (common_name, display_name, cert_pem, cert_serial, cert_not_after, state, created_at) VALUES ( ${T + '-dup-cn'}, ${'Dup Peer'}, ${'pem'}, ${T + '-serial-001'}, now() + interval '1 year', ${'pending'}, now() ) `, ).rejects.toThrow(); }, 10_000); // ── 7. FK cascade: peer delete cascades to federation_grants ───────────── it('cascade-deletes federation_grants when the owning peer is deleted', async () => { const PEER3_ID = `f2000003-0000-4000-8000-000000000003`; const cascadeGrantUserId = `${T}-cascade-grant-user`; // Insert a dedicated user and peer for this test. await sql!` INSERT INTO users (id, name, email, email_verified, created_at, updated_at) VALUES (${cascadeGrantUserId}, ${'Cascade Grant User'}, ${cascadeGrantUserId + '@example.com'}, false, now(), now()) ON CONFLICT (id) DO NOTHING `; await sql!` INSERT INTO federation_peers (id, common_name, display_name, cert_pem, cert_serial, cert_not_after, state, created_at) VALUES ( ${PEER3_ID}, ${T + '-gateway-cascade-peer'}, ${'Cascade Peer'}, ${'cert-pem-cascade'}, ${T + '-serial-003'}, now() + interval '1 year', ${'active'}, now() ) ON CONFLICT (id) DO NOTHING `; const scopeJson = JSON.stringify({ resources: ['tasks'] }); await sql!` INSERT INTO federation_grants (subject_user_id, peer_id, scope, status, created_at) VALUES ( ${cascadeGrantUserId}, ${PEER3_ID}, ${scopeJson}::jsonb, ${'active'}, now() ) `; const before = await sql!` SELECT count(*)::int AS cnt FROM federation_grants WHERE peer_id = ${PEER3_ID} `; expect(before[0]!['cnt']).toBe(1); // Delete peer → grants should cascade-delete. await sql!`DELETE FROM federation_peers WHERE id = ${PEER3_ID}`; const after = await sql!` SELECT count(*)::int AS cnt FROM federation_grants WHERE peer_id = ${PEER3_ID} `; expect(after[0]!['cnt']).toBe(0); // Cleanup await sql!`DELETE FROM users WHERE id = ${cascadeGrantUserId}`.catch(() => {}); }, 15_000); });