fix(db): stop losing secrets and notes to the boot and migration paths

An unreadable or empty encryption-key file was treated the same as a missing
one: regenerate and overwrite. That is the one case where regenerating is
the worst possible move - every secret already at rest becomes undecryptable,
permanently. Both now refuse to start and say which file to fix.

The key-rotation script advertised re-encrypting all stored secrets while its
hardcoded column lists had drifted: the AirTrail key, the per-user LLM key
and the plugin OAuth tokens were never migrated, so they silently stopped
decrypting after a rotation. The parity test now pins the full set against
what the code actually encrypts, rather than two tables of it.

The schema_version bump ran outside the migration's transaction, so a crash
in between left the schema advanced and the version stale - the next boot
replayed a non-idempotent step and exited, turning one crash into a boot
loop. And the TICKETJSON receipt migration matched with LIKE, which SQLite
treats case-insensitively, so a note somebody had typed starting 'ticketjson:'
was cut up and its text dropped.

Also: APP_VERSION was interpolated into migration SQL instead of bound, the
capture-metadata backfill passed (photoId, userId) to a (userId, photoId)
signature and its mock keyed on the wrong argument, TRUST_PROXY=0 was
accepted and silently coerced to 1, demo-reset hardcoded the db path instead
of honouring TREK_DB_FILE, and a demo instance with no DEMO_ADMIN_PASS set
now says out loud that its admin password is the published default.
This commit is contained in:
Maurice
2026-08-23 09:48:12 +02:00
committed by Maurice
parent 8e5fdaa877
commit a7aaa29c16
17 changed files with 699 additions and 16 deletions
+117 -2
View File
@@ -303,7 +303,22 @@ async function main() {
// unsplash_api_key was missing here for as long as the column has existed. It
// matters now that the resolver reads it as the per-user fallback (#1939): left
// out of a rotation it stays encrypted under the old key and reads back as unset.
const apiKeyColumns = ['maps_api_key', 'unsplash_api_key', 'openweather_api_key', 'immich_api_key', 'synology_password', 'synology_sid', 'synology_did'];
// airtrail_api_key arrived with a later migration, so an older database may
// not have the column at all — filter the list against the real table rather
// than letting one missing column throw the whole rotation.
const userColumns = new Set(
(db.prepare("SELECT name FROM pragma_table_info('users')").all() as { name: string }[]).map((r) => r.name),
);
const apiKeyColumns = [
'maps_api_key',
'unsplash_api_key',
'openweather_api_key',
'immich_api_key',
'synology_password',
'synology_sid',
'synology_did',
'airtrail_api_key',
].filter((c) => userColumns.has(c));
const users = db.prepare('SELECT id FROM users').all() as { id: number }[];
for (const user of users) {
@@ -389,7 +404,7 @@ async function main() {
}
// --- settings: per-user encrypted keys ---
const encryptedSettingKeys = ['webhook_url', 'ntfy_token', 'mapbox_access_token'];
const encryptedSettingKeys = ['webhook_url', 'ntfy_token', 'mapbox_access_token', 'llm_api_key'];
const settingRows = db.prepare(
`SELECT user_id, key, value FROM settings WHERE key IN (${encryptedSettingKeys.map(() => '?').join(', ')})`
).all(...encryptedSettingKeys) as { user_id: number; key: string; value: string }[];
@@ -402,6 +417,106 @@ async function main() {
}
}
// --- plugins: OAuth tokens and secret settings fields ---
// The plugin host encrypts with the same enc:v1: scheme as everything above:
// plugin-oauth.service stores both tokens encrypted, and plugins.service
// encrypts every settings field the manifest marked secret (instance scope in
// plugins.config, user scope in plugin_user_config.config). Left out of a
// rotation, a connected plugin reads back as "not connected" and a secret
// setting as unset, with nothing in the log to say why. Every table here
// arrived with a later migration, so each block checks that it exists.
const tableExists = (name: string): boolean =>
!!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
if (tableExists('plugin_oauth_tokens')) {
const tokenRows = db
.prepare('SELECT plugin_id, user_id, access_token, refresh_token FROM plugin_oauth_tokens')
.all() as { plugin_id: string; user_id: number; access_token: string | null; refresh_token: string | null }[];
for (const row of tokenRows) {
for (const col of ['access_token', 'refresh_token'] as const) {
const raw = row[col];
if (!raw) continue;
const newVal = migrateApiKeyValue(raw, `plugin_oauth_tokens[${row.plugin_id},${row.user_id}].${col}`);
if (newVal !== null) {
db.prepare(`UPDATE plugin_oauth_tokens SET ${col} = ? WHERE plugin_id = ? AND user_id = ?`).run(
newVal,
row.plugin_id,
row.user_id,
);
}
}
}
}
if (tableExists('plugin_settings_fields')) {
// Which keys are secret is per plugin and per scope, and it lives in the DB
// rather than in this script, so nothing has to be pinned by hand here.
const secretFields = db
.prepare("SELECT plugin_id, field_key, scope FROM plugin_settings_fields WHERE secret = 1")
.all() as { plugin_id: string; field_key: string; scope: string | null }[];
const secretKeysFor = (pluginId: string, scope: string): string[] =>
secretFields
.filter((f) => f.plugin_id === pluginId && (f.scope ?? 'instance') === scope)
.map((f) => f.field_key);
const migrateConfigJson = (raw: string | null, keys: string[], label: string): string | null => {
if (!raw || keys.length === 0) return null;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
result.errors.push(`${label}: invalid JSON — skipping`);
return null;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
result.errors.push(`${label}: not an object — skipping`);
return null;
}
const config = parsed as Record<string, unknown>;
let changed = false;
for (const key of keys) {
const value = config[key];
if (typeof value !== 'string' || !value) continue;
const newVal = migrateApiKeyValue(value, `${label}.${key}`);
if (newVal !== null) {
config[key] = newVal;
changed = true;
}
}
return changed ? JSON.stringify(config) : null;
};
if (tableExists('plugins')) {
const pluginRows = db.prepare('SELECT id, config FROM plugins').all() as { id: string; config: string | null }[];
for (const row of pluginRows) {
const next = migrateConfigJson(row.config, secretKeysFor(row.id, 'instance'), `plugins[${row.id}].config`);
if (next !== null) {
db.prepare('UPDATE plugins SET config = ? WHERE id = ?').run(next, row.id);
}
}
}
if (tableExists('plugin_user_config')) {
const userConfigRows = db
.prepare('SELECT plugin_id, user_id, config FROM plugin_user_config')
.all() as { plugin_id: string; user_id: number; config: string | null }[];
for (const row of userConfigRows) {
const next = migrateConfigJson(
row.config,
secretKeysFor(row.plugin_id, 'user'),
`plugin_user_config[${row.plugin_id},${row.user_id}].config`,
);
if (next !== null) {
db.prepare('UPDATE plugin_user_config SET config = ? WHERE plugin_id = ? AND user_id = ?').run(
next,
row.plugin_id,
row.user_id,
);
}
}
}
}
// --- trip_album_links: passphrase ---
const albumLinks = db.prepare('SELECT id, passphrase FROM trip_album_links WHERE passphrase IS NOT NULL').all() as { id: number; passphrase: string }[];
for (const row of albumLinks) {
-1
View File
@@ -108,7 +108,6 @@ The exemption list is enforced by the `no-restricted-syntax` ban on
- `numberOr` (`Number(x) || d`) treats `"0"` and negative-invalid values oddly:
`PORT=0` silently becomes 3001, `TREK_PLUGIN_RPC_BURST=-5` stays -5.
- `TRUST_PROXY=0` resolves to 1 hop (`parseInt || 1`).
- APP_URL trailing-slash stripping differs per site (feeds strips one slash,
notifications strips all).
- `DEMO_ADMIN_EMAIL` defaults differ (demo-seed: admin@trek.app, demo-reset:
+6 -1
View File
@@ -63,6 +63,9 @@ function resolveDefaultLanguage(raw: string | undefined): string {
}
export function deriveHttp(raw: RawEnv) {
// env.schema.ts accepts 0 as a valid hop count, so `|| 1` would quietly turn
// "trust nothing" into "trust one hop" and let a forged X-Forwarded-For through.
const trustProxyHops = Number.parseInt(raw.TRUST_PROXY ?? '', 10);
return {
allowedOriginsRaw: raw.ALLOWED_ORIGINS,
/** globalMiddleware CORS variant: trim + drop empty entries. */
@@ -70,7 +73,7 @@ export function deriveHttp(raw: RawEnv) {
/** websocket variant: trim only — an empty entry stays (and can never match an Origin header). */
wsOrigins: csvList(raw.ALLOWED_ORIGINS),
trustProxyRaw: raw.TRUST_PROXY,
trustProxy: Number.parseInt(raw.TRUST_PROXY ?? '') || 1,
trustProxy: Number.isFinite(trustProxyHops) ? trustProxyHops : 1,
forceHttps: parseBool(raw.FORCE_HTTPS) === true,
hstsIncludeSubdomains: parseBool(raw.HSTS_INCLUDE_SUBDOMAINS) === true,
/** COOKIE_SECURE is tri-state: an explicit falsy value disables secure cookies; anything else means auto-detect. */
@@ -144,6 +147,8 @@ export function deriveDemo(raw: RawEnv) {
/** Raw on purpose: demo-seed defaults to admin@trek.app, demo-reset to admin@nomad.app. */
adminEmailRaw: raw.DEMO_ADMIN_EMAIL,
adminPass: raw.DEMO_ADMIN_PASS || 'admin12345',
/** False means the admin account is being created with the published default. */
adminPassSet: !!raw.DEMO_ADMIN_PASS,
};
}
+21 -1
View File
@@ -36,10 +36,30 @@ if (_encryptionKey) {
// Try the dedicated key file first (covers all installs after first start).
try {
_encryptionKey = fs.readFileSync(encKeyFile, 'utf8').trim();
} catch {
} catch (readErr: unknown) {
if ((readErr as NodeJS.ErrnoException)?.code !== 'ENOENT') {
// The file is there, we just cannot read it (permissions, I/O error).
// Continuing would resolve a different key and overwrite this one below,
// which permanently orphans every stored secret. Refuse to start instead.
console.error(
'FATAL: could not read',
encKeyFile,
'-',
readErr instanceof Error ? readErr.message : readErr,
);
console.error('Fix the file permissions or set ENCRYPTION_KEY explicitly.');
process.exit(1);
}
// File not found — first start on an existing or fresh install.
}
if (!_encryptionKey && fs.existsSync(encKeyFile)) {
// Present but empty: a truncated write, a full disk, a broken restore.
// Same reasoning as above. Regenerating here would silently replace the real key.
console.error('FATAL:', encKeyFile, 'is empty. Restore it from backup or set ENCRYPTION_KEY.');
process.exit(1);
}
if (!_encryptionKey) {
// One-time migration: existing install upgrading for the first time.
// Use the JWT secret as the encryption key and immediately write it to
+20 -5
View File
@@ -2721,7 +2721,9 @@ function runMigrations(db: Database.Database): void {
`CREATE TABLE IF NOT EXISTS migrations (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, timestamp bigint NOT NULL, name varchar NOT NULL);`,
);
db.exec(`INSERT INTO migrations (timestamp, name) VALUES (1777810195344, 'InitialSchema1777810195344');`);
db.exec(`INSERT INTO app_settings (key, value) VALUES ('app_version', '${readEnv().app.appVersion || '3.0.14'}')`);
db.prepare("INSERT INTO app_settings (key, value) VALUES ('app_version', ?)").run(
readEnv().app.appVersion || '3.0.14',
);
},
// trim leading/trailing whitespace from stored usernames and emails
() => {
@@ -2739,7 +2741,9 @@ function runMigrations(db: Database.Database): void {
db.exec(`INSERT INTO schema_version_new (version) SELECT version FROM schema_version`);
db.exec(`DROP TABLE schema_version`);
db.exec(`ALTER TABLE schema_version_new RENAME TO schema_version`);
db.exec(`UPDATE app_settings SET value = '${readEnv().app.appVersion || '3.0.15'}' WHERE key = 'app_version'`);
db.prepare("UPDATE app_settings SET value = ? WHERE key = 'app_version'").run(
readEnv().app.appVersion || '3.0.15',
);
},
// Migration: OAuth 2.0 client_credentials grant — allow user-owned confidential
// clients to skip the browser consent flow entirely and obtain tokens directly
@@ -3847,13 +3851,15 @@ function runMigrations(db: Database.Database): void {
// means an expense split by receipt can never carry a written note, and
// saving an expense any other way wipes whatever was typed in the budget
// table. The receipt moves to its own column and note becomes text again.
// GLOB, not LIKE: LIKE is case-insensitive in SQLite, so a hand-written note
// starting "ticketjson:" would be chopped up and its text dropped.
() => {
const hasTicket = db.prepare("SELECT 1 FROM pragma_table_info('budget_items') WHERE name = 'ticket_json'").get();
if (!hasTicket) db.exec('ALTER TABLE budget_items ADD COLUMN ticket_json TEXT');
db.exec(`
UPDATE budget_items
SET ticket_json = substr(note, 12), note = NULL
WHERE note LIKE 'TICKETJSON:%'
WHERE note GLOB 'TICKETJSON:*'
`);
},
@@ -4091,15 +4097,24 @@ function runMigrations(db: Database.Database): void {
try {
const migration = migrations[i];
if (typeof migration === 'function') {
db.transaction(migration)();
// The version bump has to commit together with the migration. Bumped
// afterwards, a crash in between leaves the schema advanced and the
// version stale, and the next boot replays a step that is not
// idempotent, exits 1, and turns one crash into a permanent boot loop.
db.transaction(() => {
migration();
db.prepare('UPDATE schema_version SET version = ?').run(i + 1);
})();
} else {
// raw steps run outside a transaction on purpose (see PRAGMA
// foreign_keys above), so the bump stays a separate statement.
migration.raw();
db.prepare('UPDATE schema_version SET version = ?').run(i + 1);
}
} catch (err) {
console.error(`[migrations] FATAL: Migration ${i + 1} failed, rolled back:`, err);
process.exit(1);
}
db.prepare('UPDATE schema_version SET version = ?').run(i + 1);
}
console.log(`[DB] Migrations complete — schema version ${migrations.length}`);
}
+17 -1
View File
@@ -3,9 +3,15 @@ import path from 'path';
import { readEnv } from '../app-config';
const dataDir = path.join(__dirname, '../../data');
const dbPath = path.join(dataDir, 'travel.db');
const baselinePath = path.join(dataDir, 'travel-baseline.db');
// Where the live DB actually is. database.ts honours TREK_DB_FILE, so hardcoding
// data/travel.db here would copy the baseline over an unrelated file and leave
// the database we just closed untouched. The open connection knows its own path.
function liveDbPath(db: { name: string }): string {
return db.name;
}
function resetDemoUser(): void {
if (!fs.existsSync(baselinePath)) {
console.log('[Demo Reset] No baseline found, skipping. Admin must save baseline first.');
@@ -13,6 +19,11 @@ function resetDemoUser(): void {
}
const { db, closeDb, reinitialize } = require('../db/database');
const dbPath = liveDbPath(db);
if (dbPath === ':memory:') {
console.log('[Demo Reset] In-memory database, nothing to restore.');
return;
}
// Save admin's current credentials and API keys (these should survive the reset)
// NOTE: different default than demo-seed (admin@trek.app) — pinned legacy quirk.
@@ -98,6 +109,11 @@ function resetDemoUser(): void {
function saveBaseline(): void {
const { db } = require('../db/database');
const dbPath = liveDbPath(db);
if (dbPath === ':memory:') {
console.log('[Demo] In-memory database, no baseline to save.');
return;
}
// Flush WAL so baseline file is self-contained
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) {}
+12 -1
View File
@@ -1,6 +1,9 @@
import bcrypt from 'bcryptjs';
import Database from 'better-sqlite3';
import { readEnv } from '../app-config';
// Static like in demo-reset.job.ts: the module top is inert, everything that
// touches the database happens inside the functions.
import { saveBaseline, hasBaseline } from './demo-reset';
function seedDemoData(db: Database.Database): { adminId: number; demoId: number } {
const ADMIN_USER = readEnv().demo.adminUser;
@@ -12,6 +15,15 @@ function seedDemoData(db: Database.Database): { adminId: number; demoId: number
// Create admin user if not exists
let admin = db.prepare('SELECT id FROM users WHERE email = ?').get(ADMIN_EMAIL) as { id: number } | undefined;
if (!admin) {
if (!readEnv().demo.adminPassSet) {
// The default is published in the docs and in this file, so an operator who
// never set DEMO_ADMIN_PASS is handing out an admin account. Say so loudly;
// changing the default would lock out every existing demo instance.
console.warn(
`[Demo] SECURITY: DEMO_ADMIN_PASS is not set. The admin account ${ADMIN_EMAIL} is being created ` +
'with the public default password. Set DEMO_ADMIN_PASS before exposing this instance.',
);
}
const hash = bcrypt.hashSync(ADMIN_PASS, 10);
const r = db.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)').run(ADMIN_USER, ADMIN_EMAIL, hash, 'admin');
admin = { id: Number(r.lastInsertRowid) };
@@ -46,7 +58,6 @@ function seedDemoData(db: Database.Database): { adminId: number; demoId: number
seedExampleTrips(db, admin.id, demo.id);
// Auto-save baseline after first seed
const { saveBaseline, hasBaseline } = require('./demo-reset');
if (!hasBaseline()) {
saveBaseline();
}
@@ -48,7 +48,7 @@ export class PhotoCaptureBackfillService {
continue;
}
const info = await this.resolver.getPhotoInfo(id, userId);
const info = await this.resolver.getPhotoInfo(userId, id);
if (!info.success) continue;
this.photos.recordCaptureMetadata(id, {
-1
View File
@@ -1,4 +1,3 @@
import { createRequire } from 'module';
import semver from 'semver';
import { readEnv } from '../app-config';
import { db } from '../db/database.js';
+9 -1
View File
@@ -61,8 +61,10 @@ describe('deriveHttp', () => {
expect(deriveHttp({}).wsOrigins).toBeNull();
});
it('TRUST_PROXY: parseInt || 1 (globalMiddleware)', () => {
it('TRUST_PROXY: numeric hop count wins, 1 only as fallback (globalMiddleware)', () => {
expect(deriveHttp({ TRUST_PROXY: '2' }).trustProxy).toBe(2);
// 0 is a valid hop count ("trust no proxy"), not a missing value.
expect(deriveHttp({ TRUST_PROXY: '0' }).trustProxy).toBe(0);
expect(deriveHttp({ TRUST_PROXY: 'abc' }).trustProxy).toBe(1);
expect(deriveHttp({}).trustProxy).toBe(1);
});
@@ -125,6 +127,12 @@ describe('deriveDemo', () => {
expect(deriveDemo({}).adminUser).toBe('admin');
expect(deriveDemo({}).adminPass).toBe('admin12345');
});
it('DEMO_ADMIN_PASS: adminPassSet tells the default apart from an explicit value', () => {
expect(deriveDemo({}).adminPassSet).toBe(false);
expect(deriveDemo({ DEMO_ADMIN_PASS: '' }).adminPassSet).toBe(false);
expect(deriveDemo({ DEMO_ADMIN_PASS: 'hunter22' }).adminPassSet).toBe(true);
});
});
describe('deriveOidc', () => {
+89
View File
@@ -0,0 +1,89 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// config.ts resolves its key material at import time, so every case here has to
// reset the module registry and re-import it with the file system stubbed out.
const ENC_KEY_FILE = path.resolve(__dirname, '../../data/.encryption_key');
const JWT_SECRET_FILE = path.resolve(__dirname, '../../data/.jwt_secret');
const enoent = (): NodeJS.ErrnoException => {
const err = new Error('ENOENT: no such file or directory') as NodeJS.ErrnoException;
err.code = 'ENOENT';
return err;
};
describe('config — encryption key resolution', () => {
const realEnvKey = process.env.ENCRYPTION_KEY;
const realRead = fs.readFileSync;
beforeEach(() => {
// The env var short-circuits the whole file-resolution chain under test.
delete process.env.ENCRYPTION_KEY;
vi.resetModules();
});
afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
process.env.ENCRYPTION_KEY = realEnvKey;
});
// Only the key file itself is faked; everything else keeps reading from disk,
// because app-config and its dependants load in the same import.
const stubKeyFileRead = (impl: () => string) => {
vi.spyOn(fs, 'readFileSync').mockImplementation(((file: fs.PathOrFileDescriptor, ...rest: unknown[]) => {
if (String(file) === ENC_KEY_FILE) return impl();
if (String(file) === JWT_SECRET_FILE) throw enoent();
return (realRead as (...args: unknown[]) => unknown)(file, ...rest);
}) as typeof fs.readFileSync);
};
it('CFGKEY-001: aborts boot when the key file cannot be read for a reason other than ENOENT', async () => {
const exit = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit called');
}) as never);
const write = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined);
vi.spyOn(console, 'error').mockImplementation(() => undefined);
stubKeyFileRead(() => {
const err = new Error('EACCES: permission denied') as NodeJS.ErrnoException;
err.code = 'EACCES';
throw err;
});
await expect(import('../../src/config')).rejects.toThrow('process.exit called');
expect(exit).toHaveBeenCalledWith(1);
expect(write).not.toHaveBeenCalledWith(ENC_KEY_FILE, expect.anything(), expect.anything());
});
it('CFGKEY-002: refuses to regenerate over an existing but empty key file', async () => {
const exit = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit called');
}) as never);
const write = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined);
vi.spyOn(console, 'error').mockImplementation(() => undefined);
vi.spyOn(fs, 'existsSync').mockImplementation(((file: fs.PathLike) => String(file) === ENC_KEY_FILE) as typeof fs.existsSync);
stubKeyFileRead(() => ' \n');
await expect(import('../../src/config')).rejects.toThrow('process.exit called');
expect(exit).toHaveBeenCalledWith(1);
expect(write).not.toHaveBeenCalledWith(ENC_KEY_FILE, expect.anything(), expect.anything());
});
it('CFGKEY-003: still auto-generates a key when nothing is on disk yet', async () => {
const exit = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit called');
}) as never);
vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined);
vi.spyOn(console, 'log').mockImplementation(() => undefined);
// The key file is genuinely absent, so the empty-file guard must not fire.
vi.spyOn(fs, 'existsSync').mockImplementation(((file: fs.PathLike) => String(file) !== ENC_KEY_FILE) as typeof fs.existsSync);
stubKeyFileRead(() => {
throw enoent();
});
const config = await import('../../src/config');
expect(exit).not.toHaveBeenCalled();
expect(config.ENCRYPTION_KEY).toMatch(/^[0-9a-f]{64}$/);
});
});
@@ -0,0 +1,57 @@
/**
* Boot migration: move the itemized receipt out of budget_items.note (#1658).
*
* The costs UI stored the receipt as a `TICKETJSON:` prefix on the note, so the
* migration lifts it into its own column and clears the note. The match has to
* be case-SENSITIVE: LIKE is not, which would swallow a hand-written note that
* happens to start with the same word.
*/
import { describe, it, expect } from 'vitest';
import Database from 'better-sqlite3';
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
function makeDbWithNotes(): Database.Database {
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA busy_timeout = 5000');
db.exec('PRAGMA foreign_keys = ON');
createTables(db);
// Minimal FK chain: user -> trip -> budget_items rows.
db.prepare("INSERT INTO users (id, username, email, password_hash) VALUES (1, 'u', 'u@example.test', 'x')").run();
db.prepare("INSERT INTO trips (id, user_id, title) VALUES (1, 1, 'T')").run();
db.prepare(
'INSERT INTO budget_items (id, trip_id, name, note) VALUES (1, 1, ?, ?), (2, 1, ?, ?)',
).run('Dinner', 'TICKETJSON:{"items":[]}', 'Museum', 'ticketjson: buy at the door');
return db;
}
describe('budget_items ticket_json migration', () => {
it('moves a real receipt into ticket_json and clears the note', () => {
const db = makeDbWithNotes();
try {
runMigrations(db);
const row = db.prepare('SELECT note, ticket_json FROM budget_items WHERE id = 1').get() as {
note: string | null;
ticket_json: string | null;
};
expect(row).toEqual({ note: null, ticket_json: '{"items":[]}' });
} finally {
db.close();
}
});
it('leaves a lowercase note alone (LIKE would have eaten it)', () => {
const db = makeDbWithNotes();
try {
runMigrations(db);
const row = db.prepare('SELECT note, ticket_json FROM budget_items WHERE id = 2').get() as {
note: string | null;
ticket_json: string | null;
};
expect(row).toEqual({ note: 'ticketjson: buy at the door', ticket_json: null });
} finally {
db.close();
}
});
});
@@ -12,6 +12,7 @@
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { execFileSync } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@@ -47,6 +48,40 @@ const storageBackendsSection = (() => {
return script.slice(start, end);
})();
// The users loop only, bounded by its own marker and the next section.
const usersSection = (() => {
const start = script.indexOf('--- users:');
expect(start).toBeGreaterThan(-1);
const end = script.indexOf('--- app_settings: storage.backends ---', start);
expect(end).toBeGreaterThan(start);
return script.slice(start, end);
})();
// The per-user settings loop only.
const settingsSection = (() => {
const start = script.indexOf('--- settings:');
expect(start).toBeGreaterThan(-1);
const end = script.indexOf('--- plugins:', start);
expect(end).toBeGreaterThan(start);
return script.slice(start, end);
})();
// ENCRYPTED_SETTING_KEYS is module-private in settings.service.ts, so the
// canonical list is read off the source rather than imported.
const canonicalSettingKeys = (() => {
const source = fs.readFileSync(path.join(SERVER_ROOT, 'src', 'nest', 'settings', 'settings.service.ts'), 'utf8');
const marker = 'const ENCRYPTED_SETTING_KEYS = new Set([';
const start = source.indexOf(marker);
expect(start).toBeGreaterThan(-1);
const end = source.indexOf(']', start + marker.length);
return source
.slice(start + marker.length, end)
.split(',')
.map((part) => part.trim())
.filter(Boolean)
.map((quoted) => quoted.slice(1, -1));
})();
describe('migrate-encryption.ts app_settings parity', () => {
it('ROTPAR-001: rotates every instance-wide API key', () => {
for (const name of INSTANCE_API_KEY_NAMES) {
@@ -71,6 +106,36 @@ describe('migrate-encryption.ts app_settings parity', () => {
expect(storageBackendsSection).toContain(`'${field}'`);
}
});
// airtrail.service.ts stores the key with maybe_encrypt_api_key, i.e. the same
// enc:v1: scheme; missed by a rotation the integration reads back as unset.
it('ROTPAR-006: rotates the per-user AirTrail key', () => {
const airtrail = fs.readFileSync(
path.join(SERVER_ROOT, 'src', 'nest', 'integrations', 'airtrail.service.ts'),
'utf8',
);
expect(airtrail).toContain('maybe_encrypt_api_key');
expect(airtrail).toContain('airtrail_api_key');
expect(usersSection).toContain("'airtrail_api_key'");
});
it('ROTPAR-007: rotates every encrypted per-user setting key', () => {
expect(canonicalSettingKeys.length).toBeGreaterThan(0);
for (const key of canonicalSettingKeys) {
expect(settingsSection).toContain(`'${key}'`);
}
});
// Both plugin secret stores use the same scheme: OAuth tokens encrypted by
// plugin-oauth.service, and the config fields a manifest marked secret.
it('ROTPAR-008: rotates the plugin OAuth tokens and both config stores', () => {
expect(script).toContain('plugin_oauth_tokens');
expect(script).toContain('access_token');
expect(script).toContain('refresh_token');
expect(script).toContain('plugin_settings_fields');
expect(script).toContain("UPDATE plugins SET config = ?");
expect(script).toContain('plugin_user_config');
});
});
describe('migrate-encryption.ts storage.backends: malformed shape', () => {
@@ -132,3 +197,117 @@ describe('migrate-encryption.ts storage.backends: malformed shape', () => {
expect(output).toContain('app_settings.storage.backends: not an array — skipping');
}, 30000);
});
// The same enc:v1: scheme the app and the script share, so a fixture can be
// written under the OLD key and read back under the NEW one.
const ENCRYPTED_PREFIX = 'enc:v1:';
const apiKeyFor = (key: string) => crypto.createHash('sha256').update(`${key}:api_keys:v1`).digest();
function encryptWith(key: string, plain: string): string {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', apiKeyFor(key), iv);
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
return `${ENCRYPTED_PREFIX}${Buffer.concat([iv, cipher.getAuthTag(), enc]).toString('base64')}`;
}
function decryptWith(key: string, value: string): string | null {
if (!value.startsWith(ENCRYPTED_PREFIX)) return null;
try {
const buf = Buffer.from(value.slice(ENCRYPTED_PREFIX.length), 'base64');
const decipher = crypto.createDecipheriv('aes-256-gcm', apiKeyFor(key), buf.subarray(0, 12));
decipher.setAuthTag(buf.subarray(12, 28));
return Buffer.concat([decipher.update(buf.subarray(28)), decipher.final()]).toString('utf8');
} catch {
return null;
}
}
const BASE_SCHEMA = `
CREATE TABLE users (
id INTEGER PRIMARY KEY,
maps_api_key TEXT, unsplash_api_key TEXT, openweather_api_key TEXT,
immich_api_key TEXT, synology_password TEXT, synology_sid TEXT,
synology_did TEXT, airtrail_api_key TEXT, mfa_secret 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);
CREATE TABLE plugins (id TEXT PRIMARY KEY, config TEXT);
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER);
CREATE TABLE plugin_oauth_tokens (
plugin_id TEXT, user_id INTEGER, access_token TEXT, refresh_token TEXT,
PRIMARY KEY (plugin_id, user_id)
);
CREATE TABLE plugin_user_config (plugin_id TEXT, user_id INTEGER, config TEXT);
`;
describe('migrate-encryption.ts rotates the stores the app writes', () => {
let tmpDir: string;
let dbPath: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-migrate-encryption-plugins-'));
dbPath = path.join(tmpDir, 'travel.db');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('ROTPAR-009: re-encrypts AirTrail, llm_api_key, plugin OAuth tokens and plugin configs', () => {
const seed = new Database(dbPath);
seed.exec(BASE_SCHEMA);
seed.prepare('INSERT INTO users (id, airtrail_api_key) VALUES (1, ?)').run(encryptWith('old-key', 'at-token'));
seed.prepare('INSERT INTO settings (user_id, key, value) VALUES (1, ?, ?)').run('llm_api_key', encryptWith('old-key', 'sk-llm'));
seed.prepare('INSERT INTO plugins (id, config) VALUES (?, ?)').run(
'koffi',
JSON.stringify({ api_key: encryptWith('old-key', 'plugin-secret'), server: 'https://h' }),
);
seed.prepare('INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES (?, ?, ?, ?)').run('koffi', 'api_key', 'instance', 1);
seed.prepare('INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES (?, ?, ?, ?)').run('koffi', 'token', 'user', 1);
seed.prepare('INSERT INTO plugin_user_config (plugin_id, user_id, config) VALUES (?, ?, ?)').run(
'koffi',
1,
JSON.stringify({ token: encryptWith('old-key', 'user-secret') }),
);
seed.prepare('INSERT INTO plugin_oauth_tokens (plugin_id, user_id, access_token, refresh_token) VALUES (?, ?, ?, ?)').run(
'koffi',
1,
encryptWith('old-key', 'access'),
encryptWith('old-key', 'refresh'),
);
seed.close();
execFileSync(process.execPath, ['--import', 'tsx', 'scripts/migrate-encryption.ts'], {
cwd: SERVER_ROOT,
env: { ...process.env, DB_PATH: dbPath },
input: ['old-key', 'new-key', 'yes', ''].join(String.fromCharCode(10)),
stdio: 'pipe',
encoding: 'utf8',
});
const after = new Database(dbPath, { readonly: true });
const airtrail = (after.prepare('SELECT airtrail_api_key AS v FROM users WHERE id = 1').get() as { v: string }).v;
expect(decryptWith('new-key', airtrail)).toBe('at-token');
const llm = (after.prepare("SELECT value AS v FROM settings WHERE key = 'llm_api_key'").get() as { v: string }).v;
expect(decryptWith('new-key', llm)).toBe('sk-llm');
const tokens = after.prepare('SELECT access_token, refresh_token FROM plugin_oauth_tokens').get() as {
access_token: string;
refresh_token: string;
};
expect(decryptWith('new-key', tokens.access_token)).toBe('access');
expect(decryptWith('new-key', tokens.refresh_token)).toBe('refresh');
const pluginConfig = JSON.parse((after.prepare("SELECT config AS v FROM plugins WHERE id = 'koffi'").get() as { v: string }).v);
expect(decryptWith('new-key', pluginConfig.api_key)).toBe('plugin-secret');
// A non-secret field is left exactly as it was.
expect(pluginConfig.server).toBe('https://h');
const userConfig = JSON.parse((after.prepare('SELECT config AS v FROM plugin_user_config').get() as { v: string }).v);
expect(decryptWith('new-key', userConfig.token)).toBe('user-secret');
after.close();
}, 30000);
});
@@ -0,0 +1,55 @@
/**
* The schema_version bump must commit with the migration that earned it.
*
* If it lands in a statement of its own, a crash between the two leaves the
* schema advanced and the version stale, and the next boot replays a step that
* is not idempotent (an INSERT INTO app_settings, say) and exits 1 forever.
*/
import { describe, it, expect, vi } from 'vitest';
import Database from 'better-sqlite3';
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
function migratedDb(): Database.Database {
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA busy_timeout = 5000');
db.exec('PRAGMA foreign_keys = ON');
createTables(db);
runMigrations(db);
return db;
}
describe('migration version bump atomicity', () => {
it('MIGRATE-ATOMIC-001: writes schema_version inside the migration transaction', () => {
const db = migratedDb();
try {
const { version } = db.prepare('SELECT version FROM schema_version LIMIT 1').get() as { version: number };
// Rewind one slot so exactly the last migration replays.
db.prepare('UPDATE schema_version SET version = ?').run(version - 1);
const realPrepare = db.prepare.bind(db);
const inTransaction: boolean[] = [];
vi.spyOn(db, 'prepare').mockImplementation(((sql: string) => {
const stmt = realPrepare(sql);
if (sql.includes('UPDATE schema_version SET version')) {
const realRun = stmt.run.bind(stmt);
stmt.run = ((...args: unknown[]) => {
inTransaction.push(db.inTransaction);
return realRun(...(args as never[]));
}) as typeof stmt.run;
}
return stmt;
}) as typeof db.prepare);
runMigrations(db);
expect(inTransaction).toEqual([true]);
const after = db.prepare('SELECT version FROM schema_version LIMIT 1').get() as { version: number };
expect(after.version).toBe(version);
} finally {
vi.restoreAllMocks();
db.close();
}
});
});
@@ -0,0 +1,40 @@
/**
* Structural guard for demo-reset's DB path.
*
* demo-reset closes the live connection, copies the baseline over the database
* file and reopens. It used to compute that file itself as data/travel.db, which
* is wrong the moment TREK_DB_FILE moves the database elsewhere: the baseline
* lands on an unrelated file and the real database is never reset. The path has
* to come from the connection that database.ts actually opened.
*
* It is pinned on the source rather than executed because demo-reset resolves
* ../db/database through a runtime require() (the module is a boot-time
* singleton), which vitest's module runner cannot resolve.
*/
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
const source = fs.readFileSync(path.join(__dirname, '..', '..', '..', 'src', 'demo', 'demo-reset.ts'), 'utf8');
describe('demo-reset DB path', () => {
it('DEMORESET-001: never hardcodes the default travel.db path', () => {
expect(source).not.toContain("'travel.db'");
});
it('DEMORESET-002: takes the path from the open connection', () => {
expect(source).toContain('db.name');
// Both entry points, not just one of them.
const uses = source.match(/liveDbPath\(db\)/g) ?? [];
expect(uses.length).toBe(2);
});
it('DEMORESET-003: keeps the baseline next to the data directory', () => {
expect(source).toContain("path.join(dataDir, 'travel-baseline.db')");
});
it('DEMORESET-004: bails out on an in-memory database instead of copying files', () => {
const guards = source.match(/=== ':memory:'/g) ?? [];
expect(guards.length).toBe(2);
});
});
+64
View File
@@ -0,0 +1,64 @@
/**
* Demo mode boot seeding.
*
* DEMO_MODE=true creates a role=admin account on first boot. When
* DEMO_ADMIN_PASS is unset that account gets the password published in
* demo-seed itself, so the seeder has to say so out loud.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type Database from 'better-sqlite3';
import { createTestDb } from '../../helpers/test-db';
// Baseline handling is demo-reset's job and touches the file system.
vi.mock('../../../src/demo/demo-reset', () => ({
saveBaseline: vi.fn(),
hasBaseline: vi.fn(() => true),
resetDemoUser: vi.fn(),
}));
import { seedDemoData } from '../../../src/demo/demo-seed';
describe('demo seeding', () => {
let db: Database.Database;
const realPass = process.env.DEMO_ADMIN_PASS;
beforeEach(() => {
db = createTestDb();
vi.spyOn(console, 'log').mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
db.close();
if (realPass === undefined) delete process.env.DEMO_ADMIN_PASS;
else process.env.DEMO_ADMIN_PASS = realPass;
});
it('DEMOSEED-001: warns when the admin account is created with the default password', () => {
delete process.env.DEMO_ADMIN_PASS;
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
seedDemoData(db);
expect(warn).toHaveBeenCalledWith(expect.stringContaining('DEMO_ADMIN_PASS is not set'));
});
it('DEMOSEED-002: stays quiet when the operator set DEMO_ADMIN_PASS', () => {
process.env.DEMO_ADMIN_PASS = 'a-real-password';
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
seedDemoData(db);
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('DEMO_ADMIN_PASS is not set'));
});
it('DEMOSEED-003: does not warn again once the admin account exists', () => {
delete process.env.DEMO_ADMIN_PASS;
seedDemoData(db);
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
seedDemoData(db);
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining('DEMO_ADMIN_PASS is not set'));
});
});
@@ -23,7 +23,10 @@ type Row = { id: number; provider?: string; file_path?: string | null; taken_at?
function build(rows: Row[], info: Record<number, unknown>) {
const recordCaptureMetadata = vi.fn();
const getPhotoInfo = vi.fn(async (id: number) =>
// Keyed on the SECOND argument on purpose: the resolver's signature is
// (userId, photoId), and a mock that keys on the first one passes just as
// happily when the two are swapped at the call site.
const getPhotoInfo = vi.fn(async (_userId: number, id: number) =>
info[id] ? { success: true, data: info[id] } : { success: false, error: 'nope', status: 404 },
);
const photos = {
@@ -92,6 +95,14 @@ describe('PhotoCaptureBackfillService', () => {
});
});
it('CAPTURE-006a: passes the acting user and the photo id in the order the resolver declares', async () => {
const { svc, getPhotoInfo } = build([{ id: 7 }], { 7: { takenAt: '2026-03-15T10:20:00Z' } });
await svc.run([7], 42);
expect(getPhotoInfo).toHaveBeenCalledWith(42, 7);
});
it('CAPTURE-006: an empty batch touches nothing', () => {
const { svc, getPhotoInfo } = build([], {});
svc.schedule([], 1);