Files
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

355 lines
17 KiB
TypeScript
Raw Permalink 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
/**
* Container-image helpers (project `image`): run the final image as a uniquely named,
* labelled container with the Docker socket and a data directory mounted, drive the CLI
* through `docker exec`, read logs/exit codes, and inspect the root-owned database.
*/
import fs from 'node:fs';
import path from 'node:path';
import { ALPINE_IMAGE, imageRef, LABEL_RUN, LABEL_TEST, RESOURCE_PREFIX, runId } from './env';
import { HarnessError } from './errors';
import { FakeDocker } from './fake-docker';
import { buildRawRequest, rawHttp } from './http';
import { parseLogs, type LogSet } from './logs';
import { runProcess, sleep, toolEnv, type ProcResult } from './proc';
import { dockerCli } from './docker';
import { forceRemoveDir, type Workspace } from './workspace';
import { printsTokens, type CliResult } from './cli';
import type { Recorder } from './recorder';
import type { SecretRegistry } from './secrets';
/** Socket mounted at /var/run/docker.sock in the container. */
export type ImageDockerBackend = 'real' | 'none' | FakeDocker | { socketPath: string };
export interface ImageStartOptions {
/** LOG_LEVEL (default 'INFO'; null = unset). */
logLevel?: string | null;
/** HTTP_ADDR inside the container (default unset → 0.0.0.0). */
addr?: string | null;
/** HTTP_PORT inside the container (default unset → 80); the published port follows it. */
containerPort?: number;
/** DB_PATH inside the container (default '/data/dpsapi.db'; null = unset). */
dbPath?: string | null;
/** Extra env (undefined removes). */
env?: Record<string, string | undefined>;
/** Default: a new FakeDocker (empty) — pass 'real' for the host Engine. */
docker?: ImageDockerBackend;
/** Host directory mounted at /data (default: a new workspace dir). Reuse it to replace a container. */
dataDir?: string;
/** docker run --user. */
user?: string;
/** Publish the container port on 127.0.0.1:<random> (default true). */
publish?: boolean;
/** Extra `docker run` arguments (before the image). */
extraArgs?: string[];
/** Arguments after the image (default none → server mode). */
args?: string[];
/** Wait for HTTP readiness (default true when publishing and no args). */
waitReady?: boolean;
readyTimeoutMs?: number;
/** Name suffix (default img<n>). */
suffix?: string;
}
export interface ImageExecOptions {
/** `docker exec -e K=V` (undefined values are skipped). */
env?: Record<string, string | undefined>;
user?: string;
timeoutMs?: number;
}
export interface ContainerExit {
exitCode: number;
oomKilled: boolean;
stdout: string;
stderr: string;
}
export interface ImageContext {
workspace: Workspace;
recorder: Recorder;
secrets: SecretRegistry;
testKey: string;
newFakeDocker: () => Promise<FakeDocker>;
containers: ImageContainer[];
}
let seq = 0;
export class ImageContainer {
/** Published host port (0 when not published). */
hostPort = 0;
/** http://127.0.0.1:<hostPort> */
baseURL = '';
id = '';
constructor(
private readonly ctx: ImageContext,
readonly name: string,
readonly image: string,
/** Host directory mounted at /data (files inside are root-owned). */
readonly dataDir: string,
readonly options: ImageStartOptions,
readonly fakeDocker?: FakeDocker,
) {}
/** DB_PATH inside the container. */
get containerDbPath(): string | undefined {
return this.options.dbPath === null ? undefined : (this.options.dbPath ?? '/data/dpsapi.db');
}
url(p = '/api/v1/containers'): string {
return this.baseURL + p;
}
/** Polls `OPTIONS *` through the published port until the app answers (docker-proxy accepts early, so a real HTTP response is required). */
async waitReady(timeoutMs = this.options.readyTimeoutMs ?? 20_000): Promise<void> {
if (!this.hostPort) throw new HarnessError('infrastructure', `container ${this.name} has no published port`);
const deadline = performance.now() + timeoutMs;
let n = 0;
while (true) {
const r = await rawHttp({ host: '127.0.0.1', port: this.hostPort, data: buildRawRequest({ method: 'OPTIONS', target: '*' }), timeoutMs: 2000, allowTimeout: true }).catch(() => undefined);
if (r?.outcome === 'response') return;
if (++n % 10 === 0 && !(await this.isRunning())) {
const l = await this.logs();
throw new Error(`container ${this.name} exited during startup\n--- stderr\n${l.stderr}`);
}
if (performance.now() > deadline) {
const l = await this.logs();
throw new HarnessError('timeout', `container ${this.name} not ready on ${this.baseURL} within ${timeoutMs} ms\n--- stderr\n${l.stderr}`);
}
await sleep(50);
}
}
/**
* `docker exec [-e ..] <name> /app ...args` with the same result shape as CliRunner.run.
* Exit codes >= 125 (docker exec failures, signal deaths reported as 128+n) and daemon-refused execs are HarnessErrors.
*/
async exec(args: string[], opts: ImageExecOptions = {}): Promise<CliResult> {
const r = await this.execRaw(['/app', ...args], opts);
const res: CliResult = { ...r, args: [...args], via: 'docker-exec' };
if (printsTokens(args)) this.ctx.secrets.registerAllIn(r.stdout);
this.ctx.recorder.addCli({ via: 'docker-exec', command: `docker exec ${this.name} /app`, args, env: execEnv(opts.env), exitCode: r.exitCode, signal: r.signal, timedOut: r.timedOut, durationMs: Math.round(r.durationMs), stdout: r.stdout, stderr: r.stderr });
// The daemon can refuse an exec with exit 1 (e.g. unknown --user), which must not pass for an app failure.
const daemonRefused = r.exitCode !== 0 && /^(Error response from daemon:|OCI runtime exec failed)/.test(r.stderr + r.stdout);
if ((r.exitCode !== null && r.exitCode >= 125) || daemonRefused) {
throw new HarnessError('infrastructure', `docker exec ${this.name} /app ${args.slice(0, 2).join(' ')} failed at the docker level (exit ${r.exitCode}): ${(r.stderr + r.stdout).trim()}`, r);
}
return res;
}
/** `docker exec` of an arbitrary argv (e.g. ['sh'] to prove there is no shell). Never throws for exit codes. */
async execRaw(argv: string[], opts: ImageExecOptions = {}): Promise<ProcResult> {
const a = ['exec'];
if (opts.user) a.push('--user', opts.user);
for (const [k, v] of Object.entries(opts.env ?? {})) if (v !== undefined) a.push('-e', `${k}=${v}`);
a.push(this.name, ...argv);
return runProcess('docker', a, { env: toolEnv(), timeoutMs: opts.timeoutMs ?? 30_000, label: `docker exec ${argv[0]}` });
}
/** `docker logs`: the container's stdout and stderr separately. */
async logs(): Promise<{ stdout: string; stderr: string }> {
const r = await dockerCli(['logs', this.name], { allowFailure: true });
return { stdout: r.stdout, stderr: r.stderr };
}
/** Parsed application logs (container stderr). */
async parsedLogs(): Promise<LogSet> {
return parseLogs((await this.logs()).stderr);
}
async inspect(): Promise<Record<string, unknown>> {
const r = await dockerCli(['inspect', this.name]);
return (JSON.parse(r.stdout) as Array<Record<string, unknown>>)[0];
}
async isRunning(): Promise<boolean> {
const r = await dockerCli(['inspect', '-f', '{{.State.Running}}', this.name], { allowFailure: true });
return r.stdout.trim() === 'true';
}
/**
* Stops the container and returns the application's exit code.
* Default: `docker stop -t <timeoutSec>` (SIGTERM, then SIGKILL after the grace period).
* With `signal`: `docker kill -s <signal>` and wait up to timeoutSec.
* A SIGKILL by docker after the grace period shows as exitCode 137.
*/
async stop({ signal, timeoutSec = 20 }: { signal?: NodeJS.Signals; timeoutSec?: number } = {}): Promise<ContainerExit> {
if (signal) await dockerCli(['kill', '-s', signal, this.name]);
else await dockerCli(['stop', '-t', String(timeoutSec), this.name], { timeoutMs: (timeoutSec + 30) * 1000 });
return this.waitForExit((timeoutSec + 10) * 1000);
}
/** Waits for the container to exit (`docker wait`), then returns exit code and logs. */
async waitForExit(timeoutMs = 30_000): Promise<ContainerExit> {
const r = await runProcess('docker', ['wait', this.name], { env: toolEnv(), timeoutMs, label: 'docker wait' });
if (r.exitCode !== 0) throw new HarnessError('infrastructure', `docker wait ${this.name}: ${r.stderr}`);
const st = (await this.inspect()).State as { ExitCode: number; OOMKilled: boolean };
const l = await this.logs();
return { exitCode: st.ExitCode, oomKilled: st.OOMKilled, stdout: l.stdout, stderr: l.stderr };
}
/** docker rm -f -v. */
async remove(): Promise<void> {
await dockerCli(['rm', '-f', '-v', this.name], { allowFailure: true });
}
/**
* Copies the data directory (db + -wal, not -shm) into a new host-owned workspace directory and
* returns the path of the copied database file — inspect it with readTokens/assertDbInvariants.
* Works whether the app container is running, stopped or removed.
*/
async snapshotDb(): Promise<string> {
return snapshotDataDir(this.ctx, this.dataDir, path.basename(this.containerDbPath ?? '/data/dpsapi.db'));
}
}
function execEnv(env: Record<string, string | undefined> = {}): Record<string, string> {
const o: Record<string, string> = {};
for (const [k, v] of Object.entries(env)) if (v !== undefined) o[k] = v;
return o;
}
let snapSeq = 0;
/** Copies a (root-owned) data dir into a host-owned directory using a throwaway alpine container. Returns the copied db path. */
export async function snapshotDataDir(ctx: Pick<ImageContext, 'workspace' | 'testKey'>, dataDir: string, dbFile = 'dpsapi.db'): Promise<string> {
const out = ctx.workspace.mkdir(`db-snapshot-${++snapSeq}`);
const uid = process.getuid!();
const gid = process.getgid!();
const name = `${RESOURCE_PREFIX}${runId()}-${ctx.testKey}-snap${snapSeq}`;
await dockerCli([
'run', '--rm', '--name', name, '--label', `${LABEL_RUN}=${runId()}`, '--label', `${LABEL_TEST}=${ctx.testKey}`, '--network', 'none',
'-v', `${dataDir}:/data:ro`, '-v', `${out}:/out`, ALPINE_IMAGE,
'sh', '-c', `cp -a /data/. /out/ && rm -f /out/*-shm && chown -R ${uid}:${gid} /out && chmod -R u+rwX /out`,
]);
const p = path.join(out, dbFile);
if (!fs.existsSync(p)) throw new HarnessError('infrastructure', `no ${dbFile} in ${dataDir}`);
return p;
}
export class ImageRunner {
constructor(private readonly ctx: ImageContext) {}
/** The image under test ($DPSAPI_IMAGE). */
get image(): string {
const i = imageRef();
if (!i) throw new HarnessError('prerequisite', 'DPSAPI_IMAGE is not set (use `make docker-e2e` or export DPSAPI_IMAGE=<ref>)');
return i;
}
/** Name for a suite-owned container: dpsapi-e2e-<runId>-<testKey>-<suffix>. */
containerName(suffix: string): string {
return `${RESOURCE_PREFIX}${runId()}-${this.ctx.testKey}-${suffix}`;
}
/** docker run -d the image (server mode by default) and wait for readiness. */
async start(opts: ImageStartOptions = {}): Promise<ImageContainer> {
const image = this.image;
const suffix = opts.suffix ?? `img${++seq}`;
const name = this.containerName(suffix);
const dataDir = opts.dataDir ?? this.ctx.workspace.mkdir(`data-${suffix}`);
let fake: FakeDocker | undefined;
let sock: string | undefined;
const backend = opts.docker ?? (fake = await this.ctx.newFakeDocker());
if (backend === 'real') sock = '/var/run/docker.sock';
else if (backend instanceof FakeDocker) {
fake = backend;
sock = backend.socketPath;
} else if (backend !== 'none') sock = backend.socketPath;
if (sock && !fs.existsSync(sock)) throw new HarnessError('infrastructure', `socket ${sock} does not exist (docker would create a root-owned directory there)`);
const c = new ImageContainer(this.ctx, name, image, dataDir, opts, fake);
const containerPort = opts.containerPort ?? 80;
const args = ['run', '-d', '--name', name, '--label', `${LABEL_RUN}=${runId()}`, '--label', `${LABEL_TEST}=${this.ctx.testKey}`];
if (opts.publish ?? true) args.push('-p', `127.0.0.1::${containerPort}`);
if (sock) args.push('-v', `${sock}:/var/run/docker.sock`);
args.push('-v', `${dataDir}:/data`);
const env: Record<string, string | undefined> = {
DB_PATH: opts.dbPath === null ? undefined : (opts.dbPath ?? '/data/dpsapi.db'),
LOG_LEVEL: opts.logLevel === null ? undefined : (opts.logLevel ?? 'INFO'),
HTTP_ADDR: opts.addr ?? undefined,
HTTP_PORT: opts.containerPort !== undefined ? String(opts.containerPort) : undefined,
...(opts.env ?? {}),
};
for (const [k, v] of Object.entries(env)) if (v !== undefined) args.push('-e', `${k}=${v}`);
if (opts.user) args.push('--user', opts.user);
args.push(...(opts.extraArgs ?? []), image, ...(opts.args ?? []));
this.ctx.containers.push(c);
const r = await dockerCli(args);
c.id = r.stdout.trim();
this.ctx.recorder.note(`image container ${name} started: docker ${JSON.stringify(args)}`);
if (opts.publish ?? true) {
const p = await dockerCli(['port', name, `${containerPort}/tcp`], { allowFailure: true });
const m = /127\.0\.0\.1:(\d+)/.exec(p.stdout);
if (m) {
c.hostPort = Number(m[1]);
c.baseURL = `http://127.0.0.1:${c.hostPort}`;
} else if (opts.waitReady ?? !opts.args?.length) {
const l = await c.logs();
throw new Error(`container ${name} has no published port (exited?): ${p.stderr}\n--- stderr\n${l.stderr}`);
}
}
if ((opts.waitReady ?? !opts.args?.length) && (opts.publish ?? true)) await c.waitReady();
return c;
}
/**
* One-shot `docker run --rm` of the image with args (CLI mode / startup-failure checks).
* Returns the CliResult shape (via 'docker-run'); exit codes 125–127 are HarnessErrors.
*/
async run(
args: string[],
opts: { env?: Record<string, string | undefined>; dataDir?: string; docker?: ImageDockerBackend; user?: string; timeoutMs?: number; extraArgs?: string[]; suffix?: string } = {},
): Promise<CliResult> {
const name = this.containerName(opts.suffix ?? `run${++seq}`);
const dataDir = opts.dataDir ?? this.ctx.workspace.mkdir(`data-${name.slice(-12)}`);
const a = ['run', '--rm', '--name', name, '--label', `${LABEL_RUN}=${runId()}`, '--label', `${LABEL_TEST}=${this.ctx.testKey}`, '-v', `${dataDir}:/data`];
const backend = opts.docker ?? 'none';
if (backend === 'real') a.push('-v', '/var/run/docker.sock:/var/run/docker.sock');
else if (backend instanceof FakeDocker) a.push('-v', `${backend.socketPath}:/var/run/docker.sock`);
else if (backend !== 'none') a.push('-v', `${backend.socketPath}:/var/run/docker.sock`);
const env = { DB_PATH: '/data/dpsapi.db', ...(opts.env ?? {}) };
for (const [k, v] of Object.entries(env)) if (v !== undefined) a.push('-e', `${k}=${v}`);
if (opts.user) a.push('--user', opts.user);
a.push(...(opts.extraArgs ?? []), this.image, ...args);
const r = await runProcess('docker', a, { env: toolEnv(), timeoutMs: opts.timeoutMs ?? 60_000, label: `docker run ${args.slice(0, 2).join(' ')}` });
if (printsTokens(args)) this.ctx.secrets.registerAllIn(r.stdout);
this.ctx.recorder.addCli({ via: 'docker-run', command: `docker run ${this.image}`, args, env: execEnv(env), exitCode: r.exitCode, signal: r.signal, timedOut: r.timedOut, durationMs: Math.round(r.durationMs), stdout: r.stdout, stderr: r.stderr });
if (r.exitCode !== null && r.exitCode >= 125 && r.exitCode <= 127) {
throw new HarnessError('infrastructure', `docker run failed at the docker level (exit ${r.exitCode}): ${r.stderr.trim()}`, r);
}
return { ...r, args: [...args], via: 'docker-run' };
}
/**
* Copies /app out of the image (docker create + docker cp, no container is started) into the
* workspace and returns its host path — the static binary can then be driven on the host with
* cli.withBinary(path) / startServer({ binary: path }) for multi-transport comparisons.
*/
async extractBinary(): Promise<string> {
const name = this.containerName(`extract${++seq}`);
const dest = this.ctx.workspace.path(`app-from-image-${seq}`);
await dockerCli(['create', '--name', name, '--label', `${LABEL_RUN}=${runId()}`, '--label', `${LABEL_TEST}=${this.ctx.testKey}`, this.image]);
try {
await dockerCli(['cp', `${name}:/app`, dest]);
} finally {
await dockerCli(['rm', '-f', name], { allowFailure: true });
}
fs.chmodSync(dest, 0o755);
return dest;
}
/** Copies a data dir to a readable location; returns the db path (see ImageContainer.snapshotDb). */
snapshotDataDir(dataDir: string, dbFile = 'dpsapi.db'): Promise<string> {
return snapshotDataDir(this.ctx, dataDir, dbFile);
}
/** Deletes a (root-owned) data directory via a throwaway container. */
removeDataDir(dataDir: string): Promise<void> {
return forceRemoveDir(dataDir);
}
}