/** * 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'; 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], }) .overrideProvider(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); });