/** * Port-scoping integration tests (SECURITY-CRITICAL). * * Codex Addenda: Two-port testing — every entity must be invisible * when queried under a different portId. * * Skips gracefully when TEST_DATABASE_URL is not reachable. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { makeAuditMeta, makeCreateClientInput, makeCreateInterestInput } from '../helpers/factories'; const TEST_DB_URL = process.env.TEST_DATABASE_URL || 'postgresql://test:test@localhost:5433/portnimara_test'; // ─── DB Availability Check ──────────────────────────────────────────────────── let dbAvailable = false; beforeAll(async () => { try { const postgres = (await import('postgres')).default; const sql = postgres(TEST_DB_URL, { max: 1, idle_timeout: 3, connect_timeout: 3 }); await sql`SELECT 1`; await sql.end(); dbAvailable = true; } catch { console.warn('[port-scoping] Test database not available — skipping integration tests'); } }); function itDb(name: string, fn: () => Promise) { it(name, async () => { if (!dbAvailable) return; await fn(); }); } // ─── Helpers ───────────────────────────────────────────────────────────────── async function seedPorts(): Promise<{ portA: string; portB: string }> { const postgres = (await import('postgres')).default; const sql = postgres(TEST_DB_URL, { max: 1 }); const portA = crypto.randomUUID(); const portB = crypto.randomUUID(); await sql` INSERT INTO ports (id, name, slug, country, currency, timezone) VALUES (${portA}, 'Port Alpha', ${'alpha-' + portA.slice(0, 8)}, 'AU', 'AUD', 'UTC'), (${portB}, 'Port Beta', ${'beta-' + portB.slice(0, 8)}, 'NZ', 'NZD', 'UTC') `; await sql.end(); return { portA, portB }; } async function cleanupPorts(portA: string, portB: string): Promise { const postgres = (await import('postgres')).default; const sql = postgres(TEST_DB_URL, { max: 1 }); await sql`DELETE FROM ports WHERE id = ANY(${[portA, portB]})`; await sql.end(); } // ─── Tests ──────────────────────────────────────────────────────────────────── describe('Port Scoping — Clients', () => { let portA: string; let portB: string; beforeAll(async () => { if (!dbAvailable) return; ({ portA, portB } = await seedPorts()); }); afterAll(async () => { if (!dbAvailable) return; await cleanupPorts(portA, portB); }); itDb('client created in Port A is invisible to Port B list', async () => { const { createClient, listClients } = await import('@/lib/services/clients.service'); const meta = makeAuditMeta({ portId: portA }); const client = await createClient(portA, makeCreateClientInput({ fullName: 'Alice Scope' }), meta); expect(client.portId).toBe(portA); const result = await listClients(portB, { page: 1, limit: 50, sort: 'updatedAt', order: 'desc', includeArchived: false, }); const ids = (result.data as Array<{ id: string }>).map((c) => c.id); expect(ids).not.toContain(client.id); }); itDb('getClientById throws NotFoundError when portId does not match', async () => { const { createClient, getClientById } = await import('@/lib/services/clients.service'); const { NotFoundError } = await import('@/lib/errors'); const meta = makeAuditMeta({ portId: portA }); const client = await createClient(portA, makeCreateClientInput({ fullName: 'Bob Scope' }), meta); await expect(getClientById(client.id, portB)).rejects.toThrow(NotFoundError); }); itDb('updateClient on wrong port throws NotFoundError', async () => { const { createClient, updateClient } = await import('@/lib/services/clients.service'); const { NotFoundError } = await import('@/lib/errors'); const meta = makeAuditMeta({ portId: portA }); const client = await createClient(portA, makeCreateClientInput({ fullName: 'Carol Scope' }), meta); await expect( updateClient(client.id, portB, { fullName: 'Hacked' }, meta), ).rejects.toThrow(NotFoundError); }); itDb('archiveClient on wrong port throws NotFoundError', async () => { const { createClient, archiveClient } = await import('@/lib/services/clients.service'); const { NotFoundError } = await import('@/lib/errors'); const meta = makeAuditMeta({ portId: portA }); const client = await createClient(portA, makeCreateClientInput({ fullName: 'Dave Scope' }), meta); await expect(archiveClient(client.id, portB, meta)).rejects.toThrow(NotFoundError); }); }); describe('Port Scoping — Interests', () => { let portA: string; let portB: string; let clientIdA: string; beforeAll(async () => { if (!dbAvailable) return; ({ portA, portB } = await seedPorts()); const { createClient } = await import('@/lib/services/clients.service'); const meta = makeAuditMeta({ portId: portA }); const client = await createClient(portA, makeCreateClientInput({ fullName: 'Scope Test Client' }), meta); clientIdA = client.id; }); afterAll(async () => { if (!dbAvailable) return; await cleanupPorts(portA, portB); }); itDb('interest created in Port A is invisible to Port B list', async () => { const { createInterest, listInterests } = await import('@/lib/services/interests.service'); const meta = makeAuditMeta({ portId: portA }); const interest = await createInterest(portA, makeCreateInterestInput({ clientId: clientIdA }), meta); expect(interest.portId).toBe(portA); const result = await listInterests(portB, { page: 1, limit: 50, sort: 'updatedAt', order: 'desc', includeArchived: false, }); const ids = (result.data as unknown as Array<{ id: string }>).map((i) => i.id); expect(ids).not.toContain(interest.id); }); itDb('getInterestById throws NotFoundError when portId does not match', async () => { const { createInterest, getInterestById } = await import('@/lib/services/interests.service'); const { NotFoundError } = await import('@/lib/errors'); const meta = makeAuditMeta({ portId: portA }); const interest = await createInterest(portA, makeCreateInterestInput({ clientId: clientIdA }), meta); await expect(getInterestById(interest.id, portB)).rejects.toThrow(NotFoundError); }); itDb('changeInterestStage on wrong port throws NotFoundError', async () => { const { createInterest, changeInterestStage } = await import('@/lib/services/interests.service'); const { NotFoundError } = await import('@/lib/errors'); const meta = makeAuditMeta({ portId: portA }); const interest = await createInterest(portA, makeCreateInterestInput({ clientId: clientIdA }), meta); await expect( changeInterestStage(interest.id, portB, { pipelineStage: 'details_sent' }, meta), ).rejects.toThrow(NotFoundError); }); });