/** * cli:run-smoke — interact.ts (Axis 4b — custom-action contract probes) * * The HTTP axis probes every route ANONYMOUSLY with an EMPTY body, so it can * only prove a route exists (401/403 = pass). It never validates the custom-action * CONTRACT: a row/bulk action that requires a body will accept an empty one under * `EmptyBodyBehavior.Allow` (silent 200) or reject the content-type with a 415 that * the empty probe never surfaces. This axis closes that gap: * * - reads the module's `pagespecs/*.md` and derives each custom-action endpoint, * - synthesizes a VALID body from `payloadParameters[]`, * - fires it at the SENTINEL id (row) / an empty id set (bulk) so the handler * returns 404-not-found BEFORE any commit — the contract is validated, no row * is mutated, * - classifies the status: 415 / 405 / 5xx = FAIL, 2xx / 404 = PASS, * 400 / 422 = INDETERMINATE (our synthesized body may be insufficient), * 401 / 403 = AUTH-GATED (needs a token — surfaced, never a silent pass). * * The pure functions (synthesizeActionBody, classifyActionStatus, collectActionProbes, * runActionProbes with an injectable fetch) are unit-tested with no live app. */ import { readFile } from 'node:fs/promises'; import { findFiles } from '../../../../lib/fs.js'; import { STANDARD_CRUD_CODES, defaultEndpoint, expectedUrlPath } from '../../../../lib/page-spec-actions.js'; import type { ActionProbe, ActionProbeResult, ActionVerdict, InteractionReport } from './types.js'; /** All-zero Guid — matches no real row, so a row/bulk probe reaches the handler * and returns 404 (entity not found) BEFORE any persistence: contract validated, * no side effect. */ export const SENTINEL_GUID = '00000000-0000-0000-0000-000000000000'; export interface ActionParam { name: string; type: string; required?: boolean } /** Synthesize a VALID request body from an action's payloadParameters. Deterministic * (no Date/random): fixed values per type so the probe list is reproducible. `file` * params are omitted — the type is NOT wired end-to-end (the generated pipeline * posts JSON, no multipart path exists; PRD-107 flags authored `type:file` * actions upstream, uploads use the attachments pattern instead). */ export function synthesizeActionBody(params: ActionParam[]): Record { const body: Record = {}; for (const p of params) { switch (p.type) { case 'number': body[p.name] = 1; break; case 'date': body[p.name] = '2026-01-15'; break; case 'boolean': body[p.name] = true; break; case 'lookup': body[p.name] = SENTINEL_GUID; break; case 'file': break; // not wired end-to-end (JSON pipeline, no multipart) — PRD-107 flags authored ones default: body[p.name] = `SMOKE-${p.name}`; // text / textarea / select / unknown } } return body; } export interface ActionClassification { verdict: ActionVerdict; reason?: string } /** Classify an action-probe HTTP status into a verdict. Status is the signal: * 415 = the body/content-type contract is broken (the empty-body gap); 405 = * wrong verb; 5xx = handler threw on a VALID body ("action not implemented"); * 404 = route + body accepted, sentinel row not found (no mutation) = pass; * 400/422 = the synthesized body may be insufficient = indeterminate. */ export function classifyActionStatus(status: number | null): ActionClassification { if (status === null) return { verdict: 'fail', reason: 'network-error' }; if (status === 401 || status === 403) return { verdict: 'auth-gated' }; if (status === 415) return { verdict: 'fail', reason: 'smoke.action-body' }; if (status === 405) return { verdict: 'fail', reason: 'smoke.4xx' }; if (status === 404) return { verdict: 'pass' }; if (status >= 500) return { verdict: 'fail', reason: 'smoke.5xx' }; if (status >= 400) return { verdict: 'indeterminate', reason: `business-${status}` }; return { verdict: 'pass' }; } /** Read the module's pagespecs and derive one probe per custom action (kind api, * non-CRUD). De-duplicated by (module, section, scope, endpoint, verb). */ export async function collectActionProbes(moduleRoot: string): Promise { const probes: ActionProbe[] = []; const seen = new Set(); const files = await findFiles('pagespecs/*.md', { cwd: moduleRoot }); for (const abs of files) { const source = await readFile(abs, 'utf8').catch(() => ''); if (!source) continue; const blockRe = /```json\s*\n([\s\S]*?)\n```/g; let bm: RegExpExecArray | null; while ((bm = blockRe.exec(source)) !== null) { let ps: unknown; try { ps = JSON.parse(bm[1]); } catch { continue; } if (typeof ps !== 'object' || ps === null) continue; const rec = ps as Record; const module = String(rec.module ?? '').toLowerCase(); const section = String(rec.section ?? '').toLowerCase(); const entity = typeof rec.entity === 'string' ? rec.entity : ''; if (!module || !section) continue; const actions = Array.isArray(rec.actions) ? rec.actions : []; for (const a of actions) { if (typeof a !== 'object' || a === null) continue; const act = a as Record; if (typeof act.code !== 'string') continue; if ((act.kind ?? 'api') !== 'api') continue; if (STANDARD_CRUD_CODES.has(act.code.toLowerCase())) continue; const scope = act.scope; if (scope !== 'row' && scope !== 'bulk' && scope !== 'header') continue; const endpoint = typeof act.endpoint === 'string' ? act.endpoint : defaultEndpoint(act.code); const httpMethod = String(act.httpMethod ?? 'POST').toUpperCase(); const key = `${module}|${section}|${scope}|${endpoint}|${httpMethod}`; if (seen.has(key)) continue; seen.add(key); const params: ActionParam[] = Array.isArray(act.payloadParameters) ? (act.payloadParameters as ActionParam[]) : []; const suffix = expectedUrlPath(scope, endpoint).replace('{id}', SENTINEL_GUID); const payload = synthesizeActionBody(params); const body: Record = scope === 'bulk' ? { ids: [SENTINEL_GUID], ...(params.length ? { payload } : {}) } : payload; probes.push({ entity, module, section, code: act.code, scope, httpMethod, url: `/api/${module}/${section}${suffix}`, body, hasPayload: params.length > 0, }); } } } return probes.sort((x, y) => (x.url + x.httpMethod).localeCompare(y.url + y.httpMethod)); } export type FetchLike = ( url: string, init: { method: string; headers: Record; body?: string }, ) => Promise<{ status: number }>; /** Fire each probe (authenticated when a token is supplied) and classify the result. */ export async function runActionProbes( backendHost: string, probes: ActionProbe[], opts: { token?: string | null; fetchImpl?: FetchLike } = {}, ): Promise { const fetchImpl: FetchLike = opts.fetchImpl ?? (globalThis.fetch as unknown as FetchLike); const results: ActionProbeResult[] = []; for (const p of probes) { const headers: Record = { 'Content-Type': 'application/json' }; if (opts.token) headers['Authorization'] = `Bearer ${opts.token}`; const t0 = Date.now(); let status: number | null = null; try { const res = await fetchImpl(`${backendHost}${p.url}`, { method: p.httpMethod, headers, body: JSON.stringify(p.body), }); status = res.status; } catch { status = null; } const { verdict, reason } = classifyActionStatus(status); results.push({ ...p, status, verdict, reason, durationMs: Date.now() - t0 }); } return results; } /** Orchestrate the interaction axis. Never throws; degrades to `ran:false` (no * module root) or an `authenticated:false` note (no token) — never a silent pass. */ export async function runInteractionAxis( backendHost: string, moduleRoot: string | undefined, opts: { token?: string | null; fetchImpl?: FetchLike } = {}, ): Promise { if (!moduleRoot) { return { ran: false, authenticated: false, reason: 'no --module-root supplied', actions: [], failures: [] }; } const probes = await collectActionProbes(moduleRoot); if (probes.length === 0) { return { ran: true, authenticated: Boolean(opts.token), actions: [], failures: [] }; } const actions = await runActionProbes(backendHost, probes, opts); const failures = actions.filter((r) => r.verdict === 'fail'); const authenticated = Boolean(opts.token); const allAuthGated = actions.every((r) => r.verdict === 'auth-gated'); const reason = (!authenticated && allAuthGated) ? 'no admin token — custom-action body contract not validatable anonymously (every endpoint answered 401/403). Pass --admin-token to enable axis 4b.' : undefined; return { ran: true, authenticated, reason, actions, failures }; }