/** * HostProcessOrchestrator push-credential injection tests. * * Verifies the orchestrator writes `config.push` into the spawned node's * cadre.json when the pushResolver yields credentials, omits it otherwise, * re-resolves on every (re-)spawn, and never persists a raw private key in * state.json. Uses a fake CLI entrypoint that just writes its startup token. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { mkdtempSync, mkdirSync, rmSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { PushCredentials } from '@serfab/cadre-core'; import { HostProcessOrchestrator } from '../orchestrator/host-process-orchestrator.js'; import { removeAllNodes } from './orchestrator-teardown.js'; const FAKE_CLI = ` import fs from 'node:fs'; const args = process.argv.slice(2); const get = (flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined; }; const tokenPath = get('--startup-token-file'); const token = process.env.CADRE_STARTUP_TOKEN ?? ''; if (tokenPath && token) { try { fs.writeFileSync(tokenPath, token, 'utf8'); } catch (e) { console.error(e); } } process.on('SIGTERM', () => process.exit(0)); setInterval(() => {}, 1 << 30); `; const FCM = { projectId: 'proj', clientEmail: 'svc@proj.iam', privateKey: 'FCM-SECRET-PEM' }; const APNS = { keyId: 'KID', teamId: 'TEAM', bundleId: 'com.example.app', privateKey: 'APNS-SECRET-P8', production: false }; /** A storage node — the one profile whose spawn carries push. */ const NODE = { containerId: 'n1', partyId: 'p', profile: 'storage' as const }; let tmpRoot: string; let scriptPath: string; const orchestrators: HostProcessOrchestrator[] = []; beforeEach(() => { tmpRoot = mkdtempSync(join(tmpdir(), 'cadre-host-push-')); scriptPath = join(tmpRoot, 'fake-cli.mjs'); writeFileSync(scriptPath, FAKE_CLI, 'utf8'); }); afterEach(async () => { try { await removeAllNodes(orchestrators); } finally { await sleep(50); try { rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* ignore */ } } }); function makeOrchestrator(rootDir: string, pushResolver?: () => Promise): HostProcessOrchestrator { mkdirSync(rootDir, { recursive: true }); const orch = new HostProcessOrchestrator({ rootDir, portRange: { start: 18000, end: 18499 }, stopTimeoutMs: 1500, spawn: { entrypoint: scriptPath }, ...(pushResolver ? { pushResolver } : {}), }); orchestrators.push(orch); return orch; } function readChildConfig(rootDir: string, containerId = NODE.containerId): Record { const raw = readFileSync(join(rootDir, containerId, 'cadre.json'), 'utf8'); return JSON.parse(raw) as Record; } function sleep(ms: number): Promise { return new Promise((res) => setTimeout(res, ms)); } describe('HostProcessOrchestrator push injection', () => { it('omits config.push when no resolver is configured', async () => { const rootDir = join(tmpRoot, 'b'); const orch = makeOrchestrator(rootDir); await orch.init(); await orch.createContainer(NODE); const cfg = readChildConfig(rootDir); expect(cfg.push).toBeUndefined(); }); it('omits config.push when the resolver returns undefined (push opt-in)', async () => { const rootDir = join(tmpRoot, 'c'); const orch = makeOrchestrator(rootDir, async () => undefined); await orch.init(); await orch.createContainer(NODE); expect(readChildConfig(rootDir).push).toBeUndefined(); }); it('degrades to no push (does not fail the spawn) when the resolver throws', async () => { const rootDir = join(tmpRoot, 'd'); const orch = makeOrchestrator(rootDir, async () => { throw new Error('partial creds'); }); await orch.init(); // Spawn still succeeds. const created = await orch.createContainer(NODE); expect(orch.getNode(created.dockerId)?.status).toBe('running'); expect(readChildConfig(rootDir).push).toBeUndefined(); }); it('re-resolves on re-spawn and never persists a raw private key in state.json', async () => { const rootDir = join(tmpRoot, 'e'); let current: PushCredentials | undefined = { fcm: FCM }; const orch = makeOrchestrator(rootDir, async () => current); await orch.init(); const first = await orch.createContainer(NODE); expect(readChildConfig(rootDir).push).toEqual({ fcm: FCM }); // state.json must not carry the raw key — only re-resolvable references. const stateRaw = readFileSync(join(rootDir, 'state.json'), 'utf8'); expect(stateRaw).not.toContain('FCM-SECRET-PEM'); // Rotate the resolver's answer, then re-spawn — the new spawn must pick it up. current = { apns: APNS }; await orch.stopContainer(first.dockerId); await orch.createContainer(NODE); const cfg2 = readChildConfig(rootDir); expect(cfg2.push).toEqual({ apns: APNS }); expect((cfg2.push as PushCredentials).fcm).toBeUndefined(); }); it('injects push for a storage-profile managed node but not a transaction node', async () => { const rootDir = join(tmpRoot, 'f'); const orch = makeOrchestrator(rootDir, async () => ({ fcm: FCM, apns: APNS, cooldownMs: 1000 })); await orch.init(); await orch.createContainer({ containerId: 'storage-1', partyId: 'p', profile: 'storage' }); expect(readChildConfig(rootDir, 'storage-1').push).toEqual({ fcm: FCM, apns: APNS, cooldownMs: 1000 }); await orch.createContainer({ containerId: 'txn-1', partyId: 'p', profile: 'transaction' }); expect(readChildConfig(rootDir, 'txn-1').push).toBeUndefined(); }); });