mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user