/** * Tests for the restore migration contract (celilo#1269): * * 1. A restore whose post-swap migration step fails reports FAILURE, not a * warning over a success result. The failure names the recovery command * (`celilo system migrate`) and the check that says whether the schema is * complete (`celilo system doctor`). * 2. A connection held across the migration step is observable at the step's * own boundary: migrateRestoredDb throws naming the lock instead of * quietly proceeding over a locked database. * * The round-trip here (encrypted envelope → on_restore hook → staged system * files → swap → migrate) is the first module-artifact round-trip at unit * level; the envelope mechanics mirror backup-envelope-roundtrip.test.ts. */ import { Database } from 'bun:sqlite'; 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 { create as tarCreate } from 'tar'; import { getDbPath } from '../config/paths'; import { closeDb, getDb } from '../db/client'; import { runMigrations } from '../db/migrate'; import { modules } from '../db/schema'; import { getOrCreateMasterKey } from '../secrets/master-key'; import { encryptFileToFile } from '../services/backup-cipher'; import { buildManifest } from '../services/backup-manifest'; import { migrateRestoredDb } from '../services/restore-from-file'; import { resetTestDbPath } from '../test-utils/db-path'; import { runCli } from './index'; const FIXTURES_DIR = join(import.meta.dir, '../hooks/test-fixtures'); const STAGING_HOOK = join(FIXTURES_DIR, 'on-restore-staging-hook.ts'); const MODULE_ID = 'restore-migration-test'; /** The manifest the module row carries: one on_restore hook, the fixture. */ const MODULE_MANIFEST = { celilo_contract: '1.0', id: MODULE_ID, name: 'Restore Migration Test', version: '0.0.1', hooks: { on_restore: { script: STAGING_HOOK, timeout: 60000, }, }, }; /** * Build an encrypted module artifact whose envelope carries `dbBytes` as the * backed-up celilo.db. The fixture on_restore hook stages it into system/ so * the restore swaps it over the live DB before the migration step runs. */ async function buildModuleArtifact(dir: string, dbBytes: string): Promise { const envelopeDir = join(dir, 'envelope-build'); mkdirSync(join(envelopeDir, 'data'), { recursive: true }); writeFileSync( join(envelopeDir, 'manifest.json'), JSON.stringify(buildManifest({ kind: 'module', moduleId: MODULE_ID, moduleVersion: '0.0.1' })), ); writeFileSync(join(envelopeDir, 'data', 'celilo.db'), dbBytes); const tarPath = join(dir, 'envelope.tar'); await tarCreate({ file: tarPath, cwd: envelopeDir }, ['.']); const masterKey = await getOrCreateMasterKey(); const artifactPath = join(dir, 'module.backup'); await encryptFileToFile(tarPath, artifactPath, masterKey); return artifactPath; } describe('restore migration failure reporting (celilo#1269)', () => { let dir: string; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'celilo-restore-mig-fail-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); // The artifact's module must already be imported (restore-from-file's // contract with bootstrap.sh). Its sourcePath points at the workspace // fixture dir: the canonical /modules/ does not exist here, // so restore-from-file falls back to sourcePath for the hook. const db = getDb(); db.insert(modules) .values({ id: MODULE_ID, name: 'Restore Migration Test', version: '0.0.1', sourcePath: FIXTURES_DIR, manifestData: MODULE_MANIFEST, }) .run(); }); 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 */ } }); test('a restore whose migration step fails reports failure and names the recovery path', async () => { // The artifact's celilo.db is not a database at all. The swap lands it // (staging bytes are deliberately tolerated so the file-copy path stays // testable), and the migration step then fails on open — the same // failure surface a locked or half-restored DB produces. const artifact = await buildModuleArtifact(dir, 'this is not a SQLite database'); const result = await runCli(['node', 'celilo', 'restore', '--from', artifact, '--force']); expect(result.success).toBe(false); const err = result.success ? '' : (result.error ?? ''); expect(err).toContain('migrat'); expect(err).toContain('celilo system migrate'); expect(err).toContain('celilo system doctor'); }); test('the swap happened even when migrations failed (the error says so)', async () => { const artifact = await buildModuleArtifact(dir, 'this is not a SQLite database'); const result = await runCli(['node', 'celilo', 'restore', '--from', artifact, '--force']); expect(result.success).toBe(false); const err = result.success ? '' : (result.error ?? ''); // The operator must learn the swap DID land — the failure is about the // schema step, not a lost restore. expect(err).toContain('restored'); }); // The timeout is 30s, not the 5s default: the holder pins the write lock // for the full busy_timeout window before the migration step gives up, and // the two windows are the same 5s. test('a connection held across the migration step throws naming the lock', async () => { // Current schema, ledger emptied so drizzle has work to do, then a write // lock held across the step. Deleting one ledger row is not enough: the // watermark is created_at-based and every migration shares one timestamp, // so the whole ledger has to go. The re-run hits "already exists", the // ledger-repair path then tries to write, and that write blocks on the // holder until busy_timeout expires. const livePath = getDbPath(); const ledger = new Database(livePath); ledger.run('DELETE FROM __drizzle_migrations'); ledger.close(); const holder = new Database(livePath); holder.run('BEGIN IMMEDIATE'); try { await expect(migrateRestoredDb()).rejects.toThrow(/locked/i); } finally { holder.close(); } }, 30000); });