Process and API-only Playwright harness: binary and image setup, server/CLI runners, fake Docker Engine socket, read-only database inspection, secret redaction in reports, seeded fuzzing, leak detection and harness self-checks.
258 lines
12 KiB
TypeScript
258 lines
12 KiB
TypeScript
// 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<T>(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<string, Array<{ name: string; type: string; notnull: number; dflt_value: string | null; pk: number }>>;
|
||
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<Record<string, unknown>>).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<Record<string, unknown>>).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<Record<string, unknown>>).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<Record<string, unknown>>) {
|
||
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`;
|
||
}
|
||
}
|