diff --git a/backend/__tests__/integration/installFromBackupBoot.test.js b/backend/__tests__/integration/installFromBackupBoot.test.js new file mode 100644 index 00000000..976def16 --- /dev/null +++ b/backend/__tests__/integration/installFromBackupBoot.test.js @@ -0,0 +1,214 @@ +/** + * Install-from-backup boot hook — pins the trigger-file convention. + * + * The hook itself depends on `restoreService.restore`, which is hard + * to fully exercise in an integration test without a real PG cluster + * (sequence resync, DROP/CREATE, etc.). So we stub the actual restore + * and verify the BOOT HOOK logic: + * + * - No trigger file → no-op, ran=false + * - Empty trigger file → picks newest manifest from manifests/ + * - Non-empty trigger file → uses the path inside + * - DB not empty → refuses (no restore call) + * - DB not empty + FORCE env → proceeds + * - Successful restore → deletes trigger file + * - Failed restore → leaves trigger file in place + * + * These are the surfaces an admin will hit when actually using the + * feature — the docker-compose-on-real-PG end-to-end test belongs in + * the follow-up CI work captured as task #7 earlier today. + */ + +const fs = require('fs'); +const path = require('path'); + +const { bootCrmDb } = require('./helpers/crmDb'); + +// Stub the heavy lifting so the test stays fast + portable. +const mockRestore = jest.fn(); +jest.mock('../../src/services/restoreService', () => ({ + restoreService: { + restore: (...args) => mockRestore(...args), + }, +})); + +jest.setTimeout(30000); + +describe('installFromBackupBoot', () => { + let db; + let cleanup; + let storagePath; + let backupRoot; + let manifestsDir; + let tryInstallFromBackup; + let originalBackupRootEnv; + let originalForceEnv; + + beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + storagePath = process.env.STORAGE_PATH; + backupRoot = path.join(storagePath, 'backup'); + manifestsDir = path.join(backupRoot, 'manifests'); + fs.mkdirSync(manifestsDir, { recursive: true }); + + originalBackupRootEnv = process.env.BACKUP_ROOT; + originalForceEnv = process.env.INSTALL_FROM_BACKUP_FORCE; + process.env.BACKUP_ROOT = backupRoot; + + ({ tryInstallFromBackup } = require('../../src/services/_installFromBackupBoot')); + }, 120000); + + afterAll(async () => { + if (originalBackupRootEnv === undefined) { + delete process.env.BACKUP_ROOT; + } else { + process.env.BACKUP_ROOT = originalBackupRootEnv; + } + if (originalForceEnv === undefined) { + delete process.env.INSTALL_FROM_BACKUP_FORCE; + } else { + process.env.INSTALL_FROM_BACKUP_FORCE = originalForceEnv; + } + if (cleanup) await cleanup(); + }); + + beforeEach(async () => { + mockRestore.mockReset(); + mockRestore.mockResolvedValue({ success: true }); + delete process.env.INSTALL_FROM_BACKUP_FORCE; + + // Clean trigger files + manifests between tests + for (const name of ['RESTORE_ON_INSTALL', 'RESTORE_ON_INSTALL.txt']) { + const p = path.join(backupRoot, name); + if (fs.existsSync(p)) fs.unlinkSync(p); + } + for (const f of fs.readdirSync(manifestsDir)) { + fs.unlinkSync(path.join(manifestsDir, f)); + } + + // Reset DB to fresh-install state + await db('events').del(); + // Leave admin_users alone — fresh-install state has 1 row. + }); + + it('no trigger file → no-op', async () => { + const result = await tryInstallFromBackup(db); + expect(result.ran).toBe(false); + expect(mockRestore).not.toHaveBeenCalled(); + }); + + it('empty trigger file picks the newest manifest from manifests/', async () => { + const older = path.join(manifestsDir, 'backup-manifest-001.json'); + const newer = path.join(manifestsDir, 'backup-manifest-002.json'); + fs.writeFileSync(older, '{}'); + // Set the newer file's mtime slightly later so it wins the sort + const past = new Date(Date.now() - 60_000); + fs.utimesSync(older, past, past); + fs.writeFileSync(newer, '{}'); + + // Empty trigger + fs.writeFileSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'), ''); + + const result = await tryInstallFromBackup(db); + expect(result.ran).toBe(true); + expect(result.manifestPath).toBe(newer); + expect(mockRestore).toHaveBeenCalledWith(expect.objectContaining({ + source: 'local', + manifestPath: newer, + restoreType: 'full', + force: true, + skipPreBackup: true, + })); + }); + + it('non-empty trigger file uses the path inside', async () => { + const specific = path.join(manifestsDir, 'backup-manifest-specific.json'); + fs.writeFileSync(specific, '{}'); + + // Relative to backupRoot + fs.writeFileSync( + path.join(backupRoot, 'RESTORE_ON_INSTALL'), + 'manifests/backup-manifest-specific.json\n', + ); + + const result = await tryInstallFromBackup(db); + expect(result.ran).toBe(true); + expect(result.manifestPath).toBe(specific); + }); + + it('deletes the trigger file after a successful restore', async () => { + const manifest = path.join(manifestsDir, 'backup-manifest-001.json'); + fs.writeFileSync(manifest, '{}'); + const triggerPath = path.join(backupRoot, 'RESTORE_ON_INSTALL'); + fs.writeFileSync(triggerPath, ''); + + await tryInstallFromBackup(db); + expect(fs.existsSync(triggerPath)).toBe(false); + }); + + it('leaves the trigger file in place when restore throws', async () => { + mockRestore.mockRejectedValueOnce(new Error('restore exploded')); + const manifest = path.join(manifestsDir, 'backup-manifest-001.json'); + fs.writeFileSync(manifest, '{}'); + const triggerPath = path.join(backupRoot, 'RESTORE_ON_INSTALL'); + fs.writeFileSync(triggerPath, ''); + + const result = await tryInstallFromBackup(db); + expect(result.ran).toBe(false); + expect(result.error).toMatch(/restore exploded/); + expect(fs.existsSync(triggerPath)).toBe(true); + }); + + it('refuses to run when the install already has events', async () => { + // Simulate an install with existing data + await db('events').insert({ + slug: 'existing-event', + event_name: 'Existing Event', + event_type: 'wedding', + event_date: new Date(), + host_email: 'host@example.com', + admin_email: 'host@example.com', + expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + share_link: 'existing-event-token', + password_hash: 'dummy-hash-for-test', + created_at: new Date(), + }); + + const manifest = path.join(manifestsDir, 'backup-manifest-001.json'); + fs.writeFileSync(manifest, '{}'); + fs.writeFileSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'), ''); + + const result = await tryInstallFromBackup(db); + expect(result.ran).toBe(false); + expect(result.error).toMatch(/Database not empty/); + expect(mockRestore).not.toHaveBeenCalled(); + + // Trigger file should NOT be deleted — admin needs to fix + retry + expect(fs.existsSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'))).toBe(true); + }); + + it('proceeds when INSTALL_FROM_BACKUP_FORCE=true even with existing data', async () => { + await db('events').insert({ + slug: 'existing-event-2', + event_name: 'Existing Event 2', + event_type: 'wedding', + event_date: new Date(), + host_email: 'host@example.com', + admin_email: 'host@example.com', + expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + share_link: 'existing-event-2-token', + password_hash: 'dummy-hash-for-test-2', + created_at: new Date(), + }); + + const manifest = path.join(manifestsDir, 'backup-manifest-001.json'); + fs.writeFileSync(manifest, '{}'); + fs.writeFileSync(path.join(backupRoot, 'RESTORE_ON_INSTALL'), ''); + + process.env.INSTALL_FROM_BACKUP_FORCE = 'true'; + const result = await tryInstallFromBackup(db); + + expect(result.ran).toBe(true); + expect(mockRestore).toHaveBeenCalled(); + }); +}); diff --git a/backend/server.js b/backend/server.js index d0a1bf7b..afb377e7 100644 --- a/backend/server.js +++ b/backend/server.js @@ -824,6 +824,24 @@ async function startServer() { logger.warn('restore-settings self-heal failed at boot:', err.message); } + // Install-from-backup trigger. If `RESTORE_ON_INSTALL` (or + // `.txt`) exists in the /backup mount AND the DB is empty, run + // the restore HERE before any admin UI surfaces. Lets admins + // recover a picpeak install with: (a) place backup files in the + // bind mount, (b) drop the trigger file, (c) `docker compose up`. + // No onboarding wizard, no throwaway admin, no compose-file + // changes. See _installFromBackupBoot.js for the full rationale + // + the safety gates. + try { + const { tryInstallFromBackup } = require('./src/services/_installFromBackupBoot'); + const result = await tryInstallFromBackup(db, logger); + if (result.ran) { + logger.info(`Install-from-backup: completed from ${result.manifestPath}. Server will start with restored state.`); + } + } catch (err) { + logger.warn('Install-from-backup hook threw:', err.message); + } + // Start backup service await startBackupService(); diff --git a/backend/src/services/_installFromBackupBoot.js b/backend/src/services/_installFromBackupBoot.js new file mode 100644 index 00000000..71502585 --- /dev/null +++ b/backend/src/services/_installFromBackupBoot.js @@ -0,0 +1,226 @@ +/** + * Install-from-backup boot hook. + * + * **The problem this closes** + * + * Without this hook, recovering a picpeak install from a backup is a + * six-step process: + * 1. Stand up the compose stack with empty volumes + * 2. Wait for boot → land on the onboarding wizard + * 3. Create a throwaway fresh-install admin + * 4. Navigate to Backup → Restore + * 5. Walk through the wizard with Force Restore ticked + * 6. Log out, log back in with original (pre-disaster) credentials + * + * With this hook, admins skip steps 2-6. They place their backup + * artefacts in the existing bind-mounted /backup directory, drop a + * trigger file alongside, and the next container start runs the + * restore BEFORE creating the throwaway onboarding admin. Server + * comes up populated, original login works first try. + * + * **The trigger file convention (chosen for zero compose-file changes)** + * + * Drop a file named `RESTORE_ON_INSTALL` (no extension, or .txt) into + * the root of the `/backup` mount. Two payload variants: + * + * 1. EMPTY file (or pure whitespace) — auto-pick the newest + * `backup-manifest-*.json` from `/backup/manifests/`. Useful when + * the admin doesn't know or care which one is most recent. + * + * 2. NON-EMPTY file containing a relative or absolute path to a + * specific manifest. Trimmed; first line wins. Useful when the + * admin wants a specific older backup. + * + * After a successful restore the trigger file is DELETED so the next + * boot doesn't re-trigger. On failure the file is preserved + the + * error is logged, so the admin can fix the input and retry by just + * restarting the container. + * + * **Safety** + * + * Three layers gate this against accidental data loss: + * 1. Trigger file must exist (intentional admin action, not auto-magic) + * 2. DB must be empty — admin_users.count = 0 AND events.count = 0. + * If either is non-zero, the hook refuses to run. + * 3. The Stage A restore path (with all of tonight's fixes) handles + * the actual swap atomically. If anything fails, the rollback + * runs and the install stays in fresh-install state. + * + * Override: `INSTALL_FROM_BACKUP_FORCE=true` skips the empty-DB check + * for the "I know what I'm doing" edge case (e.g. dev environment + * rebuilds where there's leftover data that's safe to clobber). + */ + +const fs = require('fs'); +const path = require('path'); + +const TRIGGER_FILENAMES = ['RESTORE_ON_INSTALL', 'RESTORE_ON_INSTALL.txt']; + +/** + * Resolve which file should be treated as the trigger. Returns + * `{ triggerPath, manifestPath }` if found, or null if no trigger + * file is present (the common case — most boots). + */ +async function findTrigger(backupRoot, logger) { + for (const name of TRIGGER_FILENAMES) { + const triggerPath = path.join(backupRoot, name); + if (fs.existsSync(triggerPath)) { + let payload; + try { + payload = fs.readFileSync(triggerPath, 'utf8').trim(); + } catch (err) { + logger.warn(`Install-from-backup: trigger file ${triggerPath} is unreadable: ${err.message}`); + return null; + } + + if (!payload) { + // Auto-pick the newest manifest + const manifestsDir = path.join(backupRoot, 'manifests'); + if (!fs.existsSync(manifestsDir)) { + logger.warn(`Install-from-backup: trigger file found but ${manifestsDir} doesn't exist`); + return null; + } + const entries = fs.readdirSync(manifestsDir) + .filter((f) => /^backup-manifest-.+\.(json|ya?ml)$/i.test(f)) + .map((f) => { + const full = path.join(manifestsDir, f); + return { full, mtime: fs.statSync(full).mtimeMs }; + }) + .sort((a, b) => b.mtime - a.mtime); + if (entries.length === 0) { + logger.warn(`Install-from-backup: no manifests found in ${manifestsDir}`); + return null; + } + return { triggerPath, manifestPath: entries[0].full }; + } + + // Take the first non-empty line as the manifest path + const firstLine = payload.split(/\r?\n/).find((l) => l.trim()) || ''; + const manifestPath = path.isAbsolute(firstLine) + ? firstLine + : path.join(backupRoot, firstLine); + + if (!fs.existsSync(manifestPath)) { + logger.warn(`Install-from-backup: trigger file points at ${manifestPath} which doesn't exist`); + return null; + } + return { triggerPath, manifestPath }; + } + } + return null; +} + +/** + * Check the DB is empty enough that restoring on top is safe. + * Returns `true` if safe, `false` if there's existing data. + */ +async function isDatabaseFresh(db, logger) { + try { + if (!(await db.schema.hasTable('admin_users'))) { + // No admin_users table yet — schema is mid-migration or wholly + // empty. Definitely safe to restore on top. + return true; + } + const adminCount = await db('admin_users').count('* as c').first(); + const adminN = Number(adminCount?.c || 0); + + let eventN = 0; + if (await db.schema.hasTable('events')) { + const eventCount = await db('events').count('* as c').first(); + eventN = Number(eventCount?.c || 0); + } + + if (adminN > 1 || eventN > 0) { + logger.warn( + `Install-from-backup: refusing — install has ${adminN} admin(s) and ${eventN} event(s). ` + + 'This guard prevents accidental clobbering of production data. ' + + 'Override with INSTALL_FROM_BACKUP_FORCE=true if you really want to restore on top.' + ); + return false; + } + + // adminN === 1 is the "fresh install ran migration 001 and auto-created + // the default admin" case. That admin is throwaway — the restore will + // replace it with the backup's admin row. So we treat 1 admin + 0 + // events as fresh. + return true; + } catch (err) { + logger.warn(`Install-from-backup: fresh-install check threw: ${err.message}. Assuming NOT fresh.`); + return false; + } +} + +/** + * Public entry point — called from server.js after migrations and + * before startServer. + * + * @returns {Promise<{ ran: boolean, manifestPath?: string, error?: string }>} + */ +async function tryInstallFromBackup(db, logger) { + const log = logger || { info: () => {}, warn: () => {}, error: () => {} }; + + const backupRoot = process.env.BACKUP_ROOT || '/backup'; + if (!fs.existsSync(backupRoot)) { + return { ran: false }; + } + + const trigger = await findTrigger(backupRoot, log); + if (!trigger) { + return { ran: false }; + } + + log.info(`Install-from-backup: trigger file found at ${trigger.triggerPath}, target manifest ${trigger.manifestPath}`); + + const forceOverride = process.env.INSTALL_FROM_BACKUP_FORCE === 'true'; + const isFresh = await isDatabaseFresh(db, log); + if (!isFresh && !forceOverride) { + log.warn('Install-from-backup: skipping. Trigger file left in place so you can correct + retry.'); + return { ran: false, error: 'Database not empty' }; + } + + log.info(`Install-from-backup: restoring from ${trigger.manifestPath}...`); + + try { + const { restoreService } = require('./restoreService'); + const result = await restoreService.restore({ + source: 'local', + manifestPath: trigger.manifestPath, + restoreType: 'full', + // Force=true because the fresh-install admin auto-created by + // migration 001 trips the "1 active admin" warning — we WANT to + // override that warning, since replacing the throwaway admin + // with the backup's admin is exactly the goal. + force: true, + // SkipPreBackup=true because backing up an empty install is + // pointless. Saves a few seconds and reduces disk noise. + skipPreBackup: true, + operator: { + type: 'install-from-backup', + userId: null, + ip: null, + }, + }); + + if (result?.success === false) { + throw new Error(result?.error || 'Restore service reported failure'); + } + + log.info(`Install-from-backup: restore completed successfully from ${trigger.manifestPath}`); + + // Remove the trigger so the next boot doesn't redo it. + try { + fs.unlinkSync(trigger.triggerPath); + log.info(`Install-from-backup: removed trigger file ${trigger.triggerPath}`); + } catch (unlinkErr) { + log.warn(`Install-from-backup: could not remove trigger file (manual cleanup needed): ${unlinkErr.message}`); + } + + return { ran: true, manifestPath: trigger.manifestPath }; + } catch (err) { + log.error(`Install-from-backup: FAILED — ${err.message}`); + log.warn('Trigger file left in place so you can fix the input and retry by restarting the container.'); + return { ran: false, error: err.message }; + } +} + +module.exports = { tryInstallFromBackup };