import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { skipIntegration } from '../test-utils/integration-guard'; import type { HookContext } from '@celilo/capabilities'; // Hooks under test — imported via the scripts dir adjacent to the // modules/celilo-mgmt/manifest.yml. The dynamic import keeps the test // runnable from the apps/celilo working dir. const HOOK_DIR = join(import.meta.dirname, '../../../../modules/celilo-mgmt/scripts'); // on_backup reads its machine-pool snapshot from the context input the // framework stages (backup-create.ts, from listMachines()). These tests // inject a JSON string so the snapshot is deterministic on every host. The // pre-staging version of this hook shelled `celilo machine list --json` and // these tests had to inject a runner stub to avoid depending on the installed // CLI's age (celilo#1333); the staged input made that seam unnecessary. const STUB_MACHINE_POOL = [{ hostname: 'stub-machine', zone: 'dmz' }]; interface BackupHookOutput { artifact_count: number; size_bytes: number; schema_version: string; } interface RestoreHookOutput { restored_items: number; } function buildLogger(): { logger: HookContext['logger']; messages: string[]; } { const messages: string[] = []; const logger: HookContext['logger'] = { info: (m: string) => messages.push(`info: ${m}`), warn: (m: string) => messages.push(`warn: ${m}`), error: (m: string) => messages.push(`error: ${m}`), success: (m: string) => messages.push(`success: ${m}`), }; return { logger, messages }; } function buildContext(extras: Record): HookContext { const { logger } = buildLogger(); return { logger, config: { db_path: '', // populated by caller via env hostname: 'test-mgmt', target_ip: '127.0.0.1', event_bus_socket: '', listen_port: 8123, dns_primary: '1.1.1.1', dns_fallback: '', network_dmz_subnet: '', network_app_subnet: '', network_secure_subnet: '', network_internal_subnet: '', ssh_public_key: '', install_docker: false, install_terraform: false, } as Record, secrets: {}, capabilities: {}, debug: false, screenshotDir: '', ...extras, } as HookContext; } describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_backup', () => { let dir: string; let backupDir: string; let crossModuleRoot: string; let systemStateRoot: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'celilo-mgmt-backup-test-')); backupDir = join(dir, 'backup-dir'); mkdirSync(backupDir, { recursive: true }); crossModuleRoot = join(dir, 'cross-module-root'); mkdirSync(join(crossModuleRoot, 'modules', 'caddy', 'terraform'), { recursive: true }); writeFileSync( join(crossModuleRoot, 'modules', 'caddy', 'terraform', 'terraform.tfstate'), '{"state":"caddy"}', ); writeFileSync( join(crossModuleRoot, 'index.json'), JSON.stringify({ schemaVersion: '1.0', generatedAt: '2026-05-20T00:00:00Z', modules: [{ id: 'caddy', version: '0.0.1', terraformStateDir: 'modules/caddy/terraform' }], }), ); // celilo's own state, as the framework stages it before invoking the hook // (services/system-state-stage.ts). The hook reads nothing outside this // directory and cross_module_root — that is what design D9b bought, and // it is why there is no CELILO_DB_PATH or master.key on disk here. systemStateRoot = join(dir, 'system-state'); mkdirSync(join(systemStateRoot, 'ssh'), { recursive: true }); mkdirSync(join(systemStateRoot, 'module_src', 'caddy'), { recursive: true }); writeFileSync(join(systemStateRoot, 'celilo.db'), 'SQLite format 3\u0000snapshot'); writeFileSync(join(systemStateRoot, 'master.key'), 'fake-master-key-32-bytes-padding!'); writeFileSync(join(systemStateRoot, 'ssh', 'id_ed25519'), 'PRIVATE'); writeFileSync(join(systemStateRoot, 'module_src', 'caddy', 'manifest.yml'), 'id: caddy'); }); afterEach(() => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); it('lays the staged celilo state into backup_dir alongside the cross-module state', async () => { const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`); const result = (await hook( buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot, system_state_root: systemStateRoot, machine_pool: JSON.stringify(STUB_MACHINE_POOL), }), )) as BackupHookOutput; expect(existsSync(join(backupDir, 'celilo.db'))).toBe(true); expect(existsSync(join(backupDir, 'master.key'))).toBe(true); expect(existsSync(join(backupDir, 'ssh', 'id_ed25519'))).toBe(true); expect(existsSync(join(backupDir, 'module_src', 'caddy', 'manifest.yml'))).toBe(true); expect(existsSync(join(backupDir, 'machine-pool.json'))).toBe(true); expect( existsSync( join(backupDir, 'cross_module_state', 'modules', 'caddy', 'terraform', 'terraform.tfstate'), ), ).toBe(true); expect(existsSync(join(backupDir, 'cross_module_state', 'index.json'))).toBe(true); expect(result.schema_version).toBe('1.1'); expect(result.artifact_count).toBeGreaterThan(0); expect(result.size_bytes).toBeGreaterThan(0); }); it('refuses when the framework did not stage celilo state', async () => { const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`); // Silence is the dangerous outcome here. Without the DB and the master // key an envelope restores to nothing, and a hook that shrugged and // returned success would report artifact_count > 0 for the machine-pool // and cross-module files alone. Found at restore, which is too late. expect( hook(buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot })), ).rejects.toThrow(/system_state_root was not provided/); }); it('machine-pool.json is the staged snapshot (valid JSON array)', async () => { const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`); await hook( buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot, system_state_root: systemStateRoot, machine_pool: JSON.stringify(STUB_MACHINE_POOL), }), ); // Content, not just shape. A bare Array.isArray also passes when the // hook swallowed a snapshot failure and wrote an empty pool, which is // exactly the failure mode this stub exists to rule out. const machinePool: unknown = JSON.parse( readFileSync(join(backupDir, 'machine-pool.json'), 'utf-8'), ); expect(machinePool).toEqual(STUB_MACHINE_POOL); }); it('proceeds (with a warning) when cross_module_root is missing', async () => { const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`); const result = (await hook( buildContext({ backup_dir: backupDir, system_state_root: systemStateRoot, machine_pool: JSON.stringify(STUB_MACHINE_POOL), }), )) as BackupHookOutput; expect(existsSync(join(backupDir, 'celilo.db'))).toBe(true); expect(existsSync(join(backupDir, 'cross_module_state'))).toBe(false); expect(result.schema_version).toBe('1.1'); }); }); describe.skipIf(skipIntegration({ tools: ['wg'] }))('celilo-mgmt on_restore', () => { let dir: string; let restoreDir: string; let crossModuleWriteRoot: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'celilo-mgmt-restore-test-')); restoreDir = join(dir, 'restore-dir'); mkdirSync(restoreDir, { recursive: true }); crossModuleWriteRoot = join(dir, 'cross-module-write-root'); mkdirSync(crossModuleWriteRoot, { recursive: true }); }); afterEach(() => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); function plantArtifact(): void { writeFileSync(join(restoreDir, 'celilo.db'), 'restored-db-bytes'); writeFileSync(join(restoreDir, 'master.key'), 'restored-master-key'); writeFileSync(join(restoreDir, 'machine-pool.json'), '[]'); mkdirSync(join(restoreDir, 'cross_module_state', 'modules', 'caddy', 'terraform'), { recursive: true, }); writeFileSync( join(restoreDir, 'cross_module_state', 'modules', 'caddy', 'terraform', 'terraform.tfstate'), '{"state":"caddy-restored"}', ); mkdirSync(join(restoreDir, 'cross_module_state', 'modules', 'homebridge', 'terraform'), { recursive: true, }); writeFileSync( join( restoreDir, 'cross_module_state', 'modules', 'homebridge', 'terraform', 'terraform.tfstate', ), '{"state":"homebridge-restored"}', ); } it('moves cross-module terraform state into cross_module_write_root for the framework apply', async () => { plantArtifact(); const { default: hook } = await import(`${HOOK_DIR}/on_restore.ts`); const result = (await hook( buildContext({ restore_dir: restoreDir, schema_version: '1.0', cross_module_write_root: crossModuleWriteRoot, }), )) as RestoreHookOutput; expect( existsSync(join(crossModuleWriteRoot, 'modules', 'caddy', 'terraform', 'terraform.tfstate')), ).toBe(true); expect( existsSync( join(crossModuleWriteRoot, 'modules', 'homebridge', 'terraform', 'terraform.tfstate'), ), ).toBe(true); // 2 cross-module modules + 2 system files (db + master.key) expect(result.restored_items).toBe(4); }); it('stages celilo.db + master.key under restore_dir/system/ for Phase 4 wrapper', async () => { plantArtifact(); const { default: hook } = await import(`${HOOK_DIR}/on_restore.ts`); await hook( buildContext({ restore_dir: restoreDir, schema_version: '1.0', cross_module_write_root: crossModuleWriteRoot, }), ); expect(existsSync(join(restoreDir, 'system', 'celilo.db'))).toBe(true); expect(existsSync(join(restoreDir, 'system', 'master.key'))).toBe(true); expect(readFileSync(join(restoreDir, 'system', 'celilo.db'), 'utf-8')).toBe( 'restored-db-bytes', ); }); it('skips cross-module restore (with a warning) when cross_module_write_root is missing', async () => { plantArtifact(); const { default: hook } = await import(`${HOOK_DIR}/on_restore.ts`); const result = (await hook( buildContext({ restore_dir: restoreDir, schema_version: '1.0' }), // no cross_module_write_root )) as RestoreHookOutput; // Only system files staged; cross-module skipped. expect(result.restored_items).toBe(2); expect(existsSync(join(restoreDir, 'system', 'celilo.db'))).toBe(true); }); it('tolerates an artifact with no cross_module_state/ subdir', async () => { // No plantArtifact call's cross-module branch — write only system files. writeFileSync(join(restoreDir, 'celilo.db'), 'db'); writeFileSync(join(restoreDir, 'master.key'), 'key'); const { default: hook } = await import(`${HOOK_DIR}/on_restore.ts`); const result = (await hook( buildContext({ restore_dir: restoreDir, schema_version: '1.0', cross_module_write_root: crossModuleWriteRoot, }), )) as RestoreHookOutput; expect(result.restored_items).toBe(2); }); });