/** * Tests for the `celilo restore` top-level command (Phase 4 of * openspec/specs/management-server-backup/spec.md). * * Covers the CLI surface: routing, argument parsing, pre-flight * gating, error propagation. The actual file-direct restore + * system-file swap logic is tested in restore-from-file.test.ts; * here we focus on what the user-facing command does (and refuses * to do). */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { closeDb, getDb } from '../db/client'; import { runMigrations } from '../db/migrate'; import { modules } from '../db/schema'; import { resetTestDbPath } from '../test-utils/db-path'; import { runCli } from './index'; describe('celilo restore', () => { let dir: string; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'celilo-restore-cmd-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'); process.env.CELILO_SUPPRESS_DEPRECATION = '1'; await runMigrations(process.env.CELILO_DB_PATH); }); afterEach(() => { closeDb(); resetTestDbPath(); delete process.env.CELILO_DATA_DIR; delete process.env.CELILO_MASTER_KEY_PATH; delete process.env.CELILO_SUPPRESS_DEPRECATION; try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); test('errors when --from is missing', async () => { const result = await runCli(['node', 'celilo', 'restore']); expect(result.success).toBe(false); const err = result.success ? '' : (result.error ?? ''); expect(err).toContain('--from'); }); test('errors when --from points at a non-existent file', async () => { const result = await runCli([ 'node', 'celilo', 'restore', '--from', '/tmp/does-not-exist-celilo-test.backup', ]); expect(result.success).toBe(false); const err = result.success ? '' : (result.error ?? ''); expect(err).toContain('not found'); }); test('refuses when target DB has modules (non-empty)', async () => { // Plant a fake module to simulate a populated target. const db = getDb(); db.insert(modules) .values({ id: 'fake-existing-mod', name: 'fake', version: '0.0.1', sourcePath: '/tmp/fake', manifestData: {} as Record, }) .run(); // Plant a phony artifact file so the path check passes. const phonyArtifact = join(dir, 'phony.backup'); writeFileSync(phonyArtifact, '{}'); const result = await runCli(['node', 'celilo', 'restore', '--from', phonyArtifact]); expect(result.success).toBe(false); const err = result.success ? '' : (result.error ?? ''); expect(err).toContain('not empty'); expect(err).toContain('fake-existing-mod'); expect(err).toContain('--force'); }); test('refuses when target has terraform state on disk (non-empty)', async () => { const tfDir = join(dir, 'modules', 'half-deployed', 'generated', 'terraform'); mkdirSync(tfDir, { recursive: true }); writeFileSync(join(tfDir, 'terraform.tfstate'), '{}'); const phonyArtifact = join(dir, 'phony.backup'); writeFileSync(phonyArtifact, '{}'); const result = await runCli(['node', 'celilo', 'restore', '--from', phonyArtifact]); expect(result.success).toBe(false); const err = result.success ? '' : (result.error ?? ''); expect(err).toContain('not empty'); expect(err).toContain('half-deployed'); }); test('routes to handleRestore when --from is provided (errors at decrypt of non-artifact)', async () => { // No state in DB or disk, but the file isn't a real artifact — // we expect to get PAST pre-flight and fail at the // decrypt/manifest stage. That proves routing is wired up. const notAnArtifact = join(dir, 'random.backup'); writeFileSync(notAnArtifact, 'this is not encrypted JSON'); const result = await runCli(['node', 'celilo', 'restore', '--from', notAnArtifact]); expect(result.success).toBe(false); const err = result.success ? '' : (result.error ?? ''); // Pre-flight passed (no mention of "not empty"); decryption failed. expect(err).not.toContain('not empty'); expect(err.toLowerCase()).toMatch(/json|decrypt|artifact/); }); test('accepts artifact path as positional too (not just --from)', async () => { const notAnArtifact = join(dir, 'random2.backup'); writeFileSync(notAnArtifact, 'still not a real artifact'); const result = await runCli(['node', 'celilo', 'restore', notAnArtifact]); expect(result.success).toBe(false); const err = result.success ? '' : (result.error ?? ''); // Reached the decrypt stage = positional arg was honored. expect(err).not.toContain('missing --from'); }); });