Files
dpsapi/e2e/harness/db.ts
T
bryanpedini fe0cd4bf6d 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.
2026-10-06 10:52:25 +02:00

258 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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`;
}
}