/** * uat/cli/lib/access-classify.ts — Pure access/visibility classification (specific to /uat). * * Two PURE concerns: * 1. deriveAccessLabel — the DISPLAY label for a (role, route): allowed / denied / * redirect_login from the plan, PLUS the derived `partial` (page allowed but * >=1 subset legitimately hidden for the role). `partial` is NEVER stored in * the plan — it is computed here for the report's role x route matrix (F5). * 2. classifySubsetVisibility — the run verdict for one (role, subset, observed DOM * count): a subset a role may NOT see must be ABSENT (present => BUG-OVEREXPOSED; * absent => CONFIG-LEGITIME); a subset a role MAY see is expected present. */ import type { AccessVerdict, Route, Subset } from './plantest-schema.js'; export type AccessLabel = AccessVerdict | 'partial'; export type SubsetVerdictCategory = | 'RBAC-OK' | 'CONFIG-LEGITIME' | 'BUG-OVEREXPOSED' | 'BUG-MISSING-ELEMENT' | 'INDETERMINATE'; export interface SubsetVerdict { pass: boolean; category: SubsetVerdictCategory; } /** Is `role` expected to see `subset`? */ export function subsetVisibleForRole(subset: Subset, role: string): boolean { return subset.visible_for.includes(role); } /** * Display label for (role, route). `partial` when the page is `allowed` but at * least one subset is legitimately hidden for the role — surfaced so the report's * matrix can show "allowed, but reduced surface" rather than a flat allowed. */ export function deriveAccessLabel(route: Route, role: string): AccessLabel { const base: AccessVerdict | undefined = route.access[role]; if (base !== 'allowed') return base ?? 'denied'; const anyHidden = route.subsets.some((s) => !subsetVisibleForRole(s, role)); return anyHidden ? 'partial' : 'allowed'; } /** * Verdict for an observed subset given whether the role may see it and how many * matching nodes the DOM exposed. * - role may NOT see it: count>0 => FAIL BUG-OVEREXPOSED ; count==0 => PASS CONFIG-LEGITIME * - role MAY see it: count>0 => PASS RBAC-OK ; count==0 => FAIL BUG-MISSING-ELEMENT */ export function classifySubsetVisibility(subset: Subset, role: string, domCount: number): SubsetVerdict { const maySee = subsetVisibleForRole(subset, role); if (!maySee) { return domCount > 0 ? { pass: false, category: 'BUG-OVEREXPOSED' } : { pass: true, category: 'CONFIG-LEGITIME' }; } return domCount > 0 ? { pass: true, category: 'RBAC-OK' } : { pass: false, category: 'BUG-MISSING-ELEMENT' }; } /** * Verdict for a click_row_in_parent route whose parent list was empty: there is no * row to open, so RBAC cannot be observed — INDETERMINATE, never `denied` (F5). */ export function classifyEmptyList(): SubsetVerdict { return { pass: true, category: 'INDETERMINATE' }; }