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