/** * Unit tests for FederationClientService (FED-M3-08). * * HTTP mocking strategy: * undici MockAgent is used to intercept outbound HTTP requests. The service * uses `undici.fetch` with a `dispatcher` option, so MockAgent is set as the * global dispatcher and all requests flow through it. * * Because the service builds one `undici.Agent` per peer and passes it as * the dispatcher on every fetch call, we cannot intercept at the Agent level * in unit tests without significant refactoring. Instead, we set the global * dispatcher to a MockAgent and override the service's `doRequest` indirection * by spying on the internal fetch call. * * For the cert/key wiring, we use the real `sealClientKey` function from * peer-key.util.ts with a test secret — no stubs. * * Sealed-key setup: * Each test (or beforeAll) calls `sealClientKey(TEST_PRIVATE_KEY_PEM)` with * BETTER_AUTH_SECRET set to a deterministic test value so that * `unsealClientKey` in the service recovers the original PEM. */ import 'reflect-metadata'; import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import { MockAgent, setGlobalDispatcher, getGlobalDispatcher } from 'undici'; import type { Dispatcher } from 'undici'; import { writeFileSync, unlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { Db } from '@mosaicstack/db'; import { FederationClientService, FederationClientError } from '../federation-client.service.js'; import { sealClientKey } from '../../peer-key.util.js'; // --------------------------------------------------------------------------- // Test constants // --------------------------------------------------------------------------- const TEST_SECRET = 'test-secret-for-federation-client-spec-only'; const PEER_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; const ENDPOINT = 'https://peer.example.com'; // Minimal valid RSA/EC private key PEM — does NOT need to be a real key for // unit tests because we only verify it round-trips through seal/unseal, not // that it actually negotiates TLS (MockAgent handles that). const TEST_PRIVATE_KEY_PEM = `-----BEGIN PRIVATE KEY----- MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDummyKeyForTests -----END PRIVATE KEY-----`; // Minimal self-signed cert PEM (dummy — only used for mTLS Agent construction) const TEST_CERT_PEM = `-----BEGIN CERTIFICATE----- MIIBdummyCertForFederationClientTests== -----END CERTIFICATE-----`; const TEST_CERT_SERIAL = 'ABCDEF1234567890'; // --------------------------------------------------------------------------- // Sealed key (computed once in beforeAll) // --------------------------------------------------------------------------- let SEALED_KEY: string; // Path to a stub Step-CA root cert file written in beforeAll. The cert is never // actually used to negotiate TLS in unit tests (MockAgent + spy on resolveEntry // short-circuit the network), but loadStepCaRoot() requires the file to exist. const STUB_CA_PEM_PATH = join(tmpdir(), 'federation-client-spec-ca.pem'); const STUB_CA_PEM = `-----BEGIN CERTIFICATE----- MIIBdummyCAforFederationClientSpecOnly== -----END CERTIFICATE----- `; // --------------------------------------------------------------------------- // Peer row factory // --------------------------------------------------------------------------- function makePeerRow(overrides: Partial> = {}) { return { id: PEER_ID, commonName: 'peer-example-com', displayName: 'Test Peer', certPem: TEST_CERT_PEM, certSerial: TEST_CERT_SERIAL, certNotAfter: new Date('2030-01-01T00:00:00Z'), clientKeyPem: SEALED_KEY, state: 'active' as const, endpointUrl: ENDPOINT, lastSeenAt: null, createdAt: new Date('2026-01-01T00:00:00Z'), revokedAt: null, ...overrides, }; } // --------------------------------------------------------------------------- // Mock DB builder // --------------------------------------------------------------------------- function makeDb(selectRows: unknown[] = [makePeerRow()]): Db { const limitSelect = vi.fn().mockResolvedValue(selectRows); const whereSelect = vi.fn().mockReturnValue({ limit: limitSelect }); const fromSelect = vi.fn().mockReturnValue({ where: whereSelect }); const selectMock = vi.fn().mockReturnValue({ from: fromSelect }); return { select: selectMock, insert: vi.fn(), update: vi.fn(), delete: vi.fn(), transaction: vi.fn(), } as unknown as Db; } // --------------------------------------------------------------------------- // Helpers for MockAgent HTTP interception // --------------------------------------------------------------------------- /** * Create a MockAgent + MockPool for the peer endpoint, set it as the global * dispatcher, and return both for per-test configuration. */ function makeMockAgent() { const mockAgent = new MockAgent({ connections: 1 }); mockAgent.disableNetConnect(); setGlobalDispatcher(mockAgent); const pool = mockAgent.get(ENDPOINT); return { mockAgent, pool }; } /** * Build a FederationClientService with a mock DB and a spy on the internal * fetch so we can intercept at the HTTP layer via MockAgent. * * The service calls `fetch(url, { dispatcher: agent })` where `agent` is the * mTLS undici.Agent built from the peer's cert+key. To make MockAgent work, * we need the fetch dispatcher to be the MockAgent, not the per-peer Agent. * * Strategy: we replace the private `resolveEntry` result's `agent` field with * the MockAgent's pool, so fetch uses our interceptor. We do this by spying * on `resolveEntry` and returning a controlled entry. */ function makeService(db: Db, mockPool: Dispatcher): FederationClientService { const svc = new FederationClientService(db); // Override resolveEntry to inject MockAgent pool as the dispatcher vi.spyOn( svc as unknown as { resolveEntry: (peerId: string) => Promise }, 'resolveEntry', ).mockImplementation(async (_peerId: string) => { // Still call DB (via the real logic) to exercise peer validation, // but return mock pool as the agent. // For simplicity in unit tests, directly return a controlled entry. return { agent: mockPool, endpointUrl: ENDPOINT, certPem: TEST_CERT_PEM, certSerial: TEST_CERT_SERIAL, }; }); return svc; } // --------------------------------------------------------------------------- // Test setup // --------------------------------------------------------------------------- let originalDispatcher: Dispatcher; beforeAll(() => { // Seal the test key once — requires BETTER_AUTH_SECRET const saved = process.env['BETTER_AUTH_SECRET']; process.env['BETTER_AUTH_SECRET'] = TEST_SECRET; try { SEALED_KEY = sealClientKey(TEST_PRIVATE_KEY_PEM); } finally { if (saved === undefined) { delete process.env['BETTER_AUTH_SECRET']; } else { process.env['BETTER_AUTH_SECRET'] = saved; } } writeFileSync(STUB_CA_PEM_PATH, STUB_CA_PEM, 'utf8'); }); afterAll(() => { try { unlinkSync(STUB_CA_PEM_PATH); } catch { // best-effort cleanup } }); beforeEach(() => { originalDispatcher = getGlobalDispatcher(); process.env['BETTER_AUTH_SECRET'] = TEST_SECRET; process.env['STEP_CA_ROOT_CERT_PATH'] = STUB_CA_PEM_PATH; }); afterEach(() => { setGlobalDispatcher(originalDispatcher); vi.restoreAllMocks(); delete process.env['BETTER_AUTH_SECRET']; delete process.env['STEP_CA_ROOT_CERT_PATH']; }); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Successful list response body */ const LIST_BODY = { items: [{ id: '1', title: 'Task One' }], nextCursor: undefined, _partial: false, }; /** Successful get response body */ const GET_BODY = { item: { id: '1', title: 'Task One' }, _partial: false, }; /** Successful capabilities response body */ const CAP_BODY = { resources: ['tasks'], excluded_resources: [], max_rows_per_query: 100, supported_verbs: ['list', 'get', 'capabilities'] as const, }; // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('FederationClientService', () => { // ─── Successful verb calls ───────────────────────────────────────────────── describe('list()', () => { it('returns parsed typed response on success', async () => { const db = makeDb(); const { mockAgent, pool } = makeMockAgent(); const svc = makeService(db, pool); pool .intercept({ path: '/api/federation/v1/list/tasks', method: 'POST', }) .reply(200, LIST_BODY, { headers: { 'content-type': 'application/json' } }); const result = await svc.list(PEER_ID, 'tasks', {}); expect(result.items).toHaveLength(1); expect(result.items[0]).toMatchObject({ id: '1', title: 'Task One' }); await mockAgent.close(); }); }); describe('get()', () => { it('returns parsed typed response on success', async () => { const db = makeDb(); const { mockAgent, pool } = makeMockAgent(); const svc = makeService(db, pool); pool .intercept({ path: '/api/federation/v1/get/tasks/1', method: 'POST', }) .reply(200, GET_BODY, { headers: { 'content-type': 'application/json' } }); const result = await svc.get(PEER_ID, 'tasks', '1', {}); expect(result.item).toMatchObject({ id: '1', title: 'Task One' }); await mockAgent.close(); }); }); describe('capabilities()', () => { it('returns parsed capabilities response on success', async () => { const db = makeDb(); const { mockAgent, pool } = makeMockAgent(); const svc = makeService(db, pool); pool .intercept({ path: '/api/federation/v1/capabilities', method: 'GET', }) .reply(200, CAP_BODY, { headers: { 'content-type': 'application/json' } }); const result = await svc.capabilities(PEER_ID); expect(result.resources).toContain('tasks'); expect(result.max_rows_per_query).toBe(100); await mockAgent.close(); }); }); // ─── HTTP error surfaces ────────────────────────────────────────────────── describe('non-2xx responses', () => { it('surfaces 403 as FederationClientError({ status: 403, code: "FORBIDDEN" })', async () => { const db = makeDb(); const { mockAgent, pool } = makeMockAgent(); const svc = makeService(db, pool); pool.intercept({ path: '/api/federation/v1/list/tasks', method: 'POST' }).reply( 403, { error: { code: 'forbidden', message: 'Access denied' } }, { headers: { 'content-type': 'application/json' }, }, ); await expect(svc.list(PEER_ID, 'tasks', {})).rejects.toMatchObject({ status: 403, code: 'FORBIDDEN', peerId: PEER_ID, }); await mockAgent.close(); }); it('surfaces 404 as FederationClientError({ status: 404, code: "HTTP_404" })', async () => { const db = makeDb(); const { mockAgent, pool } = makeMockAgent(); const svc = makeService(db, pool); pool.intercept({ path: '/api/federation/v1/get/tasks/999', method: 'POST' }).reply( 404, { error: { code: 'not_found', message: 'Not found' } }, { headers: { 'content-type': 'application/json' }, }, ); await expect(svc.get(PEER_ID, 'tasks', '999', {})).rejects.toMatchObject({ status: 404, code: 'HTTP_404', peerId: PEER_ID, }); await mockAgent.close(); }); }); // ─── Network error ───────────────────────────────────────────────────────── describe('network errors', () => { it('surfaces network error as FederationClientError({ code: "NETWORK" })', async () => { const db = makeDb(); const { mockAgent, pool } = makeMockAgent(); const svc = makeService(db, pool); pool .intercept({ path: '/api/federation/v1/capabilities', method: 'GET' }) .replyWithError(new Error('ECONNREFUSED')); await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({ code: 'NETWORK', peerId: PEER_ID, }); await mockAgent.close(); }); }); // ─── Invalid response body ───────────────────────────────────────────────── describe('invalid response body', () => { it('surfaces as FederationClientError({ code: "INVALID_RESPONSE" }) when body shape is wrong', async () => { const db = makeDb(); const { mockAgent, pool } = makeMockAgent(); const svc = makeService(db, pool); // capabilities returns wrong shape (missing required fields) pool .intercept({ path: '/api/federation/v1/capabilities', method: 'GET' }) .reply(200, { totally: 'wrong' }, { headers: { 'content-type': 'application/json' } }); await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({ code: 'INVALID_RESPONSE', peerId: PEER_ID, }); await mockAgent.close(); }); }); // ─── Peer DB validation ──────────────────────────────────────────────────── describe('peer validation (without resolveEntry spy)', () => { /** * These tests exercise the real `resolveEntry` path — no spy on resolveEntry. */ it('throws PEER_NOT_FOUND when peer is not in DB', async () => { // DB returns empty array (peer not found) const db = makeDb([]); const svc = new FederationClientService(db); await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({ code: 'PEER_NOT_FOUND', peerId: PEER_ID, }); }); it('throws PEER_INACTIVE when peer state is not "active"', async () => { const db = makeDb([makePeerRow({ state: 'suspended' })]); const svc = new FederationClientService(db); await expect(svc.capabilities(PEER_ID)).rejects.toMatchObject({ code: 'PEER_INACTIVE', peerId: PEER_ID, }); }); }); // ─── Cache behaviour ─────────────────────────────────────────────────────── describe('cache behaviour', () => { it('hits cache on second call — only one DB lookup happens', async () => { // Verify cache by calling the private resolveEntry directly twice and // asserting the DB was queried only once. This avoids the HTTP layer, // which would require either a real network or per-peer Agent rewiring // that the cache invariant doesn't depend on. const db = makeDb(); const selectSpy = vi.spyOn(db, 'select'); const svc = new FederationClientService(db); const resolveEntry = ( svc as unknown as { resolveEntry: (peerId: string) => Promise } ).resolveEntry.bind(svc); const first = await resolveEntry(PEER_ID); const second = await resolveEntry(PEER_ID); expect(first).toBe(second); expect(selectSpy).toHaveBeenCalledTimes(1); }); it('serializes concurrent resolveEntry calls — only one DB lookup', async () => { const db = makeDb(); const selectSpy = vi.spyOn(db, 'select'); const svc = new FederationClientService(db); const resolveEntry = ( svc as unknown as { resolveEntry: (peerId: string) => Promise; } ).resolveEntry.bind(svc); const [a, b] = await Promise.all([resolveEntry(PEER_ID), resolveEntry(PEER_ID)]); expect(a).toBe(b); expect(selectSpy).toHaveBeenCalledTimes(1); }); it('flushPeer destroys the evicted Agent so old TLS connections close', async () => { const db = makeDb(); const svc = new FederationClientService(db); const resolveEntry = ( svc as unknown as { resolveEntry: (peerId: string) => Promise<{ agent: { destroy: () => Promise } }>; } ).resolveEntry.bind(svc); const entry = await resolveEntry(PEER_ID); const destroySpy = vi.spyOn(entry.agent, 'destroy').mockResolvedValue(); svc.flushPeer(PEER_ID); expect(destroySpy).toHaveBeenCalledTimes(1); }); it('flushPeer() invalidates cache — next call re-reads DB', async () => { const db = makeDb(); const { mockAgent, pool } = makeMockAgent(); const svc = makeService(db, pool); pool .intercept({ path: '/api/federation/v1/capabilities', method: 'GET' }) .reply(200, CAP_BODY, { headers: { 'content-type': 'application/json' } }) .times(2); // First call — populates cache (via mock resolveEntry) await svc.capabilities(PEER_ID); // Flush the cache svc.flushPeer(PEER_ID); // The spy on resolveEntry is still active — check it's called again after flush const resolveEntrySpy = vi.spyOn( svc as unknown as { resolveEntry: (peerId: string) => Promise }, 'resolveEntry', ); // Second call after flush — should call resolveEntry again await svc.capabilities(PEER_ID); // resolveEntry should have been called once after we started spying (post-flush) expect(resolveEntrySpy).toHaveBeenCalledTimes(1); await mockAgent.close(); }); }); // ─── loadStepCaRoot env-var guard ───────────────────────────────────────── describe('loadStepCaRoot() env-var guard', () => { it('throws PEER_MISCONFIGURED when STEP_CA_ROOT_CERT_PATH is not set', async () => { delete process.env['STEP_CA_ROOT_CERT_PATH']; const db = makeDb(); const svc = new FederationClientService(db); const resolveEntry = ( svc as unknown as { resolveEntry: (peerId: string) => Promise; } ).resolveEntry.bind(svc); await expect(resolveEntry(PEER_ID)).rejects.toMatchObject({ code: 'PEER_MISCONFIGURED', }); }); }); // ─── FederationClientError class ────────────────────────────────────────── describe('FederationClientError', () => { it('is instanceof Error and FederationClientError', () => { const err = new FederationClientError({ code: 'PEER_NOT_FOUND', message: 'test', peerId: PEER_ID, }); expect(err).toBeInstanceOf(Error); expect(err).toBeInstanceOf(FederationClientError); expect(err.name).toBe('FederationClientError'); }); it('carries status, code, and peerId', () => { const err = new FederationClientError({ status: 403, code: 'FORBIDDEN', message: 'forbidden', peerId: PEER_ID, }); expect(err.status).toBe(403); expect(err.code).toBe('FORBIDDEN'); expect(err.peerId).toBe(PEER_ID); }); }); });