/** * Verifies that backup and restore services refuse to run while any other * module operation is in-flight, and that this refusal happens BEFORE any * side effects (so a refused backup leaves no DB row, no temp dir, no * storage entry). * * The tests insert in-flight rows directly into module_operations rather * than spinning up real deploys/uninstalls — that keeps the test focused * on the refusal-wiring contract, not the deploy mechanics. The pid in * each fake row is process.pid (always alive during the test) so * checkInFlight() treats them as genuine conflicts. */ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { mkdtempSync, rmSync } 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 { backups, moduleOperations } from '../db/schema'; import { resetTestDbPath } from '../test-utils/db-path'; import { createModuleBackup, createSystemStateBackup } from './backup-create'; import { restoreModuleBackup, restoreSystemStateBackup } from './backup-restore'; import { InFlightError } from './module-operations'; describe('backup/restore in-flight refusal', () => { let dir: string; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), 'celilo-refuse-test-')); 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); }); afterEach(() => { closeDb(); resetTestDbPath(); delete process.env.CELILO_MASTER_KEY_PATH; try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); function insertInFlight(moduleId: string, op: 'deploy' | 'uninstall' | 'backup' | 'restore') { const db = getDb(); db.insert(moduleOperations) .values({ id: `fake-${moduleId}-${op}`, moduleId, operation: op, status: 'in_progress', pid: process.pid, }) .run(); } describe('createSystemStateBackup', () => { it('refuses when another deploy is in-flight', async () => { insertInFlight('homebridge', 'deploy'); await expect(createSystemStateBackup()).rejects.toThrow(InFlightError); }); it('refuses when another uninstall is in-flight', async () => { insertInFlight('caddy', 'uninstall'); await expect(createSystemStateBackup()).rejects.toThrow(InFlightError); }); it('refuses when another backup is in-flight', async () => { insertInFlight('authentik', 'backup'); await expect(createSystemStateBackup()).rejects.toThrow(InFlightError); }); it('refuses when another restore is in-flight', async () => { insertInFlight('authentik', 'restore'); await expect(createSystemStateBackup()).rejects.toThrow(InFlightError); }); it('leaves no backups row when refused (refusal happens before side effects)', async () => { insertInFlight('homebridge', 'deploy'); const db = getDb(); const beforeCount = db.select().from(backups).all().length; try { await createSystemStateBackup(); } catch { // expected } const afterCount = db.select().from(backups).all().length; expect(afterCount).toBe(beforeCount); }); it('error message names the conflicting operation', async () => { insertInFlight('homebridge', 'deploy'); try { await createSystemStateBackup(); } catch (err) { expect(err).toBeInstanceOf(InFlightError); expect((err as Error).message).toContain('deploy of homebridge'); return; } throw new Error('expected createSystemStateBackup to throw'); }); }); describe('createModuleBackup', () => { it('returns "module not found" error if module does not exist (refusal not reached)', async () => { insertInFlight('caddy', 'deploy'); const result = await createModuleBackup('does-not-exist'); expect(result.success).toBe(false); expect(result.error).toContain('Module not found'); }); }); describe('restoreSystemStateBackup', () => { it('refuses when a deploy is in-flight', async () => { insertInFlight('caddy', 'deploy'); // Fake backup object — refusal happens before storageId is used. const fakeBackup = { id: 'fake', moduleId: null, storageId: 'noop', storagePath: 'noop', backupType: 'system_state' as const, moduleVersion: null, schemaVersion: null, sizeBytes: null, metadata: {}, status: 'completed' as const, errorMessage: null, name: null, pid: process.pid, startedAt: new Date(), completedAt: null, }; await expect(restoreSystemStateBackup(fakeBackup)).rejects.toThrow(InFlightError); }); }); describe('restoreModuleBackup', () => { it('returns module-not-found error when module is missing (validation precedes refusal)', async () => { insertInFlight('caddy', 'deploy'); const fakeBackup = { id: 'fake', moduleId: 'absent-module', storageId: 'noop', storagePath: 'noop', backupType: 'module_data' as const, moduleVersion: null, schemaVersion: null, sizeBytes: null, metadata: {}, status: 'completed' as const, errorMessage: null, name: null, pid: process.pid, startedAt: new Date(), completedAt: null, }; const result = await restoreModuleBackup(fakeBackup); expect(result.success).toBe(false); expect(result.error).toContain('Module not found'); }); }); });