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,98 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
/**
|
||||
* Shared assertions on the HTTP contract of dpsapi.
|
||||
*/
|
||||
import { expect } from '@playwright/test';
|
||||
import type { CurlResult, HttpResult, RawHttpResponse } from './http';
|
||||
|
||||
/** Exact error bodies of the application. */
|
||||
export const ERROR_BODIES = {
|
||||
401: '{"error":"unauthorized"}',
|
||||
404: '{"error":"not found"}',
|
||||
405: '{"error":"method not allowed"}',
|
||||
500: '{"error":"internal server error"}',
|
||||
} as const;
|
||||
|
||||
export type AppErrorStatus = keyof typeof ERROR_BODIES;
|
||||
|
||||
/** Client-independent response view. */
|
||||
export interface ResponseView {
|
||||
status: number;
|
||||
/** Lower-cased names; repeated headers joined with ', '. */
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
/** Normalizes Playwright / curl / raw responses into a ResponseView. */
|
||||
export function view(r: HttpResult | CurlResult | RawHttpResponse | ResponseView): ResponseView {
|
||||
if ('bodyText' in r) {
|
||||
const headers: Record<string, string> = {};
|
||||
for (const [k, v] of r.headers) {
|
||||
const l = k.toLowerCase();
|
||||
headers[l] = l in headers ? `${headers[l]}, ${v}` : v;
|
||||
}
|
||||
return { status: r.status, headers, body: r.bodyText };
|
||||
}
|
||||
return { status: r.status, headers: r.headers, body: r.body };
|
||||
}
|
||||
|
||||
/** Substrings that must never appear in an HTTP response body. */
|
||||
export const INTERNAL_DETAIL_MARKERS = [
|
||||
'sql',
|
||||
'sqlite',
|
||||
'docker.sock',
|
||||
'/var/run',
|
||||
'unix',
|
||||
'dial',
|
||||
'goroutine',
|
||||
'panic',
|
||||
'runtime error',
|
||||
'fake engine',
|
||||
'connection refused',
|
||||
'permission denied',
|
||||
'no such file',
|
||||
'context deadline',
|
||||
'list containers',
|
||||
'token_id',
|
||||
];
|
||||
|
||||
/** Fails if the body contains any internal-detail marker (case-insensitive). */
|
||||
export function expectNoInternalDetails(body: string): void {
|
||||
const low = body.toLowerCase();
|
||||
expect(INTERNAL_DETAIL_MARKERS.filter((m) => low.includes(m)), `internal details leaked in body ${JSON.stringify(body.slice(0, 300))}`).toEqual([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts one of the application's JSON error responses exactly: status, body, Content-Type,
|
||||
* Content-Length, WWW-Authenticate (only on 401: `Bearer`) and Allow (only on 405: `GET`).
|
||||
*/
|
||||
export function expectErrorResponse(res: HttpResult | CurlResult | RawHttpResponse | ResponseView, status: AppErrorStatus, { head = false }: { head?: boolean } = {}): void {
|
||||
const r = view(res);
|
||||
const body = ERROR_BODIES[status];
|
||||
expect(r.status, `status (body ${JSON.stringify(r.body.slice(0, 200))})`).toBe(status);
|
||||
expect(r.body, 'error body').toBe(head ? '' : body);
|
||||
expect(r.headers['content-type'], 'content-type').toBe('application/json');
|
||||
expect(r.headers['content-length'], 'content-length').toBe(String(body.length));
|
||||
if (status === 401) expect(r.headers['www-authenticate'], 'www-authenticate').toBe('Bearer');
|
||||
else expect(r.headers['www-authenticate'], 'www-authenticate').toBeUndefined();
|
||||
if (status === 405) expect(r.headers['allow'], 'allow').toBe('GET');
|
||||
else expect(r.headers['allow'], 'allow').toBeUndefined();
|
||||
expect(r.headers['location'], 'no redirect').toBeUndefined();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts a successful container listing: 200, application/json, exact Content-Length, a JSON
|
||||
* array without trailing newline. Returns the parsed array.
|
||||
*/
|
||||
export function expectContainersResponse<T = Record<string, unknown>>(res: HttpResult | CurlResult | RawHttpResponse | ResponseView): T[] {
|
||||
const r = view(res);
|
||||
expect(r.status, `status (body ${JSON.stringify(r.body.slice(0, 200))})`).toBe(200);
|
||||
expect(r.headers['content-type'], 'content-type').toBe('application/json');
|
||||
expect(r.headers['content-length'], 'content-length').toBe(String(Buffer.byteLength(r.body)));
|
||||
expect(r.body.endsWith('\n'), 'no trailing newline').toBe(false);
|
||||
const parsed = JSON.parse(r.body) as unknown;
|
||||
expect(Array.isArray(parsed), 'body is a JSON array').toBe(true);
|
||||
return parsed as T[];
|
||||
}
|
||||
Reference in new issue
Block a user