test(e2e): add playwright e2e harness

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.
This commit is contained in:
bryanpedini committed 2026-10-06 10:52:25 +02:00
1 parent a02eef13a7
commit fe0cd4bf6d
33 files changed
+6370

No files matched your search

+257
View File
@@ -0,0 +1,257 @@
// 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`;
}
}