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

99 lines
3.8 KiB
TypeScript

// 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[];
}