Compare commits
2 Commits
feat/feder
...
feat/feder
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08bea8fba0 | ||
|
|
17f1423318 |
@@ -1,243 +0,0 @@
|
|||||||
/**
|
|
||||||
* Federation M2 E2E test — peer-add enrollment flow (FED-M2-10).
|
|
||||||
*
|
|
||||||
* Covers MILESTONES.md acceptance test #6:
|
|
||||||
* "`peer add <url>` 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<typeof supertest>;
|
|
||||||
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<NestFastifyApplication>(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);
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user