// SPDX-License-Identifier: GPL-2.0-or-later // Copyright (C) 2026 Bryan Joshua Pedini /** * Read-only SQLite inspection of the application database (node:sqlite). * * Tests never write to the application database through this module. */ import crypto from 'node:crypto'; import fs from 'node:fs'; import net from 'node:net'; import { DatabaseSync } from 'node:sqlite'; import { expect } from '@playwright/test'; import { HarnessError } from './errors'; import { TOKEN_RE } from './secrets'; export interface TokenRow { token: string; /** Description decoded as UTF-8 (lossy for invalid bytes; use descriptionHex for exact bytes). */ description: string; /** Exact stored bytes of the description, upper-case hex (SQLite hex()). */ descriptionHex: string; active: number; created_at: string; deleted_at: string | null; } export interface OperationRow { rowid: number; token: string; origin: string; operation: string; time: string; } /** * Strict UTC RFC3339Nano as produced by Go's time.RFC3339Nano: optional fraction of 1–9 digits * without trailing zeros, always `Z`. */ export const RFC3339NANO_UTC_RE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])T([01]\d|2[0-3]):[0-5]\d:[0-5]\d(\.\d{0,8}[1-9])?Z$/; /** Nanoseconds since the Unix epoch of a strict RFC3339Nano UTC timestamp. */ export function rfc3339ToNanos(ts: string): bigint { const m = /^(.*T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z$/.exec(ts); if (!m) throw new Error(`not an RFC3339 UTC timestamp: ${ts}`); const ms = Date.parse(m[1] + 'Z'); if (Number.isNaN(ms)) throw new Error(`invalid timestamp: ${ts}`); return BigInt(ms) * 1_000_000n + BigInt((m[2] ?? '').padEnd(9, '0') || '0'); } /** Nanoseconds since the epoch for a JS Date / epoch-ms value. */ export function msToNanos(ms: number): bigint { return BigInt(Math.floor(ms)) * 1_000_000n; } /** Opens the database read-only, runs fn, always closes. */ export function withDb(dbPath: string, fn: (db: DatabaseSync) => T): T { if (!fs.existsSync(dbPath)) throw new HarnessError('infrastructure', `database file does not exist: ${dbPath}`); let db: DatabaseSync; try { db = new DatabaseSync(dbPath, { readOnly: true, timeout: 5000 }); } catch (e) { throw new HarnessError('infrastructure', `cannot open ${dbPath} read-only: ${(e as Error).message}`); } try { return fn(db); } finally { db.close(); } } export function readTokens(dbPath: string): TokenRow[] { return withDb(dbPath, (db) => db .prepare(`SELECT token, CAST(description AS TEXT) AS description, hex(description) AS descriptionHex, active, created_at, deleted_at FROM tokens ORDER BY rowid`) .all() .map((r) => ({ ...(r as unknown as TokenRow), active: Number((r as { active: unknown }).active) })), ); } export function readToken(dbPath: string, token: string): TokenRow | undefined { return readTokens(dbPath).find((t) => t.token === token); } export function readOperations(dbPath: string): OperationRow[] { return withDb(dbPath, (db) => db.prepare(`SELECT rowid, token, origin, operation, time FROM operations ORDER BY rowid`).all() as unknown as OperationRow[]); } export function countRows(dbPath: string, table: 'tokens' | 'operations'): number { return withDb(dbPath, (db) => Number((db.prepare(`SELECT count(*) AS n FROM ${table}`).get() as { n: number }).n)); } /** Expected `PRAGMA table_info` of both tables. */ export const EXPECTED_COLUMNS = { tokens: [ { name: 'token', type: 'TEXT', notnull: 1, dflt_value: null, pk: 1 }, { name: 'description', type: 'TEXT', notnull: 1, dflt_value: "''", pk: 0 }, { name: 'active', type: 'INTEGER', notnull: 1, dflt_value: null, pk: 0 }, { name: 'created_at', type: 'TEXT', notnull: 1, dflt_value: null, pk: 0 }, { name: 'deleted_at', type: 'TEXT', notnull: 0, dflt_value: null, pk: 0 }, ], operations: [ { name: 'token', type: 'TEXT', notnull: 1, dflt_value: null, pk: 0 }, { name: 'origin', type: 'TEXT', notnull: 1, dflt_value: null, pk: 0 }, { name: 'operation', type: 'TEXT', notnull: 1, dflt_value: null, pk: 0 }, { name: 'time', type: 'TEXT', notnull: 1, dflt_value: null, pk: 0 }, ], } as const; export interface DbSchemaInfo { tables: string[]; columns: Record>; foreignKeys: Array<{ table: string; from: string; to: string }>; indexes: string[]; } export function readSchema(dbPath: string): DbSchemaInfo { return withDb(dbPath, (db) => { const tables = (db.prepare(`SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`).all() as Array<{ name: string }>).map((r) => r.name); const columns: DbSchemaInfo['columns'] = {}; for (const t of tables) { columns[t] = (db.prepare(`PRAGMA table_info(${JSON.stringify(t)})`).all() as Array>).map((c) => ({ name: String(c.name), type: String(c.type), notnull: Number(c.notnull), dflt_value: c.dflt_value === null ? null : String(c.dflt_value), pk: Number(c.pk), })); } const foreignKeys = (db.prepare(`PRAGMA foreign_key_list(operations)`).all() as Array>).map((f) => ({ table: String(f.table), from: String(f.from), to: String(f.to), })); const indexes = (db.prepare(`SELECT name FROM sqlite_master WHERE type='index' ORDER BY name`).all() as Array<{ name: string }>).map((r) => r.name); return { tables, columns, foreignKeys, indexes }; }); } export interface DbInvariantOptions { /** Also require every audit row time to be <= its token's deleted_at (false by default: requests racing deletion may legitimately audit after). */ auditBeforeDeletion?: boolean; /** Expected exact number of token rows. */ tokenCount?: number; /** Expected exact number of operations rows. */ operationCount?: number; } export interface DbInvariantReport { tokens: number; activeTokens: number; inactiveTokens: number; operations: number; } /** * Asserts every structural invariant of the application database (Playwright expect, so a * violation is a test failure with a precise message): * integrity_check = ok; foreign_key_check empty; exact schema (tables, columns, FK); tokens * well-formed (43-char base64url, typeof text/integer), active ∈ {0,1}, deleted_at NULL iff * active = 1; created_at/deleted_at/time strict RFC3339Nano UTC; deleted_at >= created_at; * every operations.token exists in tokens; operation always 'GET containers'; origin is an IP * literal; time >= the token's created_at. */ export function assertDbInvariants(dbPath: string, opts: DbInvariantOptions = {}): DbInvariantReport { expect(fs.existsSync(dbPath), `database ${dbPath} exists`).toBe(true); const { integrity, fkViolations, typeErrors } = withDb(dbPath, (db) => ({ integrity: (db.prepare('PRAGMA integrity_check').all() as Array>).map((r) => Object.values(r)[0]), fkViolations: db.prepare('PRAGMA foreign_key_check').all(), typeErrors: db .prepare( `SELECT 'tokens' AS t, rowid FROM tokens WHERE typeof(token) <> 'text' OR typeof(description) <> 'text' OR typeof(active) <> 'integer' OR typeof(created_at) <> 'text' OR (deleted_at IS NOT NULL AND typeof(deleted_at) <> 'text') UNION ALL SELECT 'operations', rowid FROM operations WHERE typeof(token) <> 'text' OR typeof(origin) <> 'text' OR typeof(operation) <> 'text' OR typeof(time) <> 'text'`, ) .all(), })); expect(integrity, 'PRAGMA integrity_check').toEqual(['ok']); expect(fkViolations, 'PRAGMA foreign_key_check').toEqual([]); expect(typeErrors, 'column storage classes').toEqual([]); const schema = readSchema(dbPath); expect(schema.tables, 'tables').toEqual(['operations', 'tokens']); expect(schema.columns.tokens, 'tokens columns').toEqual(EXPECTED_COLUMNS.tokens); expect(schema.columns.operations, 'operations columns').toEqual(EXPECTED_COLUMNS.operations); expect(schema.foreignKeys, 'operations foreign key').toEqual([{ table: 'tokens', from: 'token', to: 'token' }]); expect(schema.indexes, 'indexes').toEqual(['sqlite_autoindex_tokens_1']); const tokens = readTokens(dbPath); const ops = readOperations(dbPath); const byToken = new Map(tokens.map((t) => [t.token, t])); const problems: string[] = []; for (const t of tokens) { const id = `token row ${t.token.slice(0, 4)}…`; if (!TOKEN_RE.test(t.token)) problems.push(`${id}: token not 43-char base64url`); if (t.active !== 0 && t.active !== 1) problems.push(`${id}: active=${t.active}`); if (!RFC3339NANO_UTC_RE.test(t.created_at)) problems.push(`${id}: created_at ${JSON.stringify(t.created_at)} not strict RFC3339Nano UTC`); if (t.active === 1 && t.deleted_at !== null) problems.push(`${id}: active but deleted_at=${t.deleted_at}`); if (t.active === 0 && t.deleted_at === null) problems.push(`${id}: inactive but deleted_at NULL`); if (t.deleted_at !== null) { if (!RFC3339NANO_UTC_RE.test(t.deleted_at)) problems.push(`${id}: deleted_at ${JSON.stringify(t.deleted_at)} not strict RFC3339Nano UTC`); else if (RFC3339NANO_UTC_RE.test(t.created_at) && rfc3339ToNanos(t.deleted_at) < rfc3339ToNanos(t.created_at)) problems.push(`${id}: deleted_at before created_at`); } } for (const o of ops) { const id = `operation rowid ${o.rowid}`; const t = byToken.get(o.token); if (!t) problems.push(`${id}: token not in tokens`); if (o.operation !== 'GET containers') problems.push(`${id}: operation=${JSON.stringify(o.operation)}`); if (net.isIP(o.origin) === 0) problems.push(`${id}: origin ${JSON.stringify(o.origin)} is not an IP literal`); if (!RFC3339NANO_UTC_RE.test(o.time)) problems.push(`${id}: time ${JSON.stringify(o.time)} not strict RFC3339Nano UTC`); else if (t && RFC3339NANO_UTC_RE.test(t.created_at)) { if (rfc3339ToNanos(o.time) < rfc3339ToNanos(t.created_at)) problems.push(`${id}: audited before its token was created`); if (opts.auditBeforeDeletion && t.deleted_at && rfc3339ToNanos(o.time) > rfc3339ToNanos(t.deleted_at)) problems.push(`${id}: audited after its token was deleted`); } } expect(problems, 'database invariants').toEqual([]); if (opts.tokenCount !== undefined) expect(tokens.length, 'token row count').toBe(opts.tokenCount); if (opts.operationCount !== undefined) expect(ops.length, 'operations row count').toBe(opts.operationCount); const active = tokens.filter((t) => t.active === 1).length; return { tokens: tokens.length, activeTokens: active, inactiveTokens: tokens.length - active, operations: ops.length }; } /** Exact byte image of the main database file (for "not modified" checks; checkpointed state only). */ export function fileDigest(path: string): string | undefined { if (!fs.existsSync(path)) return undefined; return crypto.createHash('sha256').update(fs.readFileSync(path)).digest('hex'); } /** * Textual snapshot for artifacts: integrity summary plus every row. Token values must be * redacted by the caller (pass a redact function). */ export function dbSnapshot(dbPath: string, redact: (s: string) => string): string { if (!fs.existsSync(dbPath)) return `${dbPath}: does not exist\n`; try { const lines: string[] = [`database: ${dbPath}`]; withDb(dbPath, (db) => { lines.push(`integrity_check: ${JSON.stringify(db.prepare('PRAGMA integrity_check').all())}`); lines.push(`foreign_key_check: ${JSON.stringify(db.prepare('PRAGMA foreign_key_check').all())}`); lines.push(`schema:`); for (const r of db.prepare(`SELECT type, name, sql FROM sqlite_master ORDER BY name`).all() as Array>) { lines.push(` ${r.type} ${r.name}: ${String(r.sql ?? '').replace(/\s+/g, ' ')}`); } }); const tokens = readTokens(dbPath); lines.push(`tokens (${tokens.length} rows): token | description(JSON) | active | created_at | deleted_at`); for (const t of tokens.slice(0, 1000)) lines.push(` ${t.token} | ${JSON.stringify(t.description)} | ${t.active} | ${t.created_at} | ${t.deleted_at}`); const ops = readOperations(dbPath); lines.push(`operations (${ops.length} rows): rowid | token | origin | operation | time`); for (const o of ops.slice(-1000)) lines.push(` ${o.rowid} | ${o.token} | ${o.origin} | ${o.operation} | ${o.time}`); return redact(lines.join('\n') + '\n'); } catch (e) { return `${dbPath}: snapshot failed: ${(e as Error).message}\n`; } }