diff --git a/backend/__tests__/integration/picpeakRoundtrip.test.js b/backend/__tests__/integration/picpeakRoundtrip.test.js new file mode 100644 index 00000000..e31ee1d7 --- /dev/null +++ b/backend/__tests__/integration/picpeakRoundtrip.test.js @@ -0,0 +1,119 @@ +'use strict'; + +// Full .picpeak roundtrip on a temp SQLite DB: +// 1. seed a "backup" instance (admin A + a marker setting) +// 2. export → .picpeak +// 3. simulate a reinstall: wipe, create a DIFFERENT current admin B, mutate data +// 4. import the backup with currentAdminId = B +// 5. assert the backup data is restored AND the current account (B) survives, +// while the backup's admin (A) is also present (different email → added). +process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!'; + +const fs = require('fs'); +const path = require('path'); +const { bootCrmDb } = require('./helpers/crmDb'); + +let db; +let cleanup; +let tmpDir; +let createPicpeak; +let importFromPicpeak; +let superAdminRoleId; + +beforeAll(async () => { + ({ db, cleanup, tmpDir } = await bootCrmDb()); + process.env.STORAGE_PATH = tmpDir; + ({ createPicpeak } = require('../../src/services/picpeakExportService')); + ({ importFromPicpeak } = require('../../src/services/picpeakImportService')); + const role = await db('roles').where({ name: 'super_admin' }).first(); + superAdminRoleId = role.id; +}, 60000); + +afterAll(async () => { + await cleanup(); +}); + +const adminRow = (email, hash) => ({ + username: email, + email, + password_hash: hash, + role_id: superAdminRoleId, + is_active: true, + must_change_password: false, + created_at: new Date(), + updated_at: new Date(), +}); + +async function setMarker(value) { + await db('app_settings') + .insert({ setting_key: 'roundtrip_marker', setting_value: JSON.stringify(value), setting_type: 'string' }) + .onConflict('setting_key').merge(); +} +async function getMarker() { + const row = await db('app_settings').where({ setting_key: 'roundtrip_marker' }).first(); + return row ? JSON.parse(row.setting_value) : null; +} + +describe('.picpeak roundtrip (export → import)', () => { + it('restores backup data and preserves the current account', async () => { + // 1. Seed the "source" instance. + await db('admin_users').del(); + await db('admin_users').insert(adminRow('backup-admin@old.example', 'HASH_A')); + await setMarker('from_backup'); + + // 2. Export. + const { filePath } = await createPicpeak({ includePhotos: false }); + + try { + // 3. Simulate a reinstall: fresh current admin B, mutated data. + await db('admin_users').del(); + const [bId] = await db('admin_users').insert(adminRow('current-admin@new.example', 'HASH_B')).returning('id'); + const currentAdminId = typeof bId === 'object' ? bId.id : bId; + await setMarker('mutated_after_backup'); + + // 4. Import, preserving the current admin. + const result = await importFromPicpeak({ filePath: undefined, picpeakPath: filePath, currentAdminId }); + expect(result.restored).toBe(true); + expect(result.tables).toBeGreaterThan(0); + + // 5a. Backup data restored (marker reverted to the backup value). + expect(await getMarker()).toBe('from_backup'); + + // 5b. The backup's admin is present (different email → added). + const a = await db('admin_users').whereRaw('lower(email) = lower(?)', ['backup-admin@old.example']).first(); + expect(a).toBeTruthy(); + expect(a.password_hash).toBe('HASH_A'); + + // 5c. The current account SURVIVES the override, with its own credentials. + const b = await db('admin_users').whereRaw('lower(email) = lower(?)', ['current-admin@new.example']).first(); + expect(b).toBeTruthy(); + expect(b.password_hash).toBe('HASH_B'); + } finally { + fs.rmSync(path.dirname(filePath), { recursive: true, force: true }); + } + }); + + it('overwrites a backup admin that collides with the current account email', async () => { + // Source has an admin at the SAME email the current operator will use. + await db('admin_users').del(); + await db('admin_users').insert(adminRow('shared@example.com', 'OLD_HASH')); + await setMarker('collision_case'); + const { filePath } = await createPicpeak({ includePhotos: false }); + + try { + // Reinstall: current admin uses the same email but a NEW password. + await db('admin_users').del(); + const [id] = await db('admin_users').insert(adminRow('shared@example.com', 'NEW_HASH')).returning('id'); + const currentAdminId = typeof id === 'object' ? id.id : id; + + await importFromPicpeak({ picpeakPath: filePath, currentAdminId }); + + // Exactly one admin at that email, and it keeps the CURRENT password. + const rows = await db('admin_users').whereRaw('lower(email) = lower(?)', ['shared@example.com']); + expect(rows).toHaveLength(1); + expect(rows[0].password_hash).toBe('NEW_HASH'); + } finally { + fs.rmSync(path.dirname(filePath), { recursive: true, force: true }); + } + }); +}); diff --git a/backend/src/services/picpeakImportService.js b/backend/src/services/picpeakImportService.js new file mode 100644 index 00000000..4b61391f --- /dev/null +++ b/backend/src/services/picpeakImportService.js @@ -0,0 +1,219 @@ +'use strict'; + +// Receiving half of the GUI-only backup roundtrip: takes a ".picpeak" produced +// by picpeakExportService and restores it onto THIS instance. +// +// Restore semantics (agreed design): FULL OVERRIDE — every table is wiped and +// replaced by the backup's rows — EXCEPT the current logged-in admin account, +// which is preserved so the operator is never locked out. A backup admin whose +// email collides with the current account is overwritten with the current +// account's credentials (so the operator's known password keeps working). +// +// Same-engine only (pg↔pg / sqlite↔sqlite) and forward-only (an older backup +// restores onto a newer instance; a newer backup is refused). The target's own +// schema is used as-is — we never replay the backup's DDL. + +const fs = require('fs'); +const fsp = require('fs').promises; +const path = require('path'); +const os = require('os'); +const StreamZip = require('node-stream-zip'); +const { db } = require('../database/db'); +const knexConfig = require('../../knexfile'); +const { getStoragePath } = require('../config/storage'); +const { hasColumnCached } = require('../utils/schemaCache'); +const logger = require('../utils/logger'); +const { PICPEAK_FORMAT_VERSION } = require('./picpeakExportService'); + +const isPostgres = () => knexConfig.client === 'pg'; + +// Compare migrations by their numeric filename prefix (001_, 107_, 129_ …). +function migrationOrder(name) { + const m = String(name || '').match(/^(\d+)/); + return m ? parseInt(m[1], 10) : -1; +} + +async function readManifestFromZip(picpeakPath) { + const zip = new StreamZip.async({ file: picpeakPath }); + try { + return JSON.parse((await zip.entryData('manifest.json')).toString('utf8')); + } finally { + await zip.close(); + } +} + +// Returns an array of human-readable blockers ([] = OK to restore). +async function validateManifest(manifest) { + const errors = []; + if (!manifest || manifest.kind !== 'picpeak-backup') { + return ['This file is not a PicPeak backup (.picpeak).']; + } + if (Number(manifest.format) > PICPEAK_FORMAT_VERSION) { + errors.push('This backup was created by a newer version of PicPeak. Update this instance first.'); + } + const engine = isPostgres() ? 'pg' : 'sqlite'; + if (manifest.database && manifest.database.engine && manifest.database.engine !== engine) { + errors.push(`Database engine mismatch: the backup is "${manifest.database.engine}" but this instance is "${engine}". Restore is only supported between matching engines.`); + } + // Forward-only: the target schema must be at least as new as the backup's. + let targetLatest = null; + try { + const applied = await db('knex_migrations').orderBy('id', 'desc').limit(1); + targetLatest = applied[0] ? applied[0].name : null; + } catch (_) { + // No knex_migrations table (e.g. some test harnesses) — skip the check. + } + const backupLatest = manifest.database ? manifest.database.latest_migration : null; + if (backupLatest && targetLatest && migrationOrder(backupLatest) > migrationOrder(targetLatest)) { + errors.push('This backup is from a newer database schema than this instance. Update this instance to at least the backup version before restoring.'); + } + return errors; +} + +function parseNdjson(filePath) { + if (!fs.existsSync(filePath)) return []; + return fs + .readFileSync(filePath, 'utf8') + .split('\n') + .filter((l) => l.trim().length > 0) + .map((l) => JSON.parse(l)); +} + +// Re-insert the operator's account inside the restore transaction so they keep +// working credentials. If the backup already loaded an admin with the same +// email, overwrite that row's credentials with the current account's (current +// creds win); otherwise insert the snapshot with a fresh id. +async function reinjectCurrentAdmin(trx, currentAdmin) { + if (!currentAdmin) return; + const existing = await trx('admin_users').whereRaw('lower(email) = lower(?)', [currentAdmin.email]).first(); + if (existing) { + await trx('admin_users').where({ id: existing.id }).update({ + password_hash: currentAdmin.password_hash, + is_active: currentAdmin.is_active, + must_change_password: currentAdmin.must_change_password, + }); + } else { + const row = { ...currentAdmin }; + delete row.id; // let the engine assign a fresh id to avoid collision + await trx('admin_users').insert(row); + } +} + +// Whole-DB replace in one transaction with FK enforcement suspended (pg: +// session_replication_role=replica on the trx connection, reset before commit; +// sqlite: defer_foreign_keys so checks run at commit). knex_migrations is never +// in the data set, so the target's schema/migration state is left intact. +async function replaceAllTables(tables, dataDir, currentAdmin) { + await db.transaction(async (trx) => { + if (isPostgres()) await trx.raw("SET session_replication_role = 'replica'"); + else await trx.raw('PRAGMA defer_foreign_keys = ON'); + + for (const table of tables) { + await trx(table).del(); + } + for (const table of tables) { + const rows = parseNdjson(path.join(dataDir, `${table}.ndjson`)); + if (rows.length) await trx.batchInsert(table, rows, 100); + } + + await reinjectCurrentAdmin(trx, currentAdmin); + + // Reset the pg session flag BEFORE the connection returns to the pool. + if (isPostgres()) await trx.raw("SET session_replication_role = 'origin'"); + }); +} + +// Copy the archive's files/ tree into storage, overwriting existing files. +async function restoreFiles(stagingDir) { + const src = path.join(stagingDir, 'files'); + if (!fs.existsSync(src)) return 0; + const storageRoot = getStoragePath(); + let count = 0; + async function walk(rel) { + const abs = path.join(src, rel); + for (const entry of await fsp.readdir(abs, { withFileTypes: true })) { + const childRel = path.join(rel, entry.name); + if (entry.isDirectory()) { + await walk(childRel); + } else if (entry.isFile()) { + const dest = path.join(storageRoot, childRel); + await fsp.mkdir(path.dirname(dest), { recursive: true }); + await fsp.copyFile(path.join(src, childRel), dest); + count += 1; + } + } + } + await walk(''); + return count; +} + +// Does the restored data reference an external-media library? If so the caller +// shows a banner telling the admin to (re)configure the external-media mount on +// this instance — those files are NOT in the backup by design. +async function detectExternalMedia() { + try { + if (await hasColumnCached('events', 'external_path')) { + const row = await db('events').whereNotNull('external_path').first(); + if (row) return true; + } + if (await hasColumnCached('photos', 'external_relpath')) { + const row = await db('photos').whereNotNull('external_relpath').first(); + if (row) return true; + } + } catch (_) { + // Best-effort — a detection miss is not worth failing the restore. + } + return false; +} + +/** + * Restore a .picpeak onto this instance. + * @param {Object} opts + * @param {string} opts.picpeakPath path to the uploaded/staged .picpeak + * @param {number} [opts.currentAdminId] admin to preserve across the wipe + * @returns {Promise<{restored:boolean, tables:number, filesRestored:number, usesExternalMedia:boolean, manifest:object}>} + */ +async function importFromPicpeak({ picpeakPath, currentAdminId }) { + const manifest = await readManifestFromZip(picpeakPath); + const blockers = await validateManifest(manifest); + if (blockers.length) { + const err = new Error(blockers[0]); + err.statusCode = 400; + err.validation = blockers; + throw err; + } + + const currentAdmin = currentAdminId + ? await db('admin_users').where({ id: currentAdminId }).first() + : null; + + const staging = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-import-')); + try { + const zip = new StreamZip.async({ file: picpeakPath }); + try { + await zip.extract(null, staging); + } finally { + await zip.close(); + } + + const dataDir = path.join(staging, 'data'); + const tables = Object.keys(manifest.tables || {}); + + await replaceAllTables(tables, dataDir, currentAdmin); + const filesRestored = await restoreFiles(staging); + const usesExternalMedia = await detectExternalMedia(); + + logger.info( + `[picpeak-import] restored ${tables.length} tables, ${filesRestored} files (externalMedia=${usesExternalMedia})` + ); + return { restored: true, tables: tables.length, filesRestored, usesExternalMedia, manifest }; + } finally { + await fsp.rm(staging, { recursive: true, force: true }).catch(() => {}); + } +} + +module.exports = { + importFromPicpeak, + readManifestFromZip, + validateManifest, +};