- HIGH-A: resolveEntry now uses promise-cache pattern so concurrent callers serialize on a single in-flight build, eliminating duplicate key material in heap and duplicate DB round-trips - HIGH-B: flushPeer destroys the evicted undici Agent so stale TLS connections close on cert rotation - MED-C: add regression test for PEER_MISCONFIGURED when STEP_CA_ROOT_CERT_PATH is unset Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
554 lines
19 KiB
TypeScript
554 lines
19 KiB
TypeScript
/**
|
|
* 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<Record<string, unknown>> = {}) {
|
|
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<unknown> },
|
|
'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<unknown> }
|
|
).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<unknown>;
|
|
}
|
|
).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<void> } }>;
|
|
}
|
|
).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<unknown> },
|
|
'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<unknown>;
|
|
}
|
|
).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);
|
|
});
|
|
});
|
|
});
|