diff --git a/server/scripts/migrate-encryption.ts b/server/scripts/migrate-encryption.ts index 5f080e471..c00191d97 100644 --- a/server/scripts/migrate-encryption.ts +++ b/server/scripts/migrate-encryption.ts @@ -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; + 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) { diff --git a/server/src/app-config/README.md b/server/src/app-config/README.md index e07d8be4a..7244339ff 100644 --- a/server/src/app-config/README.md +++ b/server/src/app-config/README.md @@ -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: diff --git a/server/src/app-config/derive.ts b/server/src/app-config/derive.ts index 58f15fb42..0d02eea01 100644 --- a/server/src/app-config/derive.ts +++ b/server/src/app-config/derive.ts @@ -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, }; } diff --git a/server/src/config.ts b/server/src/config.ts index a58090ed9..ea963ab0e 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -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 diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 27d1d700a..3d382a65b 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -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}`); } diff --git a/server/src/demo/demo-reset.ts b/server/src/demo/demo-reset.ts index 4eec2c538..d0a911cc4 100644 --- a/server/src/demo/demo-reset.ts +++ b/server/src/demo/demo-reset.ts @@ -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) {} diff --git a/server/src/demo/demo-seed.ts b/server/src/demo/demo-seed.ts index f9cd553e4..c10291055 100644 --- a/server/src/demo/demo-seed.ts +++ b/server/src/demo/demo-seed.ts @@ -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(); } diff --git a/server/src/nest/memories/photo-capture-backfill.service.ts b/server/src/nest/memories/photo-capture-backfill.service.ts index a41908878..b7894d6fa 100644 --- a/server/src/nest/memories/photo-capture-backfill.service.ts +++ b/server/src/nest/memories/photo-capture-backfill.service.ts @@ -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, { diff --git a/server/src/systemNotices/service.ts b/server/src/systemNotices/service.ts index 4637497c9..f53bcf1a7 100644 --- a/server/src/systemNotices/service.ts +++ b/server/src/systemNotices/service.ts @@ -1,4 +1,3 @@ -import { createRequire } from 'module'; import semver from 'semver'; import { readEnv } from '../app-config'; import { db } from '../db/database.js'; diff --git a/server/tests/unit/app-config/derive.test.ts b/server/tests/unit/app-config/derive.test.ts index 7886637ed..911f854fe 100644 --- a/server/tests/unit/app-config/derive.test.ts +++ b/server/tests/unit/app-config/derive.test.ts @@ -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', () => { diff --git a/server/tests/unit/config.test.ts b/server/tests/unit/config.test.ts new file mode 100644 index 000000000..a38567387 --- /dev/null +++ b/server/tests/unit/config.test.ts @@ -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}$/); + }); +}); diff --git a/server/tests/unit/db/budget-ticket-json-migration.test.ts b/server/tests/unit/db/budget-ticket-json-migration.test.ts new file mode 100644 index 000000000..3b33821e0 --- /dev/null +++ b/server/tests/unit/db/budget-ticket-json-migration.test.ts @@ -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(); + } + }); +}); diff --git a/server/tests/unit/db/migrate-encryption-parity.test.ts b/server/tests/unit/db/migrate-encryption-parity.test.ts index 3f05bde1c..299946d07 100644 --- a/server/tests/unit/db/migrate-encryption-parity.test.ts +++ b/server/tests/unit/db/migrate-encryption-parity.test.ts @@ -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); +}); diff --git a/server/tests/unit/db/migration-version-atomicity.test.ts b/server/tests/unit/db/migration-version-atomicity.test.ts new file mode 100644 index 000000000..919cce579 --- /dev/null +++ b/server/tests/unit/db/migration-version-atomicity.test.ts @@ -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(); + } + }); +}); diff --git a/server/tests/unit/demo/demo-reset-path.test.ts b/server/tests/unit/demo/demo-reset-path.test.ts new file mode 100644 index 000000000..4c25fa501 --- /dev/null +++ b/server/tests/unit/demo/demo-reset-path.test.ts @@ -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); + }); +}); diff --git a/server/tests/unit/demo/demo-seed.test.ts b/server/tests/unit/demo/demo-seed.test.ts new file mode 100644 index 000000000..82db3bcd5 --- /dev/null +++ b/server/tests/unit/demo/demo-seed.test.ts @@ -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')); + }); +}); diff --git a/server/tests/unit/nest/photo-capture-backfill.service.test.ts b/server/tests/unit/nest/photo-capture-backfill.service.test.ts index b389a1acc..d1970adfb 100644 --- a/server/tests/unit/nest/photo-capture-backfill.service.test.ts +++ b/server/tests/unit/nest/photo-capture-backfill.service.test.ts @@ -23,7 +23,10 @@ type Row = { id: number; provider?: string; file_path?: string | null; taken_at? function build(rows: Row[], info: Record) { 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);