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

270 lines
9.5 KiB
TypeScript

// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
/**
* In-process fake Docker Engine API on a unix socket.
*
* Answers `HEAD/GET /_ping` and `GET [/vX.Y]/containers/json`; anything else gets a 404 Engine
* error. The behaviour of container listing is switchable live (the socket file is never
* recreated, so bind mounts that pinned its inode keep working).
*/
import fs from 'node:fs';
import http from 'node:http';
import type { Socket } from 'node:net';
import { HarnessError } from './errors';
import type { Rng } from './rng';
/** Subset of the Engine API container summary (GET /containers/json) used by the fake. */
export interface ContainerSummary {
Id: string;
Names: string[];
Image: string;
ImageID: string;
Command: string;
Created: number;
Ports: Array<{ IP?: string; PrivatePort: number; PublicPort?: number; Type: string }>;
Labels: Record<string, string>;
State: string;
Status: string;
HostConfig: { NetworkMode: string };
NetworkSettings: { Networks: Record<string, unknown> };
Mounts: unknown[];
[extra: string]: unknown;
}
/** Plausible running-container summary number i (deterministic unless an rng is given). */
export function makeContainerSummary(i: number, rng?: Rng, overrides: Partial<ContainerSummary> = {}): ContainerSummary {
const id = rng ? rng.bytes(32).toString('hex') : (i + 1).toString(16).padStart(64, '0');
const images = ['alpine:3.22', 'nginx:1.29', 'postgres:18', 'redis:8', 'busybox:1.37', 'example.org/team/app:v' + (i % 7)];
const image = rng ? rng.pick(images) : images[i % images.length];
return {
Id: id,
Names: [`/fake-${i}`],
Image: image,
ImageID: 'sha256:' + id,
Command: i % 3 === 0 ? 'sleep infinity' : i % 3 === 1 ? '/docker-entrypoint.sh nginx -g \'daemon off;\'' : 'sh -c "while :; do sleep 1; done"',
Created: 1_760_000_000 + i,
Ports: i % 4 === 0 ? [{ IP: '127.0.0.1', PrivatePort: 80, PublicPort: 30000 + (i % 1000), Type: 'tcp' }] : [],
Labels: { 'fake.index': String(i), 'com.example.tier': i % 2 ? 'web' : 'db' },
State: 'running',
Status: `Up ${1 + (i % 59)} minutes`,
HostConfig: { NetworkMode: 'bridge' },
NetworkSettings: { Networks: {} },
Mounts: [],
...overrides,
};
}
/** n plausible summaries: makeContainerSummary(0..n-1). */
export function makeContainerSummaries(n: number, rng?: Rng): ContainerSummary[] {
return Array.from({ length: n }, (_, i) => makeContainerSummary(i, rng));
}
/** Behaviour of `GET /containers/json`. */
export type FakeDockerMode =
| { kind: 'containers' } // 200 with `fake.containers`
| { kind: 'error'; status: number; body: string } // Engine error response
| { kind: 'destroy' } // drop the connection without a response
| { kind: 'hang' } // never answer (connection held until the fake closes)
| { kind: 'badjson'; body: string } // 200 with an unparseable body
| { kind: 'passthrough'; upstream: string }; // proxy everything to a real Engine socket
export interface FakeDockerRequest {
method: string;
url: string;
/** Path without the /vX.Y prefix and query. */
path: string;
query: string;
at: number;
}
export interface FakeDockerStats {
ping: number;
list: number;
other: number;
total: number;
}
export class FakeDocker {
/** Containers returned in 'containers' mode (and after flaky failures). */
containers: ContainerSummary[] = [];
mode: FakeDockerMode = { kind: 'containers' };
/** The next N list calls fail with 500 before `mode` applies ("temporarily failing"). */
failNext = 0;
/** Delay (ms) before answering list calls. */
delayMs = 0;
/** API version advertised by /_ping. */
apiVersion = '1.56';
readonly requests: FakeDockerRequest[] = [];
readonly stats: FakeDockerStats = { ping: 0, list: 0, other: 0, total: 0 };
private readonly server: http.Server;
private readonly sockets = new Set<Socket>();
private closed = false;
private constructor(readonly socketPath: string) {
this.server = http.createServer((req, res) => this.handle(req, res));
this.server.on('connection', (s) => {
this.sockets.add(s);
s.on('close', () => this.sockets.delete(s));
});
}
/** Starts a fake listening on socketPath (any stale file there is removed first). Starts in empty mode. */
static async start(socketPath: string): Promise<FakeDocker> {
if (Buffer.byteLength(socketPath) > 100) throw new HarnessError('infrastructure', `unix socket path too long: ${socketPath}`);
fs.rmSync(socketPath, { force: true });
const f = new FakeDocker(socketPath);
await new Promise<void>((resolve, reject) => {
f.server.once('error', reject);
f.server.listen(socketPath, () => resolve());
});
return f;
}
// ---- mode switches (live) ----
/** 200 `[]`. */
empty(): this {
return this.setContainers([]);
}
/** 200 with n generated summaries. */
many(n: number, rng?: Rng): this {
return this.setContainers(makeContainerSummaries(n, rng));
}
/** 200 with the given summaries. */
setContainers(list: ContainerSummary[]): this {
this.containers = list;
this.mode = { kind: 'containers' };
return this;
}
/** Engine error (default 500 `{"message":"fake engine failure"}`). */
error(status = 500, message = 'fake engine failure: /var/lib/docker internal detail'): this {
this.mode = { kind: 'error', status, body: JSON.stringify({ message }) };
return this;
}
/** Alias of error(500). */
error500(message?: string): this {
return this.error(500, message);
}
/** The next k list calls fail with 500, then the current mode applies again. */
flaky(k: number): this {
this.failNext = k;
return this;
}
destroy(): this {
this.mode = { kind: 'destroy' };
return this;
}
hang(): this {
this.mode = { kind: 'hang' };
return this;
}
badJson(body = '{"not":"an array"'): this {
this.mode = { kind: 'badjson', body };
return this;
}
passthrough(upstream = '/var/run/docker.sock'): this {
this.mode = { kind: 'passthrough', upstream };
return this;
}
delay(ms: number): this {
this.delayMs = ms;
return this;
}
/** Number of container-list calls received so far. */
listCalls(): number {
return this.stats.list;
}
resetStats(): void {
this.requests.length = 0;
this.stats.ping = this.stats.list = this.stats.other = this.stats.total = 0;
}
/** Closes the listener and drops every open connection (hung requests included). */
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
for (const s of this.sockets) s.destroy();
await new Promise<void>((r) => this.server.close(() => r()));
fs.rmSync(this.socketPath, { force: true });
}
private handle(req: http.IncomingMessage, res: http.ServerResponse): void {
const url = new URL(req.url ?? '/', 'http://fake');
const path = url.pathname.replace(/^\/v[0-9.]+(?=\/)/, '');
this.requests.push({ method: req.method ?? '', url: req.url ?? '', path, query: url.search, at: Date.now() });
this.stats.total++;
if (path === '/_ping') this.stats.ping++;
else if (path === '/containers/json') this.stats.list++;
else this.stats.other++;
const mode = this.mode;
if (mode.kind === 'passthrough') return this.proxy(req, res, mode.upstream);
if (path === '/_ping' && (req.method === 'GET' || req.method === 'HEAD')) {
res.writeHead(200, {
'Api-Version': this.apiVersion,
'Content-Type': 'text/plain; charset=utf-8',
'Docker-Experimental': 'false',
Ostype: 'linux',
Server: 'Docker/fake (linux)',
});
res.end(req.method === 'HEAD' ? undefined : 'OK');
return;
}
if (path === '/containers/json' && req.method === 'GET') {
const answer = () => this.answerList(res, req);
if (this.delayMs > 0 && mode.kind !== 'hang') setTimeout(answer, this.delayMs);
else answer();
return;
}
this.json(res, 404, { message: `page not found: ${req.method} ${path}` });
}
private answerList(res: http.ServerResponse, req: http.IncomingMessage): void {
if (this.closed) return;
if (this.failNext > 0) {
this.failNext--;
this.json(res, 500, { message: 'fake engine temporarily failing' });
return;
}
const mode = this.mode;
switch (mode.kind) {
case 'containers':
this.json(res, 200, this.containers);
return;
case 'error':
res.writeHead(mode.status, { 'Content-Type': 'application/json', 'Api-Version': this.apiVersion });
res.end(mode.body);
return;
case 'destroy':
req.socket.destroy();
return;
case 'hang':
return; // held until close()
case 'badjson':
res.writeHead(200, { 'Content-Type': 'application/json', 'Api-Version': this.apiVersion });
res.end(mode.body);
return;
case 'passthrough':
return; // handled earlier
}
}
private json(res: http.ServerResponse, status: number, body: unknown): void {
const data = JSON.stringify(body);
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), 'Api-Version': this.apiVersion });
res.end(data);
}
private proxy(req: http.IncomingMessage, res: http.ServerResponse, upstream: string): void {
const up = http.request({ socketPath: upstream, method: req.method, path: req.url, headers: req.headers }, (ur) => {
res.writeHead(ur.statusCode ?? 502, ur.headers);
ur.pipe(res);
});
up.on('error', () => req.socket.destroy());
req.pipe(up);
}
}