Files
stack/packages/db/src/federation.integration.test.ts
jason.woltje 54c278b871
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline failed
feat(db): federation schema — grants/peers/audit_log [FED-M2-01] (#486)
2026-04-22 02:02:21 +00:00

425 lines
15 KiB
TypeScript

/**
* 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<typeof postgres> | 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);
});