diff --git a/apps/gateway/src/__tests__/integration/federation-m3-list.integration.test.ts b/apps/gateway/src/__tests__/integration/federation-m3-list.integration.test.ts new file mode 100644 index 0000000..b597a8f --- /dev/null +++ b/apps/gateway/src/__tests__/integration/federation-m3-list.integration.test.ts @@ -0,0 +1,519 @@ +/** + * Federation M3 single-gateway integration tests (FED-M3-10). + * + * Covers MILESTONES.md M3 acceptance: + * - #6: malformed certificate OIDs fail with 401; valid cert + revoked grant fails with 403. + * - #7: max_rows_per_query caps list results. + * + * Strategy: + * - Real PostgreSQL via @mosaicstack/db. + * - Mocked TLS context/Fastify request shim for FederationAuthGuard. + * - Direct controller calls using the real POST /api/federation/v1/list/:resource contract. + * + * Run: + * FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- \ + * src/__tests__/integration/federation-m3-list.integration.test.ts + */ + +import 'reflect-metadata'; +import * as crypto from 'node:crypto'; +import type { ExecutionContext } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { + and, + createDb, + eq, + federationGrants, + federationPeers, + inArray, + missionTasks, + missions, + projects, + tasks, + teamMembers, + teams, + type Db, + type DbHandle, + users, +} from '@mosaicstack/db'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { DB } from '../../database/database.module.js'; +import { GrantsService } from '../../federation/grants.service.js'; +import { FederationAuthGuard } from '../../federation/server/federation-auth.guard.js'; +import { FederationScopeService } from '../../federation/server/scope.service.js'; +import { FederationListQueryService } from '../../federation/server/verbs/list-query.service.js'; +import { ListController } from '../../federation/server/verbs/list.controller.js'; +import { + makeMosaicIssuedCert, + makeSelfSignedCert, +} from '../../federation/__tests__/helpers/test-cert.js'; + +const run = process.env['FEDERATED_INTEGRATION'] === '1'; +const PG_URL = process.env['DATABASE_URL'] ?? 'postgresql://mosaic:mosaic@localhost:5433/mosaic'; +const RUN_ID = `fed-m3-10-${crypto.randomUUID()}`; +const CERT_SERIAL_HEX = crypto.randomUUID().replace(/-/g, '').toUpperCase(); + +interface TestIds { + readonly subjectUserId: string; + readonly otherUserId: string; + readonly peerId: string; + readonly revokedPeerId: string; + readonly activeGrantId: string; + readonly revokedGrantId: string; + readonly subjectProjectId: string; + readonly subjectMissionId: string; + readonly otherProjectId: string; + readonly teamId: string; + readonly unauthorizedTeamId: string; + readonly teamProjectId: string; + readonly taskIds: readonly string[]; + readonly excludedTaskIds: readonly string[]; + readonly subjectNoteId: string; + readonly otherUserNoteId: string; +} + +function pemToDer(pem: string): Buffer { + return Buffer.from( + pem + .replace(/-----BEGIN CERTIFICATE-----/, '') + .replace(/-----END CERTIFICATE-----/, '') + .replace(/\s+/g, ''), + 'base64', + ); +} + +function makeFederationRequest(certPem: string): FastifyRequest { + return { + raw: { + socket: { + getPeerCertificate: () => ({ + raw: pemToDer(certPem), + serialNumber: CERT_SERIAL_HEX, + }), + }, + }, + } as unknown as FastifyRequest; +} + +function makeGuardContext(request: FastifyRequest): { + readonly context: ExecutionContext; + readonly sent: { statusCode?: number; payload?: unknown }; +} { + const sent: { statusCode?: number; payload?: unknown } = {}; + const reply = { + status: (statusCode: number) => { + sent.statusCode = statusCode; + return { + header: () => ({ + send: (payload: unknown) => { + sent.payload = payload; + }, + }), + }; + }, + } as unknown as FastifyReply; + + const context = { + switchToHttp: () => ({ + getRequest: () => request, + getResponse: () => reply, + }), + } as unknown as ExecutionContext; + + return { context, sent }; +} + +async function insertUser(db: Db, id: string, label: string): Promise { + await db.insert(users).values({ + id, + name: `${RUN_ID}-${label}`, + email: `${RUN_ID}-${label}@federation-test.invalid`, + emailVerified: false, + }); +} + +async function seedFixtures(db: Db): Promise { + const subjectUserId = `${RUN_ID}-subject`; + const otherUserId = `${RUN_ID}-other`; + const peerId = crypto.randomUUID(); + const revokedPeerId = crypto.randomUUID(); + const activeGrantId = crypto.randomUUID(); + const revokedGrantId = crypto.randomUUID(); + const subjectProjectId = crypto.randomUUID(); + const subjectMissionId = crypto.randomUUID(); + const otherProjectId = crypto.randomUUID(); + const teamId = crypto.randomUUID(); + const unauthorizedTeamId = crypto.randomUUID(); + const teamProjectId = crypto.randomUUID(); + const taskIds = [crypto.randomUUID(), crypto.randomUUID(), crypto.randomUUID()] as const; + const excludedTaskIds = [crypto.randomUUID(), crypto.randomUUID()] as const; + const subjectNoteId = crypto.randomUUID(); + const otherUserNoteId = crypto.randomUUID(); + + await insertUser(db, subjectUserId, 'subject'); + await insertUser(db, otherUserId, 'other'); + + await db.insert(teams).values([ + { + id: teamId, + name: `${RUN_ID} allowed team`, + slug: `${RUN_ID}-allowed-team`, + ownerId: subjectUserId, + managerId: subjectUserId, + }, + { + id: unauthorizedTeamId, + name: `${RUN_ID} unauthorized team`, + slug: `${RUN_ID}-unauthorized-team`, + ownerId: otherUserId, + managerId: otherUserId, + }, + ]); + + await db.insert(teamMembers).values([ + { teamId, userId: subjectUserId, role: 'member' }, + { teamId: unauthorizedTeamId, userId: subjectUserId, role: 'member' }, + ]); + + await db.insert(projects).values([ + { + id: subjectProjectId, + name: `${RUN_ID} subject personal project`, + ownerType: 'user', + ownerId: subjectUserId, + }, + { + id: otherProjectId, + name: `${RUN_ID} other personal project`, + ownerType: 'user', + ownerId: otherUserId, + }, + { + id: teamProjectId, + name: `${RUN_ID} unauthorized team project`, + ownerType: 'team', + teamId: unauthorizedTeamId, + }, + ]); + + await db.insert(missions).values({ + id: subjectMissionId, + name: `${RUN_ID} subject mission`, + projectId: subjectProjectId, + userId: subjectUserId, + }); + + await db.insert(tasks).values([ + { + id: taskIds[0], + title: `${RUN_ID} visible task 1`, + missionId: subjectMissionId, + createdAt: new Date('2026-06-25T03:00:00.000Z'), + updatedAt: new Date('2026-06-25T03:00:00.000Z'), + }, + { + id: taskIds[1], + title: `${RUN_ID} visible task 2`, + projectId: subjectProjectId, + createdAt: new Date('2026-06-25T02:00:00.000Z'), + updatedAt: new Date('2026-06-25T02:00:00.000Z'), + }, + { + id: taskIds[2], + title: `${RUN_ID} visible task 3`, + projectId: subjectProjectId, + createdAt: new Date('2026-06-25T01:00:00.000Z'), + updatedAt: new Date('2026-06-25T01:00:00.000Z'), + }, + { + id: excludedTaskIds[0], + title: `${RUN_ID} other user task`, + projectId: otherProjectId, + createdAt: new Date('2026-06-25T04:00:00.000Z'), + updatedAt: new Date('2026-06-25T04:00:00.000Z'), + }, + { + id: excludedTaskIds[1], + title: `${RUN_ID} unauthorized team task`, + projectId: teamProjectId, + createdAt: new Date('2026-06-25T05:00:00.000Z'), + updatedAt: new Date('2026-06-25T05:00:00.000Z'), + }, + ]); + + await db.insert(missionTasks).values([ + { + id: subjectNoteId, + missionId: subjectMissionId, + userId: subjectUserId, + notes: `${RUN_ID} subject visible note`, + createdAt: new Date('2026-06-25T03:30:00.000Z'), + updatedAt: new Date('2026-06-25T03:30:00.000Z'), + }, + { + id: otherUserNoteId, + missionId: subjectMissionId, + userId: otherUserId, + notes: `${RUN_ID} other user note on subject mission`, + createdAt: new Date('2026-06-25T04:30:00.000Z'), + updatedAt: new Date('2026-06-25T04:30:00.000Z'), + }, + ]); + + await db.insert(federationPeers).values([ + { + id: peerId, + commonName: `${RUN_ID}-active-peer`, + displayName: `${RUN_ID} Active Peer`, + certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n', + certSerial: CERT_SERIAL_HEX, + certNotAfter: new Date(Date.now() + 86_400_000), + state: 'active', + }, + { + id: revokedPeerId, + commonName: `${RUN_ID}-revoked-peer`, + displayName: `${RUN_ID} Revoked Peer`, + certPem: '-----BEGIN CERTIFICATE-----\nMOCK\n-----END CERTIFICATE-----\n', + certSerial: `${CERT_SERIAL_HEX}${RUN_ID.replace(/-/g, '').slice(0, 8).toUpperCase()}`, + certNotAfter: new Date(Date.now() + 86_400_000), + state: 'active', + }, + ]); + + await db.insert(federationGrants).values([ + { + id: activeGrantId, + peerId, + subjectUserId, + status: 'active', + scope: { + resources: ['tasks', 'notes'], + excluded_resources: [], + filters: { + tasks: { include_personal: true, include_teams: [] }, + notes: { include_personal: true, include_teams: [] }, + }, + max_rows_per_query: 2, + }, + }, + { + id: revokedGrantId, + peerId, + subjectUserId, + status: 'revoked', + revokedAt: new Date(), + revokedReason: `${RUN_ID} revoked grant fixture`, + scope: { + resources: ['tasks'], + excluded_resources: [], + max_rows_per_query: 2, + }, + }, + ]); + + return { + subjectUserId, + otherUserId, + peerId, + revokedPeerId, + activeGrantId, + revokedGrantId, + subjectProjectId, + subjectMissionId, + otherProjectId, + teamId, + unauthorizedTeamId, + teamProjectId, + taskIds, + excludedTaskIds, + subjectNoteId, + otherUserNoteId, + }; +} + +async function cleanupFixtures(db: Db, ids: TestIds | undefined): Promise { + if (!ids) { + return; + } + + await db + .delete(missionTasks) + .where(inArray(missionTasks.id, [ids.subjectNoteId, ids.otherUserNoteId])) + .catch(() => {}); + await db + .delete(tasks) + .where(inArray(tasks.id, [...ids.taskIds, ...ids.excludedTaskIds])) + .catch(() => {}); + await db + .delete(missions) + .where(eq(missions.id, ids.subjectMissionId)) + .catch(() => {}); + await db + .delete(projects) + .where(inArray(projects.id, [ids.subjectProjectId, ids.otherProjectId, ids.teamProjectId])) + .catch(() => {}); + await db + .delete(teamMembers) + .where( + and( + eq(teamMembers.userId, ids.subjectUserId), + inArray(teamMembers.teamId, [ids.teamId, ids.unauthorizedTeamId]), + ), + ) + .catch(() => {}); + await db + .delete(teams) + .where(inArray(teams.id, [ids.teamId, ids.unauthorizedTeamId])) + .catch(() => {}); + await db + .delete(federationGrants) + .where(inArray(federationGrants.id, [ids.activeGrantId, ids.revokedGrantId])) + .catch(() => {}); + await db + .delete(federationPeers) + .where(inArray(federationPeers.id, [ids.peerId, ids.revokedPeerId])) + .catch(() => {}); + await db + .delete(users) + .where(inArray(users.id, [ids.subjectUserId, ids.otherUserId])) + .catch(() => {}); +} + +describe.skipIf(!run)('federation M3 list verb — single-gateway integration', () => { + let handle: DbHandle; + let db: Db; + let moduleRef: TestingModule; + let guard: FederationAuthGuard; + let listController: ListController; + let ids: TestIds | undefined; + + beforeAll(async () => { + handle = createDb(PG_URL); + db = handle.db; + ids = await seedFixtures(db); + + moduleRef = await Test.createTestingModule({ + controllers: [ListController], + providers: [ + { provide: DB, useValue: db }, + GrantsService, + FederationAuthGuard, + FederationScopeService, + FederationListQueryService, + ], + }).compile(); + + guard = moduleRef.get(FederationAuthGuard); + listController = moduleRef.get(ListController); + }, 30_000); + + afterAll(async () => { + await moduleRef?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e)); + await cleanupFixtures(db, ids).catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e)); + await handle?.close().catch((e: unknown) => console.error('[fed-m3-10 cleanup]', e)); + }); + + it('#6 — rejects a client cert with malformed/missing Mosaic OIDs with 401', async () => { + const malformedOidCert = await makeSelfSignedCert(); + const request = makeFederationRequest(malformedOidCert); + const { context, sent } = makeGuardContext(request); + + await expect(guard.canActivate(context)).resolves.toBe(false); + expect(sent.statusCode).toBe(401); + expect(sent.payload).toMatchObject({ + error: { + code: 'unauthorized', + message: expect.stringContaining('missing required OID'), + }, + }); + expect(request.federationContext).toBeUndefined(); + }); + + it('#6 — rejects a valid client cert when its grant is revoked with 403', async () => { + expect(ids).toBeDefined(); + const revokedCert = await makeMosaicIssuedCert({ + grantId: ids!.revokedGrantId, + subjectUserId: ids!.subjectUserId, + }); + const request = makeFederationRequest(revokedCert); + const { context, sent } = makeGuardContext(request); + + await expect(guard.canActivate(context)).resolves.toBe(false); + expect(sent.statusCode).toBe(403); + expect(sent.payload).toMatchObject({ + error: { + code: 'forbidden', + message: 'Federation access denied', + }, + }); + expect(request.federationContext).toBeUndefined(); + }); + + it('#7 — enforces max_rows_per_query on POST /api/federation/v1/list/:resource', async () => { + expect(ids).toBeDefined(); + const activeCert = await makeMosaicIssuedCert({ + grantId: ids!.activeGrantId, + subjectUserId: ids!.subjectUserId, + }); + const request = makeFederationRequest(activeCert); + const { context } = makeGuardContext(request); + + await expect(guard.canActivate(context)).resolves.toBe(true); + + const response = await listController.list('tasks', request, { limit: 100 }); + const returnedIds = response.items.map((item) => item['id']); + + expect(response.items).toHaveLength(2); + expect(response._truncated).toBe(true); + expect(response.nextCursor).toEqual(expect.any(String)); + expect(returnedIds).toEqual([ids!.taskIds[0], ids!.taskIds[1]]); + expect(returnedIds).not.toContain(ids!.taskIds[2]); + for (const excludedId of ids!.excludedTaskIds) { + expect(returnedIds).not.toContain(excludedId); + } + expect(response.items.every((item) => item._source === 'local')).toBe(true); + }); + + it('excludes another user mission task notes on the same authorized mission', async () => { + expect(ids).toBeDefined(); + const activeCert = await makeMosaicIssuedCert({ + grantId: ids!.activeGrantId, + subjectUserId: ids!.subjectUserId, + }); + const request = makeFederationRequest(activeCert); + const { context } = makeGuardContext(request); + + await expect(guard.canActivate(context)).resolves.toBe(true); + + const response = await listController.list('notes', request, { limit: 10 }); + const returnedIds = response.items.map((item) => item['id']); + + expect(returnedIds).toEqual([ids!.subjectNoteId]); + expect(returnedIds).not.toContain(ids!.otherUserNoteId); + expect(response.items.every((item) => item._source === 'local')).toBe(true); + }); + + it('fails closed for unsupported list resources', async () => { + expect(ids).toBeDefined(); + const activeCert = await makeMosaicIssuedCert({ + grantId: ids!.activeGrantId, + subjectUserId: ids!.subjectUserId, + }); + const request = makeFederationRequest(activeCert); + const { context } = makeGuardContext(request); + + await expect(guard.canActivate(context)).resolves.toBe(true); + + await expect(listController.list('widgets', request, {})).rejects.toMatchObject({ + response: { + error: { + code: 'scope_violation', + message: 'Requested federation resource is not supported', + }, + }, + status: 403, + }); + }); +}); diff --git a/docs/scratchpads/FED-M3-10-integration-tests.md b/docs/scratchpads/FED-M3-10-integration-tests.md new file mode 100644 index 0000000..eaeb416 --- /dev/null +++ b/docs/scratchpads/FED-M3-10-integration-tests.md @@ -0,0 +1,60 @@ +# FED-M3-10 — Federation M3 Integration Tests + +## Objective + +Add single-gateway gateway integration tests for M3 acceptance #6 and #7. + +## Branch / base + +- Branch: `feat/federation-m3-integration` +- Base: `origin/next` (`838701bd` after M3-06/#683 merge) +- PR base when unblocked: `next` + +## Scope + +- Real PostgreSQL via `@mosaicstack/db`. +- Mocked TLS context / Fastify request shim for `FederationAuthGuard`. +- Direct controller calls using the real M3 route contract: `POST /api/federation/v1/list/:resource` with body `{ limit?, cursor? }`. +- Gated by `FEDERATED_INTEGRATION=1`. +- No federation harness dependency. + +## Fixture notes + +Aligned with the B2 seed design vocabulary: + +- `tasks` visibility uses personal `projects` + `missions` chain. +- `notes` are `mission_tasks.notes`; the integration suite asserts subject-only note visibility on an authorized mission. +- Seed includes a second user and unauthorized team/project tasks to prove exclusion from the max-row-cap list result. +- Grants/peers are direct DB fixtures; cert auth still runs through `FederationAuthGuard` using real X.509 certs generated by existing test helpers. + +## Current implementation + +Added `apps/gateway/src/__tests__/integration/federation-m3-list.integration.test.ts` covering: + +1. M3 #6 — cert missing Mosaic OIDs returns 401 federation `unauthorized` envelope. +2. M3 #6 — valid cert whose grant row is `revoked` returns 403 federation `forbidden` envelope. +3. M3 #7 — active grant with `max_rows_per_query: 2` caps `list tasks`, returns `_truncated` + `nextCursor`, source-tags rows, and excludes other-user / unauthorized-team tasks. +4. Cross-user notes invariant — subject can list their own `mission_tasks.notes` row while another user's note on the same authorized mission is excluded. +5. Unsupported-resource invariant — `list widgets` fails closed with a federation `scope_violation` envelope. + +## Verification + +- `pnpm --filter @mosaicstack/types build` — PASS. +- `pnpm --filter @mosaicstack/db build` — PASS. +- `pnpm --filter @mosaicstack/storage build` — PASS. +- `pnpm --filter @mosaicstack/brain build` — PASS. +- `pnpm --filter @mosaicstack/queue build` — PASS. +- `pnpm --filter @mosaicstack/config build` — PASS. +- `pnpm --filter @mosaicstack/auth build` — PASS. +- `pnpm --filter @mosaicstack/gateway test -- src/__tests__/integration/federation-m3-list.integration.test.ts` — PASS skipped when `FEDERATED_INTEGRATION` unset (5 skipped). +- `FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/gateway test -- src/__tests__/integration/federation-m3-list.integration.test.ts` — PASS (5 tests) after local `docker compose up -d postgres` + `pnpm --filter @mosaicstack/db db:push`. +- `pnpm --filter @mosaicstack/gateway typecheck` — PASS. +- `pnpm --filter @mosaicstack/gateway lint` — PASS. +- `pnpm format:check` — PASS. +- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS; approve, no findings. +- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS; risk level none, no findings. + +## Push / PR + +- #683 landed in `next`; branch rebased onto `origin/next` before push. +- CI is serialized; run queue guard before push.