Files
TREK/server/tests/unit/db/migration-version-atomicity.test.ts
T
Maurice a7aaa29c16 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.
2026-08-23 11:59:12 +02:00

56 lines
2.0 KiB
TypeScript

/**
* 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();
}
});
});