425 lines
15 KiB
TypeScript
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);
|
|
});
|