screentinker/server/test/schema-check.test.js
ScreenTinker 7ab19adcea fix(db): observable migrations + fail-fast schema verification (#37)
Self-hosters rebuilding could end up schema-behind-code, failing only at runtime
(a missing users.must_change_password locked out all logins). Two root causes:

1. The migration loop swallowed EVERY error (catch {}), so a real ALTER failure
   was indistinguishable from the benign 'duplicate column' on an already-migrated
   DB. Now only 'duplicate column'/'already exists' is treated as a no-op; any
   other error is logged loudly, and a one-line summary reports how many new
   column migrations actually applied this boot.

2. Nothing verified the schema after migrations. Added lib/schema-check.js:
   verifyAndRepairSchema() checks the tables + columns the request path REQUIRES,
   idempotently repairs missing repairable columns (logging each), and if anything
   required is STILL missing, prints a loud FATAL block and exits - failing fast at
   boot instead of at the first authed request.

Note: the reported 'audit_log missing' was a misdiagnosis - the code uses
activity_log (0 refs to audit_log), created by schema.sql on every boot.

Tests: healthy (no-op), auto-repair of must_change_password, missing-table report.
2026-06-09 09:31:52 -05:00

50 lines
2.1 KiB
JavaScript

'use strict';
// #37: verifyAndRepairSchema - repairs missing repairable columns, and reports
// (fail-fast hook) anything still missing. We inject onMissing so the fail path
// doesn't call process.exit during tests. Node v20 built-ins only.
const test = require('node:test');
const assert = require('node:assert/strict');
const Database = require('better-sqlite3');
const { verifyAndRepairSchema, REQUIRED_TABLES } = require('../lib/schema-check');
function freshDb(withMustChange = true) {
const db = new Database(':memory:');
for (const t of REQUIRED_TABLES) {
if (t === 'users') {
db.exec(`CREATE TABLE users (id TEXT PRIMARY KEY, role TEXT, plan_id TEXT${withMustChange ? ', must_change_password INTEGER NOT NULL DEFAULT 0' : ''})`);
} else {
db.exec(`CREATE TABLE ${t} (id TEXT PRIMARY KEY)`);
}
}
return db;
}
const hasCol = (db, t, c) => db.prepare(`PRAGMA table_info(${t})`).all().some(x => x.name === c);
test('healthy schema: nothing missing, onMissing not called', () => {
const db = freshDb(true);
let called = false;
const missing = verifyAndRepairSchema(db, { onMissing: () => { called = true; } });
assert.deepEqual(missing, []);
assert.equal(called, false);
});
test('missing repairable column (must_change_password) is auto-repaired - no fail', () => {
const db = freshDb(false);
assert.equal(hasCol(db, 'users', 'must_change_password'), false, 'precondition: column absent');
let called = false;
const missing = verifyAndRepairSchema(db, { onMissing: () => { called = true; } });
assert.deepEqual(missing, [], 'no residual missing after repair');
assert.equal(called, false, 'onMissing not called when repair succeeds');
assert.equal(hasCol(db, 'users', 'must_change_password'), true, 'column was added');
});
test('missing required table -> reported to onMissing (fail-fast hook fires)', () => {
const db = freshDb(true);
db.exec('DROP TABLE activity_log');
let got = null;
verifyAndRepairSchema(db, { onMissing: (m) => { got = m; } });
assert.ok(got && got.some(x => /activity_log/.test(x)), 'reported the missing table');
});