mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
205759e3cf
CronJob ran a tick on schedule even when the previous one had not finished, so an hourly auto-backup of a large library could overlap itself, and a rejected tick only reached cron's own console line. Jobs now wait for completion and log failures through the app logger. The backup also stores photos, videos and PDFs instead of deflating already-compressed files at level 9.
1577 lines
64 KiB
TypeScript
1577 lines
64 KiB
TypeScript
/**
|
|
* Unit tests for backupService.
|
|
* Covers BACKUP-031 to BACKUP-060.
|
|
*/
|
|
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(),
|
|
}));
|
|
|
|
// Plan 3i Task 3 (R1): BK1/BK2 (the WAL checkpoint, the VACUUM INTO
|
|
// snapshot) now go through MaintenanceRepository, resolved via
|
|
// RequestContext.getEntityManager() — a real static accessor this unit-test
|
|
// file does not mock (it boots neither a real MikroORM nor a real Nest
|
|
// request), so it genuinely returns `undefined` here, the same as it would
|
|
// for any caller outside a request context. `maintenanceRepoMock` proves the
|
|
// NEGATIVE: the two `if (em)`/`if (!em) throw` guards correctly detect the
|
|
// missing context and take the best-effort/fallback path WITHOUT ever
|
|
// constructing MaintenanceRepository — not a spy standing in for a real
|
|
// snapshot (that positive case is `MaintenanceRepository.test.ts`'s own
|
|
// unit coverage, plus the real ORM/request-context boot in
|
|
// `tests/e2e/backup.e2e.test.ts`).
|
|
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/db/repositories/MaintenanceRepository', () => ({
|
|
MaintenanceRepository: vi.fn().mockImplementation(function () {
|
|
return maintenanceRepoMock;
|
|
}),
|
|
}));
|
|
vi.mock('../../../src/nest/audit/audit-log.logger', () => logMock);
|
|
vi.mock('../../../src/config', () => ({
|
|
JWT_SECRET: 'test-secret',
|
|
ENCRYPTION_KEY: 'a'.repeat(64),
|
|
updateJwtSecret: () => {},
|
|
}));
|
|
vi.mock('fs', () => ({ default: fsMock, ...fsMock }));
|
|
vi.mock('archiver', () => ({ default: archiverMock }));
|
|
vi.mock('unzipper', () => ({ default: unzipperMock }));
|
|
import {
|
|
formatSize,
|
|
parseIntField,
|
|
parseAutoBackupBody,
|
|
isValidBackupFilename,
|
|
checkRateLimit,
|
|
createBackup,
|
|
deleteBackup,
|
|
restoreFromZip,
|
|
restoreBackup,
|
|
BACKUP_RATE_WINDOW,
|
|
backupFileExists,
|
|
listBackups,
|
|
sendBackupToResponse,
|
|
} from '../../../src/nest/backup/backup.impl';
|
|
import type { StorageService } from '../../../src/nest/storage/storage.service';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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(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(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(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(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(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(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 missing request context (no EntityManager) is swallowed, best-effort, same as a WAL checkpoint failure (non-critical)', async () => {
|
|
// Plan 3i Task 3: BK1 resolves RequestContext.getEntityManager() — a
|
|
// real, unmocked static accessor here — which is genuinely `undefined`
|
|
// outside a request context (this suite boots no Nest app), so
|
|
// MaintenanceRepository is never even constructed; the backup must still
|
|
// succeed either way, matching the legacy `db.exec` throw case's own
|
|
// "should not throw" guarantee.
|
|
fsMock.existsSync.mockReturnValue(false);
|
|
setupArchiveSuccess();
|
|
const storage = stubStorage({ stat: statOf(512) });
|
|
|
|
const result = await createBackup(storage);
|
|
expect(result).toHaveProperty('filename');
|
|
expect(result.size).toBe(512);
|
|
expect(maintenanceRepoMock.walCheckpoint).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(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 — includes travel.db when it exists; falls back to archiving the live file when no EntityManager is available for the VACUUM INTO snapshot', async () => {
|
|
// Plan 3i Task 3 (R1): the positive "snapshots into the spool" case (a
|
|
// real EntityManager, VACUUM INTO succeeding) is `MaintenanceRepository
|
|
// .test.ts`'s own unit coverage (MAINTREPO-002/003) plus the real
|
|
// request-context boot in `tests/e2e/backup.e2e.test.ts` — this
|
|
// heavily-mocked unit-test file boots no Nest app, so
|
|
// RequestContext.getEntityManager() is genuinely `undefined` here,
|
|
// exercising the SAME fallback path a real disk/lock VACUUM INTO
|
|
// failure would: MaintenanceRepository is never constructed, and the
|
|
// checkpointed live file is archived instead of a missing snapshot.
|
|
fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('travel.db'));
|
|
setupArchiveSuccess();
|
|
const storage = stubStorage({ stat: statOf(1024) });
|
|
|
|
await createBackup(storage);
|
|
|
|
expect(maintenanceRepoMock.vacuumInto).not.toHaveBeenCalled();
|
|
expect(archiverInstanceMock.file).toHaveBeenCalledWith(
|
|
expect.stringMatching(/travel\.db$/),
|
|
{ name: 'travel.db' },
|
|
);
|
|
expect(archiverInstanceMock.file).not.toHaveBeenCalledWith(
|
|
expect.stringContaining('travel-snap-backup-'),
|
|
expect.anything(),
|
|
);
|
|
// Plan 3i Task 4 fix wave (should-land 7): the missing-EntityManager
|
|
// fallback used to be entirely silent — same swallowed shape as a real
|
|
// disk/lock VACUUM INTO failure, with no way for an operator to tell
|
|
// "archived the live file because no request context wrapped this run"
|
|
// apart from any other reason. Both guards (BK1's checkpoint skip, BK2's
|
|
// snapshot skip) now log a warning; the fallback itself is unchanged.
|
|
expect(logMock.logWarn).toHaveBeenCalledWith(expect.stringContaining('skipping the WAL checkpoint'));
|
|
expect(logMock.logWarn).toHaveBeenCalledWith(expect.stringContaining('archiving the live travel.db instead of a VACUUM INTO snapshot'));
|
|
});
|
|
|
|
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(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(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(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(storage);
|
|
|
|
expect(archiverInstanceMock.file).not.toHaveBeenCalledWith(
|
|
expect.stringContaining('.encryption_key'),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it('BACKUP-036i — the auto-backup prefix names the zip (the scratch snapshot naming, `travel-snap-auto-backup-*`, is MaintenanceRepository.test.ts\'s own coverage)', 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(storage, 'auto-backup');
|
|
|
|
expect(result.filename).toMatch(/^auto-backup-.*\.zip$/);
|
|
// No real EntityManager in this unit-test file (see BACKUP-036d) — the
|
|
// VACUUM INTO snapshot falls back to the live file, same as the default
|
|
// prefix's case; the auto-backup-specific behaviour this test pins is
|
|
// the zip/put naming, not the (unreachable here) snapshot path.
|
|
expect(archiverInstanceMock.file).toHaveBeenCalledWith(
|
|
expect.stringMatching(/travel\.db$/),
|
|
{ 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(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(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(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(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(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(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(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(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(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(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(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(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(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 { RequestContext } = await import('@mikro-orm/core');
|
|
const em = vi.spyOn(RequestContext, 'getEntityManager').mockReturnValue({} as never);
|
|
onTestFinished(() => em.mockRestore());
|
|
|
|
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(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-045h — a database that cannot be copied does not block the restore', async () => {
|
|
setupSuccessfulExtraction();
|
|
setupAllTablesPresent();
|
|
const { RequestContext } = await import('@mikro-orm/core');
|
|
const em = vi.spyOn(RequestContext, 'getEntityManager').mockReturnValue({} as never);
|
|
onTestFinished(() => em.mockRestore());
|
|
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(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-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(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(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(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(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(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(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(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(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(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.
|