/** * End-to-end test of the manifest-bearing envelope format. Creates a real * system backup, decrypts the artifact, untars it, and verifies: * - manifest.json is present at the envelope root * - schemaVersion matches the current build * - celilo.db is present * * Then restores into a different DB path and verifies the round-trip is * lossless (a row inserted before backup is present after restore). * * Module-backup round-trip would require setting up an on_backup hook + * a manifest contract; covered by integration tests under * test-integration/ once those exist. This unit-level test focuses on * the envelope mechanics that are easy to break. */ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { execSync } from 'node:child_process'; import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { getDbPath } from '../config/paths'; import { closeDb, getDb } from '../db/client'; import { runMigrations } from '../db/migrate'; import { backups, systemConfig } from '../db/schema'; import { getOrCreateMasterKey } from '../secrets/master-key'; import { resetTestDbPath } from '../test-utils/db-path'; import { decryptFileToFile, encryptFileToFile } from './backup-cipher'; import { createSystemStateBackup } from './backup-create'; import { MANIFEST_SCHEMA_VERSION, buildManifest, parseManifest } from './backup-manifest'; import { restoreSystemStateBackup } from './backup-restore'; import { addBackupStorage, setDefaultBackupStorage, verifyBackupStorage } from './backup-storage'; describe('backup envelope round-trip', () => { let dir: string; let storageDir: string; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'celilo-envelope-test-')); storageDir = join(dir, 'storage'); mkdirSync(storageDir, { recursive: true }); process.env.CELILO_DB_PATH = join(dir, 'celilo.db'); process.env.CELILO_MASTER_KEY_PATH = join(dir, 'master.key'); await runMigrations(process.env.CELILO_DB_PATH); // Add a local-FS storage destination, verify it (required before // createBackup will accept it as the default), and set as default. const storage = await addBackupStorage({ name: 'test-local', providerName: 'local', credentials: { path: storageDir }, providerConfig: {}, }); await verifyBackupStorage(storage.id); setDefaultBackupStorage(storage.id); }); afterEach(() => { closeDb(); resetTestDbPath(); delete process.env.CELILO_MASTER_KEY_PATH; try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); it('system backup produces an envelope with manifest.json + celilo.db', async () => { // Plant a sentinel row so the round-trip has something to verify. const db = getDb(); db.insert(systemConfig).values({ key: 'envelope-test-sentinel', value: 'before-backup' }).run(); const result = await createSystemStateBackup(); expect(result.success).toBe(true); expect(result.storagePath).toBeDefined(); // Locate the artifact and decrypt by hand to inspect its contents. const artifactPath = join(storageDir, 'celilo-backups', result.storagePath as string); expect(existsSync(artifactPath)).toBe(true); const masterKey = await getOrCreateMasterKey(); const extractDir = join(dir, 'extract'); mkdirSync(extractDir, { recursive: true }); const tarPath = join(dir, 'envelope.tar'); await decryptFileToFile(artifactPath, tarPath, masterKey); execSync(`tar -xf '${tarPath}' -C '${extractDir}'`); // manifest.json present at root, valid, matches expectations. const manifestPath = join(extractDir, 'manifest.json'); expect(existsSync(manifestPath)).toBe(true); const manifest = parseManifest(readFileSync(manifestPath, 'utf-8')); expect(manifest.kind).toBe('system'); expect(manifest.schemaVersion).toBe(MANIFEST_SCHEMA_VERSION); expect(manifest.moduleId).toBeUndefined(); expect(manifest.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); // celilo.db present alongside manifest.json. expect(existsSync(join(extractDir, 'celilo.db'))).toBe(true); }); it('system backup round-trips through restore successfully', async () => { // Verifies the artifact is restorable: the envelope decrypts, the // manifest validates, the celilo.db is extracted, and the live DB // file gets replaced. Data-level lossless-ness is implicitly covered // since the restore copies the exact bytes that were backed up; // explicitly re-opening the restored DB in the same Bun process // triggers a macOS vnode-cache issue (SQLITE_IOERR_VNODE) that // production never hits — CLI invocations exit after a successful // restore, and the next invocation opens fresh. Out of scope for // this unit test; a multi-process e2e covers that path. const db = getDb(); db.insert(systemConfig).values({ key: 'roundtrip-key', value: 'original-value' }).run(); const backupResult = await createSystemStateBackup(); expect(backupResult.success).toBe(true); const backupRow = db.select().from(backups).all()[0]; expect(backupRow).toBeDefined(); const restoreResult = await restoreSystemStateBackup(backupRow); expect(restoreResult.success).toBe(true); expect(restoreResult.error).toBeUndefined(); }); it('an archive written by SYSTEM tar (the pre-library producer) restores via the tar library', async () => { // The tar library replaced `tar -cf/-xf` shell-outs (celilo#1235). A // self-consistent round-trip cannot detect a layout change, because the // new extract sites would happily read back the new layout. So this test // builds the envelope with the SYSTEM tar — byte-for-byte the command the // old code ran — and restores it with the current code, proving a backup // taken BEFORE the change still restores after it. const db = getDb(); db.insert(systemConfig).values({ key: 'pre-change-sentinel', value: 'before-backup' }).run(); // Take a real backup to get a valid row + storage location, then swap the // artifact for one produced by the system tar the old code shelled out to. const result = await createSystemStateBackup(); expect(result.success).toBe(true); const artifactPath = join(storageDir, 'celilo-backups', result.storagePath as string); const envelopeDir = join(dir, 'pre-change-envelope'); mkdirSync(envelopeDir, { recursive: true }); copyFileSync(getDbPath(), join(envelopeDir, 'celilo.db')); writeFileSync( join(envelopeDir, 'manifest.json'), JSON.stringify(buildManifest({ kind: 'system' }), null, 2), ); const tarPath = join(dir, 'pre-change.tar'); execSync(`tar -cf '${tarPath}' -C '${envelopeDir}' .`); const masterKey = await getOrCreateMasterKey(); await encryptFileToFile(tarPath, artifactPath, masterKey); const backupRow = db.select().from(backups).all()[0]; const restoreResult = await restoreSystemStateBackup(backupRow); expect(restoreResult.success).toBe(true); expect(restoreResult.error).toBeUndefined(); }); it('system restore refuses an artifact with a wrong manifest kind', async () => { // Create a valid backup, then poison the manifest by re-packing. const result = await createSystemStateBackup(); const artifactPath = join(storageDir, 'celilo-backups', result.storagePath as string); const masterKey = await getOrCreateMasterKey(); const repackDir = join(dir, 'repack'); mkdirSync(repackDir, { recursive: true }); const tarPath = join(dir, 'orig.tar'); await decryptFileToFile(artifactPath, tarPath, masterKey); execSync(`tar -xf '${tarPath}' -C '${repackDir}'`); // Rewrite manifest to claim kind='module'. const poisoned = JSON.parse(readFileSync(join(repackDir, 'manifest.json'), 'utf-8')); poisoned.kind = 'module'; poisoned.moduleId = 'fake'; writeFileSync(join(repackDir, 'manifest.json'), JSON.stringify(poisoned)); // Re-tar + re-encrypt + overwrite the storage entry. const repackedTarPath = join(dir, 'repacked.tar'); execSync(`tar -cf '${repackedTarPath}' -C '${repackDir}' .`); await encryptFileToFile(repackedTarPath, artifactPath, masterKey); const db = getDb(); const backupRow = db.select().from(backups).all()[0]; const restoreResult = await restoreSystemStateBackup(backupRow); expect(restoreResult.success).toBe(false); expect(restoreResult.error).toContain("artifact kind is 'module'"); }); it('system restore refuses an incompatible schemaVersion', async () => { const result = await createSystemStateBackup(); const artifactPath = join(storageDir, 'celilo-backups', result.storagePath as string); const masterKey = await getOrCreateMasterKey(); const repackDir = join(dir, 'repack-schema'); mkdirSync(repackDir, { recursive: true }); const tarPath = join(dir, 'orig-schema.tar'); await decryptFileToFile(artifactPath, tarPath, masterKey); execSync(`tar -xf '${tarPath}' -C '${repackDir}'`); // Bump schemaVersion to a different MAJOR version. const poisoned = JSON.parse(readFileSync(join(repackDir, 'manifest.json'), 'utf-8')); const currentMajor = Number(String(MANIFEST_SCHEMA_VERSION).split('.')[0]); poisoned.schemaVersion = `${currentMajor + 1}.0`; writeFileSync(join(repackDir, 'manifest.json'), JSON.stringify(poisoned)); const repackedTarPath = join(dir, 'repacked-schema.tar'); execSync(`tar -cf '${repackedTarPath}' -C '${repackDir}' .`); await encryptFileToFile(repackedTarPath, artifactPath, masterKey); const db = getDb(); const backupRow = db.select().from(backups).all()[0]; const restoreResult = await restoreSystemStateBackup(backupRow); expect(restoreResult.success).toBe(false); expect(restoreResult.error).toContain('envelope schema'); }); });