/** * The broker: the capability surface crossing a process boundary. * * The claim under test is design D2's — that one generic proxy covers all * twelve capabilities because every hook-facing method is already * `(request: JSON) => Promise`. So these tests fix the SHAPES a call can * take (returns, throws, a structured throw the framework reads, an absent * optional method, an unknown method) and say nothing about any particular * capability. A per-method suite would prove the same thing thirty-seven times * and go stale the moment a provider gained a method. */ import { describe, expect, test } from 'bun:test'; import { execSync } from 'node:child_process'; import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { MissingProviderInputError } from '@celilo/capabilities'; import { capabilityShape } from './broker'; import { executeHookScript } from './executor'; import type { HookStoreBackend, HookStores } from './hook-store'; import { createCapturingLogger } from './logger'; import { configStore, secretStore } from './test-fixtures/store-backed'; import type { HookContext } from './types'; const FIXTURES = join(__dirname, 'test-fixtures'); function demoCapabilities(): Record { return { demo: { providerModuleId: 'demo-provider', version: '1.0.0', echo: async (request: unknown) => ({ echoed: request }), returnsNothing: async () => undefined, boom: async () => { throw new Error('plain failure'); }, missingInput: async () => { throw new MissingProviderInputError({ providerModuleId: 'caddy', ensureId: 'hostnames', value: 'foo.example.com', humanContext: 'so the route resolves', }); }, }, }; } async function runCapabilityHook(): Promise> { const dir = mkdtempSync(join(tmpdir(), 'celilo-broker-')); try { const context: HookContext = { config: configStore(), secrets: secretStore(), systems: [], logger: createCapturingLogger().logger, debug: false, screenshotDir: dir, stateDir: dir, capabilities: demoCapabilities(), }; await executeHookScript(join(FIXTURES, 'capability-calling-hook.ts'), context, { timeoutMs: 30_000, idleTimeoutMs: 30_000, }); // The fixture hands its report through the state directory: hook return // values are no longer carried anywhere (hook-owned-state D5). return JSON.parse(readFileSync(join(dir, 'report.json'), 'utf-8')) as Record; } finally { rmSync(dir, { recursive: true, force: true }); } } /** * In-memory stand-ins for the broker-side stores. The storage mechanics have * their own suite (`hook-store.test.ts`, real DB); what this layer must prove * is the ROUND TRIP — a write in the hook's process landing in the broker's * store and the read coming back. */ function memoryStores(): HookStores { const makeBackend = ( kind: 'secret' | 'hook-owned config', declared: string[], ): HookStoreBackend => { const rows = new Map(); const assertDeclared = (name: string) => { if (!declared.includes(name)) { throw new Error( `Module 'test-module' has no declared ${kind} '${name}'. Declared ${kind} names: ${declared.join(', ')}.`, ); } }; return { get: (name) => { assertDeclared(name); return Promise.resolve(rows.get(name)); }, set: (name, value) => { assertDeclared(name); rows.set(name, value); return Promise.resolve(); }, delete: (name) => { assertDeclared(name); rows.delete(name); return Promise.resolve(); }, applyTransaction: (ops) => { for (const entry of ops) { assertDeclared(entry.name); if (entry.op === 'set') rows.set(entry.name, entry.value ?? ''); else rows.delete(entry.name); } return Promise.resolve(); }, }; }; return { secrets: makeBackend('secret', ['bot_token', 'api_key']), config: makeBackend('hook-owned config', ['public_ip']), declaredSecretNames: ['bot_token', 'api_key'], declaredConfigNames: ['public_ip'], }; } async function runStoreHook( contextData: Record, ): Promise> { const dir = mkdtempSync(join(tmpdir(), 'celilo-broker-store-')); try { const context = { config: { mapOnlyValue: 'from-the-context-frame' }, secrets: {}, systems: [], logger: createCapturingLogger().logger, debug: false, screenshotDir: dir, stateDir: dir, capabilities: {}, ...contextData, } as unknown as HookContext; await executeHookScript(join(FIXTURES, 'store-writing-hook.ts'), context, { timeoutMs: 30_000, idleTimeoutMs: 30_000, hookStores: () => Promise.resolve(memoryStores()), }); // The fixture hands its report through the state directory: hook return // values are no longer carried anywhere (hook-owned-state D5). return JSON.parse(readFileSync(join(dir, 'report.json'), 'utf-8')) as Record; } finally { rmSync(dir, { recursive: true, force: true }); } } describe('capabilityShape', () => { test('splits functions from data', () => { const shape = capabilityShape(demoCapabilities()); expect(shape.demo.methods.sort()).toEqual(['boom', 'echo', 'missingInput', 'returnsNothing']); expect(shape.demo.data).toEqual({ providerModuleId: 'demo-provider', version: '1.0.0' }); }); test('an unimplemented optional method is simply absent', () => { // Not "present and throwing". `if (cap.registerTrustedSource)` is real // code in the wireguard module and it has to keep answering correctly. expect(capabilityShape(demoCapabilities()).demo.methods).not.toContain('sometimesAbsent'); }); test('symbol keys are dropped — they cannot cross JSON', () => { const brand = Symbol('brand'); const shape = capabilityShape({ demo: { [brand]: 'x', ok: async () => 1 } }); expect(shape.demo.methods).toEqual(['ok']); expect(shape.demo.data).toEqual({}); }); test('a value JSON cannot carry is left out of data rather than corrupted', () => { const shape = capabilityShape({ demo: { nan: Number.NaN, inf: Number.POSITIVE_INFINITY, n: 1 }, }); expect(shape.demo.data).toEqual({ n: 1 }); }); test('a non-object capability entry is skipped, not crashed on', () => { expect(capabilityShape({ broken: null, alsoBroken: 'string' })).toEqual({}); }); }); describe('capability calls across the boundary', () => { test('every call shape survives the round trip', async () => { const outputs = await runCapabilityHook(); expect(outputs.providerModuleId).toBe('demo-provider'); expect(outputs.version).toBe('1.0.0'); expect(outputs.optionalMethodAbsent).toBe(true); expect(outputs.returned).toEqual({ echoed: { x: 1, nested: { y: [2, 3] } } }); expect(outputs.undefinedBecomesNull).toBeNull(); expect(outputs.plainThrow).toEqual({ isError: true, name: 'Error', message: 'plain failure', hasStack: true, }); // The one error the framework READS rather than displays. Lose these four // fields and the cross-module ensure interview never runs — the deploy // fails with a message where it should have asked a question. expect(outputs.missingProviderInput).toEqual({ recognised: true, providerModuleId: 'caddy', ensureId: 'hostnames', value: 'foo.example.com', humanContext: 'so the route resolves', }); // A method that is not in the shape is not on the proxy, so this fails on // this side and never reaches the broker — the same TypeError a hook gets // in-process today. The broker's own "no such method" guard is for a // shape/map disagreement, which is a skew bug and not this path. expect(outputs.unknownMethod).toContain('not a function'); }, 30_000); test('the hook ran in a process of its own and left none behind', async () => { await runCapabilityHook(); // By parent pid, not by name: the hook runner is a direct child of this // process, and matching on the script name instead picks up whatever shell // happens to have the filename in its own command line. const orphans = execSync('ps -Ao ppid=,args=', { encoding: 'utf-8' }) .split('\n') .filter( (line) => Number.parseInt(line.trim(), 10) === process.pid && line.includes('hook-runner'), ); expect(orphans).toEqual([]); }, 30_000); }); describe('hook-owned-state store calls across the boundary', () => { test('every store operation survives the round trip', async () => { const outputs = await runStoreHook({}); expect(outputs.secretRoundTrip).toBe('token-value'); expect(outputs.configRoundTrip).toBe('203.0.113.7'); // The old map surface still answers beside the new methods. expect(outputs.mapRead).toBe('from-the-context-frame'); expect(outputs.deletedIsGone).toBeUndefined(); // D4: plain sets land immediately; a transaction discards on throw. expect(outputs.transactionA).toBe('committed-a'); expect(outputs.transactionB).toBe('committed-b'); expect(outputs.discarded).toBe('hook failed midway'); expect(outputs.afterDiscard).toBe('committed-a'); // D2: the undeclared name is an ERROR naming the module and the set. The // message text itself is pinned by hook-store.test.ts; here it only has // to have survived the wire intact. expect(outputs.undeclaredSecret).toContain('not_declared'); expect(outputs.undeclaredSecret).toContain('bot_token'); }, 30_000); });