mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
1839 lines
72 KiB
TypeScript
1839 lines
72 KiB
TypeScript
/**
|
|
* 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<string, unknown> = {}): 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<unknown>) => 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<string, Function> = {};
|
|
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<string, Array<{ key: string; size?: number; mtimeMs?: number }>>) =>
|
|
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<string, Function> = {};
|
|
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<string, Function> = {};
|
|
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<typeof vi.fn>).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<string, Array<(arg?: unknown) => 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<string, Array<() => 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<string, Array<(arg?: unknown) => 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<string, ReturnType<typeof dirent>[]>) {
|
|
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<typeof vi.fn>).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.
|