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:
1 parent
a02eef13a7
commit
fe0cd4bf6d
33 files changed
+6370
No files matched your search
@@ -0,0 +1,269 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user