From 04b62539c74ab306009fb29c3ceabf36be2ccbc9 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Wed, 22 Apr 2026 00:23:33 -0500 Subject: [PATCH 1/3] test(federation): M2 E2E peer-add enrollment flow test (FED-M2-10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests MILESTONES.md acceptance criterion #6: peer add flow yields an active peer record with a valid cert + key. Simulates two gateways against a single NestJS app instance with FederationModule + overridden AdminGuard. Steps: keypair → enrollment → cert storage → DB assertion. Gated by FEDERATED_INTEGRATION=1 and STEP_CA_AVAILABLE=1. Co-Authored-By: Claude Sonnet 4.6 --- .../federation-m2-e2e.integration.test.ts | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 apps/gateway/src/__tests__/integration/federation-m2-e2e.integration.test.ts 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..a2234bf --- /dev/null +++ b/apps/gateway/src/__tests__/integration/federation-m2-e2e.integration.test.ts @@ -0,0 +1,239 @@ +/** + * 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); +}); -- 2.49.1 From 5ea040af4c28614195151a88cf43ffc458706652 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Wed, 22 Apr 2026 00:28:46 -0500 Subject: [PATCH 2/3] test(federation): require all Step-CA env vars for stepCaRun gate Guard against partial env var sets where STEP_CA_AVAILABLE=1 is set but provisioner key or root cert path are missing, which would cause CaService constructor to throw during NestJS module instantiation. Co-Authored-By: Claude Sonnet 4.6 --- .../integration/federation-m2-e2e.integration.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 index a2234bf..df77af9 100644 --- a/apps/gateway/src/__tests__/integration/federation-m2-e2e.integration.test.ts +++ b/apps/gateway/src/__tests__/integration/federation-m2-e2e.integration.test.ts @@ -54,7 +54,12 @@ 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 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'; -- 2.49.1 From f84706e1224e37110d0ec8492098551a80218043 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Wed, 22 Apr 2026 00:35:59 -0500 Subject: [PATCH 3/3] test(federation): fix DB provider injection in M2 E2E test module Move DB token from overrideProvider (which requires an existing binding) to the providers array so Nest can resolve GrantsService dependencies when FederationModule is tested without DatabaseModule. Co-Authored-By: Claude Sonnet 4.6 --- .../integration/federation-m2-e2e.integration.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 index df77af9..17bc3fa 100644 --- a/apps/gateway/src/__tests__/integration/federation-m2-e2e.integration.test.ts +++ b/apps/gateway/src/__tests__/integration/federation-m2-e2e.integration.test.ts @@ -86,9 +86,8 @@ describe.skipIf(!stepCaRun)('federation M2 E2E — peer add enrollment flow', () const moduleRef = await Test.createTestingModule({ imports: [FederationModule], + providers: [{ provide: DB, useValue: db }], }) - .overrideProvider(DB) - .useValue(db) .overrideGuard(AdminGuard) .useValue({ canActivate: () => true }) .compile(); -- 2.49.1