test(#462): add federation M3 integration coverage (#685)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
FED-M3-10 integration tests for the federation M3 verbs (list/get/scope). Test-infra + docs only; green PR-event CI 1623 (all steps incl ci-postgres). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit was merged in pull request #685.
This commit is contained in:
@@ -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<void> {
|
||||||
|
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<TestIds> {
|
||||||
|
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<void> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
60
docs/scratchpads/FED-M3-10-integration-tests.md
Normal file
60
docs/scratchpads/FED-M3-10-integration-tests.md
Normal file
@@ -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.
|
||||||
Reference in New Issue
Block a user