/** * uat-api/plan-calls.ts — PURE projection of plan endpoints onto executable calls. * * Decides, per (endpoint × role), HOW to assert without ever writing real data: * * GET (any expectation) → exact: actual must equal expected. * any verb when expected ≥ 400 → exact: the authorization gate answers BEFORE * model binding, so an empty body is safe and * the 401/403 must come back precisely. * write verb when expected is 2xx → authz_only: probe with an EMPTY JSON body; * the gate must let it through (anything but * 401/403 passes — typically a 400 validation * reply). The REAL 2xx write is exercised by * the UI axis through actual form submits. * * Routes with path parameters ({id}, :id) are not guessable deterministically → * emitted as executed:false/reason:'route_params' so the report counts them * honestly instead of silently dropping them. */ import type { PlanTest, Endpoint } from '../lib/plantest-schema.js'; import type { ApiAssertMode } from '../lib/run-results.js'; export interface PlannedCall { endpointId: string; method: Endpoint['method']; route: string; role: string; expected: number; mode: ApiAssertMode; /** False → carried into results as a skip (reason set). */ execute: boolean; reason?: string; /** JSON body to send (write probes). */ body?: string; /** No Authorization header (anonymous axis). */ anonymous: boolean; /** Permission the plan resolved for the endpoint (report diagnosis). */ permission?: string; /** How the plan obtained the permission (declared / declared-unseeded / inferred). */ permissionSource?: string; /** The endpoint carries NO permission gate (DEV-API-033 defect surface) — * the expected 200-for-every-role rows are the DEPLOYED behaviour, loudly * flagged in the report, never a certification. */ ungated?: boolean; /** Controller class the endpoint was discovered on. */ controller?: string; } /** Does the route template carry unresolvable parameters? PURE. */ export function hasRouteParams(route: string): boolean { return /\{[^}]+\}|:(?!\d)\w+/.test(route); } const WRITE_VERBS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); /** Project plan endpoints × roles onto planned calls, in deterministic plan order. PURE. */ export function planApiCalls( plan: PlanTest, opts: { roles?: readonly string[]; includeWriteProbes: boolean }, ): PlannedCall[] { const roles = (opts.roles && opts.roles.length > 0 ? opts.roles : plan.roles).filter((r) => plan.roles.includes(r), ); const calls: PlannedCall[] = []; for (const endpoint of plan.endpoints) { const paramRoute = hasRouteParams(endpoint.route); for (const role of roles) { const expected = endpoint.expected_by_role[role]; if (expected === undefined) continue; // invariant 3 guarantees presence; stay defensive. const anonymous = role === 'anonymous'; const isWrite = WRITE_VERBS.has(endpoint.method); const denied = expected >= 400; const base = { endpointId: endpoint.id, method: endpoint.method, route: endpoint.route, role, expected, anonymous, ...(endpoint.permission ? { permission: endpoint.permission } : {}), ...(endpoint.permission_source ? { permissionSource: endpoint.permission_source } : {}), ...(endpoint.ungated ? { ungated: true } : {}), ...(endpoint.controller ? { controller: endpoint.controller } : {}), }; if (paramRoute) { calls.push({ ...base, mode: 'exact', execute: false, reason: 'route_params' }); continue; } if (!isWrite) { calls.push({ ...base, mode: 'exact', execute: true }); continue; } if (denied) { // Authorization answers before model binding — empty body, exact status. calls.push({ ...base, mode: 'exact', execute: true, body: '{}' }); continue; } if (!opts.includeWriteProbes) { calls.push({ ...base, mode: 'authz_only', execute: false, reason: 'write_probes_disabled' }); continue; } calls.push({ ...base, mode: 'authz_only', execute: true, body: '{}' }); } } return calls; } /** Assert a received status against a planned call. PURE. */ export function assertCall(call: PlannedCall, actual: number): { ok: boolean; note?: string } { if (call.mode === 'exact') return { ok: actual === call.expected }; // authz_only: the gate must NOT reject — any non-401/403 means the role is authorized. if (actual === 401 || actual === 403) return { ok: false }; // A real 2xx on an empty-body write probe means the action ACTUALLY executed // (e.g. a parameterless custom/bulk action with EmptyBodyBehavior.Allow) — never // a silent green. Surface it so a real mutation is visible in the report. if (actual >= 200 && actual < 300) return { ok: true, note: 'real_write_executed' }; return { ok: true, note: actual === call.expected ? undefined : 'authorized_validation_only', }; }