mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
157 lines
6.4 KiB
TypeScript
157 lines
6.4 KiB
TypeScript
/**
|
|
* BOOT-RESTORE-001..008 — 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 { BootRestoreError, restoreOnFirstBoot } from '../../../src/nest/backup/boot-restore';
|
|
|
|
import archiver from 'archiver';
|
|
import Database from 'better-sqlite3';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
|
|
const { stageMock } = vi.hoisted(() => ({ stageMock: vi.fn() }));
|
|
vi.mock('../../../src/nest/plugins/plugin-backup', () => ({ stageExtractedPluginTrees: stageMock }));
|
|
|
|
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);
|
|
});
|
|
|
|
it('BOOT-RESTORE-008: an archive with only a database restores it, without inventing a key or an uploads tree', async () => {
|
|
const src = path.join(root, 'src.db');
|
|
trekDb(src);
|
|
const zipPath = await archive({ 'travel.db': { file: src } });
|
|
|
|
const out = await restoreOnFirstBoot({ archive: zipPath, dbFile, dataDir });
|
|
|
|
expect(out).toEqual({ restored: true, uploads: null, staging: expect.stringContaining('restore-boot-') });
|
|
expect(fs.existsSync(dbFile)).toBe(true);
|
|
expect(fs.existsSync(path.join(dataDir, '.encryption_key'))).toBe(false);
|
|
});
|
|
});
|