diff --git a/apps/gateway/src/__tests__/integration/federation-m2-e2e.integration.test.ts b/apps/gateway/src/__tests__/integration/federation-m2-e2e.integration.test.ts new file mode 100644 index 0000000..17bc3fa --- /dev/null +++ b/apps/gateway/src/__tests__/integration/federation-m2-e2e.integration.test.ts @@ -0,0 +1,243 @@ +/** + * Federation M2 E2E test — peer-add enrollment flow (FED-M2-10). + * + * Covers MILESTONES.md acceptance test #6: + * "`peer add ` on Server A yields an `active` peer record with a valid cert + key" + * + * This test simulates two gateways using a single bootstrapped NestJS app: + * - "Server A": the admin API that generates a keypair and stores the cert + * - "Server B": the enrollment endpoint that signs the CSR + * Both share the same DB + Step-CA in the test environment. + * + * Prerequisites: + * docker compose -f docker-compose.federated.yml --profile federated up -d + * + * Run: + * 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-e2e.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 + * + * Skipped unless both FEDERATED_INTEGRATION=1 and STEP_CA_AVAILABLE=1 are set. + */ + +import * as crypto from 'node:crypto'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { Test } from '@nestjs/testing'; +import { ValidationPipe } from '@nestjs/common'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; +import supertest from 'supertest'; +import { + createDb, + type Db, + type DbHandle, + federationPeers, + federationGrants, + federationEnrollmentTokens, + inArray, + eq, +} from '@mosaicstack/db'; +import * as schema from '@mosaicstack/db'; +import { DB } from '../../database/database.module.js'; +import { AdminGuard } from '../../admin/admin.guard.js'; +import { FederationModule } from '../../federation/federation.module.js'; +import { GrantsService } from '../../federation/grants.service.js'; +import { EnrollmentService } from '../../federation/enrollment.service.js'; + +const run = process.env['FEDERATED_INTEGRATION'] === '1'; +const stepCaRun = + run && + process.env['STEP_CA_AVAILABLE'] === '1' && + !!process.env['STEP_CA_URL'] && + !!process.env['STEP_CA_PROVISIONER_KEY_JSON'] && + !!process.env['STEP_CA_ROOT_CERT_PATH']; + +const PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic'; + +const RUN_ID = crypto.randomUUID(); + +describe.skipIf(!stepCaRun)('federation M2 E2E — peer add enrollment flow', () => { + let handle: DbHandle; + let db: Db; + let app: NestFastifyApplication; + let agent: ReturnType; + let grantsService: GrantsService; + let enrollmentService: EnrollmentService; + + const createdTokenGrantIds: string[] = []; + const createdGrantIds: string[] = []; + const createdPeerIds: string[] = []; + const createdUserIds: string[] = []; + + beforeAll(async () => { + process.env['BETTER_AUTH_SECRET'] ??= 'test-e2e-sealing-key'; + + handle = createDb(PG_URL); + db = handle.db; + + const moduleRef = await Test.createTestingModule({ + imports: [FederationModule], + providers: [{ provide: DB, useValue: db }], + }) + .overrideGuard(AdminGuard) + .useValue({ canActivate: () => true }) + .compile(); + + app = moduleRef.createNestApplication(new FastifyAdapter()); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + await app.init(); + await app.getHttpAdapter().getInstance().ready(); + + agent = supertest(app.getHttpServer()); + + grantsService = moduleRef.get(GrantsService); + enrollmentService = moduleRef.get(EnrollmentService); + }, 30_000); + + afterAll(async () => { + if (db && createdTokenGrantIds.length > 0) { + await db + .delete(federationEnrollmentTokens) + .where(inArray(federationEnrollmentTokens.grantId, createdTokenGrantIds)) + .catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e)); + } + if (db && createdGrantIds.length > 0) { + await db + .delete(federationGrants) + .where(inArray(federationGrants.id, createdGrantIds)) + .catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e)); + } + if (db && createdPeerIds.length > 0) { + await db + .delete(federationPeers) + .where(inArray(federationPeers.id, createdPeerIds)) + .catch((e: unknown) => console.error('[federation-m2-e2e 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-e2e cleanup]', e)); + } + if (app) + await app.close().catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e)); + if (handle) + await handle.close().catch((e: unknown) => console.error('[federation-m2-e2e cleanup]', e)); + }); + + // ------------------------------------------------------------------------- + // #6 — peer add: keypair → enrollment → cert storage → active peer record + // ------------------------------------------------------------------------- + it('#6 — peer add flow: keypair → enrollment → cert storage → active peer record', async () => { + // Create a subject user to satisfy FK on federation_grants.subject_user_id + const userId = crypto.randomUUID(); + await db + .insert(schema.users) + .values({ + id: userId, + name: `e2e-user-${RUN_ID}`, + email: `e2e-${RUN_ID}@federation-test.invalid`, + emailVerified: false, + }) + .onConflictDoNothing(); + createdUserIds.push(userId); + + // ── Step A: "Server B" setup ───────────────────────────────────────── + // Server B admin creates a grant and generates an enrollment token to + // share out-of-band with Server A's operator. + + // Insert a placeholder peer on "Server B" to satisfy the grant FK + const serverBPeerId = crypto.randomUUID(); + await db + .insert(federationPeers) + .values({ + id: serverBPeerId, + commonName: `server-b-peer-${RUN_ID}`, + displayName: 'Server B Placeholder', + certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n', + certSerial: `serial-b-${serverBPeerId}`, + certNotAfter: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), + state: 'pending', + }) + .onConflictDoNothing(); + createdPeerIds.push(serverBPeerId); + + const grant = await grantsService.createGrant({ + subjectUserId: userId, + scope: { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 100 }, + peerId: serverBPeerId, + }); + createdGrantIds.push(grant.id); + createdTokenGrantIds.push(grant.id); + + const { token } = await enrollmentService.createToken({ + grantId: grant.id, + peerId: serverBPeerId, + ttlSeconds: 900, + }); + + // ── Step B: "Server A" generates keypair ───────────────────────────── + const keypairRes = await agent + .post('/api/admin/federation/peers/keypair') + .send({ + commonName: `e2e-peer-${RUN_ID.slice(0, 8)}`, + displayName: 'E2E Test Peer', + endpointUrl: 'https://test.invalid', + }) + .set('Content-Type', 'application/json'); + + expect(keypairRes.status).toBe(201); + const { peerId, csrPem } = keypairRes.body as { peerId: string; csrPem: string }; + expect(typeof peerId).toBe('string'); + expect(csrPem).toContain('-----BEGIN CERTIFICATE REQUEST-----'); + createdPeerIds.push(peerId); + + // ── Step C: Enrollment (simulates Server A sending CSR to Server B) ── + const enrollRes = await agent + .post(`/api/federation/enrollment/${token}`) + .send({ csrPem }) + .set('Content-Type', 'application/json'); + + expect(enrollRes.status).toBe(200); + const { certPem, certChainPem } = enrollRes.body as { + certPem: string; + certChainPem: string; + }; + expect(certPem).toContain('-----BEGIN CERTIFICATE-----'); + expect(certChainPem).toContain('-----BEGIN CERTIFICATE-----'); + + // ── Step D: "Server A" stores the cert ─────────────────────────────── + const storeRes = await agent + .patch(`/api/admin/federation/peers/${peerId}/cert`) + .send({ certPem }) + .set('Content-Type', 'application/json'); + + expect(storeRes.status).toBe(200); + + // ── Step E: Verify peer record in DB ───────────────────────────────── + const [peer] = await db + .select() + .from(federationPeers) + .where(eq(federationPeers.id, peerId)) + .limit(1); + + expect(peer).toBeDefined(); + expect(peer?.state).toBe('active'); + expect(peer?.certPem).toContain('-----BEGIN CERTIFICATE-----'); + expect(typeof peer?.certSerial).toBe('string'); + expect((peer?.certSerial ?? '').length).toBeGreaterThan(0); + // clientKeyPem is a sealed ciphertext — must not be a raw PEM + expect(peer?.clientKeyPem?.startsWith('-----BEGIN')).toBe(false); + // certNotAfter must be in the future + expect(peer?.certNotAfter?.getTime()).toBeGreaterThan(Date.now()); + }, 60_000); +});