/** * Unit tests for backupService. * Covers BACKUP-031 to BACKUP-060. */ import type { MaintenanceRepository } from '../../../src/db/repositories/MaintenanceRepository'; import { formatSize, parseIntField, parseAutoBackupBody, isValidBackupFilename, checkRateLimit, createBackup, deleteBackup, restoreFromZip, restoreBackup, BACKUP_RATE_WINDOW, backupFileExists, listBackups, sendBackupToResponse, } from '../../../src/nest/backup/backup.impl'; import { SqliteDatabaseBackup } from '../../../src/nest/backup/sqlite-database-backup'; import type { DatabaseLifecycle } from '../../../src/nest/database/database-lifecycle.service'; import type { StorageService } from '../../../src/nest/storage/storage.service'; import path from 'node:path'; import { describe, it, expect, vi, beforeEach, onTestFinished } from 'vitest'; // --------------------------------------------------------------------------- // Hoisted mocks — must be defined before any vi.mock() calls // --------------------------------------------------------------------------- const fsMock = vi.hoisted(() => ({ existsSync: vi.fn(), mkdirSync: vi.fn(), createWriteStream: vi.fn(), unlinkSync: vi.fn(), statSync: vi.fn(), readdirSync: vi.fn(), createReadStream: vi.fn(), rmSync: vi.fn(), copyFileSync: vi.fn(), renameSync: vi.fn(), cpSync: vi.fn(), // Identity by default: when uploadsDir is a plain directory, realpathSync // returns it unchanged. Tests that exercise the symlink case override this. realpathSync: vi.fn((p: string) => p), })); const archiverInstanceMock = vi.hoisted(() => ({ pipe: vi.fn(), file: vi.fn(), directory: vi.fn(), glob: vi.fn(), finalize: vi.fn(), on: vi.fn(), })); const archiverMock = vi.hoisted(() => vi.fn()); const unzipperMock = vi.hoisted(() => ({ Extract: vi.fn(), // Central-directory reader used for the pre-extract zip-bomb size check. // Default to an empty archive so existing restore tests proceed to Extract. Open: { file: vi.fn().mockResolvedValue({ files: [] }) }, })); const dbMock = vi.hoisted(() => ({ db: { exec: vi.fn(), prepare: vi.fn(), }, closeDb: vi.fn(), reinitialize: vi.fn(), getPlaceWithTags: vi.fn(), canAccessTrip: vi.fn(), isOwner: vi.fn(), })); // The database port's SQLite implementation is built for real below with this // stand-in for the MaintenanceRepository Nest injects into it: the WAL // checkpoint and the VACUUM INTO snapshot are what a backup and a restore ask // of it. The real statements are MaintenanceRepository.test.ts's coverage. const maintenanceRepoMock = vi.hoisted(() => ({ walCheckpoint: vi.fn().mockResolvedValue(undefined), vacuumInto: vi.fn().mockResolvedValue(undefined), })); const logMock = vi.hoisted(() => ({ logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() })); vi.mock('../../../src/db/database', () => dbMock); vi.mock('../../../src/nest/audit/audit-log.logger', () => logMock); vi.mock('fs', () => ({ default: fsMock, ...fsMock })); vi.mock('archiver', () => ({ default: archiverMock })); vi.mock('unzipper', () => ({ default: unzipperMock })); // --------------------------------------------------------------------------- // The database port. The real SqliteDatabaseBackup over a lifecycle stand-in: // closing and reopening go to the mocked db/database module (so the closeDb/ // reinitialize assertions below keep pinning the swap), and the database file // is whatever `liveDb.file` says, data/travel.db unless a case moves it the // way TREK_DB_FILE does. // --------------------------------------------------------------------------- const DEFAULT_LIVE_DB = path.join(__dirname, '../../../data/travel.db'); const liveDb = { file: DEFAULT_LIVE_DB }; beforeEach(() => { liveDb.file = DEFAULT_LIVE_DB; }); const lifecycleStub = { get file() { return liveDb.file; }, close: () => dbMock.closeDb(), reopen: async () => { await dbMock.reinitialize(); }, } as unknown as DatabaseLifecycle; function deps(storage: StorageService) { return { storage, database: new SqliteDatabaseBackup(lifecycleStub, maintenanceRepoMock as unknown as MaintenanceRepository), }; } // --------------------------------------------------------------------------- // Storage stub — backup.impl functions receive StorageService as a parameter // (BackupService injects it and forwards). This file mocks fs wholesale, so a // real LocalDriver would see the mocked fs: use plain stub objects instead. // --------------------------------------------------------------------------- function stubStorage(overrides: Record = {}): StorageService { return { // async generator; tests override with listOf(...) entries list: vi.fn(async function* () {}), stat: vi.fn(async () => null), exists: vi.fn(async () => false), delete: vi.fn(async () => {}), put: vi.fn(async () => {}), getStream: vi.fn(), sendToResponse: vi.fn(async () => {}), withLocalFile: vi.fn(async (_c: string, _k: string, fn: (p: string) => Promise) => fn('/stub/local/path')), // Default: every object has a local path (the zero-copy default-install branch). getLocalPathOrNull: vi.fn(async () => '/stub/local/path'), spoolDirFor: vi.fn(() => '/stub/spool'), tempDir: vi.fn(() => '/stub/tmp'), health: vi.fn(() => ({ replicaFailures: [] })), reloadConfig: vi.fn(), ...overrides, } as unknown as StorageService; } const listOf = (entries: Array<{ key: string; size?: number; mtimeMs?: number }>) => vi.fn(async function* () { for (const e of entries) yield { size: 0, mtimeMs: 0, ...e }; }); // --------------------------------------------------------------------------- // formatSize // --------------------------------------------------------------------------- describe('BACKUP-031 formatSize', () => { it('formats bytes < 1024 as B', () => { expect(formatSize(500)).toBe('500 B'); }); it('formats bytes in KB range', () => { expect(formatSize(1024)).toBe('1.0 KB'); expect(formatSize(2048)).toBe('2.0 KB'); }); it('formats bytes in MB range', () => { expect(formatSize(1024 * 1024)).toBe('1.0 MB'); expect(formatSize(1.5 * 1024 * 1024)).toBe('1.5 MB'); }); it('boundary: exactly 1024 bytes is 1.0 KB', () => { expect(formatSize(1023)).toBe('1023 B'); expect(formatSize(1024)).toBe('1.0 KB'); }); }); // --------------------------------------------------------------------------- // parseIntField // --------------------------------------------------------------------------- describe('BACKUP-032 parseIntField', () => { it('returns numeric value as-is when finite', () => { expect(parseIntField(5, 99)).toBe(5); }); it('floors float numbers', () => { expect(parseIntField(7.9, 0)).toBe(7); }); it('parses numeric strings', () => { expect(parseIntField('12', 0)).toBe(12); }); it('returns fallback for non-numeric string', () => { expect(parseIntField('abc', 3)).toBe(3); }); it('returns fallback for null', () => { expect(parseIntField(null, 7)).toBe(7); }); it('returns fallback for undefined', () => { expect(parseIntField(undefined, 7)).toBe(7); }); it('returns fallback for Infinity', () => { expect(parseIntField(Infinity, 5)).toBe(5); }); it('returns fallback for empty string', () => { expect(parseIntField('', 4)).toBe(4); }); }); // --------------------------------------------------------------------------- // parseAutoBackupBody // --------------------------------------------------------------------------- describe('BACKUP-033 parseAutoBackupBody', () => { it('parses all valid fields', () => { const result = parseAutoBackupBody({ enabled: true, interval: 'weekly', keep_days: 14, hour: 6, day_of_week: 5, day_of_month: 15, }); expect(result).toEqual({ enabled: true, interval: 'weekly', keep_days: 14, hour: 6, day_of_week: 5, day_of_month: 15, }); }); it('defaults to daily when interval is invalid', () => { const result = parseAutoBackupBody({ interval: 'not-valid' }); expect(result.interval).toBe('daily'); }); it('clamps hour to 0-23', () => { expect(parseAutoBackupBody({ hour: 999 }).hour).toBe(23); expect(parseAutoBackupBody({ hour: -1 }).hour).toBe(0); }); it('clamps day_of_week to 0-6', () => { expect(parseAutoBackupBody({ day_of_week: 10 }).day_of_week).toBe(6); expect(parseAutoBackupBody({ day_of_week: -1 }).day_of_week).toBe(0); }); it('clamps day_of_month to 1-28', () => { expect(parseAutoBackupBody({ day_of_month: 99 }).day_of_month).toBe(28); expect(parseAutoBackupBody({ day_of_month: 0 }).day_of_month).toBe(1); }); it('treats enabled = "true" string as true', () => { expect(parseAutoBackupBody({ enabled: 'true' }).enabled).toBe(true); }); it('treats enabled = 1 as true', () => { expect(parseAutoBackupBody({ enabled: 1 }).enabled).toBe(true); }); it('treats enabled = false as false', () => { expect(parseAutoBackupBody({ enabled: false }).enabled).toBe(false); }); }); // --------------------------------------------------------------------------- // isValidBackupFilename // --------------------------------------------------------------------------- describe('BACKUP-034 isValidBackupFilename', () => { it('accepts valid backup filename', () => { expect(isValidBackupFilename('backup-2026-04-06T12-00-00.zip')).toBe(true); }); it('rejects path traversal', () => { expect(isValidBackupFilename('../../etc/passwd')).toBe(false); }); it('rejects filename without .zip extension', () => { expect(isValidBackupFilename('backup-2026-04-06T12-00-00.tar.gz')).toBe(false); }); it('rejects filename with spaces', () => { expect(isValidBackupFilename('backup 2026.zip')).toBe(false); }); it('rejects empty string', () => { expect(isValidBackupFilename('')).toBe(false); }); it('accepts filename with hyphens and underscores', () => { expect(isValidBackupFilename('backup-my_trek-2026.zip')).toBe(true); }); it('accepts auto-backup filename', () => { expect(isValidBackupFilename('auto-backup-2026-04-21T00-00-00.zip')).toBe(true); }); it('rejects auto-backup with empty body', () => { expect(isValidBackupFilename('auto-backup-.zip')).toBe(false); }); it('rejects backup with empty body', () => { expect(isValidBackupFilename('backup-.zip')).toBe(false); }); it('rejects arbitrary auto- prefix that is not auto-backup', () => { expect(isValidBackupFilename('auto-notbackup-2026.zip')).toBe(false); }); }); // --------------------------------------------------------------------------- // checkRateLimit // --------------------------------------------------------------------------- describe('BACKUP-035 checkRateLimit', () => { // Each test uses a unique key to avoid state pollution between tests it('allows first request', () => { expect(checkRateLimit('test-key-1', 3, BACKUP_RATE_WINDOW)).toBe(true); }); it('allows requests up to maxAttempts', () => { const key = 'test-key-2'; expect(checkRateLimit(key, 2, BACKUP_RATE_WINDOW)).toBe(true); expect(checkRateLimit(key, 2, BACKUP_RATE_WINDOW)).toBe(true); }); it('blocks request exceeding maxAttempts within window', () => { const key = 'test-key-3'; checkRateLimit(key, 2, BACKUP_RATE_WINDOW); checkRateLimit(key, 2, BACKUP_RATE_WINDOW); expect(checkRateLimit(key, 2, BACKUP_RATE_WINDOW)).toBe(false); }); it('resets counter after window expires', () => { vi.useFakeTimers(); const key = 'test-key-4'; const windowMs = 100; checkRateLimit(key, 1, windowMs); checkRateLimit(key, 1, windowMs); // this one is blocked vi.advanceTimersByTime(200); // After window expires, should be allowed again expect(checkRateLimit(key, 1, windowMs)).toBe(true); vi.useRealTimers(); }); }); // --------------------------------------------------------------------------- // createBackup // --------------------------------------------------------------------------- describe('BACKUP-036 createBackup', () => { beforeEach(() => { vi.clearAllMocks(); }); /** Wires the write-stream + archiver mocks so finalize() resolves the build. */ function setupArchiveSuccess() { const writableEvents: Record = {}; fsMock.createWriteStream.mockReturnValue({ on: vi.fn((event: string, cb: Function) => { writableEvents[event] = cb; }), } as never); archiverInstanceMock.on.mockImplementation(() => {}); archiverInstanceMock.pipe.mockReturnValue(undefined); archiverInstanceMock.finalize.mockImplementation(() => { writableEvents['close']?.(); }); archiverMock.mockReturnValue(archiverInstanceMock); return writableEvents; } /** storage.stat stub for the post-put BackupInfo read. */ const statOf = (size: number, mtimeMs = Date.parse('2026-04-06T12:00:00Z')) => vi.fn(async (_c: string, key: string) => ({ key, size, mtimeMs })); /** storage.list stub keyed by category. */ const listByCategory = (map: Record>) => vi.fn((category: string) => (async function* () { for (const e of map[category] ?? []) yield { size: 1, mtimeMs: 0, ...e }; })(), ); it('BACKUP-036a — happy path: builds in the backups spool, commits via put, returns BackupInfo', async () => { // No travel.db, no enc key, no plugin roots. fsMock.existsSync.mockReturnValue(false); setupArchiveSuccess(); const storage = stubStorage({ stat: statOf(2048) }); const result = await createBackup(deps(storage)); expect(result.filename).toMatch(/^backup-.*\.zip$/); expect(result.size).toBe(2048); expect(result.sizeText).toBe('2.0 KB'); expect(result.created_at).toBe('2026-04-06T12:00:00.000Z'); expect(archiverMock).toHaveBeenCalledWith('zip', { zlib: { level: 9 } }); expect(archiverInstanceMock.pipe).toHaveBeenCalled(); expect(archiverInstanceMock.finalize).toHaveBeenCalled(); // The zip is built in the backups backend's spool, then committed via put. expect(fsMock.createWriteStream).toHaveBeenCalledWith(expect.stringContaining('/stub/spool/zip-build-backup-')); expect(storage.put).toHaveBeenCalledWith('backups', result.filename, { tmpPath: expect.stringContaining('/stub/spool/zip-build-backup-'), }); expect(storage.stat).toHaveBeenCalledWith('backups', result.filename); }); it('BACKUP-036j — archives category objects under the legacy uploads/ entry names via the local fast path', async () => { fsMock.existsSync.mockReturnValue(false); setupArchiveSuccess(); const storage = stubStorage({ stat: statOf(1024), list: listByCategory({ files: [{ key: 'a.pdf' }], journey: [{ key: 'thumbs/x.jpg' }], }), }); await createBackup(deps(storage)); expect(storage.getLocalPathOrNull).toHaveBeenCalledWith('files', 'a.pdf'); expect(storage.getLocalPathOrNull).toHaveBeenCalledWith('journey', 'thumbs/x.jpg'); expect(archiverInstanceMock.file).toHaveBeenCalledWith('/stub/local/path', { name: 'uploads/files/a.pdf', store: true, }); expect(archiverInstanceMock.file).toHaveBeenCalledWith('/stub/local/path', { name: 'uploads/journey/thumbs/x.jpg', store: true, }); // The local fast path never touches getStream — this is the zero-copy default-install branch. expect(storage.getStream).not.toHaveBeenCalled(); }); it('BACKUP-036k — a remote-driver object (no local path) is streamed into per-backup staging and archived from there', async () => { fsMock.existsSync.mockReturnValue(false); const writableEvents = setupArchiveSuccess(); const { PassThrough } = await import('node:stream'); const remoteStream = new PassThrough(); remoteStream.end(Buffer.from('remote upload bytes')); fsMock.createWriteStream.mockImplementation((p: string) => { // The zip destination stream still needs the writableEvents wiring // setupArchiveSuccess set up (finalize() resolves the build via 'close'); // staged files use a minimal fake that just resolves the pipeline. if (String(p).includes('zip-build-')) { return { on: vi.fn((event: string, cb: Function) => { writableEvents[event] = cb; }), }; } // pipeline() (real, from node:stream/promises) needs a real Writable — // a PassThrough gives it one without touching the real filesystem. return new PassThrough(); }); const storage = stubStorage({ stat: statOf(2048), list: listByCategory({ files: [{ key: 'remote.pdf' }] }), getLocalPathOrNull: vi.fn(async () => null), getStream: vi.fn(async (category: string, key: string) => { expect(category).toBe('files'); expect(key).toBe('remote.pdf'); return { stream: remoteStream, stat: { key, size: 20, mtimeMs: 0 } }; }), }); await createBackup(deps(storage)); expect(storage.getLocalPathOrNull).toHaveBeenCalledWith('files', 'remote.pdf'); expect(storage.getStream).toHaveBeenCalledWith('files', 'remote.pdf'); const fileCall = archiverInstanceMock.file.mock.calls.find( (c: unknown[]) => (c[1] as { name?: string })?.name === 'uploads/files/remote.pdf', ); expect(fileCall).toBeDefined(); const stagedPath = fileCall![0] as string; expect(stagedPath).toContain('/stub/spool/staging-backup-'); expect(stagedPath).toContain('files/remote.pdf'); // The staging dir is removed alongside zipSpool/dbSnap in the existing finally. expect(fsMock.rmSync).toHaveBeenCalledWith(expect.stringContaining('/stub/spool/staging-backup-'), { recursive: true, force: true, }); }); it('BACKUP-036l — a vanished remote object (archiver "warning") fails the backup instead of silently dropping it', async () => { fsMock.existsSync.mockReturnValue(false); const archiveEvents: Record = {}; fsMock.createWriteStream.mockReturnValue({ on: vi.fn() } as never); archiverInstanceMock.on.mockImplementation((event: string, cb: Function) => { archiveEvents[event] = cb; }); archiverInstanceMock.pipe.mockReturnValue(undefined); archiverInstanceMock.finalize.mockImplementation(() => { // archiver emits 'warning' (not 'error') for entries it couldn't stat/read // (e.g. ENOENT) — without archive.on('warning', reject) this resolves clean. archiveEvents['warning']?.(new Error('ENOENT: no such file, stat entry')); }); archiverMock.mockReturnValue(archiverInstanceMock); const storage = stubStorage(); await expect(createBackup(deps(storage))).rejects.toThrow('ENOENT'); expect(storage.put).not.toHaveBeenCalled(); }); it('BACKUP-036m — a mirror replica failure surfaced via health never fails the request', async () => { // Mirror semantics: put succeeds against the primary; replica failures are // recorded on health(), not thrown. createBackup must still return its info. fsMock.existsSync.mockReturnValue(false); setupArchiveSuccess(); const storage = stubStorage({ stat: statOf(1024), health: vi.fn(() => ({ replicaFailures: [{ backend: 'nas-backups', key: 'backup-x.zip', error: 'EIO' }] })), }); const result = await createBackup(deps(storage)); expect(result.filename).toMatch(/^backup-.*\.zip$/); expect(storage.put).toHaveBeenCalledOnce(); }); it('BACKUP-036p — archives the plugin data + code trees when present, skipping dev-links', async () => { // Only the plugin roots exist (db/uploads absent → skipped). fsMock.existsSync.mockImplementation((p: string) => String(p).includes('plugins')); // Two plugin code dirs: 'notes' is real, 'devlink' resolves outside the root. // The plugin-data snapshot reads with { withFileTypes: true }; hand it Dirent-likes there. const dirent = (name: string) => ({ name, isDirectory: () => true }); fsMock.readdirSync.mockImplementation( (_p: string, opts?: { withFileTypes?: boolean }) => (opts?.withFileTypes ? [dirent('notes'), dirent('devlink')] : ['notes', 'devlink']) as never, ); fsMock.realpathSync.mockImplementation((p: string) => String(p).endsWith('devlink') ? '/somewhere/else/devlink' : p, ); fsMock.statSync.mockReturnValue({ isDirectory: () => true } as never); setupArchiveSuccess(); const storage = stubStorage({ stat: statOf(2048) }); await createBackup(deps(storage)); // the consistent snapshot of the data tree is archived under plugins-data/ expect(archiverInstanceMock.directory).toHaveBeenCalledWith( expect.stringContaining('plugins-snap'), 'plugins-data', ); // the real code dir is archived, the dev-link is skipped expect(archiverInstanceMock.directory).toHaveBeenCalledWith(expect.stringContaining('notes'), 'plugins-code/notes'); expect(archiverInstanceMock.directory).not.toHaveBeenCalledWith(expect.anything(), 'plugins-code/devlink'); // the snapshot staging lives in the backups spool expect(archiverInstanceMock.directory).toHaveBeenCalledWith( expect.stringContaining('/stub/spool/plugins-snap-backup-'), 'plugins-data', ); }); it('BACKUP-036b: a failed WAL checkpoint is logged and the backup still goes ahead (best effort)', async () => { fsMock.existsSync.mockReturnValue(false); setupArchiveSuccess(); maintenanceRepoMock.walCheckpoint.mockRejectedValueOnce(new Error('database is locked')); const storage = stubStorage({ stat: statOf(512) }); const result = await createBackup(deps(storage)); expect(result).toHaveProperty('filename'); expect(result.size).toBe(512); expect(logMock.logWarn).toHaveBeenCalledWith( expect.stringContaining('WAL checkpoint before the snapshot failed (database is locked)'), ); }); it('BACKUP-036t: a failure that is not an Error is still named in the log and the thrown error', async () => { fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('travel.db')); setupArchiveSuccess(); maintenanceRepoMock.walCheckpoint.mockRejectedValueOnce('SQLITE_BUSY'); maintenanceRepoMock.vacuumInto.mockRejectedValueOnce('SQLITE_FULL'); const storage = stubStorage({ stat: statOf(512) }); await expect(createBackup(deps(storage))).rejects.toThrow('Database snapshot failed: SQLITE_FULL'); expect(logMock.logWarn).toHaveBeenCalledWith(expect.stringContaining('(SQLITE_BUSY)')); expect(storage.put).not.toHaveBeenCalled(); }); it('BACKUP-036c — archiver error cleans up the spool staging, skips put and re-throws', async () => { fsMock.existsSync.mockReturnValue(false); const archiveEvents: Record = {}; fsMock.createWriteStream.mockReturnValue({ on: vi.fn() } as never); archiverInstanceMock.on.mockImplementation((event: string, cb: Function) => { archiveEvents[event] = cb; }); archiverInstanceMock.pipe.mockReturnValue(undefined); archiverInstanceMock.finalize.mockImplementation(() => { // Simulate archive error instead of success archiveEvents['error']?.(new Error('disk full')); }); archiverMock.mockReturnValue(archiverInstanceMock); const storage = stubStorage(); await expect(createBackup(deps(storage))).rejects.toThrow('disk full'); // Nothing is committed; the half-built spool file is removed in the finally. expect(storage.put).not.toHaveBeenCalled(); expect(fsMock.rmSync).toHaveBeenCalledWith(expect.stringContaining('zip-build-backup-'), { force: true }); }); it('BACKUP-036d: archives a VACUUM INTO snapshot of the database, never the live file', async () => { fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('travel.db')); setupArchiveSuccess(); const storage = stubStorage({ stat: statOf(1024) }); await createBackup(deps(storage)); expect(maintenanceRepoMock.walCheckpoint).toHaveBeenCalled(); expect(maintenanceRepoMock.vacuumInto).toHaveBeenCalledWith( expect.stringContaining('/stub/spool/travel-snap-backup-'), ); expect(archiverInstanceMock.file).toHaveBeenCalledWith(expect.stringContaining('/stub/spool/travel-snap-backup-'), { name: 'travel.db', }); expect(archiverInstanceMock.file).not.toHaveBeenCalledWith(DEFAULT_LIVE_DB, expect.anything()); // The scratch snapshot goes with the rest of the spool. expect(fsMock.rmSync).toHaveBeenCalledWith(expect.stringContaining('travel-snap-backup-'), { force: true }); }); it('BACKUP-036q: a failed VACUUM INTO fails the backup: no live file archived, nothing committed, said in the log', async () => { // This used to fall back to archiving the live WAL-mode file without a // word: the torn copy the snapshot exists to prevent, passed off as a // good backup. fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('travel.db')); setupArchiveSuccess(); maintenanceRepoMock.vacuumInto.mockRejectedValueOnce(new Error('database or disk is full')); const storage = stubStorage({ stat: statOf(1024) }); await expect(createBackup(deps(storage))).rejects.toThrow('Database snapshot failed: database or disk is full'); // Nothing was archived and nothing reached the backups store, so the // existing backups are exactly what they were. expect(archiverMock).not.toHaveBeenCalled(); expect(archiverInstanceMock.file).not.toHaveBeenCalled(); expect(storage.put).not.toHaveBeenCalled(); // A partial snapshot and the spool are cleaned up. expect(fsMock.rmSync).toHaveBeenCalledWith(expect.stringContaining('travel-snap-backup-'), { force: true }); expect(fsMock.rmSync).toHaveBeenCalledWith(expect.stringContaining('zip-build-backup-'), { force: true }); expect(logMock.logError).toHaveBeenCalledWith(expect.stringContaining('could not take a snapshot of the database')); expect(logMock.logError).toHaveBeenCalledWith(expect.stringContaining('No backup was written')); }); it('BACKUP-036r: backs up the database the connection runs on when TREK_DB_FILE moves it out of data/', async () => { // The old code looked for data/travel.db only: with the database elsewhere // the backup silently held no database at all. liveDb.file = path.join(path.sep, 'srv', 'trek', 'custom-name.db'); fsMock.existsSync.mockImplementation((p: string) => p === liveDb.file); setupArchiveSuccess(); const storage = stubStorage({ stat: statOf(1024) }); await createBackup(deps(storage)); expect(maintenanceRepoMock.vacuumInto).toHaveBeenCalledWith( expect.stringContaining('/stub/spool/travel-snap-backup-'), ); expect(archiverInstanceMock.file).toHaveBeenCalledWith(expect.stringContaining('/stub/spool/travel-snap-backup-'), { name: 'travel.db', }); }); it('BACKUP-036s: no database file to copy is said in the log, not passed over in silence', async () => { fsMock.existsSync.mockReturnValue(false); setupArchiveSuccess(); const storage = stubStorage({ stat: statOf(1024) }); await createBackup(deps(storage)); expect(maintenanceRepoMock.vacuumInto).not.toHaveBeenCalled(); expect(archiverInstanceMock.file).not.toHaveBeenCalledWith(expect.anything(), { name: 'travel.db' }); expect(logMock.logWarn).toHaveBeenCalledWith(expect.stringContaining('this backup holds no database')); }); it('BACKUP-036e — excludes the re-derivable photo caches nested under photos/', async () => { // In mode A the google/trek caches live inside the photos/ prefix, so the // category walk must skip them. (In mode B photos-google is a separate // category that is simply never enumerated — no extra branch needed.) fsMock.existsSync.mockReturnValue(false); setupArchiveSuccess(); const storage = stubStorage({ stat: statOf(1024), list: listByCategory({ photos: [{ key: 'flat.jpg' }, { key: 'google/g.jpg' }, { key: 'trek/t.bin' }], }), }); await createBackup(deps(storage)); expect(archiverInstanceMock.file).toHaveBeenCalledWith('/stub/local/path', { name: 'uploads/photos/flat.jpg', store: true, }); const names = archiverInstanceMock.file.mock.calls.map((c) => c[1]?.name as string); expect(names.some((n) => n?.includes('google/'))).toBe(false); expect(names.some((n) => n?.includes('trek/'))).toBe(false); // Only the archived categories are ever listed — the excludes are structural. expect(storage.getLocalPathOrNull).toHaveBeenCalledTimes(1); }); it('BACKUP-036h — only category prefixes are ever archived; backups/ and restore-* are structurally out (issue #1358)', async () => { // The uploads/** glob is gone: even when data and uploads map to the same // directory, enumeration only ever walks the six archived categories, so // prior backup zips and restore-* staging can never be swept into the zip. fsMock.existsSync.mockReturnValue(false); setupArchiveSuccess(); const storage = stubStorage({ stat: statOf(1024) }); await createBackup(deps(storage)); expect(archiverInstanceMock.glob).not.toHaveBeenCalled(); const listed = (storage.list as ReturnType).mock.calls.map((c) => c[0] as string); expect(listed.sort()).toEqual(['avatars', 'covers', 'files', 'journey', 'photos', 'places']); expect(listed).not.toContain('backups'); }); it('BACKUP-036f — bundles .encryption_key when present and ENCRYPTION_KEY env is unset', async () => { const prevEnvKey = process.env.ENCRYPTION_KEY; delete process.env.ENCRYPTION_KEY; try { fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('.encryption_key')); setupArchiveSuccess(); const storage = stubStorage({ stat: statOf(1024) }); await createBackup(deps(storage)); expect(archiverInstanceMock.file).toHaveBeenCalledWith(expect.stringContaining('.encryption_key'), { name: '.encryption_key', }); } finally { process.env.ENCRYPTION_KEY = prevEnvKey; } }); it('BACKUP-036g — does NOT bundle .encryption_key when ENCRYPTION_KEY env is set', async () => { // setup.ts sets process.env.ENCRYPTION_KEY, so the env is the source of truth. fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('.encryption_key')); setupArchiveSuccess(); const storage = stubStorage({ stat: statOf(1024) }); await createBackup(deps(storage)); expect(archiverInstanceMock.file).not.toHaveBeenCalledWith( expect.stringContaining('.encryption_key'), expect.anything(), ); }); it('BACKUP-036i: the auto-backup prefix names the zip and the scratch snapshot', async () => { // The scheduler passes 'auto-backup' so retention and the admin panel can // still tell scheduled archives apart by filename. fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('travel.db')); setupArchiveSuccess(); const storage = stubStorage({ stat: statOf(1024) }); const result = await createBackup(deps(storage), 'auto-backup'); expect(result.filename).toMatch(/^auto-backup-.*\.zip$/); expect(archiverInstanceMock.file).toHaveBeenCalledWith(expect.stringContaining('travel-snap-auto-backup-'), { name: 'travel.db', }); expect(storage.put).toHaveBeenCalledWith('backups', result.filename, { tmpPath: expect.stringContaining('zip-build-auto-backup-'), }); }); }); // --------------------------------------------------------------------------- // deleteBackup // --------------------------------------------------------------------------- describe('BACKUP-037 deleteBackup', () => { beforeEach(() => { vi.clearAllMocks(); }); it('BACKUP-037a — happy path: deletes through storage under the backups category', async () => { const storage = stubStorage(); await deleteBackup(storage, 'backup-2026-04-06T12-00-00.zip'); expect(storage.delete).toHaveBeenCalledOnce(); expect(storage.delete).toHaveBeenCalledWith('backups', 'backup-2026-04-06T12-00-00.zip'); }); it('BACKUP-037b — propagates a storage delete failure', async () => { // Note: storage.delete is idempotent on a MISSING object (route parity holds // because the controller pre-checks existence and 404s); this pins that a // real backend failure still surfaces. const storage = stubStorage({ delete: vi.fn(async () => { throw new Error('EIO: i/o error'); }), }); await expect(deleteBackup(storage, 'backup-missing.zip')).rejects.toThrow('EIO'); }); }); // --------------------------------------------------------------------------- // restoreFromZip // --------------------------------------------------------------------------- describe('BACKUP-038 restoreFromZip', () => { beforeEach(() => { vi.clearAllMocks(); }); it('BACKUP-038a — returns error when travel.db not found in zip', async () => { // Simulate successful extraction but missing travel.db const fakeReadStream = { pipe: vi.fn() }; const fakeExtractStream = { promise: vi.fn().mockResolvedValue(undefined) }; fsMock.createReadStream.mockReturnValue(fakeReadStream); fakeReadStream.pipe.mockReturnValue(fakeExtractStream); unzipperMock.Extract.mockReturnValue(fakeExtractStream); // extractedDb does not exist fsMock.existsSync.mockImplementation((p: string) => { if (String(p).endsWith('travel.db')) return false; return true; // extractDir exists for cleanup }); fsMock.rmSync.mockReturnValue(undefined); const result = await restoreFromZip(deps(stubStorage()), '/data/tmp/upload.zip'); expect(result.success).toBe(false); expect(result.error).toMatch(/travel\.db not found/i); expect(result.status).toBe(400); }); it('BACKUP-038b — rejects a zip bomb whose declared decompressed size exceeds the cap', async () => { unzipperMock.Open.file.mockResolvedValueOnce({ files: [{ uncompressedSize: 6 * 1024 * 1024 * 1024 }], // 6 GB > 5 GB cap }); const result = await restoreFromZip(deps(stubStorage()), '/data/tmp/bomb.zip'); expect(result.success).toBe(false); expect(result.status).toBe(400); expect(result.error).toMatch(/decompressed size/i); expect(unzipperMock.Extract).not.toHaveBeenCalled(); // bailed before extracting }); }); // --------------------------------------------------------------------------- // restoreFromZip — the per-entry extraction loop // // This is the part of the restore that decides what an attacker-supplied archive is // allowed to write, and it had no tests of its own: the zip-slip refusal, the running // decompressed-byte cap (the declared size in the central directory is // attacker-controlled, so the real guard counts bytes as they land) and the failure // path that leaves the process without a reopened DB. // --------------------------------------------------------------------------- /** A minimal unzipper entry: emits `chunks` when its stream is piped. */ function zipEntry(entryPath: string, chunks: Buffer[] = [Buffer.alloc(8)], type = 'File') { return { path: entryPath, type, uncompressedSize: chunks.reduce((n, c) => n + c.length, 0), stream() { const handlers: Record void>> = {}; return { on(event: string, cb: (arg?: unknown) => void) { (handlers[event] ??= []).push(cb); return this; }, destroy: vi.fn(), // The production code pipes AFTER registering handlers, so emitting here is // the moment every listener is in place. pipe(out: { emit(event: string): void }) { for (const chunk of chunks) for (const cb of handlers.data ?? []) cb(chunk); out.emit('finish'); }, }; }, }; } /** A write stream that only has to carry 'finish' back to the awaiting promise. */ function fakeWriteStream() { const handlers: Record void>> = {}; return { on(event: string, cb: () => void) { (handlers[event] ??= []).push(cb); return this; }, destroy: vi.fn(), emit(event: string) { for (const cb of handlers[event] ?? []) cb(); }, }; } describe('BACKUP-061 restoreFromZip extraction', () => { beforeEach(() => { vi.clearAllMocks(); fsMock.createWriteStream.mockImplementation(() => fakeWriteStream()); fsMock.existsSync.mockReturnValue(true); fsMock.statSync.mockReturnValue({ size: 10 }); }); it('BACKUP-061a — refuses an entry whose path escapes the archive root (zip-slip)', async () => { unzipperMock.Open.file.mockResolvedValueOnce({ files: [zipEntry('../../etc/passwd')] }); const result = await restoreFromZip(deps(stubStorage()), '/data/tmp/slip.zip'); expect(result.success).toBe(false); expect(result.status).toBe(400); expect(result.error).toMatch(/escapes the archive root/i); // Nothing of that archive is left behind, and no byte of it was written. expect(fsMock.rmSync).toHaveBeenCalledWith(expect.stringContaining('restore-'), { recursive: true, force: true }); expect(fsMock.createWriteStream).not.toHaveBeenCalled(); }); // The `path.isAbsolute(rel)` half of the guard is only reachable where drive letters // exist: on POSIX, path.join always keeps an entry under extractDir, so a leading // slash is normalised away and only the `..` check above can fire. Asserting it // unconditionally passed locally on Windows and failed on the Linux CI runner, which // is the wrong way round for a security test to be discovered. it.runIf(process.platform === 'win32')( 'BACKUP-061b — a drive-letter entry path is refused the same way', async () => { unzipperMock.Open.file.mockResolvedValueOnce({ files: [zipEntry('C:/Windows/system32/evil.dll')] }); const result = await restoreFromZip(deps(stubStorage()), '/data/tmp/abs.zip'); expect(result.success).toBe(false); expect(result.status).toBe(400); expect(fsMock.createWriteStream).not.toHaveBeenCalled(); }, ); it('BACKUP-061c — directory entries are skipped rather than written', async () => { unzipperMock.Open.file.mockResolvedValueOnce({ files: [zipEntry('uploads/', [], 'Directory'), zipEntry('travel.db')], }); // What happens after extraction is BACKUP-042..045's business; this case only // cares that the directory entry never reached the writer. await restoreFromZip(deps(stubStorage()), '/data/tmp/dirs.zip').catch(() => undefined); expect(fsMock.createWriteStream).toHaveBeenCalledTimes(1); }); it('BACKUP-061d — stops mid-stream once the ACTUAL decompressed bytes cross the cap', async () => { // The declared size is a lie: the central directory claims 8 bytes while the // stream delivers well past the 5 GB cap. Only the running total catches this. const lying = zipEntry('travel.db', [Buffer.alloc(3 * 1024 * 1024 * 1024), Buffer.alloc(3 * 1024 * 1024 * 1024)]); lying.uncompressedSize = 8; unzipperMock.Open.file.mockResolvedValueOnce({ files: [lying] }); const result = await restoreFromZip(deps(stubStorage()), '/data/tmp/liar.zip'); expect(result.success).toBe(false); expect(result.status).toBe(400); expect(result.error).toMatch(/decompressed size/i); expect(fsMock.rmSync).toHaveBeenCalledWith(expect.stringContaining('restore-'), { recursive: true, force: true }); }); it('BACKUP-061e — a stream error is not swallowed as a size refusal', async () => { const entry = zipEntry('travel.db'); entry.stream = () => { const handlers: Record void>> = {}; return { on(event: string, cb: (arg?: unknown) => void) { (handlers[event] ??= []).push(cb); return this; }, destroy: vi.fn(), pipe() { for (const cb of handlers.error ?? []) cb(new Error('corrupt deflate stream')); }, }; }; unzipperMock.Open.file.mockResolvedValueOnce({ files: [entry] }); // A corrupt stream is NOT dressed up as a 400 "too large": it leaves the function // as a throw, which is what makes the controller answer 500 rather than telling the // admin their perfectly-sized backup is over the cap. await expect(restoreFromZip(deps(stubStorage()), '/data/tmp/corrupt.zip')).rejects.toThrow( 'corrupt deflate stream', ); expect(fsMock.rmSync).toHaveBeenCalledWith(expect.stringContaining('restore-'), { recursive: true, force: true }); }); it('BACKUP-061f — a reopen failure after the swap reports "restart required", not success', async () => { unzipperMock.Open.file.mockResolvedValueOnce({ files: [zipEntry('travel.db')] }); const restored = { prepare: vi .fn() .mockReturnValueOnce({ get: vi.fn().mockReturnValue({ integrity_check: 'ok' }) }) .mockReturnValueOnce({ all: vi .fn() .mockReturnValue([ { name: 'users' }, { name: 'trips' }, { name: 'trip_members' }, { name: 'places' }, { name: 'days' }, ]), }), pragma: vi.fn(), close: vi.fn(), }; DatabaseMock.mockImplementation(function () { return restored; }); fsMock.existsSync.mockImplementation((path: string) => !String(path).includes('uploads')); dbMock.reinitialize.mockImplementationOnce(() => { throw new Error('database is locked'); }); const storage = stubStorage(); const result = await restoreFromZip(deps(storage), '/data/tmp/ok.zip'); // The files already landed, so this is neither a success nor a plain failure: the // admin has to restart, and the message has to say so. expect(result.success).toBe(false); expect(result.status).toBe(500); expect(result.error).toMatch(/restart the server/i); // A failed reopen leaves no live DB handle for the registry to read: reload // (and with it any rehydration) is skipped rather than reading through a // torn/unavailable connection — already reported as "restart required". expect(storage.reloadConfig).not.toHaveBeenCalled(); expect(storage.put).not.toHaveBeenCalled(); // Plugin-tree staging is pure filesystem (plugin-backup.ts), has no DB // dependency, and is the archive's ONLY copy of that data — it must still // run even when the DB reopen failed, or extractDir's cleanup right after // would delete it with no recovery path. fs.cpSync only fires from inside // stageExtractedPluginTrees in this flow, so its call is the proof. expect(fsMock.cpSync).toHaveBeenCalled(); }); }); // --------------------------------------------------------------------------- // better-sqlite3 mock — hoisted by Vitest regardless of file position // --------------------------------------------------------------------------- const DatabaseMock = vi.hoisted(() => vi.fn()); vi.mock('better-sqlite3', () => ({ default: DatabaseMock })); // BACKUP-039 (backupFilePath) retired: every consumer addresses backups as // (category, name) through StorageService now — no absolute path leaves the impl. // --------------------------------------------------------------------------- // backupFileExists // --------------------------------------------------------------------------- describe('BACKUP-040 backupFileExists', () => { beforeEach(() => { vi.clearAllMocks(); }); it('BACKUP-040a — returns true when storage.exists resolves true', async () => { const storage = stubStorage({ exists: vi.fn(async () => true) }); await expect(backupFileExists(storage, 'backup-2026-01-01T00-00-00.zip')).resolves.toBe(true); expect(storage.exists).toHaveBeenCalledWith('backups', 'backup-2026-01-01T00-00-00.zip'); }); it('BACKUP-040b — returns false when storage.exists resolves false', async () => { const storage = stubStorage({ exists: vi.fn(async () => false) }); await expect(backupFileExists(storage, 'backup-missing.zip')).resolves.toBe(false); }); }); // --------------------------------------------------------------------------- // sendBackupToResponse // --------------------------------------------------------------------------- describe('BACKUP-062 sendBackupToResponse', () => { beforeEach(() => { vi.clearAllMocks(); }); it('BACKUP-062a — serves through storage with the res.download attachment header', async () => { const storage = stubStorage(); const res = { setHeader: vi.fn() } as unknown as import('express').Response; await sendBackupToResponse(storage, 'backup-2026-01-01T00-00-00.zip', res); expect(storage.sendToResponse).toHaveBeenCalledOnce(); expect(storage.sendToResponse).toHaveBeenCalledWith('backups', 'backup-2026-01-01T00-00-00.zip', res, { disposition: 'attachment; filename="backup-2026-01-01T00-00-00.zip"', }); }); it('BACKUP-062b — propagates a storage failure (the controller owns the miss contract)', async () => { const storage = stubStorage({ sendToResponse: vi.fn(async () => { throw new Error('missing'); }), }); const res = {} as import('express').Response; await expect(sendBackupToResponse(storage, 'backup-x.zip', res)).rejects.toThrow('missing'); }); }); // --------------------------------------------------------------------------- // listBackups // --------------------------------------------------------------------------- describe('BACKUP-041 listBackups', () => { beforeEach(() => { vi.clearAllMocks(); }); it('BACKUP-041a — returns empty array when the category has no objects', async () => { const storage = stubStorage(); await expect(listBackups(storage)).resolves.toEqual([]); expect(storage.list).toHaveBeenCalledWith('backups'); }); it('BACKUP-041b — returns BackupInfo for each .zip object', async () => { const storage = stubStorage({ list: listOf([ { key: 'backup-2026-01-01T00-00-00.zip', size: 1024, mtimeMs: Date.parse('2026-01-01T00:00:00Z') }, ]), }); const result = await listBackups(storage); expect(result).toHaveLength(1); expect(result[0].filename).toBe('backup-2026-01-01T00-00-00.zip'); expect(result[0].size).toBe(1024); expect(result[0].sizeText).toBe('1.0 KB'); expect(result[0].created_at).toBe('2026-01-01T00:00:00.000Z'); }); it('BACKUP-041c — sorts results newest-first by mtime', async () => { const storage = stubStorage({ list: listOf([ { key: 'backup-2026-01-01T00-00-00.zip', size: 512, mtimeMs: Date.parse('2026-01-01T00:00:00Z') }, { key: 'backup-2026-06-01T00-00-00.zip', size: 2048, mtimeMs: Date.parse('2026-06-01T00:00:00Z') }, ]), }); const result = await listBackups(storage); expect(result).toHaveLength(2); expect(result[0].filename).toBe('backup-2026-06-01T00-00-00.zip'); expect(result[1].filename).toBe('backup-2026-01-01T00-00-00.zip'); }); it('BACKUP-041d — filters out non-.zip objects', async () => { const storage = stubStorage({ list: listOf([ { key: 'backup-2026-01-01T00-00-00.zip', size: 1024, mtimeMs: Date.parse('2026-01-01T00:00:00Z') }, { key: 'README.txt' }, { key: 'backup-partial.tar.gz' }, ]), }); const result = await listBackups(storage); expect(result).toHaveLength(1); expect(result[0].filename).toBe('backup-2026-01-01T00-00-00.zip'); }); it('BACKUP-041e — skips nested keys (storage.list recurses; the legacy readdir was single-level)', async () => { // A restore-* staging tree only sits under the backups root when an install // maps data and uploads to the same directory — it must not surface. const storage = stubStorage({ list: listOf([ { key: 'restore-123/uploads/x.zip' }, { key: 'backup-2026-01-01T00-00-00.zip', size: 1024, mtimeMs: Date.parse('2026-01-01T00:00:00Z') }, ]), }); const result = await listBackups(storage); expect(result).toHaveLength(1); expect(result[0].filename).toBe('backup-2026-01-01T00-00-00.zip'); }); }); // --------------------------------------------------------------------------- // restoreFromZip — extended paths (BACKUP-042 through BACKUP-046) // --------------------------------------------------------------------------- /** Shared helper: configures the stream mocks so extraction succeeds. */ function setupSuccessfulExtraction() { const fakeExtractStream = { promise: vi.fn().mockResolvedValue(undefined) }; const fakeReadStream = { pipe: vi.fn().mockReturnValue(fakeExtractStream) }; fsMock.createReadStream.mockReturnValue(fakeReadStream); unzipperMock.Extract.mockReturnValue(fakeExtractStream); return { fakeReadStream, fakeExtractStream }; } describe('BACKUP-042 restoreFromZip — integrity check fails', () => { beforeEach(() => { vi.clearAllMocks(); }); it('BACKUP-042a — returns status 400 with integrity check error message', async () => { setupSuccessfulExtraction(); fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('travel.db')); fsMock.rmSync.mockReturnValue(undefined); const fakeDbInstance = { prepare: vi.fn().mockReturnValue({ get: vi.fn().mockReturnValue({ integrity_check: 'corruption' }), all: vi.fn(), }), pragma: vi.fn(), close: vi.fn(), }; DatabaseMock.mockImplementation(function () { return fakeDbInstance; }); const result = await restoreFromZip(deps(stubStorage()), '/data/tmp/upload.zip'); expect(result.success).toBe(false); expect(result.status).toBe(400); expect(result.error).toMatch(/integrity check/i); expect(fsMock.rmSync).toHaveBeenCalled(); }); }); describe('BACKUP-043 restoreFromZip — missing required table', () => { beforeEach(() => { vi.clearAllMocks(); }); it('BACKUP-043a — returns status 400 with missing required table error', async () => { setupSuccessfulExtraction(); fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('travel.db')); fsMock.rmSync.mockReturnValue(undefined); const fakeDbInstance = { prepare: vi .fn() .mockReturnValueOnce({ get: vi.fn().mockReturnValue({ integrity_check: 'ok' }), }) .mockReturnValueOnce({ all: vi.fn().mockReturnValue([{ name: 'users' }, { name: 'trips' }]), }), pragma: vi.fn(), close: vi.fn(), }; DatabaseMock.mockImplementation(function () { return fakeDbInstance; }); const result = await restoreFromZip(deps(stubStorage()), '/data/tmp/upload.zip'); expect(result.success).toBe(false); expect(result.status).toBe(400); expect(result.error).toMatch(/missing required table/i); expect(fsMock.rmSync).toHaveBeenCalled(); }); }); describe('BACKUP-044 restoreFromZip — Database constructor throws (invalid SQLite)', () => { beforeEach(() => { vi.clearAllMocks(); }); it('BACKUP-044a — returns status 400 with "not a valid SQLite database" error', async () => { setupSuccessfulExtraction(); fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('travel.db')); fsMock.rmSync.mockReturnValue(undefined); DatabaseMock.mockImplementation(function () { throw new Error('file is not a database'); }); const result = await restoreFromZip(deps(stubStorage()), '/data/tmp/upload.zip'); expect(result.success).toBe(false); expect(result.status).toBe(400); expect(result.error).toMatch(/not a valid SQLite database/i); expect(fsMock.rmSync).toHaveBeenCalled(); }); }); describe('BACKUP-045 restoreFromZip — full success path (no uploads)', () => { beforeEach(() => { vi.clearAllMocks(); }); function setupAllTablesPresent() { const fakeDbInstance = { prepare: vi .fn() .mockReturnValueOnce({ get: vi.fn().mockReturnValue({ integrity_check: 'ok' }), }) .mockReturnValueOnce({ all: vi .fn() .mockReturnValue([ { name: 'users' }, { name: 'trips' }, { name: 'trip_members' }, { name: 'places' }, { name: 'days' }, ]), }), pragma: vi.fn(), close: vi.fn(), }; DatabaseMock.mockImplementation(function () { return fakeDbInstance; }); return fakeDbInstance; } it('BACKUP-045a — returns { success: true } on full success', async () => { setupSuccessfulExtraction(); const uploaded = setupAllTablesPresent(); fsMock.existsSync.mockImplementation((p: string) => { if (String(p).endsWith('travel.db')) return true; if (String(p).includes('uploads')) return false; return true; }); fsMock.unlinkSync.mockReturnValue(undefined); fsMock.copyFileSync.mockReturnValue(undefined); fsMock.rmSync.mockReturnValue(undefined); const result = await restoreFromZip(deps(stubStorage()), '/data/tmp/upload.zip'); expect(result).toEqual({ success: true }); // The uploaded database is checked through the shared opener, read-only and // with its temp files in memory like every other handle (#2518). expect(DatabaseMock).toHaveBeenCalledWith(expect.stringContaining('travel.db'), { readonly: true }); expect(uploaded.pragma).toHaveBeenCalledWith('temp_store = MEMORY'); }); it('BACKUP-045b — closeDb is called before file copy operations', async () => { setupSuccessfulExtraction(); setupAllTablesPresent(); const callOrder: string[] = []; dbMock.closeDb.mockImplementation(() => { callOrder.push('closeDb'); }); fsMock.copyFileSync.mockImplementation(() => { callOrder.push('copyFileSync'); }); fsMock.unlinkSync.mockReturnValue(undefined); fsMock.rmSync.mockReturnValue(undefined); fsMock.existsSync.mockImplementation((p: string) => { if (String(p).endsWith('travel.db')) return true; if (String(p).includes('uploads')) return false; return true; }); await restoreFromZip(deps(stubStorage()), '/data/tmp/upload.zip'); expect(callOrder.indexOf('closeDb')).toBeLessThan(callOrder.indexOf('copyFileSync')); }); it('BACKUP-045g: keeps a copy of the database it replaces, before closing it', async () => { setupSuccessfulExtraction(); setupAllTablesPresent(); const callOrder: string[] = []; maintenanceRepoMock.vacuumInto.mockImplementation(async (target: string) => { callOrder.push(`vacuum:${target}`); }); dbMock.closeDb.mockImplementation(() => { callOrder.push('closeDb'); }); fsMock.unlinkSync.mockReturnValue(undefined); fsMock.rmSync.mockReturnValue(undefined); fsMock.existsSync.mockImplementation((p: string) => !String(p).includes('uploads')); await restoreFromZip(deps(stubStorage()), '/data/tmp/upload.zip'); expect(callOrder[0]).toMatch(/^vacuum:.*pre-restore-\d+\.db$/); expect(callOrder.indexOf('closeDb')).toBeGreaterThan(0); expect(logMock.logInfo).toHaveBeenCalledWith(expect.stringContaining('the replaced database was kept as')); }); it('BACKUP-045i: swaps the archive into the database the connection runs on when TREK_DB_FILE moves it', async () => { // The restore used to write data/travel.db whatever TREK_DB_FILE said: the // live database was never replaced and the restore reported success. setupSuccessfulExtraction(); setupAllTablesPresent(); liveDb.file = path.join(path.sep, 'srv', 'trek', 'custom-name.db'); maintenanceRepoMock.vacuumInto.mockResolvedValue(undefined); fsMock.unlinkSync.mockReturnValue(undefined); fsMock.copyFileSync.mockReturnValue(undefined); fsMock.rmSync.mockReturnValue(undefined); fsMock.existsSync.mockImplementation( (p: string) => !String(p).includes('uploads') && !String(p).endsWith('.encryption_key'), ); const result = await restoreFromZip(deps(stubStorage()), '/data/tmp/upload.zip'); expect(result).toEqual({ success: true }); const tmp = liveDb.file + '.restore-tmp'; expect(fsMock.copyFileSync).toHaveBeenCalledWith(expect.stringMatching(/restore-\d+[\\/]travel\.db$/), tmp); expect(fsMock.renameSync).toHaveBeenCalledWith(tmp, liveDb.file); expect(fsMock.unlinkSync).toHaveBeenCalledWith(liveDb.file + '-wal'); expect(fsMock.copyFileSync).not.toHaveBeenCalledWith(expect.anything(), DEFAULT_LIVE_DB + '.restore-tmp'); // The safety copy sits next to that database too. expect(maintenanceRepoMock.vacuumInto).toHaveBeenCalledWith( expect.stringMatching(/[\\/]srv[\\/]trek[\\/]pre-restore-\d+\.db$/), ); }); it('BACKUP-045h: a database that cannot be copied does not block the restore', async () => { setupSuccessfulExtraction(); setupAllTablesPresent(); maintenanceRepoMock.vacuumInto.mockRejectedValueOnce(new Error('database disk image is malformed')); fsMock.unlinkSync.mockReturnValue(undefined); fsMock.rmSync.mockReturnValue(undefined); fsMock.existsSync.mockImplementation((p: string) => !String(p).includes('uploads')); const result = await restoreFromZip(deps(stubStorage()), '/data/tmp/upload.zip'); expect(result.success).toBe(true); expect(logMock.logWarn).toHaveBeenCalledWith( expect.stringContaining('could not keep a copy of the current database'), ); expect(dbMock.closeDb).toHaveBeenCalled(); }); it('BACKUP-045i: the sessions signed in before the restore are put back after the reopen', async () => { setupSuccessfulExtraction(); setupAllTablesPresent(); const callOrder: string[] = []; const carried = [ { id: 'sess-1', user_id: 1, email: 'admin@example.test', created_at: 'a', last_seen_at: 'b', expires_at: 'c', user_agent: null, }, ]; const sessionsRepo = { listActiveToCarry: vi.fn(async () => { callOrder.push('read'); return carried; }), restoreCarried: vi.fn(async () => { callOrder.push('restore'); return 1; }), }; const { RequestContext } = await import('@mikro-orm/core'); const em = vi .spyOn(RequestContext, 'getEntityManager') .mockReturnValue({ getRepository: () => sessionsRepo } as never); const fork = vi .spyOn(RequestContext, 'create') .mockImplementation(((_em: unknown, next: () => unknown) => next()) as never); onTestFinished(() => { em.mockRestore(); fork.mockRestore(); }); dbMock.closeDb.mockImplementation(() => { callOrder.push('closeDb'); }); dbMock.reinitialize.mockImplementation(() => { callOrder.push('reinitialize'); }); fsMock.unlinkSync.mockReturnValue(undefined); fsMock.copyFileSync.mockReturnValue(undefined); fsMock.rmSync.mockReturnValue(undefined); fsMock.existsSync.mockImplementation( (p: string) => !String(p).includes('uploads') && !String(p).endsWith('.encryption_key'), ); const result = await restoreFromZip(deps(stubStorage()), '/data/tmp/upload.zip'); expect(result).toEqual({ success: true }); expect(callOrder).toEqual(['read', 'closeDb', 'reinitialize', 'restore']); expect(sessionsRepo.restoreCarried).toHaveBeenCalledWith(carried); // Put back in a fresh fork, not through the one that read the replaced file. expect(fork).toHaveBeenCalledTimes(1); }); it('BACKUP-045j: sessions that cannot be read or put back do not fail the restore', async () => { setupSuccessfulExtraction(); setupAllTablesPresent(); const sessionsRepo = { listActiveToCarry: vi .fn() .mockRejectedValueOnce(new Error('no such table: user_sessions')) .mockResolvedValueOnce([{ id: 's' }]), restoreCarried: vi.fn().mockRejectedValue(new Error('database is locked')), }; const { RequestContext } = await import('@mikro-orm/core'); const em = vi .spyOn(RequestContext, 'getEntityManager') .mockReturnValue({ getRepository: () => sessionsRepo } as never); const fork = vi .spyOn(RequestContext, 'create') .mockImplementation(((_em: unknown, next: () => unknown) => next()) as never); onTestFinished(() => { em.mockRestore(); fork.mockRestore(); }); fsMock.unlinkSync.mockReturnValue(undefined); fsMock.copyFileSync.mockReturnValue(undefined); fsMock.rmSync.mockReturnValue(undefined); fsMock.existsSync.mockImplementation( (p: string) => !String(p).includes('uploads') && !String(p).endsWith('.encryption_key'), ); const unreadable = await restoreFromZip(deps(stubStorage()), '/data/tmp/upload.zip'); expect(unreadable).toEqual({ success: true }); expect(logMock.logWarn).toHaveBeenCalledWith( expect.stringContaining('could not read the active sessions (no such table: user_sessions)'), ); expect(sessionsRepo.restoreCarried).not.toHaveBeenCalled(); setupSuccessfulExtraction(); setupAllTablesPresent(); const unwritable = await restoreFromZip(deps(stubStorage()), '/data/tmp/upload.zip'); expect(unwritable).toEqual({ success: true }); expect(logMock.logWarn).toHaveBeenCalledWith( expect.stringContaining('could not keep the active sessions (database is locked)'), ); }); it('BACKUP-045c — reinitialize is called even when copyFileSync throws', async () => { setupSuccessfulExtraction(); setupAllTablesPresent(); fsMock.existsSync.mockImplementation((p: string) => { if (String(p).endsWith('travel.db')) return true; if (String(p).includes('uploads')) return false; return true; }); fsMock.unlinkSync.mockReturnValue(undefined); fsMock.copyFileSync.mockImplementation(() => { throw new Error('disk full'); }); fsMock.rmSync.mockReturnValue(undefined); await expect(restoreFromZip(deps(stubStorage()), '/data/tmp/upload.zip')).rejects.toThrow('disk full'); expect(dbMock.reinitialize).toHaveBeenCalled(); }); it('BACKUP-045d — restores bundled .encryption_key when the archive carries one', async () => { setupSuccessfulExtraction(); setupAllTablesPresent(); fsMock.existsSync.mockImplementation((p: string) => { if (String(p).endsWith('travel.db')) return true; if (String(p).endsWith('.encryption_key')) return true; // extracted key present if (String(p).includes('uploads')) return false; return true; }); fsMock.unlinkSync.mockReturnValue(undefined); fsMock.copyFileSync.mockReturnValue(undefined); fsMock.rmSync.mockReturnValue(undefined); const result = await restoreFromZip(deps(stubStorage()), '/data/tmp/upload.zip'); expect(result).toEqual({ success: true }); // Key copied from the extract dir into the live data dir. expect(fsMock.copyFileSync).toHaveBeenCalledWith( expect.stringContaining('.encryption_key'), expect.stringContaining('.encryption_key'), ); }); it('BACKUP-045e — skips key restore when the archive has no .encryption_key', async () => { setupSuccessfulExtraction(); setupAllTablesPresent(); fsMock.existsSync.mockImplementation((p: string) => { if (String(p).endsWith('travel.db')) return true; if (String(p).endsWith('.encryption_key')) return false; // no key in archive if (String(p).includes('uploads')) return false; return true; }); fsMock.unlinkSync.mockReturnValue(undefined); fsMock.copyFileSync.mockReturnValue(undefined); fsMock.rmSync.mockReturnValue(undefined); const result = await restoreFromZip(deps(stubStorage()), '/data/tmp/upload.zip'); expect(result).toEqual({ success: true }); expect(fsMock.copyFileSync).not.toHaveBeenCalledWith( expect.stringContaining('.encryption_key'), expect.stringContaining('.encryption_key'), ); }); it('BACKUP-045f — reloadConfig runs after reinitialize and before uploads rehydration (audit #4)', async () => { // The registry reads storage.* app_settings through the DB handle — which // is closed and reopened around this restore. reloadConfig() must run // AFTER that reopen (else it reads a torn/unavailable connection) and // BEFORE any rehydrated byte is put, so rehydration lands per the // RESTORED config rather than the stale pre-restore one. setupSuccessfulExtraction(); setupAllTablesPresent(); const callOrder: string[] = []; dbMock.reinitialize.mockImplementation(() => { callOrder.push('reinitialize'); }); const dirent = (name: string, dir = false) => ({ name, isDirectory: () => dir, isFile: () => !dir }); fsMock.existsSync.mockImplementation((p: string) => !String(p).endsWith('.encryption_key')); fsMock.readdirSync.mockImplementation((p: string, opts?: { withFileTypes?: boolean }) => { const s = String(p); const entries = s.endsWith('uploads') ? [dirent('files', true)] : s.endsWith('files') ? [dirent('a.pdf')] : []; return (opts?.withFileTypes ? entries : entries.map((e) => e.name)) as never; }); fsMock.unlinkSync.mockReturnValue(undefined); fsMock.copyFileSync.mockReturnValue(undefined); fsMock.rmSync.mockReturnValue(undefined); const storage = stubStorage({ reloadConfig: vi.fn(() => { callOrder.push('reloadConfig'); }), put: vi.fn(async () => { callOrder.push('put:rehydrate'); }), }); const result = await restoreFromZip(deps(storage), '/data/tmp/upload.zip'); expect(result).toEqual({ success: true }); expect(callOrder).toEqual(['reinitialize', 'reloadConfig', 'put:rehydrate']); }); }); describe('BACKUP-046 restoreFromZip — uploads rehydration through storage', () => { beforeEach(() => { vi.clearAllMocks(); }); function setupAllTablesPresent() { const fakeDbInstance = { prepare: vi .fn() .mockReturnValueOnce({ get: vi.fn().mockReturnValue({ integrity_check: 'ok' }), }) .mockReturnValueOnce({ all: vi .fn() .mockReturnValue([ { name: 'users' }, { name: 'trips' }, { name: 'trip_members' }, { name: 'places' }, { name: 'days' }, ]), }), pragma: vi.fn(), close: vi.fn(), }; DatabaseMock.mockImplementation(function () { return fakeDbInstance; }); return fakeDbInstance; } const dirent = (name: string, dir = false) => ({ name, isDirectory: () => dir, isFile: () => !dir }); /** Common fs wiring: travel.db + extracted uploads exist; the extracted tree * is described per-path via `tree` (walked with { withFileTypes: true }). */ function setupExtractedUploads(tree: Record[]>) { fsMock.existsSync.mockImplementation((p: string) => { if (String(p).endsWith('.encryption_key')) return false; return true; }); fsMock.readdirSync.mockImplementation((p: string, opts?: { withFileTypes?: boolean }) => { const s = String(p); const key = Object.keys(tree).find((k) => s.endsWith(k)); const entries = key ? tree[key] : []; return (opts?.withFileTypes ? entries : entries.map((e) => e.name)) as never; }); fsMock.unlinkSync.mockReturnValue(undefined); fsMock.copyFileSync.mockReturnValue(undefined); fsMock.rmSync.mockReturnValue(undefined); } it('BACKUP-046a — wipes only top-level category objects, then puts each extracted entry (legacy wipe parity)', async () => { setupSuccessfulExtraction(); setupAllTablesPresent(); setupExtractedUploads({ '/uploads': [dirent('files', true), dirent('journey', true)], '/uploads/files': [dirent('a.pdf')], '/uploads/journey': [dirent('thumbs', true)], '/uploads/journey/thumbs': [dirent('t.jpg')], }); // Pre-existing objects: one bare per category plus a nested journey thumb — // the legacy wipe unlinked one level deep only, so nested keys must survive. // One delete rejects to pin the swallowed-per-file-error behavior. const storage = stubStorage({ list: vi.fn((category: string) => (async function* () { yield { key: 'old.bin', size: 1, mtimeMs: 0 }; if (category === 'journey') yield { key: 'thumbs/old.jpg', size: 1, mtimeMs: 0 }; })(), ), delete: vi.fn(async (category: string) => { // one category's wipe fails — swallowed per-file, exactly like the old // unlink loop (exercises the best-effort .catch) if (category === 'covers') throw new Error('EACCES'); }), }); const result = await restoreFromZip(deps(storage), '/data/tmp/upload.zip'); expect(result).toEqual({ success: true }); // wipe: bare keys deleted in every archived category, nested keys never const deleted = (storage.delete as ReturnType).mock.calls; expect(deleted.every(([, key]) => !(key as string).includes('/'))).toBe(true); expect(deleted.map(([c]) => c).sort()).toEqual(['avatars', 'covers', 'files', 'journey', 'photos', 'places']); // rehydration: every extracted file becomes a category put, nested keys intact expect(storage.put).toHaveBeenCalledWith('files', 'a.pdf', { tmpPath: expect.stringContaining('/uploads/files/a.pdf'), }); expect(storage.put).toHaveBeenCalledWith('journey', 'thumbs/t.jpg', { tmpPath: expect.stringContaining('/uploads/journey/thumbs/t.jpg'), }); // No uploads bulk copy remains (plugin-tree staging still uses cpSync — out of scope). const cpTargets = fsMock.cpSync.mock.calls.map((c) => String(c[1])); expect(cpTargets.some((t) => t.includes('uploads'))).toBe(false); }); it('BACKUP-046b — skips entries that cannot map to a storage key, with a warning (2026-08-17 decision)', async () => { setupSuccessfulExtraction(); setupAllTablesPresent(); setupExtractedUploads({ '/uploads': [dirent('files', true), dirent('mystery', true), dirent('stray.txt')], '/uploads/files': [dirent('a.pdf')], '/uploads/mystery': [dirent('b.bin')], }); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const { StorageInvalidKeyError } = await import('../../../src/nest/storage/storage.types'); const storage = stubStorage({ put: vi.fn(async (_c: string, key: string) => { // dot-segment keys from old `dot: true` archives are rejected by // central key validation — the restore must skip, not fail if (key === 'a.pdf') throw new StorageInvalidKeyError(key); }), }); const result = await restoreFromZip(deps(storage), '/data/tmp/upload.zip'); expect(result).toEqual({ success: true }); // unknown top-level dir + top-level file + invalid key: all skipped, warned expect(warn).toHaveBeenCalledWith(expect.stringContaining('mystery/b.bin')); expect(warn).toHaveBeenCalledWith(expect.stringContaining('stray.txt')); expect(warn).toHaveBeenCalledWith(expect.stringContaining('a.pdf')); warn.mockRestore(); }); it('BACKUP-046c — a genuine put failure still fails the restore', async () => { setupSuccessfulExtraction(); setupAllTablesPresent(); setupExtractedUploads({ '/uploads': [dirent('files', true)], '/uploads/files': [dirent('a.pdf')], }); const storage = stubStorage({ put: vi.fn(async () => { throw new Error('ENOSPC: no space left'); }), }); await expect(restoreFromZip(deps(storage), '/data/tmp/upload.zip')).rejects.toThrow('ENOSPC'); // the DB reopen still ran (finally) — the process is never left closed expect(dbMock.reinitialize).toHaveBeenCalled(); }); }); // --------------------------------------------------------------------------- // restoreBackup (stored-zip restore via withLocalFile) // --------------------------------------------------------------------------- describe('BACKUP-063 restoreBackup', () => { beforeEach(() => { vi.clearAllMocks(); }); it('BACKUP-063a — reads the stored zip through withLocalFile("backups") and returns the restore result', async () => { const storage = stubStorage({ withLocalFile: vi.fn(async () => ({ success: true })), }); await expect(restoreBackup(deps(storage), 'backup-2026-01-01T00-00-00.zip')).resolves.toEqual({ success: true }); expect(storage.withLocalFile).toHaveBeenCalledWith( 'backups', 'backup-2026-01-01T00-00-00.zip', expect.any(Function), ); }); it('BACKUP-063b — the local path handed back by storage feeds the restore core', async () => { // The default stub invokes the callback with a local path; the restore core // runs against it for real (empty archive → travel.db missing → 400 result). fsMock.existsSync.mockReturnValue(false); unzipperMock.Open.file.mockResolvedValue({ files: [] }); const storage = stubStorage(); const result = await restoreBackup(deps(storage), 'backup-2026-01-01T00-00-00.zip'); expect(result).toEqual({ success: false, error: 'Invalid backup: travel.db not found', status: 400 }); }); }); // BACKUP-047 (updateAutoSettings) moved to tests/unit/auto-backup.test.ts with // the function — it lives on AutoBackupJob now.