/** * Unit tests for FederationAuthGuard (FED-M3-03). * * Coverage: * - Missing cert (no TLS socket / no getPeerCertificate) → 401 * - Cert parse failure (corrupt DER raw bytes) → 401 * - Missing grantId OID → 401 * - Missing subjectUserId OID → 401 * - Grant not found (GrantsService throws NotFoundException) → 403 * - Grant in `pending` status → 403 * - Grant in `revoked` status → 403 * - Grant in `expired` status → 403 * - Cert serial mismatch → 403 * - Happy path: active grant + matching cert serial → context attached, returns true */ import 'reflect-metadata'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { ExecutionContext } from '@nestjs/common'; import { NotFoundException } from '@nestjs/common'; import { FederationAuthGuard } from '../federation-auth.guard.js'; import { makeMosaicIssuedCert } from '../../__tests__/helpers/test-cert.js'; import type { GrantsService, GrantWithPeer } from '../../grants.service.js'; // --------------------------------------------------------------------------- // Test constants // --------------------------------------------------------------------------- const GRANT_ID = 'a1111111-1111-1111-1111-111111111111'; const USER_ID = 'b2222222-2222-2222-2222-222222222222'; const PEER_ID = 'c3333333-3333-3333-3333-333333333333'; // Node.js TLS serialNumber is uppercase hex (no colons) const CERT_SERIAL_HEX = '01'; const VALID_SCOPE = { resources: ['tasks'], max_rows_per_query: 100 }; // --------------------------------------------------------------------------- // Mock builders // --------------------------------------------------------------------------- /** * Build a minimal GrantWithPeer-shaped mock. */ function makeGrantWithPeer(overrides: Partial = {}): GrantWithPeer { return { id: GRANT_ID, peerId: PEER_ID, subjectUserId: USER_ID, scope: VALID_SCOPE, status: 'active', expiresAt: null, createdAt: new Date('2026-01-01T00:00:00Z'), revokedAt: null, revokedReason: null, peer: { id: PEER_ID, commonName: 'test-peer', displayName: 'Test Peer', certPem: '', certSerial: CERT_SERIAL_HEX, certNotAfter: new Date(Date.now() + 86_400_000), clientKeyPem: null, state: 'active', endpointUrl: null, lastSeenAt: null, createdAt: new Date('2026-01-01T00:00:00Z'), revokedAt: null, }, ...overrides, }; } /** * Build a mock ExecutionContext with a pre-built TLS peer certificate. * * `certPem` — PEM string to present as the raw DER cert (converted to Buffer). * Pass null to simulate "no cert presented". * `certSerialHex` — serialNumber string returned by the TLS socket. * Node.js returns uppercase hex. * `hasTlsSocket` — if false, raw.socket has no getPeerCertificate (plain HTTP). */ function makeContext(opts: { certPem: string | null; certSerialHex?: string; hasTlsSocket?: boolean; }): { ctx: ExecutionContext; statusMock: ReturnType; sendMock: ReturnType; } { const { certPem, certSerialHex = CERT_SERIAL_HEX, hasTlsSocket = true } = opts; // Build peerCert object that Node.js TLS socket.getPeerCertificate() returns let peerCert: Record; if (certPem === null) { // Simulate no cert: Node.js returns object with empty string fields peerCert = { raw: null, serialNumber: '' }; } else { // Convert PEM to DER Buffer (strip headers + base64 decode) const b64 = certPem .replace(/-----BEGIN CERTIFICATE-----/, '') .replace(/-----END CERTIFICATE-----/, '') .replace(/\s+/g, ''); const raw = Buffer.from(b64, 'base64'); peerCert = { raw, serialNumber: certSerialHex }; } const getPeerCertificate = vi.fn().mockReturnValue(peerCert); const socket = hasTlsSocket ? { getPeerCertificate } : {}; // No getPeerCertificate → non-TLS // Fastify reply mocks const sendMock = vi.fn().mockReturnValue(undefined); const headerMock = vi.fn().mockReturnValue({ send: sendMock }); const statusMock = vi.fn().mockReturnValue({ header: headerMock }); const request = { raw: { socket, }, }; const reply = { status: statusMock, }; const ctx = { switchToHttp: () => ({ getRequest: () => request, getResponse: () => reply, }), } as unknown as ExecutionContext; return { ctx, statusMock, sendMock }; } /** * Build a mock GrantsService. */ function makeGrantsService( overrides: Partial> = {}, ): GrantsService { return { getGrantWithPeer: vi.fn().mockResolvedValue(makeGrantWithPeer()), ...overrides, } as unknown as GrantsService; } // --------------------------------------------------------------------------- // Test suite // --------------------------------------------------------------------------- describe('FederationAuthGuard', () => { let certPem: string; beforeEach(async () => { // Generate a real Mosaic-issued cert with the standard OIDs certPem = await makeMosaicIssuedCert({ grantId: GRANT_ID, subjectUserId: USER_ID }); }); // ── 401: No TLS socket ──────────────────────────────────────────────────── it('returns 401 when there is no TLS socket (plain HTTP connection)', async () => { const { ctx, statusMock, sendMock } = makeContext({ certPem: certPem, hasTlsSocket: false, }); const guard = new FederationAuthGuard(makeGrantsService()); const result = await guard.canActivate(ctx); expect(result).toBe(false); expect(statusMock).toHaveBeenCalledWith(401); expect(sendMock).toHaveBeenCalledWith( expect.objectContaining({ error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }), }), ); }); // ── 401: Cert not presented ─────────────────────────────────────────────── it('returns 401 when the peer did not present a certificate', async () => { const { ctx, statusMock, sendMock } = makeContext({ certPem: null }); const guard = new FederationAuthGuard(makeGrantsService()); const result = await guard.canActivate(ctx); expect(result).toBe(false); expect(statusMock).toHaveBeenCalledWith(401); expect(sendMock).toHaveBeenCalledWith( expect.objectContaining({ error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }), }), ); }); // ── 401: Cert parse failure ─────────────────────────────────────────────── it('returns 401 when the certificate DER bytes are corrupt', async () => { // Build context with a cert that has garbage DER bytes const corruptPem = '-----BEGIN CERTIFICATE-----\naW52YWxpZA==\n-----END CERTIFICATE-----'; const { ctx, statusMock, sendMock } = makeContext({ certPem: corruptPem }); const guard = new FederationAuthGuard(makeGrantsService()); const result = await guard.canActivate(ctx); expect(result).toBe(false); expect(statusMock).toHaveBeenCalledWith(401); expect(sendMock).toHaveBeenCalledWith( expect.objectContaining({ error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }), }), ); }); // ── 401: Missing grantId OID ───────────────────────────────────────────── it('returns 401 when the cert is missing the grantId OID', async () => { // makeSelfSignedCert produces a cert without any Mosaic OIDs const { makeSelfSignedCert } = await import('../../__tests__/helpers/test-cert.js'); const plainCert = await makeSelfSignedCert(); const { ctx, statusMock, sendMock } = makeContext({ certPem: plainCert }); const guard = new FederationAuthGuard(makeGrantsService()); const result = await guard.canActivate(ctx); expect(result).toBe(false); expect(statusMock).toHaveBeenCalledWith(401); expect(sendMock).toHaveBeenCalledWith( expect.objectContaining({ error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }), }), ); }); // ── 401: Missing subjectUserId OID ─────────────────────────────────────── it('returns 401 when the cert has grantId OID but is missing subjectUserId OID', async () => { // Build a cert with only the grantId OID by importing cert generator internals const { webcrypto } = await import('node:crypto'); const { X509CertificateGenerator, Extension, KeyUsagesExtension, KeyUsageFlags, BasicConstraintsExtension, cryptoProvider, } = await import('@peculiar/x509'); cryptoProvider.set(webcrypto as unknown as Parameters[0]); const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' } as const; const keys = await webcrypto.subtle.generateKey(alg, false, ['sign', 'verify']); const now = new Date(); const tomorrow = new Date(now.getTime() + 86_400_000); // Encode grantId only — missing subjectUserId extension const utf8 = new TextEncoder().encode(GRANT_ID); const encoded = new Uint8Array(2 + utf8.length); encoded[0] = 0x0c; encoded[1] = utf8.length; encoded.set(utf8, 2); const cert = await X509CertificateGenerator.createSelfSigned({ serialNumber: '01', name: 'CN=partial-oid-test', notBefore: now, notAfter: tomorrow, signingAlgorithm: alg, keys, extensions: [ new BasicConstraintsExtension(false), new KeyUsagesExtension(KeyUsageFlags.digitalSignature), new Extension('1.3.6.1.4.1.99999.1', false, encoded), // grantId only ], }); const { ctx, statusMock, sendMock } = makeContext({ certPem: cert.toString('pem') }); const guard = new FederationAuthGuard(makeGrantsService()); const result = await guard.canActivate(ctx); expect(result).toBe(false); expect(statusMock).toHaveBeenCalledWith(401); expect(sendMock).toHaveBeenCalledWith( expect.objectContaining({ error: expect.objectContaining({ code: 'unauthorized', message: expect.any(String) }), }), ); }); // ── 403: Grant not found ───────────────────────────────────────────────── it('returns 403 when the grantId from the cert does not exist in DB', async () => { const grantsService = makeGrantsService({ getGrantWithPeer: vi .fn() .mockRejectedValue(new NotFoundException(`Grant ${GRANT_ID} not found`)), }); const { ctx, statusMock, sendMock } = makeContext({ certPem }); const guard = new FederationAuthGuard(grantsService); const result = await guard.canActivate(ctx); expect(result).toBe(false); expect(statusMock).toHaveBeenCalledWith(403); expect(sendMock).toHaveBeenCalledWith( expect.objectContaining({ error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }), }), ); }); // ── 403: Grant in `pending` status ─────────────────────────────────────── it('returns 403 when the grant is in pending status', async () => { const grantsService = makeGrantsService({ getGrantWithPeer: vi.fn().mockResolvedValue(makeGrantWithPeer({ status: 'pending' })), }); const { ctx, statusMock, sendMock } = makeContext({ certPem }); const guard = new FederationAuthGuard(grantsService); const result = await guard.canActivate(ctx); expect(result).toBe(false); expect(statusMock).toHaveBeenCalledWith(403); expect(sendMock).toHaveBeenCalledWith( expect.objectContaining({ error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }), }), ); }); // ── 403: Grant in `revoked` status ─────────────────────────────────────── it('returns 403 when the grant is in revoked status', async () => { const grantsService = makeGrantsService({ getGrantWithPeer: vi .fn() .mockResolvedValue(makeGrantWithPeer({ status: 'revoked', revokedAt: new Date() })), }); const { ctx, statusMock, sendMock } = makeContext({ certPem }); const guard = new FederationAuthGuard(grantsService); const result = await guard.canActivate(ctx); expect(result).toBe(false); expect(statusMock).toHaveBeenCalledWith(403); expect(sendMock).toHaveBeenCalledWith( expect.objectContaining({ error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }), }), ); }); // ── 403: Grant in `expired` status ─────────────────────────────────────── it('returns 403 when the grant is in expired status', async () => { const grantsService = makeGrantsService({ getGrantWithPeer: vi.fn().mockResolvedValue(makeGrantWithPeer({ status: 'expired' })), }); const { ctx, statusMock, sendMock } = makeContext({ certPem }); const guard = new FederationAuthGuard(grantsService); const result = await guard.canActivate(ctx); expect(result).toBe(false); expect(statusMock).toHaveBeenCalledWith(403); expect(sendMock).toHaveBeenCalledWith( expect.objectContaining({ error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }), }), ); }); // ── 403: Cert serial mismatch ───────────────────────────────────────────── it('returns 403 when the cert serial does not match the registered peer cert serial', async () => { // Return a grant whose peer has a different stored serial const grantsService = makeGrantsService({ getGrantWithPeer: vi.fn().mockResolvedValue( makeGrantWithPeer({ peer: { id: PEER_ID, commonName: 'test-peer', displayName: 'Test Peer', certPem: '', certSerial: 'DEADBEEF', // different from CERT_SERIAL_HEX='01' certNotAfter: new Date(Date.now() + 86_400_000), clientKeyPem: null, state: 'active', endpointUrl: null, lastSeenAt: null, createdAt: new Date('2026-01-01T00:00:00Z'), revokedAt: null, }, }), ), }); // Context presents cert with serial '01' but DB has 'DEADBEEF' const { ctx, statusMock, sendMock } = makeContext({ certPem, certSerialHex: '01' }); const guard = new FederationAuthGuard(grantsService); const result = await guard.canActivate(ctx); expect(result).toBe(false); expect(statusMock).toHaveBeenCalledWith(403); expect(sendMock).toHaveBeenCalledWith( expect.objectContaining({ error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }), }), ); }); // ── 403: subjectUserId cert/DB mismatch (CRIT-1 regression test) ───────── it('returns 403 when the cert subjectUserId does not match the DB grant subjectUserId', async () => { // Build a cert that claims an attacker's subjectUserId const attackerSubjectUserId = 'attacker-user-id'; const attackerCertPem = await makeMosaicIssuedCert({ grantId: GRANT_ID, subjectUserId: attackerSubjectUserId, }); // DB returns a grant with the legitimate USER_ID const grantsService = makeGrantsService({ getGrantWithPeer: vi.fn().mockResolvedValue(makeGrantWithPeer({ subjectUserId: USER_ID })), }); // Cert presents attacker-user-id but DB has USER_ID — should be rejected const { ctx, statusMock, sendMock } = makeContext({ certPem: attackerCertPem, certSerialHex: CERT_SERIAL_HEX, }); const guard = new FederationAuthGuard(grantsService); const result = await guard.canActivate(ctx); expect(result).toBe(false); expect(statusMock).toHaveBeenCalledWith(403); expect(sendMock).toHaveBeenCalledWith( expect.objectContaining({ error: expect.objectContaining({ code: 'forbidden', message: 'Federation access denied' }), }), ); }); // ── Happy path ──────────────────────────────────────────────────────────── it('returns true and attaches federationContext on happy path', async () => { const grant = makeGrantWithPeer({ status: 'active', peer: { id: PEER_ID, commonName: 'test-peer', displayName: 'Test Peer', certPem: '', certSerial: CERT_SERIAL_HEX, certNotAfter: new Date(Date.now() + 86_400_000), clientKeyPem: null, state: 'active', endpointUrl: null, lastSeenAt: null, createdAt: new Date('2026-01-01T00:00:00Z'), revokedAt: null, }, }); const grantsService = makeGrantsService({ getGrantWithPeer: vi.fn().mockResolvedValue(grant), }); // Build context manually to capture what gets set on request.federationContext const b64 = certPem .replace(/-----BEGIN CERTIFICATE-----/, '') .replace(/-----END CERTIFICATE-----/, '') .replace(/\s+/g, ''); const raw = Buffer.from(b64, 'base64'); const peerCert = { raw, serialNumber: CERT_SERIAL_HEX }; const sendMock = vi.fn().mockReturnValue(undefined); const headerMock = vi.fn().mockReturnValue({ send: sendMock }); const statusMock = vi.fn().mockReturnValue({ header: headerMock }); const request: Record = { raw: { socket: { getPeerCertificate: vi.fn().mockReturnValue(peerCert) }, }, }; const reply = { status: statusMock }; const ctx = { switchToHttp: () => ({ getRequest: () => request, getResponse: () => reply, }), } as unknown as ExecutionContext; const guard = new FederationAuthGuard(grantsService); const result = await guard.canActivate(ctx); expect(result).toBe(true); expect(statusMock).not.toHaveBeenCalled(); // Verify the context was attached correctly expect(request['federationContext']).toEqual({ grantId: GRANT_ID, subjectUserId: USER_ID, peerId: PEER_ID, scope: VALID_SCOPE, }); }); });