/** * Federation M2 integration tests (FED-M2-09). * * Covers MILESTONES.md acceptance tests #1, #2, #3, #5, #7, #8. * * Prerequisites: * docker compose -f docker-compose.federated.yml --profile federated up -d * * Run DB-only tests (no Step-CA): * FEDERATED_INTEGRATION=1 BETTER_AUTH_SECRET=test-secret pnpm --filter @mosaicstack/gateway test \ * src/__tests__/integration/federation-m2.integration.test.ts * * Run all tests including Step-CA-dependent ones: * FEDERATED_INTEGRATION=1 STEP_CA_AVAILABLE=1 \ * STEP_CA_URL=https://localhost:9000 \ * STEP_CA_PROVISIONER_KEY_JSON="$(docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json)" \ * STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt \ * pnpm --filter @mosaicstack/gateway test \ * src/__tests__/integration/federation-m2.integration.test.ts * * Obtaining Step-CA credentials: * # Extract provisioner key from running container: * # docker exec $(docker ps -qf name=step-ca) cat /home/step/secrets/mosaic-fed.json * # Copy root cert from container: * # docker cp $(docker ps -qf name=step-ca):/home/step/certs/root_ca.crt /tmp/step-ca-root.crt * # Then: export STEP_CA_ROOT_CERT_PATH=/tmp/step-ca-root.crt */ import * as crypto from 'node:crypto'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { Test } from '@nestjs/testing'; import { GoneException } from '@nestjs/common'; import { Pkcs10CertificateRequestGenerator, X509Certificate as PeculiarX509 } from '@peculiar/x509'; import { createDb, type Db, type DbHandle, federationPeers, federationGrants, federationEnrollmentTokens, inArray, eq, } from '@mosaicstack/db'; import * as schema from '@mosaicstack/db'; import { seal } from '@mosaicstack/auth'; import { DB } from '../../database/database.module.js'; import { GrantsService } from '../../federation/grants.service.js'; import { EnrollmentService } from '../../federation/enrollment.service.js'; import { CaService } from '../../federation/ca.service.js'; import { FederationScopeError } from '../../federation/scope-schema.js'; const run = process.env['FEDERATED_INTEGRATION'] === '1'; const stepCaRun = run && process.env['STEP_CA_AVAILABLE'] === '1'; const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic'; // --------------------------------------------------------------------------- // Helpers for test data isolation // --------------------------------------------------------------------------- /** Unique run prefix to identify rows created by this test run. */ const RUN_ID = crypto.randomUUID(); /** Insert a minimal user row to satisfy the FK on federation_grants.subject_user_id. */ async function insertTestUser(db: Db, id: string): Promise { await db .insert(schema.users) .values({ id, name: `test-user-${id}`, email: `test-${id}@federation-test.invalid`, emailVerified: false, }) .onConflictDoNothing(); } /** Insert a minimal peer row to satisfy the FK on federation_grants.peer_id. */ async function insertTestPeer(db: Db, id: string, suffix: string = ''): Promise { await db .insert(federationPeers) .values({ id, commonName: `test-peer-${RUN_ID}-${suffix}`, displayName: `Test Peer ${suffix}`, certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n', certSerial: `test-serial-${id}`, certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), state: 'pending', }) .onConflictDoNothing(); } // --------------------------------------------------------------------------- // DB-only test module (CaService mocked so env vars not required) // --------------------------------------------------------------------------- function buildDbModule(db: Db) { return Test.createTestingModule({ providers: [ { provide: DB, useValue: db }, GrantsService, { provide: CaService, useValue: { issueCert: async () => { throw new Error('CaService.issueCert should not be called in DB-only tests'); }, }, }, EnrollmentService, ], }).compile(); } // --------------------------------------------------------------------------- // Test suite — DB-only (no Step-CA) // --------------------------------------------------------------------------- describe.skipIf(!run)('federation M2 — DB-only tests', () => { let handle: DbHandle; let db: Db; let grantsService: GrantsService; /** IDs created during this run — cleaned up in afterAll. */ const createdGrantIds: string[] = []; const createdPeerIds: string[] = []; const createdUserIds: string[] = []; beforeAll(async () => { process.env['BETTER_AUTH_SECRET'] ??= 'test-integration-sealing-key-not-for-prod'; handle = createDb(PG_URL); db = handle.db; const moduleRef = await buildDbModule(db); grantsService = moduleRef.get(GrantsService); }); afterAll(async () => { // Clean up in FK-safe order: tokens → grants → peers → users if (db && createdGrantIds.length > 0) { await db .delete(federationEnrollmentTokens) .where(inArray(federationEnrollmentTokens.grantId, createdGrantIds)) .catch((e: unknown) => console.error('[federation-m2-test cleanup]', e)); await db .delete(federationGrants) .where(inArray(federationGrants.id, createdGrantIds)) .catch((e: unknown) => console.error('[federation-m2-test cleanup]', e)); } if (db && createdPeerIds.length > 0) { await db .delete(federationPeers) .where(inArray(federationPeers.id, createdPeerIds)) .catch((e: unknown) => console.error('[federation-m2-test cleanup]', e)); } if (db && createdUserIds.length > 0) { await db .delete(schema.users) .where(inArray(schema.users.id, createdUserIds)) .catch((e: unknown) => console.error('[federation-m2-test cleanup]', e)); } if (handle) await handle.close().catch((e: unknown) => console.error('[federation-m2-test cleanup]', e)); }); // ------------------------------------------------------------------------- // #1 — grant create writes a pending row // ------------------------------------------------------------------------- it('#1 — createGrant writes a pending row to DB', async () => { const userId = crypto.randomUUID(); const peerId = crypto.randomUUID(); const validScope = { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 100, }; await insertTestUser(db, userId); await insertTestPeer(db, peerId, 'test1'); createdUserIds.push(userId); createdPeerIds.push(peerId); const grant = await grantsService.createGrant({ subjectUserId: userId, scope: validScope, peerId, }); createdGrantIds.push(grant.id); // Verify the row exists in DB with correct shape const [row] = await db .select() .from(federationGrants) .where(eq(federationGrants.id, grant.id)) .limit(1); expect(row).toBeDefined(); expect(row?.status).toBe('pending'); expect(row?.peerId).toBe(peerId); expect(row?.subjectUserId).toBe(userId); const storedScope = row?.scope as Record; expect(storedScope['resources']).toEqual(['tasks']); expect(storedScope['max_rows_per_query']).toBe(100); }, 15_000); // ------------------------------------------------------------------------- // #7 — scope with unknown resource type rejected // ------------------------------------------------------------------------- it('#7 — createGrant rejects scope with unknown resource type', async () => { const userId = crypto.randomUUID(); const peerId = crypto.randomUUID(); const invalidScope = { resources: ['totally_unknown_resource'], excluded_resources: [], max_rows_per_query: 100, }; await insertTestUser(db, userId); await insertTestPeer(db, peerId, 'test7'); createdUserIds.push(userId); createdPeerIds.push(peerId); await expect( grantsService.createGrant({ subjectUserId: userId, scope: invalidScope, peerId, }), ).rejects.toThrow(FederationScopeError); }, 15_000); // ------------------------------------------------------------------------- // #8 — listGrants returns accurate status for grants in various states // ------------------------------------------------------------------------- it('#8 — listGrants returns accurate status for grants in various states', async () => { const userId = crypto.randomUUID(); const peerId = crypto.randomUUID(); const validScope = { resources: ['notes'], excluded_resources: [], max_rows_per_query: 50, }; await insertTestUser(db, userId); await insertTestPeer(db, peerId, 'test8'); createdUserIds.push(userId); createdPeerIds.push(peerId); // Create two pending grants via GrantsService const grantA = await grantsService.createGrant({ subjectUserId: userId, scope: validScope, peerId, }); const grantB = await grantsService.createGrant({ subjectUserId: userId, scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 50 }, peerId, }); createdGrantIds.push(grantA.id, grantB.id); // Insert a third grant directly in 'revoked' state to test status variety const [grantC] = await db .insert(federationGrants) .values({ id: crypto.randomUUID(), subjectUserId: userId, peerId, scope: validScope, status: 'revoked', revokedAt: new Date(), }) .returning(); createdGrantIds.push(grantC!.id); // List all grants for this peer const allForPeer = await grantsService.listGrants({ peerId }); const ourGrantIds = new Set([grantA.id, grantB.id, grantC!.id]); const ourGrants = allForPeer.filter((g) => ourGrantIds.has(g.id)); expect(ourGrants).toHaveLength(3); const pendingGrants = ourGrants.filter((g) => g.status === 'pending'); const revokedGrants = ourGrants.filter((g) => g.status === 'revoked'); expect(pendingGrants).toHaveLength(2); expect(revokedGrants).toHaveLength(1); // Status-filtered query const pendingOnly = await grantsService.listGrants({ peerId, status: 'pending' }); const ourPending = pendingOnly.filter((g) => ourGrantIds.has(g.id)); expect(ourPending.every((g) => g.status === 'pending')).toBe(true); // Verify peer list from DB also shows the peer rows with correct state const peers = await db.select().from(federationPeers).where(eq(federationPeers.id, peerId)); expect(peers).toHaveLength(1); expect(peers[0]?.state).toBe('pending'); }, 15_000); // ------------------------------------------------------------------------- // #5 — client_key_pem encrypted at rest // ------------------------------------------------------------------------- it('#5 — clientKeyPem stored in DB is a sealed ciphertext (not a valid PEM)', async () => { const peerId = crypto.randomUUID(); const rawPem = '-----BEGIN PRIVATE KEY-----\nMOCK\n-----END PRIVATE KEY-----\n'; const sealed = seal(rawPem); await db.insert(federationPeers).values({ id: peerId, commonName: `test-peer-${RUN_ID}-sealed`, displayName: 'Sealed Key Test Peer', certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n', certSerial: `test-serial-sealed-${peerId}`, certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), state: 'pending', clientKeyPem: sealed, }); createdPeerIds.push(peerId); const [row] = await db .select() .from(federationPeers) .where(eq(federationPeers.id, peerId)) .limit(1); expect(row).toBeDefined(); // The stored value must NOT be a valid PEM — it's a sealed ciphertext blob expect(row?.clientKeyPem).toBeDefined(); expect(row?.clientKeyPem?.startsWith('-----BEGIN')).toBe(false); // The sealed value should be non-trivial (at least 20 chars) expect((row?.clientKeyPem ?? '').length).toBeGreaterThan(20); }, 15_000); }); // --------------------------------------------------------------------------- // Test suite — Step-CA gated // --------------------------------------------------------------------------- describe.skipIf(!stepCaRun)('federation M2 — Step-CA tests', () => { let handle: DbHandle; let db: Db; let grantsService: GrantsService; let enrollmentService: EnrollmentService; const createdGrantIds: string[] = []; const createdPeerIds: string[] = []; const createdUserIds: string[] = []; beforeAll(async () => { handle = createDb(PG_URL); db = handle.db; // Use real CaService — env vars (STEP_CA_URL, STEP_CA_PROVISIONER_KEY_JSON, // STEP_CA_ROOT_CERT_PATH) must be set when STEP_CA_AVAILABLE=1 const moduleRef = await Test.createTestingModule({ providers: [{ provide: DB, useValue: db }, CaService, GrantsService, EnrollmentService], }).compile(); grantsService = moduleRef.get(GrantsService); enrollmentService = moduleRef.get(EnrollmentService); }); afterAll(async () => { if (db && createdGrantIds.length > 0) { await db .delete(federationEnrollmentTokens) .where(inArray(federationEnrollmentTokens.grantId, createdGrantIds)) .catch((e: unknown) => console.error('[federation-m2-test cleanup]', e)); await db .delete(federationGrants) .where(inArray(federationGrants.id, createdGrantIds)) .catch((e: unknown) => console.error('[federation-m2-test cleanup]', e)); } if (db && createdPeerIds.length > 0) { await db .delete(federationPeers) .where(inArray(federationPeers.id, createdPeerIds)) .catch((e: unknown) => console.error('[federation-m2-test cleanup]', e)); } if (db && createdUserIds.length > 0) { await db .delete(schema.users) .where(inArray(schema.users.id, createdUserIds)) .catch((e: unknown) => console.error('[federation-m2-test cleanup]', e)); } if (handle) await handle.close().catch((e: unknown) => console.error('[federation-m2-test cleanup]', e)); }); /** Generate a P-256 key pair and PKCS#10 CSR, returning the CSR as PEM. */ async function generateCsrPem(cn: string): Promise { const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' }; const keyPair = await crypto.subtle.generateKey(alg, true, ['sign', 'verify']); const csr = await Pkcs10CertificateRequestGenerator.create({ name: `CN=${cn}`, keys: keyPair, signingAlgorithm: alg, }); return csr.toString('pem'); } // ------------------------------------------------------------------------- // #2 — enrollment signs CSR and returns cert // ------------------------------------------------------------------------- it('#2 — redeem returns a certPem containing a valid PEM certificate', async () => { const userId = crypto.randomUUID(); const peerId = crypto.randomUUID(); const validScope = { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 100, }; await insertTestUser(db, userId); await insertTestPeer(db, peerId, 'ca-test2'); createdUserIds.push(userId); createdPeerIds.push(peerId); const grant = await grantsService.createGrant({ subjectUserId: userId, scope: validScope, peerId, }); createdGrantIds.push(grant.id); const { token } = await enrollmentService.createToken({ grantId: grant.id, peerId, ttlSeconds: 900, }); const csrPem = await generateCsrPem(`gateway-test-${RUN_ID.slice(0, 8)}`); const result = await enrollmentService.redeem(token, csrPem); expect(result.certPem).toContain('-----BEGIN CERTIFICATE-----'); expect(result.certChainPem).toContain('-----BEGIN CERTIFICATE-----'); // Verify the issued cert parses cleanly const cert = new PeculiarX509(result.certPem); expect(cert.serialNumber).toBeTruthy(); }, 30_000); // ------------------------------------------------------------------------- // #3 — token single-use; second attempt returns GoneException // ------------------------------------------------------------------------- it('#3 — second redeem of the same token throws GoneException', async () => { const userId = crypto.randomUUID(); const peerId = crypto.randomUUID(); const validScope = { resources: ['notes'], excluded_resources: [], max_rows_per_query: 50, }; await insertTestUser(db, userId); await insertTestPeer(db, peerId, 'ca-test3'); createdUserIds.push(userId); createdPeerIds.push(peerId); const grant = await grantsService.createGrant({ subjectUserId: userId, scope: validScope, peerId, }); createdGrantIds.push(grant.id); const { token } = await enrollmentService.createToken({ grantId: grant.id, peerId, ttlSeconds: 900, }); const csrPem = await generateCsrPem(`gateway-test-replay-${RUN_ID.slice(0, 8)}`); // First redeem must succeed const result = await enrollmentService.redeem(token, csrPem); expect(result.certPem).toContain('-----BEGIN CERTIFICATE-----'); // Second redeem with the same token must be rejected await expect(enrollmentService.redeem(token, csrPem)).rejects.toThrow(GoneException); }, 30_000); });