Files
TREK/server/tests/unit/nest/boot-restore.test.ts
T
Maurice 8fd46c4c18 feat: quality of life sweep from the feature discussions
Small requests from the discussions, each built end to end: planner and
map (cycling mode, date first headings, clear a day, stay times per day,
off-route stops, map lock, compact unplanned markers, pending stays,
cable car, transit quick picks), places (location filter, category from
OSM, open in maps, geo: link, image from an attachment, enrichment on
file import, places near a pin, place name language, Amap first in
China), lists and costs (packed counter, inherited sharing, Markdown
to-dos, payment notes, bookings CSV, my plan PDF), sharing (unplanned
places, travel only, without photos), journey (photo zoom, drafts,
photo order, trip suggestion, day jump), atlas (region counter, region
bucket list, places per country), vacay half company holidays, studio
copy and paste plus the book's own layouts, and RESTORE_FROM_BACKUP for
a first start restore.
2026-09-30 23:00:12 +02:00

133 lines
5.8 KiB
TypeScript

/**
* BOOT-RESTORE-001..007 — restoring a backup on a first start (#1089).
*
* Real files, a real archive and a real SQLite file: what is being protected is
* the order of things on disk, and a mocked filesystem would only confirm the
* calls this file makes. The plugin staging is the one seam stubbed, because
* it moves trees under the plugin roots of the machine running the tests.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import archiver from 'archiver';
import Database from 'better-sqlite3';
const { stageMock } = vi.hoisted(() => ({ stageMock: vi.fn() }));
vi.mock('../../../src/nest/plugins/plugin-backup', () => ({ stageExtractedPluginTrees: stageMock }));
import { BootRestoreError, restoreOnFirstBoot } from '../../../src/nest/backup/boot-restore';
let root: string;
let dataDir: string;
let dbFile: string;
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-boot-restore-'));
dataDir = path.join(root, 'data');
fs.mkdirSync(dataDir);
dbFile = path.join(dataDir, 'travel.db');
stageMock.mockClear();
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(root, { recursive: true, force: true });
});
/** A database with the tables every TREK has, and one user to recognise it by. */
function trekDb(file: string, tables = ['users', 'trips', 'trip_members', 'places', 'days']) {
const db = new Database(file);
for (const t of tables) db.exec(`CREATE TABLE ${t} (id INTEGER PRIMARY KEY, name TEXT)`);
if (tables.includes('users')) db.prepare('INSERT INTO users (name) VALUES (?)').run('from-backup');
db.close();
}
async function archive(entries: Record<string, string | { file: string }>): Promise<string> {
const zipPath = path.join(root, 'backup.zip');
await new Promise<void>((resolve, reject) => {
const out = fs.createWriteStream(zipPath);
const zip = archiver('zip');
out.on('close', () => resolve());
zip.on('error', reject);
zip.pipe(out);
for (const [name, content] of Object.entries(entries)) {
if (typeof content === 'string') zip.append(content, { name });
else zip.file(content.file, { name });
}
void zip.finalize();
});
return zipPath;
}
async function goodArchive(extra: Record<string, string> = {}) {
const src = path.join(root, 'src.db');
trekDb(src);
return archive({ 'travel.db': { file: src }, '.encryption_key': 'a'.repeat(64), 'uploads/covers/c.jpg': 'jpeg', ...extra });
}
describe('restoreOnFirstBoot', () => {
it('BOOT-RESTORE-001: without the variable nothing happens', async () => {
expect(await restoreOnFirstBoot({ archive: null, dbFile, dataDir })).toEqual({ restored: false, reason: 'unset' });
expect(fs.existsSync(dbFile)).toBe(false);
});
it('BOOT-RESTORE-002: on a first start the backup\'s database, key and uploads are put in place', async () => {
const zipPath = await goodArchive();
const out = await restoreOnFirstBoot({ archive: zipPath, dbFile, dataDir });
expect(out.restored).toBe(true);
const db = new Database(dbFile, { readonly: true });
expect(db.prepare('SELECT name FROM users').get()).toEqual({ name: 'from-backup' });
db.close();
expect(fs.readFileSync(path.join(dataDir, '.encryption_key'), 'utf8')).toBe('a'.repeat(64));
if (!out.restored) throw new Error('unreachable');
expect(fs.readFileSync(path.join(out.uploads!, 'covers', 'c.jpg'), 'utf8')).toBe('jpeg');
expect(stageMock).toHaveBeenCalledWith(out.staging);
expect(fs.existsSync(`${dbFile}.restore-tmp`)).toBe(false);
});
it('BOOT-RESTORE-003: with a database already there the backup is left alone, so a restart never rolls back', async () => {
const zipPath = await goodArchive();
trekDb(dbFile, ['users', 'trips', 'trip_members', 'places', 'days']);
const before = fs.readFileSync(dbFile);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(await restoreOnFirstBoot({ archive: zipPath, dbFile, dataDir })).toEqual({ restored: false, reason: 'database-exists' });
expect(fs.readFileSync(dbFile).equals(before)).toBe(true);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('only restored on a first start'));
});
it('BOOT-RESTORE-004: a missing archive stops the start instead of coming up empty', async () => {
await expect(restoreOnFirstBoot({ archive: path.join(root, 'nope.zip'), dbFile, dataDir }))
.rejects.toBeInstanceOf(BootRestoreError);
expect(fs.existsSync(dbFile)).toBe(false);
});
it('BOOT-RESTORE-005: an archive without a database is refused and leaves no scratch behind', async () => {
const zipPath = await archive({ 'notes.txt': 'hello' });
await expect(restoreOnFirstBoot({ archive: zipPath, dbFile, dataDir }))
.rejects.toThrow('RESTORE_FROM_BACKUP: Invalid backup: travel.db not found');
expect(fs.existsSync(dbFile)).toBe(false);
expect(fs.readdirSync(dataDir).filter(n => n.startsWith('restore-boot-'))).toEqual([]);
});
it('BOOT-RESTORE-006: a database that is not a TREK one is refused', async () => {
const src = path.join(root, 'other.db');
trekDb(src, ['users']);
const zipPath = await archive({ 'travel.db': { file: src } });
await expect(restoreOnFirstBoot({ archive: zipPath, dbFile, dataDir }))
.rejects.toThrow('missing required table: trips');
expect(fs.existsSync(dbFile)).toBe(false);
});
it('BOOT-RESTORE-007: an entry escaping the archive is refused', async () => {
const zipPath = await archive({ '../escape.txt': 'x', 'travel.db': 'not a db' });
await expect(restoreOnFirstBoot({ archive: zipPath, dbFile, dataDir }))
.rejects.toThrow(/escapes the archive root|not a valid SQLite database/);
expect(fs.existsSync(dbFile)).toBe(false);
});
});