/** * uat-report/build-model.ts — PURE: run artifacts → report view-model. * * Everything the HTML needs, pre-computed: the run verdict, KPIs per axis, the * overall pass rate, failure groups by CAUSE (failures-first reading), coverage * breakdowns (skips are visible, never hidden), latency bands, the role × unit * RBAC matrices, and per-role / per-group rollups. No I/O, no clock — * `generatedAt` is injected by the caller, screenshot embeds are collected by * the caller and passed through untouched. */ import { DEFAULT_THRESHOLDS, aggregateApi, aggregateUi, rollupByRole, type ApiAggregate, type ApiRunFile, type ApiRunResult, type RoleRollup, type UiAggregate, type UiRunFile, type UiRunResult, } from '../lib/run-results.js'; import { parsePermissionPath } from '../../../lib/permission-actions.js'; export interface GroupRollup { group: string; apiPassed: number; apiFailed: number; uiPassed: number; uiFailed: number; } export interface ReportThresholds { apiWarnMs: number; apiSlowMs: number; uiWarnMs: number; uiSlowMs: number; } // ── Failure causes (failures-first reading) ───────────────────────────────── export type FailureCause = | 'rbac_mismatch' | 'server_error' | 'network_error' | 'status_mismatch' | 'console_errors' | 'failed_requests' | 'navigation_failed' | 'flow_failed' | 'other'; /** Fixed rendering order — RBAC first (the pipeline's raison d'être). */ export const FAILURE_CAUSE_ORDER: readonly FailureCause[] = [ 'rbac_mismatch', 'server_error', 'network_error', 'status_mismatch', 'console_errors', 'failed_requests', 'navigation_failed', 'flow_failed', 'other', ]; export const FAILURE_CAUSE_LABELS: Record = { rbac_mismatch: 'RBAC mismatch (role got the wrong answer)', server_error: 'Server error (5xx)', network_error: 'Network error (no response)', status_mismatch: 'Unexpected status', console_errors: 'Console errors on an allowed page', failed_requests: 'Failed requests on an allowed page', navigation_failed: 'Navigation failed', flow_failed: 'Write flow failed', other: 'Other', }; /** Cause of a FAILED (executed, !ok) API row. PURE. */ export function apiFailureCause(r: ApiRunResult): FailureCause { if (r.actual === null || r.actual === 0) return 'network_error'; if (r.actual >= 500) return 'server_error'; // authz_only fails ONLY when the gate rejected an allowed role (401/403). if (r.mode === 'authz_only') return 'rbac_mismatch'; if (r.expected >= 400 && r.actual < 400) return 'rbac_mismatch'; // over-exposure if (r.expected < 400 && (r.actual === 401 || r.actual === 403)) return 'rbac_mismatch'; // under-exposure if (r.expected >= 400 && r.actual >= 400 && r.actual !== r.expected) return 'rbac_mismatch'; // wrong denial return 'status_mismatch'; } /** Cause of a FAILED (executed, determinate, !ok) UI row. PURE. */ export function uiFailureCause(r: UiRunResult): FailureCause { if (r.kind !== 'page') return 'flow_failed'; if (r.actual === 'error') return r.reason === 'navigation_failed' ? 'navigation_failed' : 'other'; if (r.expected !== r.actual) return 'rbac_mismatch'; // An allowed page that failed one of the quality gates: if (r.consoleErrors.length > 0) return 'console_errors'; if (r.network.failed.some((f) => f.status >= 500)) return 'server_error'; if (r.network.failed.length > 0) return 'failed_requests'; return 'other'; } export interface FailureGroup { cause: FailureCause; label: string; api: ApiRunResult[]; ui: UiRunResult[]; } /** Bucket every failure by cause, in FAILURE_CAUSE_ORDER, non-empty only. PURE. */ export function buildFailureGroups(api: readonly ApiRunResult[], ui: readonly UiRunResult[]): FailureGroup[] { const byCause = new Map(); const get = (cause: FailureCause): FailureGroup => { let g = byCause.get(cause); if (!g) { g = { cause, label: FAILURE_CAUSE_LABELS[cause], api: [], ui: [] }; byCause.set(cause, g); } return g; }; for (const r of api) { if (r.executed && !r.ok) get(apiFailureCause(r)).api.push(r); } for (const r of ui) { if (r.executed && r.actual !== 'indeterminate' && !r.ok) get(uiFailureCause(r)).ui.push(r); } return FAILURE_CAUSE_ORDER.filter((c) => byCause.has(c)).map((c) => byCause.get(c)!); } // ── Coverage (skips are part of the story) ────────────────────────────────── export interface CoverageBreakdown { executed: number; skipped: number; /** Sorted by count desc, then reason asc. */ skipReasons: { reason: string; count: number }[]; } export function coverageOf(rows: readonly { executed: boolean; reason?: string }[]): CoverageBreakdown { const executed = rows.filter((r) => r.executed).length; const counts = new Map(); for (const r of rows) { if (r.executed) continue; const reason = r.reason ?? 'unspecified'; counts.set(reason, (counts.get(reason) ?? 0) + 1); } const skipReasons = [...counts.entries()] .map(([reason, count]) => ({ reason, count })) .sort((a, b) => b.count - a.count || (a.reason < b.reason ? -1 : 1)); return { executed, skipped: rows.length - executed, skipReasons }; } // ── Latency bands (chart data) ────────────────────────────────────────────── export interface LatencyBand { label: string; count: number; band: 'fast' | 'ok' | 'warn' | 'slow'; } /** 4 bands: < warn/2, < warn, < slow, ≥ slow. PURE. */ export function latencyBands(values: readonly number[], warnMs: number, slowMs: number): LatencyBand[] { const half = Math.floor(warnMs / 2); const bands: LatencyBand[] = [ { label: `< ${half} ms`, count: 0, band: 'fast' }, { label: `< ${warnMs} ms`, count: 0, band: 'ok' }, { label: `< ${slowMs} ms`, count: 0, band: 'warn' }, { label: `≥ ${slowMs} ms`, count: 0, band: 'slow' }, ]; for (const v of values) { if (v < half) bands[0].count += 1; else if (v < warnMs) bands[1].count += 1; else if (v < slowMs) bands[2].count += 1; else bands[3].count += 1; } return bands; } // ── RBAC matrix (role × unit) ─────────────────────────────────────────────── export type MatrixCell = | { kind: 'pass' | 'fail'; title: string } | { kind: 'skip'; reason: string } | { kind: 'absent' }; export interface MatrixRow { id: string; label: string; permission?: string; /** Aligned to RbacMatrix.roles. */ cells: MatrixCell[]; } export interface MatrixGroup { group: string; rows: MatrixRow[]; passed: number; failed: number; skipped: number; } export interface RbacMatrix { roles: string[]; groups: MatrixGroup[]; } function apiMatrixCell(r: ApiRunResult | undefined): MatrixCell { if (!r) return { kind: 'absent' }; if (!r.executed) return { kind: 'skip', reason: r.reason ?? 'unspecified' }; // authz_only asserts "gate ≠ 401/403", NOT a literal status — say so, never // print a misleading `expected 201`. const title = r.mode === 'authz_only' ? `authz gate (≠ 401/403) · got ${r.actual}${r.note ? ` · ${r.note}` : ''}` : `expected ${r.expected} · got ${r.actual}`; return { kind: r.ok ? 'pass' : 'fail', title }; } function uiMatrixCell(r: UiRunResult | undefined): MatrixCell { if (!r) return { kind: 'absent' }; if (!r.executed) return { kind: 'skip', reason: r.reason ?? 'unspecified' }; if (r.actual === 'indeterminate') return { kind: 'skip', reason: r.reason ?? 'indeterminate' }; return { kind: r.ok ? 'pass' : 'fail', title: `expected ${r.expected} · observed ${r.actual}` }; } interface MatrixSource { rows: readonly T[]; unitId: (r: T) => string; unitLabel: (r: T) => string; group: (r: T) => string; role: (r: T) => string; permission: (r: T) => string | undefined; cell: (r: T | undefined) => MatrixCell; } function buildMatrix(src: MatrixSource, roles: readonly string[]): RbacMatrix { // Unit rows keep artifact order (deterministic); groups sort alphabetically. const units = new Map }>(); for (const r of src.rows) { const id = src.unitId(r); let unit = units.get(id); if (!unit) { unit = { label: src.unitLabel(r), group: src.group(r), byRole: new Map() }; units.set(id, unit); } if (unit.permission === undefined) { const p = src.permission(r); if (p) unit.permission = p; } unit.byRole.set(src.role(r), r); } const groups = new Map(); for (const [id, unit] of units) { let group = groups.get(unit.group); if (!group) { group = { group: unit.group, rows: [], passed: 0, failed: 0, skipped: 0 }; groups.set(unit.group, group); } const cells = roles.map((role) => src.cell(unit.byRole.get(role))); for (const cell of cells) { if (cell.kind === 'pass') group.passed += 1; else if (cell.kind === 'fail') group.failed += 1; else if (cell.kind === 'skip') group.skipped += 1; } group.rows.push({ id, label: unit.label, ...(unit.permission ? { permission: unit.permission } : {}), cells }); } return { roles: [...roles], groups: [...groups.values()].sort((a, b) => (a.group < b.group ? -1 : a.group > b.group ? 1 : 0)), }; } export function buildApiMatrix(rows: readonly ApiRunResult[], roles: readonly string[]): RbacMatrix { return buildMatrix( { rows, unitId: (r) => r.id, unitLabel: (r) => `${r.method} ${r.route}`, group: groupKeyApi, role: (r) => r.role, permission: (r) => r.permission, cell: apiMatrixCell, }, roles, ); } /** UI matrix covers PAGE rows only — write flows live in the tables/failures. */ export function buildUiMatrix(rows: readonly UiRunResult[], roles: readonly string[]): RbacMatrix { return buildMatrix( { rows: rows.filter((r) => r.kind === 'page'), unitId: (r) => r.routeId, unitLabel: (r) => r.routeId, group: groupKeyUi, role: (r) => r.role, permission: (r) => r.permission, cell: uiMatrixCell, }, roles, ); } // ── Grouping (permission-derived when available) ──────────────────────────── /** Group key of an API row: the 2 path segments after /api/. PURE. */ export function apiGroup(route: string): string { const segments = route.replace(/^\/?api\//i, '').split('/').filter(Boolean); return segments.slice(0, 2).join('.') || route; } /** Group key of a UI row: the first 2 componentKey segments. PURE. */ export function uiGroup(row: UiRunResult): string { if (row.componentKey) return row.componentKey.split('.').slice(0, 2).join('.'); return row.routeId.toLowerCase(); } /** * Group key from a permission path: the LAST 2 segments of the bearing node's * path. Last (not first) so an app-qualified seed path (`rh.administration.users.read`) * and a 3-seg constant (`administration.users.read`) land in the SAME * `administration.users` group — which is also what the URL/componentKey * heuristics produce, keeping both axes aligned. PURE. */ export function groupOfPermission(permission: string): string { const parsed = parsePermissionPath(permission); const nodePath = parsed ? parsed.nodePath : permission.split('.').slice(0, -1).join('.'); const segments = nodePath.split('.').filter(Boolean); return (segments.length > 2 ? segments.slice(-2) : segments).join('.') || permission; } /** Permission-derived group when the row carries one, else the URL heuristic. */ export function groupKeyApi(r: ApiRunResult): string { return r.permission ? groupOfPermission(r.permission) : apiGroup(r.route); } /** Permission-derived group when the row carries one, else the componentKey heuristic. */ export function groupKeyUi(r: UiRunResult): string { return r.permission ? groupOfPermission(r.permission) : uiGroup(r); } export function buildGroupRollups(api: readonly ApiRunResult[], ui: readonly UiRunResult[]): GroupRollup[] { const groups = new Map(); const get = (key: string): GroupRollup => { let g = groups.get(key); if (!g) { g = { group: key, apiPassed: 0, apiFailed: 0, uiPassed: 0, uiFailed: 0 }; groups.set(key, g); } return g; }; for (const row of api) { if (!row.executed) continue; const g = get(groupKeyApi(row)); if (row.ok) g.apiPassed += 1; else g.apiFailed += 1; } for (const row of ui) { if (!row.executed || row.actual === 'indeterminate') continue; const g = get(groupKeyUi(row)); if (row.ok) g.uiPassed += 1; else g.uiFailed += 1; } return [...groups.values()].sort((a, b) => (a.group < b.group ? -1 : a.group > b.group ? 1 : 0)); } // ── Thresholds (single resolver for BOTH callers) ─────────────────────────── /** * Precedence: explicit spec value > the plan's `execution.perf` (UI bands only — * the plan does not parameterize the API bands) > DEFAULT_THRESHOLDS. */ export function resolveThresholds( overrides: Partial, uiPerf?: { warn_ms: number; slow_ms: number } | null, ): ReportThresholds { return { apiWarnMs: overrides.apiWarnMs ?? DEFAULT_THRESHOLDS.apiWarnMs, apiSlowMs: overrides.apiSlowMs ?? DEFAULT_THRESHOLDS.apiSlowMs, uiWarnMs: overrides.uiWarnMs ?? uiPerf?.warn_ms ?? DEFAULT_THRESHOLDS.uiWarnMs, uiSlowMs: overrides.uiSlowMs ?? uiPerf?.slow_ms ?? DEFAULT_THRESHOLDS.uiSlowMs, }; } // ── Model ─────────────────────────────────────────────────────────────────── /** Screenshot embed collected by the caller (IO) — data URI or a cap note. */ export interface ScreenshotEmbed { dataUri?: string; note?: string; } export interface ReportModel { title: string; application: string; runId: string; generatedAt: string; startedAt?: string; finishedAt?: string; /** finishedAt − startedAt when both parse as dates. */ durationMs?: number; apiUrl?: string; frontendUrl?: string; planPath?: string; planSignatureShort?: string; roles: string[]; /** FAIL as soon as one determinate check failed. */ verdict: 'PASS' | 'FAIL'; overallPassRate: number | null; api: (ApiAggregate & { rows: ApiRunResult[] }) | null; ui: (UiAggregate & { rows: UiRunResult[] }) | null; roleRollups: RoleRollup[]; groupRollups: GroupRollup[]; failureGroups: FailureGroup[]; /** Rows at/over the warn band — advisory, never failures. Sorted slowest first. */ perfOffenders: { api: ApiRunResult[]; ui: UiRunResult[] }; coverage: { api: CoverageBreakdown | null; ui: CoverageBreakdown | null }; latency: { api: LatencyBand[] | null; ui: LatencyBand[] | null }; apiMatrix: RbacMatrix | null; uiMatrix: RbacMatrix | null; /** * The security surface the run can NOT certify: endpoints with no * permission gate at all (their green 200-for-every-role rows are the * deployed behaviour, i.e. the DEV-API-033 defect), and endpoints whose * DECLARED permission exists in no live permission row (every role denied — * the lost-seed drift). Null when the API axis is absent or clean. */ securityFindings: SecurityFindings | null; /** Keyed by the artifact's relative screenshot path (POSIX). */ screenshots: Record; thresholds: ReportThresholds; runWarnings: string[]; } export interface SecurityFindings { ungated: { method: string; route: string; controller?: string }[]; unseeded: { method: string; route: string; permission?: string }[]; } /** Distinct flagged endpoints across the api rows (one entry per method:route). PURE. */ export function buildSecurityFindings(api: readonly ApiRunResult[]): SecurityFindings | null { const seen = new Set(); const ungated: SecurityFindings['ungated'] = []; const unseeded: SecurityFindings['unseeded'] = []; for (const r of api) { const key = `${r.method}:${r.route}`; if (seen.has(key)) continue; seen.add(key); if (r.ungated) { ungated.push({ method: r.method, route: r.route, ...(r.controller ? { controller: r.controller } : {}) }); } else if (r.permissionSource === 'declared-unseeded') { unseeded.push({ method: r.method, route: r.route, ...(r.permission ? { permission: r.permission } : {}) }); } } return ungated.length > 0 || unseeded.length > 0 ? { ungated, unseeded } : null; } export interface BuildModelInput { apiFile: ApiRunFile | null; uiFile: UiRunFile | null; title?: string; generatedAt: string; thresholds: ReportThresholds; /** Extra warnings surfaced by the orchestrator (drift notes, skipped phases…). */ runWarnings?: string[]; /** Screenshot embeds collected by the caller (IO) — pass-through. */ screenshots?: Record; } export function buildModel(input: BuildModelInput): ReportModel { const meta = input.apiFile?.meta ?? input.uiFile?.meta ?? null; if (!meta) throw new Error('buildModel needs at least one run artifact (api or ui)'); const apiRows = input.apiFile?.results ?? []; const uiRows = input.uiFile?.results ?? []; const apiAgg = input.apiFile ? aggregateApi(apiRows) : null; const uiAgg = input.uiFile ? aggregateUi(uiRows, input.thresholds.uiWarnMs) : null; const passed = (apiAgg?.passed ?? 0) + (uiAgg?.passed ?? 0); const failed = (apiAgg?.failed ?? 0) + (uiAgg?.failed ?? 0); const determinate = passed + failed; const roles = [...new Set([...(input.apiFile?.meta.roles ?? []), ...(input.uiFile?.meta.roles ?? [])])]; const sig = meta.planSignature; const sigShort = [sig.nav_sha, sig.rbac_sha, sig.registry_sha] .map((s) => (s ? s.slice(0, 8) : '—')) .join(' · '); const startedAt = input.apiFile?.meta.startedAt ?? input.uiFile?.meta.startedAt; const finishedAt = input.uiFile?.meta.finishedAt ?? input.apiFile?.meta.finishedAt; const startMs = startedAt ? Date.parse(startedAt) : NaN; const endMs = finishedAt ? Date.parse(finishedAt) : NaN; const durationMs = Number.isFinite(startMs) && Number.isFinite(endMs) && endMs >= startMs ? endMs - startMs : undefined; const apiDurations = apiRows.filter((r) => r.executed).map((r) => r.durationMs); const uiReadies = uiRows.filter((r) => r.executed && r.kind === 'page').map((r) => r.perf.fullyReadyMs); const apiOffenders = apiRows .filter((r) => r.executed && r.durationMs >= input.thresholds.apiWarnMs) .sort((a, b) => b.durationMs - a.durationMs); const uiOffenders = uiRows .filter((r) => r.executed && r.kind === 'page' && r.perf.fullyReadyMs >= input.thresholds.uiWarnMs) .sort((a, b) => b.perf.fullyReadyMs - a.perf.fullyReadyMs); const apiMatrixRoles = input.apiFile?.meta.roles.length ? input.apiFile.meta.roles : roles; const uiMatrixRoles = input.uiFile?.meta.roles.length ? input.uiFile.meta.roles : roles; return { title: input.title ?? `UAT — ${meta.application}`, application: meta.application, runId: meta.runId, generatedAt: input.generatedAt, startedAt, finishedAt, ...(durationMs !== undefined ? { durationMs } : {}), apiUrl: input.apiFile?.meta.apiUrl, frontendUrl: input.uiFile?.meta.frontendUrl, planPath: meta.planPath, planSignatureShort: sigShort, roles, verdict: failed > 0 ? 'FAIL' : 'PASS', overallPassRate: determinate > 0 ? Math.round((passed / determinate) * 100) : null, api: apiAgg ? { ...apiAgg, rows: apiRows } : null, ui: uiAgg ? { ...uiAgg, rows: uiRows } : null, roleRollups: rollupByRole(roles, apiRows, uiRows), groupRollups: buildGroupRollups(apiRows, uiRows), failureGroups: buildFailureGroups(apiRows, uiRows), perfOffenders: { api: apiOffenders, ui: uiOffenders }, coverage: { api: input.apiFile ? coverageOf(apiRows) : null, ui: input.uiFile ? coverageOf(uiRows) : null, }, latency: { api: input.apiFile ? latencyBands(apiDurations, input.thresholds.apiWarnMs, input.thresholds.apiSlowMs) : null, ui: input.uiFile ? latencyBands(uiReadies, input.thresholds.uiWarnMs, input.thresholds.uiSlowMs) : null, }, apiMatrix: input.apiFile ? buildApiMatrix(apiRows, apiMatrixRoles) : null, uiMatrix: input.uiFile ? buildUiMatrix(uiRows, uiMatrixRoles) : null, securityFindings: buildSecurityFindings(apiRows), screenshots: input.screenshots ?? {}, thresholds: input.thresholds, runWarnings: input.runWarnings ?? [], }; }