/** * Tests for the file-direct restore service (Phase 4). * * Focused on the apply-staged-system-files step + restoreFromArtifactFile's * error-handling for malformed / mismatched / missing artifacts. * * Full round-trip (create artifact via celilo-mgmt backup, then restore * via this service) is an e2e concern, not exercised at the unit level. */ import { Database } from 'bun:sqlite'; import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { closeDb } from '../db/client'; import { runMigrations } from '../db/migrate'; import { resetTestDbPath } from '../test-utils/db-path'; import { applyStagedSystemFiles, restoreFromArtifactFile } from './restore-from-file'; describe('applyStagedSystemFiles', () => { let dir: string; let stagingDir: string; let livePath: string; let keyPath: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'celilo-staged-apply-test-')); stagingDir = join(dir, 'system'); mkdirSync(stagingDir, { recursive: true }); livePath = join(dir, 'celilo.db'); keyPath = join(dir, 'master.key'); process.env.CELILO_DB_PATH = livePath; process.env.CELILO_MASTER_KEY_PATH = keyPath; process.env.CELILO_DATA_DIR = dir; // getModuleStoragePath() = dir/modules }); afterEach(() => { closeDb(); resetTestDbPath(); delete process.env.CELILO_MASTER_KEY_PATH; delete process.env.CELILO_DATA_DIR; try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); it('copies celilo.db + master.key into their live paths', () => { writeFileSync(join(stagingDir, 'celilo.db'), 'restored-db'); writeFileSync(join(stagingDir, 'master.key'), 'restored-key'); const result = applyStagedSystemFiles(stagingDir); expect(result.dbApplied).toBe(true); expect(result.keyApplied).toBe(true); expect(readFileSync(livePath, 'utf-8')).toBe('restored-db'); expect(readFileSync(keyPath, 'utf-8')).toBe('restored-key'); }); it('overwrites pre-existing files at the live paths', () => { writeFileSync(livePath, 'old-db'); writeFileSync(keyPath, 'old-key'); writeFileSync(join(stagingDir, 'celilo.db'), 'new-db'); writeFileSync(join(stagingDir, 'master.key'), 'new-key'); applyStagedSystemFiles(stagingDir); expect(readFileSync(livePath, 'utf-8')).toBe('new-db'); expect(readFileSync(keyPath, 'utf-8')).toBe('new-key'); }); it('cleans up stale WAL/SHM siblings of the restored DB', () => { writeFileSync(`${livePath}-wal`, 'stale-wal'); writeFileSync(`${livePath}-shm`, 'stale-shm'); writeFileSync(join(stagingDir, 'celilo.db'), 'fresh-db'); applyStagedSystemFiles(stagingDir); expect(existsSync(`${livePath}-wal`)).toBe(false); expect(existsSync(`${livePath}-shm`)).toBe(false); }); // celilo#1293 live repro 2026-09-06: the restore process's own pre-swap // connection can outlive closeDb() at the fd level (measured: bun:sqlite's // close() returned while the fds stayed open), and an IN-PLACE copy // overwrites the inode that connection holds locks on — the swapped-in DB // then inherits locks it must not have, and the next opener's DELETE→WAL // conversion fails with "database is locked". The swap must therefore // replace the INODE (rename), so any lingering lock lands on the unlinked // old inode and is irrelevant to the new file. it('replaces the live DB inode, not just its content', () => { writeFileSync(livePath, 'old-db'); writeFileSync(join(stagingDir, 'celilo.db'), 'new-db'); const inodeBefore = statSync(livePath).ino; applyStagedSystemFiles(stagingDir); expect(readFileSync(livePath, 'utf-8')).toBe('new-db'); expect(statSync(livePath).ino).not.toBe(inodeBefore); expect(existsSync(`${livePath}.restore-swap`)).toBe(false); }); it('handles partial staging (only master.key, no DB)', () => { writeFileSync(join(stagingDir, 'master.key'), 'just-the-key'); const result = applyStagedSystemFiles(stagingDir); expect(result.dbApplied).toBe(false); expect(result.keyApplied).toBe(true); }); it('handles partial staging (only DB, no master.key)', () => { writeFileSync(join(stagingDir, 'celilo.db'), 'just-the-db'); const result = applyStagedSystemFiles(stagingDir); expect(result.dbApplied).toBe(true); expect(result.keyApplied).toBe(false); expect(result.sshApplied).toBe(false); }); it('restores the fleet SSH keypair next to the DB with strict perms', () => { const stagedSsh = join(stagingDir, 'ssh'); mkdirSync(stagedSsh, { recursive: true }); writeFileSync(join(stagedSsh, 'id_ed25519'), 'PRIVATE-KEY-BYTES'); writeFileSync(join(stagedSsh, 'id_ed25519.pub'), 'ssh-ed25519 AAAA... celilo-fleet'); const result = applyStagedSystemFiles(stagingDir); expect(result.sshApplied).toBe(true); // Lands at dirname(getDbPath())/.ssh — which is `dir` here (CELILO_DB_PATH=dir/celilo.db). const liveSshDir = join(dir, '.ssh'); expect(readFileSync(join(liveSshDir, 'id_ed25519'), 'utf-8')).toBe('PRIVATE-KEY-BYTES'); expect(readFileSync(join(liveSshDir, 'id_ed25519.pub'), 'utf-8')).toBe( 'ssh-ed25519 AAAA... celilo-fleet', ); // Private key 0600, public 0644, dir 0700. expect(statSync(join(liveSshDir, 'id_ed25519')).mode & 0o777).toBe(0o600); expect(statSync(join(liveSshDir, 'id_ed25519.pub')).mode & 0o777).toBe(0o644); expect(statSync(liveSshDir).mode & 0o777).toBe(0o700); }); it('is a no-op when staging dir does not exist', () => { rmSync(stagingDir, { recursive: true }); const result = applyStagedSystemFiles(stagingDir); expect(result.dbApplied).toBe(false); expect(result.keyApplied).toBe(false); }); it('lays down staged module source dirs at the modules storage path', () => { const stagedSrc = join(stagingDir, 'module_src'); mkdirSync(join(stagedSrc, 'caddy', 'scripts'), { recursive: true }); writeFileSync(join(stagedSrc, 'caddy', 'manifest.yml'), 'id: caddy'); writeFileSync(join(stagedSrc, 'caddy', 'scripts', 'hook.ts'), '// hook'); const result = applyStagedSystemFiles(stagingDir); expect(result.moduleSourcesApplied).toBe(1); // getModuleStoragePath() = /modules = dir/modules. const laid = join(dir, 'modules', 'caddy'); expect(readFileSync(join(laid, 'manifest.yml'), 'utf-8')).toBe('id: caddy'); expect(readFileSync(join(laid, 'scripts', 'hook.ts'), 'utf-8')).toBe('// hook'); }); it("rewrites every module's source_path to this box on the staged DB before swap", () => { // A real staged SQLite DB whose module points at a FOREIGN (macOS) path — // exactly the macOS→Linux cross-host case ISS-0051 broke on. Build a minimal // self-contained DB (no runMigrations, which would hold a connection and // lock the journal-mode switch the rewrite needs). const stagedDbPath = join(stagingDir, 'celilo.db'); const seed = new Database(stagedDbPath); seed.run( 'CREATE TABLE modules (id TEXT PRIMARY KEY, name TEXT, version TEXT, manifest_data TEXT, source_path TEXT)', ); seed.run( "INSERT INTO modules (id, name, version, manifest_data, source_path) VALUES ('caddy', 'caddy', '2.0.0', '{}', '/Users/someone/Library/Application Support/celilo/modules/caddy')", ); seed.close(); const result = applyStagedSystemFiles(stagingDir); expect(result.dbApplied).toBe(true); // The swapped-in live DB must now point at THIS box's modules dir, not the // source box's dead macOS path. const live = new Database(livePath); const row = live.query("SELECT source_path AS sp FROM modules WHERE id = 'caddy'").get() as { sp: string; }; live.close(); expect(row.sp).toBe(join(dir, 'modules', 'caddy')); }); }); describe('restoreFromArtifactFile error paths', () => { let dir: string; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'celilo-restore-from-file-test-')); process.env.CELILO_DB_PATH = join(dir, 'celilo.db'); process.env.CELILO_DATA_DIR = dir; process.env.CELILO_MASTER_KEY_PATH = join(dir, 'master.key'); await runMigrations(process.env.CELILO_DB_PATH); }); afterEach(() => { closeDb(); resetTestDbPath(); delete process.env.CELILO_DATA_DIR; delete process.env.CELILO_MASTER_KEY_PATH; try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); it('returns an error for a non-existent file', async () => { const result = await restoreFromArtifactFile('/tmp/definitely-does-not-exist.backup'); expect(result.success).toBe(false); expect(result.error).toContain('not found'); }); it('returns an error for a file that is not valid JSON', async () => { const bogus = join(dir, 'bogus.backup'); writeFileSync(bogus, 'this is not JSON'); const result = await restoreFromArtifactFile(bogus); expect(result.success).toBe(false); expect(result.error).toContain('JSON'); }); it('returns an error for valid JSON that fails decryption', async () => { // Valid JSON shape that decryptSecret will fail on. const bogus = join(dir, 'malformed.backup'); writeFileSync( bogus, JSON.stringify({ encryptedValue: 'not-real', iv: 'not-real', authTag: 'not-real' }), ); const result = await restoreFromArtifactFile(bogus); expect(result.success).toBe(false); expect((result.error ?? '').toLowerCase()).toMatch(/decrypt|json|artifact/); }); });