fix(backup): snapshot the database instead of archiving it live (#1675)

The scheduled backup archived travel.db directly. The archiver reads its
entries during finalize(), so a WAL checkpoint writing pages back mid-stream
tore the copy — and the -wal that would make it recoverable isn't in the zip.
The manual path was moved to VACUUM INTO for exactly this reason; the
scheduler never followed. It calls the same createBackup() now, which also
gets it the encryption key and plugin data it was missing, so a scheduled
backup is finally restorable onto another install.

The pre-rotation copy in migrate-encryption.ts had the same problem: a plain
file copy of a live WAL database, and it is the operator's only rollback if a
key rotation goes wrong.

Auto-backups now exclude the re-derivable photo caches, matching manual ones.
This commit is contained in:
Maurice
2026-07-29 16:42:47 +02:00
committed by Maurice
parent 14bc41f2cf
commit e62dceb4bb
6 changed files with 250 additions and 32 deletions
+156
View File
@@ -0,0 +1,156 @@
/**
* Scheduled backup run.
*
* The auto-backup used to archive the live travel.db: the archiver reads its
* entries while streaming, so a WAL auto-checkpoint writing pages back mid-run
* left a torn copy in the zip — without the -wal that would make it recoverable.
* It also shipped without .encryption_key, so the archive could not be decrypted
* on another install. The run now goes through backupService.createBackup(),
* which snapshots the DB with VACUUM INTO and bundles the key.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const fsMock = vi.hoisted(() => ({
existsSync: vi.fn(() => false),
mkdirSync: vi.fn(),
readFileSync: vi.fn(() => '{}'),
writeFileSync: vi.fn(),
readdirSync: vi.fn(() => [] as unknown as string[]),
statSync: vi.fn(() => ({ size: 0, birthtime: new Date(0), mtimeMs: 0 })),
unlinkSync: vi.fn(),
createWriteStream: vi.fn(),
createReadStream: vi.fn(),
rmSync: vi.fn(),
copyFileSync: vi.fn(),
renameSync: vi.fn(),
cpSync: vi.fn(),
realpathSync: vi.fn((p: string) => p),
}));
const archiveMock = 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 dbMock = vi.hoisted(() => ({
db: { exec: vi.fn(), prepare: vi.fn() },
closeDb: vi.fn(),
reinitialize: vi.fn(),
}));
const logMock = vi.hoisted(() => ({ logInfo: vi.fn(), logError: vi.fn() }));
const cronMock = vi.hoisted(() => ({ schedule: vi.fn(), validate: vi.fn(() => true) }));
vi.mock('node-cron', () => ({ default: cronMock, ...cronMock }));
vi.mock('fs', () => ({ default: fsMock, ...fsMock }));
vi.mock('node:fs', () => ({ default: fsMock, ...fsMock }));
vi.mock('archiver', () => ({ default: archiverMock }));
vi.mock('unzipper', () => ({ default: { Extract: vi.fn(), Open: { file: vi.fn() } } }));
vi.mock('../../src/db/database', () => dbMock);
vi.mock('../../src/nest/audit/audit-log.logger', () => logMock);
vi.mock('../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a'.repeat(64),
updateJwtSecret: () => {},
}));
import path from 'node:path';
import { start } from '../../src/scheduler';
const liveDb = path.join(__dirname, '../../data', 'travel.db');
/** Wires up the archiver so finalize() resolves the run, and returns its events. */
function stubArchiver(): Record<string, (arg?: unknown) => void> {
const outputEvents: Record<string, (arg?: unknown) => void> = {};
const archiveEvents: Record<string, (arg?: unknown) => void> = {};
fsMock.createWriteStream.mockReturnValue({
on: vi.fn((event: string, cb: () => void) => { outputEvents[event] = cb; }),
} as never);
archiveMock.on.mockImplementation((event: string, cb: (arg?: unknown) => void) => { archiveEvents[event] = cb; });
archiveMock.finalize.mockImplementation(() => { outputEvents['close']?.(); });
archiverMock.mockReturnValue(archiveMock);
return archiveEvents;
}
/** Starts the scheduler with auto-backup enabled and hands back the cron callback. */
function scheduledRun(): () => Promise<void> {
fsMock.readFileSync.mockReturnValue(JSON.stringify({ enabled: true, interval: 'daily', keep_days: 7 }));
start();
return cronMock.schedule.mock.calls.at(-1)?.[1] as () => Promise<void>;
}
describe('auto-backup run', () => {
const envKey = process.env.ENCRYPTION_KEY;
beforeEach(() => {
vi.clearAllMocks();
// No ENCRYPTION_KEY in the env means the key file is the source of truth and
// has to travel with the backup.
delete process.env.ENCRYPTION_KEY;
fsMock.statSync.mockReturnValue({ size: 4096, birthtime: new Date('2026-04-27T02:00:00Z'), mtimeMs: Date.now() } as never);
});
afterEach(() => {
process.env.ENCRYPTION_KEY = envKey;
});
it('archives a VACUUM INTO snapshot, never the live travel.db', async () => {
fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('travel.db') || String(p).endsWith('backup-settings.json'));
stubArchiver();
await scheduledRun()();
expect(dbMock.db.exec).toHaveBeenCalledWith(expect.stringContaining('VACUUM INTO'));
const dbEntry = archiveMock.file.mock.calls.find(([, opts]) => opts?.name === 'travel.db');
expect(dbEntry).toBeDefined();
expect(dbEntry?.[0]).toContain('.travel-snap-auto-backup-');
expect(dbEntry?.[0]).not.toBe(liveDb);
});
it('bundles the at-rest encryption key', async () => {
fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('.encryption_key') || String(p).endsWith('backup-settings.json'));
stubArchiver();
await scheduledRun()();
expect(archiveMock.file).toHaveBeenCalledWith(
expect.stringContaining('.encryption_key'),
{ name: '.encryption_key' },
);
});
it('keeps the auto-backup-*.zip naming so retention still prunes it', async () => {
fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('backup-settings.json'));
fsMock.readdirSync.mockReturnValue(['auto-backup-2020-01-01T02-00-00.zip'] as unknown as string[]);
stubArchiver();
await scheduledRun()();
expect(logMock.logInfo).toHaveBeenCalledWith(expect.stringMatching(/^Auto-Backup created: auto-backup-[\dT-]+\.zip$/));
// cleanupOldBackups(keep_days) still runs after the archive and only sees
// files it can match by prefix
expect(fsMock.unlinkSync).toHaveBeenCalledWith(expect.stringContaining('auto-backup-2020-01-01T02-00-00.zip'));
});
it('logs the failure, drops the partial zip and skips retention', async () => {
fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('.zip') || String(p).endsWith('backup-settings.json'));
const archiveEvents = stubArchiver();
archiveMock.finalize.mockImplementation(() => { archiveEvents['error']?.(new Error('disk full')); });
await scheduledRun()();
expect(logMock.logError).toHaveBeenCalledWith('Auto-Backup: disk full');
expect(logMock.logInfo).not.toHaveBeenCalledWith(expect.stringContaining('Auto-Backup created'));
expect(fsMock.unlinkSync).toHaveBeenCalledWith(expect.stringContaining('auto-backup-'));
// the snapshot scratch file is cleaned up too, and retention never runs
expect(fsMock.rmSync).toHaveBeenCalledWith(expect.stringContaining('.travel-snap-auto-backup-'), { force: true });
expect(fsMock.readdirSync).not.toHaveBeenCalled();
});
});
@@ -609,6 +609,34 @@ describe('BACKUP-036 createBackup', () => {
expect.anything(),
);
});
it('BACKUP-036i — the auto-backup prefix names both the zip and its scratch snapshots', 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'));
fsMock.mkdirSync.mockReturnValue(undefined);
const writableEvents: Record<string, Function> = {};
fsMock.createWriteStream.mockReturnValue({
on: vi.fn((event: string, cb: Function) => { writableEvents[event] = cb; }),
} as never);
archiverInstanceMock.on.mockImplementation((_e: string, _cb: Function) => {});
archiverInstanceMock.pipe.mockReturnValue(undefined);
archiverInstanceMock.finalize.mockImplementation(() => {
if (writableEvents['close']) writableEvents['close']();
});
archiverMock.mockReturnValue(archiverInstanceMock);
fsMock.statSync.mockReturnValue({ size: 1024, birthtime: new Date('2026-04-06T12:00:00Z') });
const result = await createBackup('auto-backup');
expect(result.filename).toMatch(/^auto-backup-.*\.zip$/);
expect(archiverInstanceMock.file).toHaveBeenCalledWith(
expect.stringContaining('.travel-snap-auto-backup-'),
{ name: 'travel.db' },
);
});
});
// ---------------------------------------------------------------------------