/** * build-model.test — artifacts → report view-model: KPIs, pass rate, rollups, * group keys, signature shortening, verdict, failure causes, coverage, latency * bands, RBAC matrices, threshold resolution. */ import { describe, it, expect } from 'vitest'; import { ApiRunFileSchema, DEFAULT_THRESHOLDS, UiRunFileSchema, type ApiRunResult, type UiRunResult } from '../../lib/run-results.js'; import { apiFailureCause, apiGroup, buildApiMatrix, buildFailureGroups, buildGroupRollups, buildModel, buildUiMatrix, coverageOf, groupOfPermission, latencyBands, resolveThresholds, uiFailureCause, uiGroup, } from '../build-model.js'; const THRESHOLDS = { apiWarnMs: 1000, apiSlowMs: 3000, uiWarnMs: 3000, uiSlowMs: 8000 }; const API_FILE = ApiRunFileSchema.parse({ kind: 'uat-api', meta: { runId: 'r1', application: 'administration', planPath: 'plan.yml', planSignature: { nav_sha: 'a'.repeat(64), rbac_sha: 'b'.repeat(64), registry_sha: 'c'.repeat(64) }, apiUrl: 'http://api', roles: ['admin', 'viewer'], startedAt: '2026-06-12T00:00:00.000Z', }, results: [ { id: 'get:administration/users', method: 'GET', route: '/api/administration/users', role: 'admin', mode: 'exact', expected: 200, actual: 200, ok: true, executed: true, durationMs: 120, sizeBytes: 2000 }, { id: 'get:administration/users', method: 'GET', route: '/api/administration/users', role: 'viewer', mode: 'exact', expected: 403, actual: 200, ok: false, executed: true, durationMs: 80, sizeBytes: 500 }, { id: 'get:administration/users/{id}', method: 'GET', route: '/api/administration/users/{id}', role: 'admin', mode: 'exact', expected: 200, actual: null, ok: false, executed: false, reason: 'route_params' }, ], }); const UI_FILE = UiRunFileSchema.parse({ kind: 'uat-ui', meta: { runId: 'r1', application: 'administration', planPath: 'plan.yml', planSignature: { nav_sha: 'a'.repeat(64) }, frontendUrl: 'http://front', roles: ['admin', 'viewer'], startedAt: '2026-06-12T00:01:00.000Z', finishedAt: '2026-06-12T00:05:00.000Z', }, results: [ { routeId: 'ADMINISTRATION_USERS', componentKey: 'administration.users', role: 'admin', url: '/administration/users', expected: 'allowed', actual: 'allowed', ok: true, perf: { navMs: 60, fullyReadyMs: 700 }, network: { requestCount: 5, transferredBytes: 40_000, failed: [] } }, { routeId: 'ADMINISTRATION_USERS', componentKey: 'administration.users', role: 'viewer', url: '/administration/users', expected: 'denied', actual: 'allowed', ok: false }, { routeId: 'ADMINISTRATION_USERS_DETAIL', componentKey: 'administration.users.detail', role: 'viewer', url: '/administration/users/:id', expected: 'allowed', actual: 'indeterminate', ok: false, reason: 'empty_parent_list' }, ], }); describe('group keys', () => { it('apiGroup takes the 2 segments after /api/', () => { expect(apiGroup('/api/administration/users')).toBe('administration.users'); expect(apiGroup('/api/health')).toBe('health'); }); it('uiGroup takes the first 2 componentKey segments, else the route id', () => { expect(uiGroup(UI_FILE.results[0])).toBe('administration.users'); expect(uiGroup({ ...UI_FILE.results[0], componentKey: undefined })).toBe('administration_users'); }); }); describe('buildGroupRollups', () => { it('counts executed determinate results per group across both axes', () => { const rollups = buildGroupRollups(API_FILE.results, UI_FILE.results); expect(rollups).toEqual([ { group: 'administration.users', apiPassed: 1, apiFailed: 1, uiPassed: 1, uiFailed: 1 }, ]); }); }); describe('buildModel', () => { it('assembles KPIs, pass rate, roles and signature', () => { const model = buildModel({ apiFile: API_FILE, uiFile: UI_FILE, generatedAt: '2026-06-12T01:00:00.000Z', thresholds: THRESHOLDS, runWarnings: ['note'], }); expect(model.title).toBe('UAT — administration'); expect(model.runId).toBe('r1'); expect(model.api).toMatchObject({ executed: 2, passed: 1, failed: 1, skipped: 1 }); expect(model.ui).toMatchObject({ executed: 3, passed: 1, failed: 1, indeterminate: 1 }); // pass rate = (1+1) / (2+2) = 50% expect(model.overallPassRate).toBe(50); expect(model.roles).toEqual(['admin', 'viewer']); expect(model.planSignatureShort).toBe(`${'a'.repeat(8)} · ${'b'.repeat(8)} · ${'c'.repeat(8)}`); expect(model.startedAt).toBe('2026-06-12T00:00:00.000Z'); expect(model.finishedAt).toBe('2026-06-12T00:05:00.000Z'); expect(model.runWarnings).toEqual(['note']); }); it('works single-axis and throws with no artifact at all', () => { const apiOnly = buildModel({ apiFile: API_FILE, uiFile: null, generatedAt: 'x', thresholds: THRESHOLDS }); expect(apiOnly.ui).toBeNull(); expect(apiOnly.overallPassRate).toBe(50); expect(apiOnly.uiMatrix).toBeNull(); expect(apiOnly.latency.ui).toBeNull(); expect(() => buildModel({ apiFile: null, uiFile: null, generatedAt: 'x', thresholds: THRESHOLDS })).toThrow( /at least one run artifact/, ); }); it('computes verdict, run duration, coverage, latency, matrices and screenshot pass-through', () => { const model = buildModel({ apiFile: API_FILE, uiFile: UI_FILE, generatedAt: 'x', thresholds: THRESHOLDS, screenshots: { 'screenshots/admin/X.png': { dataUri: 'data:image/png;base64,AA==' } }, }); expect(model.verdict).toBe('FAIL'); // 2 determinate failures // startedAt (api) 00:00 → finishedAt (ui) 00:05 = 5 min expect(model.durationMs).toBe(5 * 60 * 1000); expect(model.coverage.api).toEqual({ executed: 2, skipped: 1, skipReasons: [{ reason: 'route_params', count: 1 }] }); expect(model.coverage.ui).toEqual({ executed: 3, skipped: 0, skipReasons: [] }); // API durations 120/80 both < 500 (warn/2) → fast band expect(model.latency.api![0]).toMatchObject({ band: 'fast', count: 2 }); expect(model.apiMatrix!.roles).toEqual(['admin', 'viewer']); expect(model.uiMatrix!.roles).toEqual(['admin', 'viewer']); expect(model.screenshots['screenshots/admin/X.png'].dataUri).toContain('base64'); const allPass = buildModel({ apiFile: { ...API_FILE, results: [API_FILE.results[0]] }, uiFile: null, generatedAt: 'x', thresholds: THRESHOLDS, }); expect(allPass.verdict).toBe('PASS'); }); it('collects perf offenders (≥ warn band) sorted slowest first, pages only on the UI axis', () => { const slowApi: ApiRunResult = { ...API_FILE.results[0], id: 'get:slow', durationMs: 2500 }; const slowerApi: ApiRunResult = { ...API_FILE.results[0], id: 'get:slower', durationMs: 3500 }; const slowUiFlow: UiRunResult = { ...UI_FILE.results[0], routeId: 'FLOW', kind: 'create_flow', perf: { navMs: 0, fullyReadyMs: 9000 } }; const model = buildModel({ apiFile: { ...API_FILE, results: [...API_FILE.results, slowApi, slowerApi] }, uiFile: { ...UI_FILE, results: [...UI_FILE.results, slowUiFlow] }, generatedAt: 'x', thresholds: THRESHOLDS, }); expect(model.perfOffenders.api.map((r) => r.id)).toEqual(['get:slower', 'get:slow']); expect(model.perfOffenders.ui).toEqual([]); // the slow row is a flow, not a page }); }); describe('failure causes', () => { const apiRow = (over: Partial): ApiRunResult => ({ ...API_FILE.results[1], ...over, }); const uiRow = (over: Partial): UiRunResult => ({ ...UI_FILE.results[1], ...over, }); it('classifies API failures (truth table)', () => { expect(apiFailureCause(apiRow({ actual: 0 }))).toBe('network_error'); expect(apiFailureCause(apiRow({ actual: null }))).toBe('network_error'); expect(apiFailureCause(apiRow({ actual: 500 }))).toBe('server_error'); expect(apiFailureCause(apiRow({ mode: 'authz_only', expected: 201, actual: 403 }))).toBe('rbac_mismatch'); expect(apiFailureCause(apiRow({ expected: 403, actual: 200 }))).toBe('rbac_mismatch'); // over-exposure expect(apiFailureCause(apiRow({ expected: 200, actual: 403 }))).toBe('rbac_mismatch'); // under-exposure expect(apiFailureCause(apiRow({ expected: 401, actual: 403 }))).toBe('rbac_mismatch'); // wrong denial status expect(apiFailureCause(apiRow({ expected: 200, actual: 404 }))).toBe('status_mismatch'); }); it('classifies UI failures (truth table)', () => { expect(uiFailureCause(uiRow({ kind: 'create_flow' }))).toBe('flow_failed'); expect(uiFailureCause(uiRow({ actual: 'error', reason: 'navigation_failed' }))).toBe('navigation_failed'); expect(uiFailureCause(uiRow({ expected: 'denied', actual: 'allowed' }))).toBe('rbac_mismatch'); expect(uiFailureCause(uiRow({ expected: 'allowed', actual: 'allowed', consoleErrors: ['boom'] }))).toBe('console_errors'); expect( uiFailureCause(uiRow({ expected: 'allowed', actual: 'allowed', network: { requestCount: 1, transferredBytes: 0, failed: [{ url: 'x', status: 500 }] } })), ).toBe('server_error'); expect( uiFailureCause(uiRow({ expected: 'allowed', actual: 'allowed', network: { requestCount: 1, transferredBytes: 0, failed: [{ url: 'x', status: 404 }] } })), ).toBe('failed_requests'); }); it('buildFailureGroups buckets executed determinate failures only, in fixed cause order', () => { const groups = buildFailureGroups( [apiRow({}), apiRow({ id: 'get:crash', actual: 500 }), apiRow({ id: 'get:skip', executed: false, ok: false })], [uiRow({}), uiRow({ routeId: 'IND', actual: 'indeterminate' })], ); expect(groups.map((g) => g.cause)).toEqual(['rbac_mismatch', 'server_error']); expect(groups[0].api).toHaveLength(1); expect(groups[0].ui).toHaveLength(1); // the viewer over-exposure UI row expect(groups[1].api.map((r) => r.id)).toEqual(['get:crash']); }); }); describe('coverageOf / latencyBands', () => { it('counts skip reasons sorted by count then name', () => { const cov = coverageOf([ { executed: true }, { executed: false, reason: 'route_params' }, { executed: false, reason: 'route_params' }, { executed: false, reason: 'login_failed' }, { executed: false }, ]); expect(cov).toEqual({ executed: 1, skipped: 4, skipReasons: [ { reason: 'route_params', count: 2 }, { reason: 'login_failed', count: 1 }, { reason: 'unspecified', count: 1 }, ], }); }); it('bands latencies into fast/ok/warn/slow', () => { const bands = latencyBands([100, 499, 500, 999, 1000, 2999, 3000, 9000], 1000, 3000); expect(bands.map((b) => `${b.band}:${b.count}`)).toEqual(['fast:2', 'ok:2', 'warn:2', 'slow:2']); }); }); describe('groupOfPermission + permission-aware grouping', () => { it('takes the last 2 node segments so 3-seg constants and 4-seg app-qualified paths align', () => { expect(groupOfPermission('administration.users.read')).toBe('administration.users'); expect(groupOfPermission('rh.administration.users.read')).toBe('administration.users'); expect(groupOfPermission('rh.administration.users.read.all')).toBe('administration.users'); expect(groupOfPermission('administration.access')).toBe('administration'); expect(groupOfPermission('weird')).toBe('weird'); // malformed → verbatim fallback }); it('buildGroupRollups prefers the permission-derived group (screens stratum re-mapped)', () => { const screensRow: ApiRunResult = { ...API_FILE.results[0], id: 'get:screens/users', route: '/api/screens/users', permission: 'rh.administration.users.read', }; const rollups = buildGroupRollups([screensRow], []); expect(rollups).toEqual([{ group: 'administration.users', apiPassed: 1, apiFailed: 0, uiPassed: 0, uiFailed: 0 }]); }); }); describe('RBAC matrices', () => { it('aligns cells to roles with pass/fail/skip/absent kinds and per-group rollups', () => { const matrix = buildApiMatrix(API_FILE.results, ['admin', 'viewer', 'ghost']); expect(matrix.roles).toEqual(['admin', 'viewer', 'ghost']); expect(matrix.groups).toHaveLength(1); const group = matrix.groups[0]; expect(group.group).toBe('administration.users'); expect(group.rows.map((r) => r.id)).toEqual(['get:administration/users', 'get:administration/users/{id}']); const [list, detail] = group.rows; expect(list.cells.map((c) => c.kind)).toEqual(['pass', 'fail', 'absent']); expect(detail.cells.map((c) => c.kind)).toEqual(['skip', 'absent', 'absent']); expect((detail.cells[0] as { reason: string }).reason).toBe('route_params'); expect(group).toMatchObject({ passed: 1, failed: 1, skipped: 1 }); }); it('authz_only cells describe the gate, never a misleading literal status', () => { const row: ApiRunResult = { ...API_FILE.results[0], mode: 'authz_only', expected: 201, actual: 400, note: 'authorized_validation_only', }; const matrix = buildApiMatrix([row], ['admin']); const cell = matrix.groups[0].rows[0].cells[0] as { kind: string; title: string }; expect(cell.title).toContain('authz gate'); expect(cell.title).not.toContain('expected 201'); }); it('UI matrix keeps pages only; indeterminates are skip cells', () => { const flow: UiRunResult = { ...UI_FILE.results[0], routeId: 'X_CREATE', kind: 'create_flow' }; const matrix = buildUiMatrix([...UI_FILE.results, flow], ['admin', 'viewer']); const ids = matrix.groups.flatMap((g) => g.rows.map((r) => r.id)); expect(ids).toEqual(['ADMINISTRATION_USERS', 'ADMINISTRATION_USERS_DETAIL']); const detail = matrix.groups[0].rows.find((r) => r.id === 'ADMINISTRATION_USERS_DETAIL')!; expect(detail.cells.map((c) => c.kind)).toEqual(['absent', 'skip']); expect((detail.cells[1] as { reason: string }).reason).toBe('empty_parent_list'); }); it('a matrix row carries the first permission seen for the unit', () => { const rows: ApiRunResult[] = [ { ...API_FILE.results[0], permission: 'administration.users.read' }, { ...API_FILE.results[1] }, ]; const matrix = buildApiMatrix(rows, ['admin', 'viewer']); expect(matrix.groups[0].rows[0].permission).toBe('administration.users.read'); }); }); describe('resolveThresholds', () => { it('precedence: explicit override > plan perf (UI only) > defaults', () => { expect(resolveThresholds({})).toEqual(DEFAULT_THRESHOLDS); expect(resolveThresholds({}, { warn_ms: 2000, slow_ms: 5000 })).toEqual({ apiWarnMs: DEFAULT_THRESHOLDS.apiWarnMs, apiSlowMs: DEFAULT_THRESHOLDS.apiSlowMs, uiWarnMs: 2000, uiSlowMs: 5000, }); expect(resolveThresholds({ uiWarnMs: 1500, apiWarnMs: 500 }, { warn_ms: 2000, slow_ms: 5000 })).toEqual({ apiWarnMs: 500, apiSlowMs: DEFAULT_THRESHOLDS.apiSlowMs, uiWarnMs: 1500, uiSlowMs: 5000, }); }); }); // ── Security findings (audit chantier 1.4 — the run cannot certify these) ── describe('buildSecurityFindings', () => { const row = (over: Partial): ApiRunResult => ({ id: 'get:x', method: 'GET', route: '/api/x', role: 'admin', mode: 'exact', expected: 200, actual: 200, ok: true, executed: true, durationMs: 1, sizeBytes: 1, ...over, }) as ApiRunResult; it('collects ungated + declared-unseeded endpoints, deduped by method:route', async () => { const { buildSecurityFindings } = await import('../build-model.js'); const findings = buildSecurityFindings([ row({ id: 'get:a', route: '/api/a', ungated: true, role: 'admin' }), row({ id: 'get:a', route: '/api/a', ungated: true, role: 'viewer' }), // same endpoint, other role row({ id: 'post:b', route: '/api/b', method: 'POST', permission: 'x.y.export', permissionSource: 'declared-unseeded' }), row({ id: 'get:c', route: '/api/c', permission: 'x.y.read', permissionSource: 'declared' }), ]); expect(findings).not.toBeNull(); expect(findings!.ungated).toEqual([{ method: 'GET', route: '/api/a' }]); expect(findings!.unseeded).toEqual([{ method: 'POST', route: '/api/b', permission: 'x.y.export' }]); }); it('null when the api axis is clean — no empty scary section', async () => { const { buildSecurityFindings } = await import('../build-model.js'); expect(buildSecurityFindings([row({ permissionSource: 'declared', permission: 'x.y.read' })])).toBeNull(); }); });