Files
TREK/server/tests/unit/db/durability.test.ts
Maurice 724c88c201 style: format server and shared with prettier
A mechanical prettier --write over server/src, server/tests, server/scripts
and shared/src with the workspaces' .prettierrc; nothing but layout and
import order changes, and every file keeps the same set of imports. shared
is formatted throughout and its format baseline is empty. The server
baseline keeps the 35 files that Prettier would grow past their lint:size
limit (they need a split first, which is not a formatting change), and the
generated entity files stay as their generator writes them.
2026-10-10 21:23:37 +02:00

272 lines
10 KiB
TypeScript

import { resolveDurability } from '../../../src/app-config/parsers';
import { applyDurabilityPragmas } from '../../../src/db/durability';
import Database from 'better-sqlite3';
import { execFileSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// journal_mode is written into the database file header, so it outlives the
// connection that set it. Anything asserting a mode here therefore reopens the
// file: that is the property operators on SMB/NFS volumes actually depend on.
const SERVER_ROOT = path.resolve(__dirname, '../../..');
let tmpDir: string;
let dbPath: string;
function readModes(file: string): { journalMode: string; synchronous: number } {
const db = new Database(file);
try {
return {
journalMode: String(db.pragma('journal_mode', { simple: true })).toUpperCase(),
synchronous: Number(db.pragma('synchronous', { simple: true })),
};
} finally {
db.close();
}
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-durability-'));
dbPath = path.join(tmpDir, 'travel.db');
delete process.env.TREK_DB_JOURNAL_MODE;
delete process.env.TREK_DB_SYNCHRONOUS;
});
afterEach(() => {
delete process.env.TREK_DB_JOURNAL_MODE;
delete process.env.TREK_DB_SYNCHRONOUS;
vi.restoreAllMocks();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe('resolveDurability', () => {
it('defaults to WAL with the synchronous level a WAL database already runs at', () => {
expect(resolveDurability(undefined, undefined)).toEqual({
journalMode: 'WAL',
synchronous: 'NORMAL',
warnings: [],
});
expect(resolveDurability('', '')).toEqual({ journalMode: 'WAL', synchronous: 'NORMAL', warnings: [] });
});
it('accepts every SQLite journal mode, case- and padding-insensitively', () => {
expect(resolveDurability('delete', undefined).journalMode).toBe('DELETE');
expect(resolveDurability(' Truncate ', undefined).journalMode).toBe('TRUNCATE');
expect(resolveDurability('PERSIST', undefined).journalMode).toBe('PERSIST');
expect(resolveDurability('memory', undefined).journalMode).toBe('MEMORY');
expect(resolveDurability('off', undefined).journalMode).toBe('OFF');
});
it('raises the default to FULL once a rollback journal is in play', () => {
// NORMAL is only safe under WAL; on DELETE/TRUNCATE it lets a power cut eat
// committed transactions, which would defeat the point of leaving WAL.
expect(resolveDurability('DELETE', undefined).synchronous).toBe('FULL');
expect(resolveDurability('TRUNCATE', undefined).synchronous).toBe('FULL');
expect(resolveDurability('WAL', undefined).synchronous).toBe('NORMAL');
});
it('lets synchronous be set independently of the journal mode', () => {
expect(resolveDurability('WAL', 'full').synchronous).toBe('FULL');
expect(resolveDurability('DELETE', 'normal').synchronous).toBe('NORMAL');
expect(resolveDurability(undefined, 'EXTRA').synchronous).toBe('EXTRA');
expect(resolveDurability(undefined, 'off').synchronous).toBe('OFF');
});
it('falls back and reports instead of rejecting an unusable value', () => {
const result = resolveDurability('WALL', 'sort-of');
expect(result.journalMode).toBe('WAL');
expect(result.synchronous).toBe('NORMAL');
expect(result.warnings).toHaveLength(2);
expect(result.warnings[0]).toContain('TREK_DB_JOURNAL_MODE="WALL"');
expect(result.warnings[1]).toContain('TREK_DB_SYNCHRONOUS="sort-of"');
});
it('keeps the per-mode synchronous default when only that value is bogus', () => {
const result = resolveDurability('DELETE', 'maybe');
expect(result.journalMode).toBe('DELETE');
expect(result.synchronous).toBe('FULL');
expect(result.warnings).toHaveLength(1);
});
});
describe('applyDurabilityPragmas', () => {
it('leaves an unconfigured install on WAL', () => {
const db = new Database(dbPath);
const active = applyDurabilityPragmas(db);
db.exec('CREATE TABLE t (a)');
db.close();
expect(active).toEqual({ journalMode: 'WAL', synchronous: 'NORMAL' });
expect(readModes(dbPath).journalMode).toBe('WAL');
});
it('switches the file to the configured mode and persists it', () => {
process.env.TREK_DB_JOURNAL_MODE = 'DELETE';
const wal = new Database(dbPath);
wal.exec('PRAGMA journal_mode = WAL');
wal.exec('CREATE TABLE t (a)');
wal.close();
expect(readModes(dbPath).journalMode).toBe('WAL');
const db = new Database(dbPath);
const active = applyDurabilityPragmas(db);
db.close();
expect(active.journalMode).toBe('DELETE');
// FULL (2) — a rollback journal without it is no safer than the WAL setup
// the operator moved away from.
expect(active.synchronous).toBe('FULL');
expect(readModes(dbPath)).toEqual({ journalMode: 'DELETE', synchronous: 2 });
});
it('applies an explicit synchronous level on top of the journal mode', () => {
process.env.TREK_DB_SYNCHRONOUS = 'FULL';
const db = new Database(dbPath);
const active = applyDurabilityPragmas(db);
expect(active).toEqual({ journalMode: 'WAL', synchronous: 'FULL' });
expect(Number(db.pragma('synchronous', { simple: true }))).toBe(2);
db.close();
});
it('warns and stays on the default when the mode is misspelled', () => {
process.env.TREK_DB_JOURNAL_MODE = 'DELET';
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const db = new Database(dbPath);
const active = applyDurabilityPragmas(db);
db.close();
expect(active.journalMode).toBe('WAL');
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0][0])).toContain('TREK_DB_JOURNAL_MODE="DELET"');
});
it('warns and keeps the mode default when synchronous is misspelled', () => {
process.env.TREK_DB_SYNCHRONOUS = 'VERY';
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const db = new Database(dbPath);
const active = applyDurabilityPragmas(db);
db.close();
expect(active.synchronous).toBe('NORMAL');
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0][0])).toContain('TREK_DB_SYNCHRONOUS="VERY"');
});
it('reports what the engine settled on, not what was asked for', () => {
process.env.TREK_DB_JOURNAL_MODE = 'WAL';
const db = new Database(':memory:');
expect(applyDurabilityPragmas(db).journalMode).toBe('MEMORY');
db.close();
});
});
// The other two processes that open travel.db read-write. They cannot import
// the module above (standalone scripts, and src/ is not in the container
// image), so they are exercised as processes — a copy of the resolution that
// drifts flips the header back and silently un-does the operator's choice.
describe('standalone scripts honour the same configuration', () => {
function seedWalDb(): void {
const db = new Database(dbPath);
db.exec('PRAGMA journal_mode = WAL');
db.exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE,
email TEXT UNIQUE,
password_hash TEXT,
role TEXT,
must_change_password INTEGER DEFAULT 0,
password_version INTEGER NOT NULL DEFAULT 0,
mfa_secret TEXT,
maps_api_key TEXT,
openweather_api_key TEXT,
immich_api_key TEXT,
synology_password TEXT,
synology_sid TEXT,
synology_did TEXT
);
CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE settings (user_id INTEGER, key TEXT, value TEXT);
CREATE TABLE trip_album_links (id INTEGER PRIMARY KEY, passphrase TEXT);
CREATE TABLE trek_photos (id INTEGER PRIMARY KEY, passphrase TEXT);
`);
db.close();
expect(readModes(dbPath).journalMode).toBe('WAL');
}
it('reset-admin.js applies TREK_DB_JOURNAL_MODE instead of leaving WAL behind', () => {
seedWalDb();
execFileSync(process.execPath, ['reset-admin.js'], {
cwd: SERVER_ROOT,
env: {
...process.env,
TREK_DB_FILE: dbPath,
TREK_DB_JOURNAL_MODE: 'DELETE',
RESET_ADMIN_EMAIL: 'locked-out@example.com',
RESET_ADMIN_PASSWORD: 'Recovery12345!',
},
stdio: 'pipe',
});
expect(readModes(dbPath)).toEqual({ journalMode: 'DELETE', synchronous: 2 });
const db = new Database(dbPath);
// test-sql-allow: the file reset-admin.js just rewrote has no ORM bound to it; it is read as the script left it.
const admin = db.prepare('SELECT role FROM users WHERE email = ?').get('locked-out@example.com') as
{ role: string } | undefined;
db.close();
expect(admin?.role).toBe('admin');
}, 60000);
it('reset-admin.js finds the account whatever the case, and ends its sessions', () => {
seedWalDb();
const seed = new Database(dbPath);
// test-sql-allow: the database file reset-admin.js works on has no ORM bound to it.
seed
.prepare(
"INSERT INTO users (username, email, password_hash, role, password_version) VALUES ('boss', 'Boss@Example.com', 'x', 'user', 3)",
)
.run();
seed.close();
execFileSync(process.execPath, ['reset-admin.js'], {
cwd: SERVER_ROOT,
env: {
...process.env,
TREK_DB_FILE: dbPath,
RESET_ADMIN_EMAIL: 'boss@example.com',
RESET_ADMIN_PASSWORD: 'Recovery12345!',
},
stdio: 'pipe',
});
const db = new Database(dbPath);
// test-sql-allow: the file reset-admin.js just rewrote has no ORM bound to it; it is read as the script left it.
const rows = db.prepare('SELECT email, role, password_version, must_change_password FROM users').all();
db.close();
expect(rows).toEqual([{ email: 'Boss@Example.com', role: 'admin', password_version: 4, must_change_password: 1 }]);
}, 60000);
it('migrate-encryption.ts no longer forces the database back to WAL', () => {
seedWalDb();
execFileSync(process.execPath, ['--import', 'tsx', 'scripts/migrate-encryption.ts'], {
cwd: SERVER_ROOT,
env: { ...process.env, DB_PATH: dbPath, TREK_DB_JOURNAL_MODE: 'DELETE' },
input: 'old-key\nnew-key\nyes\n',
stdio: 'pipe',
});
expect(readModes(dbPath)).toEqual({ journalMode: 'DELETE', synchronous: 2 });
}, 120000);
});