|
| 1 | +/** |
| 2 | + * Schema-drift tripwire (Task 1.6 / Phase 3.3 prerequisite). |
| 3 | + * |
| 4 | + * Compares the schema produced by two bootstrap paths: |
| 5 | + * |
| 6 | + * Variant A ("bootstrap path") — the real production fresh-install path: |
| 7 | + * DatabaseService constructor → createTables() + createIndexes() + migration loop. |
| 8 | + * This is what every real deployment runs today. |
| 9 | + * |
| 10 | + * Variant B ("replay path") — migration chain only: |
| 11 | + * createTestDb() replays migration 001 baseline → 112 against :memory:. |
| 12 | + * This is Phase 3.3's target (delete createTables/createIndexes). |
| 13 | + * |
| 14 | + * Phase 3.3 deletes createTables()/createIndexes() and makes the replay path the only |
| 15 | + * path. Until then, this test proves A ≡ B modulo the documented allowlist and |
| 16 | + * enforces allowlist burn-down: new entries are rejected; stale entries are rejected. |
| 17 | + * |
| 18 | + * Raw sqlite_master reads live in a *.test.ts file and are therefore exempt from the |
| 19 | + * project's no-restricted-syntax (raw SQL) ESLint ban. |
| 20 | + */ |
| 21 | + |
| 22 | +import { afterAll, describe, it } from 'vitest'; |
| 23 | +import Database from 'better-sqlite3'; |
| 24 | +import * as fs from 'fs'; |
| 25 | +import * as os from 'os'; |
| 26 | +import * as path from 'path'; |
| 27 | + |
| 28 | +// ─── Variant A: must set DATABASE_PATH BEFORE importing the singleton ────────── |
| 29 | +// |
| 30 | +// vitest.config.ts sets env.DATABASE_PATH = ':memory:' which would be picked up |
| 31 | +// by the singleton, but we need a temp file so we can open a second read-only |
| 32 | +// connection to read sqlite_master after construction (a :memory: DB can't be |
| 33 | +// re-opened by a second connection). Override here at module top, before any |
| 34 | +// dynamic import of the singleton. |
| 35 | +const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'meshmonitor-schema-drift-')); |
| 36 | +const tempDbFile = path.join(tempDir, 'bootstrap.db'); |
| 37 | +process.env.DATABASE_PATH = tempDbFile; |
| 38 | + |
| 39 | +// ─── Variant B ──────────────────────────────────────────────────────────────── |
| 40 | +import { createTestDb, type TestDb } from '../server/test-helpers/testDb.js'; |
| 41 | +import { SCHEMA_DRIFT_ALLOWLIST, type DriftKind } from './schemaDrift.allowlist.js'; |
| 42 | + |
| 43 | +// ─── Types ──────────────────────────────────────────────────────────────────── |
| 44 | + |
| 45 | +interface SchemaRow { |
| 46 | + type: string; |
| 47 | + name: string; |
| 48 | + sql: string | null; |
| 49 | +} |
| 50 | + |
| 51 | +interface OnlyInOne { key: string; sql: string; } |
| 52 | +interface Mismatch { key: string; sqlA: string; sqlB: string; } |
| 53 | + |
| 54 | +// ─── Normalization ──────────────────────────────────────────────────────────── |
| 55 | + |
| 56 | +/** |
| 57 | + * Normalize a sqlite_master DDL string for comparison. |
| 58 | + * |
| 59 | + * Rules (per spec): |
| 60 | + * 1. Strip `IF NOT EXISTS` (case-insensitive). |
| 61 | + * 2. Collapse all whitespace runs to a single space. |
| 62 | + * 3. Remove spaces around `,`, `(`, `)`. |
| 63 | + * 4. Strip `"` and `` ` `` (SQLite quotes identifiers inconsistently). |
| 64 | + * 5. Lowercase. |
| 65 | + * 6. Trim. |
| 66 | + * |
| 67 | + * Column order is intentionally preserved — it is significant for catching |
| 68 | + * category-(3) drift (createTables column order vs ALTER TABLE ADD COLUMN order). |
| 69 | + */ |
| 70 | +function normalizeDdl(sql: string | null): string { |
| 71 | + if (!sql) return ''; |
| 72 | + return sql |
| 73 | + .replace(/IF NOT EXISTS/gi, '') |
| 74 | + .replace(/\s+/g, ' ') |
| 75 | + .replace(/\s*,\s*/g, ',') |
| 76 | + .replace(/\s*\(\s*/g, '(') |
| 77 | + .replace(/\s*\)\s*/g, ')') |
| 78 | + .replace(/["`]/g, '') |
| 79 | + .toLowerCase() |
| 80 | + .trim(); |
| 81 | +} |
| 82 | + |
| 83 | +/** Collect all non-sqlite_% schema objects from a connection, keyed by `type:name`. */ |
| 84 | +function collectSchema(conn: Database.Database): Map<string, string> { |
| 85 | + const rows = conn.prepare( |
| 86 | + `SELECT type, name, sql FROM sqlite_master |
| 87 | + WHERE name NOT LIKE 'sqlite_%' |
| 88 | + ORDER BY type, name` |
| 89 | + ).all() as SchemaRow[]; |
| 90 | + return new Map(rows.map(r => [`${r.type}:${r.name}`, normalizeDdl(r.sql)])); |
| 91 | +} |
| 92 | + |
| 93 | +// ─── Test ───────────────────────────────────────────────────────────────────── |
| 94 | + |
| 95 | +describe('schema bootstrap drift', () => { |
| 96 | + let testDbB: TestDb; |
| 97 | + let connA: Database.Database; |
| 98 | + |
| 99 | + afterAll(() => { |
| 100 | + try { connA?.close(); } catch { /* ignore */ } |
| 101 | + try { testDbB?.close(); } catch { /* ignore */ } |
| 102 | + try { fs.rmSync(tempDir, { recursive: true }); } catch { /* ignore */ } |
| 103 | + // Restore env to avoid polluting subsequent test files in the same fork |
| 104 | + process.env.DATABASE_PATH = ':memory:'; |
| 105 | + }); |
| 106 | + |
| 107 | + it('createTables()/createIndexes() and migration replay agree modulo the allowlist', { timeout: 30000 }, async () => { |
| 108 | + // ── Variant A: import the DatabaseService singleton ──────────────────── |
| 109 | + // The dynamic import must happen INSIDE the test (not at module top) so that |
| 110 | + // process.env.DATABASE_PATH is already overridden when the module is first |
| 111 | + // evaluated. Vitest per-file module isolation makes this reliable. |
| 112 | + const { default: _dbService } = await import('../services/database.js'); |
| 113 | + |
| 114 | + // Open a second read-only connection to the same file to read sqlite_master. |
| 115 | + // (We can't reuse the singleton's internal connection because it is private.) |
| 116 | + connA = new Database(tempDbFile, { readonly: true }); |
| 117 | + const mapA = collectSchema(connA); |
| 118 | + |
| 119 | + // ── Variant B: migration-replay-only path ────────────────────────────── |
| 120 | + testDbB = createTestDb(); |
| 121 | + const mapB = collectSchema(testDbB.sqlite); |
| 122 | + |
| 123 | + // ── Diff ─────────────────────────────────────────────────────────────── |
| 124 | + const onlyInBootstrap: OnlyInOne[] = []; |
| 125 | + const onlyInReplay: OnlyInOne[] = []; |
| 126 | + const sqlMismatch: Mismatch[] = []; |
| 127 | + |
| 128 | + for (const [key, sqlA] of mapA) { |
| 129 | + if (!mapB.has(key)) { |
| 130 | + onlyInBootstrap.push({ key, sql: sqlA }); |
| 131 | + } else if (mapB.get(key) !== sqlA) { |
| 132 | + sqlMismatch.push({ key, sqlA, sqlB: mapB.get(key)! }); |
| 133 | + } |
| 134 | + } |
| 135 | + for (const [key, sqlB] of mapB) { |
| 136 | + if (!mapA.has(key)) { |
| 137 | + onlyInReplay.push({ key, sql: sqlB }); |
| 138 | + } |
| 139 | + } |
| 140 | + |
| 141 | + // ── Allowlist check ──────────────────────────────────────────────────── |
| 142 | + // Build a set of allowlisted (key, kind) pairs for fast lookup. |
| 143 | + type AllowKey = `${string}::${DriftKind}`; |
| 144 | + const allowSet = new Set<AllowKey>( |
| 145 | + SCHEMA_DRIFT_ALLOWLIST.map(e => `${e.key}::${e.kind}` as AllowKey) |
| 146 | + ); |
| 147 | + |
| 148 | + // Track which allowlist entries were "consumed" (i.e. actually found in the diff). |
| 149 | + const consumed = new Set<AllowKey>(); |
| 150 | + |
| 151 | + // Unexpected divergences (not in allowlist) → failure. |
| 152 | + const unexpected: string[] = []; |
| 153 | + |
| 154 | + // Helper to check one divergence against the allowlist. |
| 155 | + function check(key: string, kind: DriftKind, label: string): void { |
| 156 | + const ak: AllowKey = `${key}::${kind}`; |
| 157 | + if (allowSet.has(ak)) { |
| 158 | + consumed.add(ak); |
| 159 | + } else { |
| 160 | + unexpected.push(label); |
| 161 | + } |
| 162 | + } |
| 163 | + |
| 164 | + for (const { key, sql } of onlyInBootstrap) { |
| 165 | + check(key, 'onlyInBootstrap', |
| 166 | + ` + ${key} (in fresh-install/createTables path, MISSING from migration replay)\n` + |
| 167 | + ` DDL: ${sql}\n` + |
| 168 | + ` hint: add a migration creating this, or add to allowlist if intentional pre-Phase-3.3` |
| 169 | + ); |
| 170 | + } |
| 171 | + for (const { key, sql } of onlyInReplay) { |
| 172 | + check(key, 'onlyInReplay', |
| 173 | + ` - ${key} (in migration replay, MISSING from createTables path)\n` + |
| 174 | + ` DDL: ${sql}` |
| 175 | + ); |
| 176 | + } |
| 177 | + for (const { key, sqlA, sqlB } of sqlMismatch) { |
| 178 | + check(key, 'sqlMismatch', |
| 179 | + ` ~ ${key} (DDL differs)\n` + |
| 180 | + ` bootstrap: ${sqlA}\n` + |
| 181 | + ` replay: ${sqlB}` |
| 182 | + ); |
| 183 | + } |
| 184 | + |
| 185 | + // Stale allowlist entries (allowlisted but no longer divergent) → failure. |
| 186 | + const stale: string[] = []; |
| 187 | + for (const e of SCHEMA_DRIFT_ALLOWLIST) { |
| 188 | + const ak: AllowKey = `${e.key}::${e.kind}`; |
| 189 | + if (!consumed.has(ak)) { |
| 190 | + stale.push( |
| 191 | + ` ! ${e.key} was allowlisted (kind: ${e.kind}) but is no longer divergent` + |
| 192 | + ` — remove it from schemaDrift.allowlist.ts` |
| 193 | + ); |
| 194 | + } |
| 195 | + } |
| 196 | + |
| 197 | + if (unexpected.length > 0 || stale.length > 0) { |
| 198 | + const lines: string[] = [ |
| 199 | + 'Schema bootstrap drift changed. createTables()/createIndexes() (src/services/database.ts)' + |
| 200 | + ' and the migration chain (src/server/migrations/) disagree.' + |
| 201 | + ' Reconcile, or update schemaDrift.allowlist.ts. See Task 1.6 / Phase 3.3.', |
| 202 | + '', |
| 203 | + ]; |
| 204 | + if (unexpected.length > 0) { |
| 205 | + lines.push(`UNEXPECTED DIVERGENCES (${unexpected.length}):`, ...unexpected, ''); |
| 206 | + } |
| 207 | + if (stale.length > 0) { |
| 208 | + lines.push(`STALE ALLOWLIST ENTRIES (${stale.length}):`, ...stale, ''); |
| 209 | + } |
| 210 | + throw new Error(lines.join('\n')); |
| 211 | + } |
| 212 | + }); |
| 213 | +}); |
0 commit comments