import { describe, expect, test } from 'bun:test'; import type { DbClient } from '../../db/client'; import type { ModuleManifest } from '../../manifest/schema'; import type { AuditDeps } from '../audit'; import { unusedPublicDnsProbe } from '../audit/public-dns'; import { buildModuleGraph } from './dep-graph'; import { type ModuleSnapshot, type OrchestratorOps, consumerSkipReason, runSystemUpdate, } from './orchestrator'; import { capturingProgress } from './progress'; const fakeDb = {} as DbClient; function makeManifest( id: string, opts: { provides?: string[]; requires?: string[] } = {}, ): ModuleManifest { return { id, name: id, version: '1.0.0', celilo_contract: '1.0', provides: opts.provides ? { capabilities: opts.provides.map((name) => ({ name, version: '1.0.0', data: {}, functions: [], })), } : { capabilities: [] }, requires: opts.requires ? { capabilities: opts.requires.map((name) => ({ name, version: '1.0.0' })) } : { capabilities: [] }, } as unknown as ModuleManifest; } function makeSnapshot( id: string, opts: { installed?: string; latest?: string | null; provides?: Record; requires?: Record; } = {}, ): ModuleSnapshot { return { id, installedVersion: opts.installed ?? '1.0.0', latestVersion: opts.latest === undefined ? '1.1.0' : opts.latest, installedProvides: opts.provides ?? {}, pendingRequires: opts.requires ?? {}, }; } function makeOps(overrides: Partial = {}): OrchestratorOps { return { backup: async () => ({ ok: true }), upgrade: async () => ({ ok: true }), deploy: async () => ({ ok: true }), health: async () => ({ status: 'healthy' }), snapshotCeliloDb: async () => ({ ok: true }), ...overrides, }; } const cleanAudit: AuditDeps = { cliVersion: { installedVersion: '0.1.5', fetcher: async () => '0.1.5' }, schema: { journal: () => null, applied: () => [], db: fakeDb }, capabilityAbi: { modules: [] }, browserPin: { consumers: [], provisioned: null }, terraformPlan: { modules: [], run: async () => ({ exitCode: 0, stdout: '', stderr: '' }) }, moduleVersions: { installed: [], fetcher: async () => ({ latest: null }) }, moduleConfigs: { modules: [] }, moduleIntegrity: { results: [] }, detectWithoutConverge: { modules: [] }, jailExemptions: { exemptions: [] }, health: { results: [] }, backups: { modules: [] }, abandonedOperations: { records: [] }, undeployedModules: { modules: [] }, unconfiguredModules: { modules: [] }, servicesCredentials: { results: [] }, secretsDecryptable: { results: [] }, servicesReachable: { results: [] }, machinesReachable: { results: [] }, diskSpace: { results: [] }, transportReads: { statuses: [], now: new Date(), staleAfterMs: 30 * 60_000 }, trustedSources: { firewalls: [], unreachableFirewalls: [] }, publicDns: { records: [], probe: unusedPublicDnsProbe }, }; describe('consumerSkipReason', () => { const graph = buildModuleGraph([ makeManifest('iptables', { provides: ['firewall'] }), makeManifest('caddy', { requires: ['firewall'] }), ]); test('returns null when consumer requires same major as provider provides', () => { const provider = makeSnapshot('iptables', { provides: { firewall: '1.0.0' } }); const consumer = makeSnapshot('caddy', { requires: { firewall: '1.2.0' } }); expect(consumerSkipReason(consumer, provider, graph)).toBeNull(); }); test('returns reason when consumer requires a newer major than provider provides', () => { const provider = makeSnapshot('iptables', { provides: { firewall: '1.0.0' } }); const consumer = makeSnapshot('caddy', { requires: { firewall: '2.0.0' } }); const reason = consumerSkipReason(consumer, provider, graph); expect(reason).toContain('blocked by iptables'); expect(reason).toContain("firewall@1.0.0 doesn't satisfy firewall@2.0.0"); }); test('returns conservative reason when no shared capability is declared', () => { const provider = makeSnapshot('iptables', { provides: { firewall: '1.0.0' } }); const consumer = makeSnapshot('caddy', { requires: { something_else: '1.0.0' } }); const reason = consumerSkipReason(consumer, provider, graph); expect(reason).toContain('cannot verify compatibility'); }); }); describe('runSystemUpdate', () => { test('bails before any work when audit is BLOCKED', async () => { const blockingAudit: AuditDeps = { ...cleanAudit, schema: { journal: () => ({ version: '5', dialect: 'sqlite', entries: [{ idx: 0, tag: '0001_pending', when: 1 }], }), applied: () => [], db: fakeDb, }, }; const ops = makeOps(); let upgradeCalls = 0; ops.upgrade = async () => { upgradeCalls++; return { ok: true }; }; const result = await runSystemUpdate({ audit: blockingAudit, graph: buildModuleGraph([]), snapshots: new Map(), ops, selfUpdate: { installedVersion: '0.1.5', fetcher: async () => '0.1.5', updater: async () => ({ ok: true, stderr: '' }), }, progress: capturingProgress(), now: () => new Date('2026-04-25T00:00:00Z'), idGen: () => 'fixed-id', }); expect(result.ok).toBe(false); expect(result.audit.verdict).toBe('BLOCKED'); expect(result.modules).toEqual([]); expect(upgradeCalls).toBe(0); }); test('happy path: clean audit + one drifting module → backup, upgrade, deploy, health, done', async () => { const ops = makeOps(); const calls: string[] = []; ops.backup = async (id) => { calls.push(`backup:${id}`); return { ok: true }; }; ops.upgrade = async (id) => { calls.push(`upgrade:${id}`); return { ok: true }; }; ops.deploy = async (id) => { calls.push(`deploy:${id}`); return { ok: true }; }; ops.health = async (id) => { calls.push(`health:${id}`); return { status: 'healthy' }; }; ops.snapshotCeliloDb = async () => { calls.push('snapshot-celilo-db'); return { ok: true }; }; const graph = buildModuleGraph([makeManifest('caddy', { provides: ['public_web'] })]); const snapshots = new Map([ ['caddy', makeSnapshot('caddy', { installed: '1.0.0', latest: '1.1.0' })], ]); const result = await runSystemUpdate({ audit: cleanAudit, graph, snapshots, ops, selfUpdate: { installedVersion: '0.1.5', fetcher: async () => '0.1.5', updater: async () => ({ ok: true, stderr: '' }), }, progress: capturingProgress(), now: () => new Date('2026-04-25T00:00:00Z'), idGen: () => 'fixed-id', }); expect(result.ok).toBe(true); expect(result.backupsCreated).toBe(true); expect(result.modules).toHaveLength(1); expect(result.modules[0].step).toBe('done'); expect(calls).toEqual([ 'snapshot-celilo-db', 'backup:caddy', 'upgrade:caddy', 'deploy:caddy', 'health:caddy', ]); }); test('skips backup when --no-backup is set', async () => { const ops = makeOps(); let backupCalls = 0; let snapCalls = 0; ops.backup = async () => { backupCalls++; return { ok: true }; }; ops.snapshotCeliloDb = async () => { snapCalls++; return { ok: true }; }; await runSystemUpdate({ audit: cleanAudit, graph: buildModuleGraph([makeManifest('caddy')]), snapshots: new Map([['caddy', makeSnapshot('caddy')]]), ops, selfUpdate: { installedVersion: '0.1.5', fetcher: async () => '0.1.5', updater: async () => ({ ok: true, stderr: '' }), }, progress: capturingProgress(), now: () => new Date('2026-04-25T00:00:00Z'), idGen: () => 'id', noBackup: true, }); expect(backupCalls).toBe(0); expect(snapCalls).toBe(0); }); test('refuses to proceed when celilo-db snapshot fails', async () => { const ops = makeOps({ snapshotCeliloDb: async () => ({ ok: false, error: 'disk full' }), }); let upgradeCalls = 0; ops.upgrade = async () => { upgradeCalls++; return { ok: true }; }; const result = await runSystemUpdate({ audit: cleanAudit, graph: buildModuleGraph([makeManifest('caddy')]), snapshots: new Map([['caddy', makeSnapshot('caddy')]]), ops, selfUpdate: { installedVersion: '0.1.5', fetcher: async () => '0.1.5', updater: async () => ({ ok: true, stderr: '' }), }, progress: capturingProgress(), now: () => new Date('2026-04-25T00:00:00Z'), idGen: () => 'id', }); expect(result.ok).toBe(false); expect(result.modules).toEqual([]); expect(upgradeCalls).toBe(0); }); test('module deploy failure → state.step = failed, downstream consumers skipped', async () => { const graph = buildModuleGraph([ makeManifest('iptables', { provides: ['firewall'] }), makeManifest('caddy', { requires: ['firewall'], provides: ['public_web'] }), makeManifest('lunacycle', { requires: ['public_web'] }), ]); const snapshots = new Map([ [ 'iptables', makeSnapshot('iptables', { installed: '1.0.0', latest: '1.1.0', provides: { firewall: '1.0.0' }, }), ], [ 'caddy', makeSnapshot('caddy', { installed: '1.0.0', latest: '1.1.0', provides: { public_web: '2.0.0' }, // caddy's NEW manifest still requires firewall@1.x → compatible w/ old iptables requires: { firewall: '1.0.0' }, }), ], [ 'lunacycle', makeSnapshot('lunacycle', { installed: '1.0.0', latest: '1.1.0', // lunacycle's NEW manifest requires public_web@2.x → caddy still satisfies it requires: { public_web: '2.0.0' }, }), ], ]); let iptablesDeployed = false; const ops = makeOps({ deploy: async (id) => { if (id === 'iptables') { iptablesDeployed = true; return { ok: false, error: 'ssh refused' }; } return { ok: true }; }, }); const result = await runSystemUpdate({ audit: cleanAudit, graph, snapshots, ops, selfUpdate: { installedVersion: '0.1.5', fetcher: async () => '0.1.5', updater: async () => ({ ok: true, stderr: '' }), }, progress: capturingProgress(), now: () => new Date('2026-04-25T00:00:00Z'), idGen: () => 'id', }); expect(iptablesDeployed).toBe(true); expect(result.ok).toBe(false); const byId = new Map(result.modules.map((m) => [m.moduleId, m])); expect(byId.get('iptables')?.step).toBe('failed'); // Caddy's new manifest requires firewall@1.x, iptables still has firewall@1.0.0 // installed → compatible, caddy proceeds. expect(byId.get('caddy')?.step).toBe('done'); // Lunacycle requires public_web@2 from caddy which is now upgraded → done. expect(byId.get('lunacycle')?.step).toBe('done'); }); test('consumer with incompatible new requirements is skipped on upstream failure', async () => { const graph = buildModuleGraph([ makeManifest('iptables', { provides: ['firewall'] }), makeManifest('caddy', { requires: ['firewall'] }), ]); const snapshots = new Map([ [ 'iptables', makeSnapshot('iptables', { installed: '1.0.0', latest: '2.0.0', provides: { firewall: '1.0.0' }, // installed firewall@1 }), ], [ 'caddy', makeSnapshot('caddy', { installed: '1.0.0', latest: '2.0.0', requires: { firewall: '2.0.0' }, // caddy's NEW manifest needs firewall@2 }), ], ]); const ops = makeOps({ deploy: async (id) => (id === 'iptables' ? { ok: false, error: 'oops' } : { ok: true }), }); const result = await runSystemUpdate({ audit: cleanAudit, graph, snapshots, ops, selfUpdate: { installedVersion: '0.1.5', fetcher: async () => '0.1.5', updater: async () => ({ ok: true, stderr: '' }), }, progress: capturingProgress(), now: () => new Date('2026-04-25T00:00:00Z'), idGen: () => 'id', }); const caddy = result.modules.find((m) => m.moduleId === 'caddy'); expect(caddy?.step).toBe('skipped'); expect(caddy?.skipReason).toContain('blocked by iptables'); }); test('emits progress events in the right order', async () => { const progress = capturingProgress(); await runSystemUpdate({ audit: cleanAudit, graph: buildModuleGraph([makeManifest('caddy')]), snapshots: new Map([ ['caddy', makeSnapshot('caddy', { installed: '1.0.0', latest: '1.1.0' })], ]), ops: makeOps(), selfUpdate: { installedVersion: '0.1.5', fetcher: async () => '0.1.5', updater: async () => ({ ok: true, stderr: '' }), }, progress, now: () => new Date('2026-04-25T00:00:00Z'), idGen: () => 'id', }); const kinds = progress.events.map((e) => e.kind); expect(kinds[0]).toBe('plan'); expect(kinds).toContain('self-update-skipped'); // already-current expect(kinds).toContain('module-start'); expect(kinds.includes('module-step')).toBe(true); expect(kinds[kinds.length - 1]).toBe('run-done'); }); test('preserves the same updateId across self-update, snapshot, and module-backup calls', async () => { const seenIds = new Set(); const ops = makeOps({ backup: async (_id, updateId) => { seenIds.add(updateId); return { ok: true }; }, snapshotCeliloDb: async (updateId) => { seenIds.add(updateId); return { ok: true }; }, }); const result = await runSystemUpdate({ audit: cleanAudit, graph: buildModuleGraph([ makeManifest('iptables', { provides: ['firewall'] }), makeManifest('caddy', { requires: ['firewall'] }), ]), snapshots: new Map([ ['iptables', makeSnapshot('iptables')], ['caddy', makeSnapshot('caddy')], ]), ops, selfUpdate: { installedVersion: '0.1.5', fetcher: async () => '0.1.5', updater: async () => ({ ok: true, stderr: '' }), }, progress: capturingProgress(), now: () => new Date('2026-04-25T00:00:00Z'), idGen: () => 'shared-id', }); expect(result.updateId).toBe('shared-id'); expect([...seenIds]).toEqual(['shared-id']); }); });