/** * uat-plan/discover — pure correlation tests (no DB, no filesystem): path scoping, * component-key building, registry parsing, permission resolution, route/endpoint * correlation. */ import { describe, it, expect } from 'vitest'; import { pathToPrefix, inScope, buildComponentKeys, parseRegistry, resolvePagePermission, resolveEndpointPermission, correlateRoutes, correlateEndpoints, } from '../discover.js'; import type { NavNode } from '../../../../lib/sql-discovery.js'; import { parseController, type ParsedEndpoint } from '../discover-endpoints.js'; function nav(over: Partial & Pick): NavNode { return { kind: 'section', label: '', parentId: '', route: '', isPersonal: false, ...over, }; } const NODES: NavNode[] = [ nav({ kind: 'application', id: 'a1', code: 'administration', label: 'Administration', route: '/administration' }), nav({ id: 's1', code: 'users', label: 'Users', parentId: 'a1', route: '/administration/users' }), nav({ id: 's2', code: 'roles', label: 'Roles', parentId: 'a1', route: '/administration/roles' }), nav({ kind: 'module', id: 'm1', code: 'noroute', parentId: 'a1', route: '' }), // no route → excluded nav({ kind: 'application', id: 'o1', code: 'other', route: '/other' }), nav({ id: 'x1', code: 'thing', parentId: 'o1', route: '/other/thing' }), // out of scope ]; describe('pathToPrefix / inScope', () => { it('converts a slash path to a dot-prefix and tolerates a trailing slash', () => { expect(pathToPrefix('administration/users')).toBe('administration.users'); expect(pathToPrefix('administration/')).toBe('administration'); }); it('scopes on dot boundaries (no bare prefix false-positives)', () => { expect(inScope('administration.users', 'administration')).toBe(true); expect(inScope('administration', 'administration')).toBe(true); expect(inScope('administrationX', 'administration')).toBe(false); expect(inScope('other', 'administration')).toBe(false); expect(inScope('anything', '')).toBe(true); }); }); describe('buildComponentKeys', () => { it('joins parent codes into dotted keys', () => { const keyOf = buildComponentKeys(NODES); expect(keyOf.get('a1')).toBe('administration'); expect(keyOf.get('s1')).toBe('administration.users'); expect(keyOf.get('x1')).toBe('other.thing'); }); }); describe('parseRegistry', () => { it('extracts string-literal keys (single or double quotes) and counts PAGE_KEYS entries', () => { const { keys, pageKeysOnly } = parseRegistry(` PageRegistry.register('administration.users', UsersPage); PageRegistry.register("administration.users.detail", DetailPage); PageRegistry.register(PAGE_KEYS.USERS_LIST, lazyWithRetry(() => import('x'))); `); expect(keys).toEqual(['administration.users', 'administration.users.detail']); expect(pageKeysOnly).toBe(1); }); }); describe('resolvePagePermission', () => { it('prefers read, then view, then the bare key, else undefined', () => { expect(resolvePagePermission('a.b', new Set(['a.b.read', 'a.b.view']))).toBe('a.b.read'); expect(resolvePagePermission('a.b', new Set(['a.b.view']))).toBe('a.b.view'); expect(resolvePagePermission('a.b', new Set(['a.b']))).toBe('a.b'); expect(resolvePagePermission('a.b', new Set(['x']))).toBeUndefined(); }); }); describe('resolveEndpointPermission', () => { const set = new Set(['administration.users.read', 'administration.users.create']); it('maps the verb to an action and reconciles against the live set', () => { expect(resolveEndpointPermission('administration.users', 'GET', set)).toBe('administration.users.read'); expect(resolveEndpointPermission('administration.users', 'POST', set)).toBe('administration.users.create'); expect(resolveEndpointPermission('a.b', 'GET', new Set(['a.b.view']))).toBe('a.b.view'); }); it('falls back to the primary-action candidate when none is in the set, but stays gated', () => { expect(resolveEndpointPermission('a.b', 'DELETE', new Set())).toBe('a.b.delete'); expect(resolveEndpointPermission(undefined, 'GET', set)).toBeUndefined(); }); // v3.62 — /lookup endpoints are dual-gated (lookup OR read, ANY semantics). it('prefers the dedicated lookup grant on /lookup routes', () => { const withLookup = new Set(['crm.clients.lookup', 'crm.clients.read']); expect( resolveEndpointPermission('crm.clients', 'GET', withLookup, '/api/crm/clients/lookup'), ).toBe('crm.clients.lookup'); }); it('falls back to read on /lookup when no lookup permission is seeded (pre-3.62 project)', () => { expect( resolveEndpointPermission('crm.clients', 'GET', new Set(['crm.clients.read']), '/api/crm/clients/lookup'), ).toBe('crm.clients.read'); }); it('non-lookup routes never pick the lookup grant', () => { const withLookup = new Set(['crm.clients.lookup', 'crm.clients.read']); expect(resolveEndpointPermission('crm.clients', 'GET', withLookup, '/api/crm/clients')).toBe( 'crm.clients.read', ); }); }); describe('parseController — [RequirePermission] arguments (v3.62 dual gate)', () => { // Minimal integration controller in the exact scaffold-controller shape — // GetAll single-gated, /lookup carrying the dual gate (ANY semantics). const CONTROLLER = ` using SmartStack.Api.Routing; using SmartStack.Api.Authorization; [ApiController] [NavRoute("crm.clients")] [Authorize] public class ClientsController : ControllerBase { [HttpGet] [RequirePermission(CrmPermissions.Clients.Read)] public async Task>> GetAll(CancellationToken ct = default) { return Ok(await _service.GetAllAsync(ct)); } [HttpGet("lookup")] [RequirePermission(CrmPermissions.Clients.Lookup, CrmPermissions.Clients.Read)] public async Task>> GetLookup(CancellationToken ct = default) { return Ok(await _service.GetLookupAsync(ct)); } } `; it('splits a two-arg gate into permissionExprs[], keeping permissionExpr verbatim', () => { const endpoints = parseController(CONTROLLER); const lookup = endpoints.find((e) => e.route.endsWith('/lookup'))!; expect(lookup).toBeDefined(); expect(lookup.permissionExpr).toBe('CrmPermissions.Clients.Lookup, CrmPermissions.Clients.Read'); // The FIRST entry is the minimal grant (Lookup) — the gate is ANY-of. expect(lookup.permissionExprs).toEqual([ 'CrmPermissions.Clients.Lookup', 'CrmPermissions.Clients.Read', ]); }); it('single-gated endpoints yield a one-entry permissionExprs[]', () => { const endpoints = parseController(CONTROLLER); const list = endpoints.find((e) => e.method === 'GET' && e.route === '/api/crm/clients')!; expect(list).toBeDefined(); expect(list.permissionExpr).toBe('CrmPermissions.Clients.Read'); expect(list.permissionExprs).toEqual(['CrmPermissions.Clients.Read']); }); }); describe('correlateRoutes', () => { const keyOf = buildComponentKeys(NODES); const registry = new Set(['administration.users.detail', 'administration.users.create']); const perms = new Set(['administration.users.read', 'administration.roles.read']); const routes = correlateRoutes(NODES, keyOf, registry, perms, 'administration'); it('keeps only in-scope navigable nodes, sorted by component key', () => { expect(routes.map((r) => r.componentKey)).toEqual([ 'administration', // app landing 'administration.roles', 'administration.users', ]); // "administration.noroute" excluded (no route); "other.*" excluded (out of scope) }); it('derives views from the registry (base list + present suffixes) and resolves permission', () => { const users = routes.find((r) => r.componentKey === 'administration.users')!; expect(users.views).toEqual(['list', 'detail', 'create']); expect(users.permission).toBe('administration.users.read'); expect(users.parentComponentKey).toBe('administration'); const roles = routes.find((r) => r.componentKey === 'administration.roles')!; expect(roles.views).toEqual(['list']); expect(roles.permission).toBe('administration.roles.read'); }); }); describe('correlateEndpoints', () => { it('drops out-of-scope endpoints, resolves the DECLARED permission + status, and sorts deterministically', () => { const parsed: ParsedEndpoint[] = [ { controller: 'UsersController', method: 'POST', route: '/api/administration/users', navRoute: 'administration.users', okStatus: 201, permissionExprs: ['AdministrationPermissions.Users.Create'] }, { controller: 'X', method: 'GET', route: '/api/other/x', navRoute: 'other.x', permissionExprs: ['OtherPermissions.X.Read'] }, // out of scope { controller: 'UsersController', method: 'GET', route: '/api/administration/users', navRoute: 'administration.users', permissionExprs: ['AdministrationPermissions.Users.Read'] }, ]; const perms = new Set(['administration.users.read', 'administration.users.create']); const eps = correlateEndpoints(parsed, perms, 'administration'); expect(eps.map((e) => `${e.method} ${e.permission}`)).toEqual([ 'GET administration.users.read', 'POST administration.users.create', ]); expect(eps[1].okStatus).toBe(201); expect(eps.every((e) => e.permissionSource === 'declared')).toBe(true); }); it('correlates the screen stratum (/api/screens/{plural}) back to its owning section', () => { // Screen controllers carry no [NavRoute]; navroute-parser infers `screens.{plural}`. const parsed: ParsedEndpoint[] = [ { controller: 'UsersScreenController', method: 'GET', route: '/api/screens/users/list', navRoute: 'screens.users', permissionExprs: ['AdministrationPermissions.Users.Read'] }, { controller: 'OrphanScreenController', method: 'GET', route: '/api/screens/ghosts/list', navRoute: 'screens.ghosts', permissionExprs: ['AdministrationPermissions.Ghosts.Read'] }, ]; const perms = new Set(['administration.users.read']); // The nav routes discovered for the scope carry the real section + its URL. const navRoutes = [ { componentKey: 'administration.users', baseRoute: '/administration/users', navKind: 'section' as const, views: ['list' as const] }, ]; const eps = correlateEndpoints(parsed, perms, 'administration', navRoutes); // /api/screens/users → section administration.users, gated on its real read permission. expect(eps).toHaveLength(1); expect(eps[0]).toMatchObject({ route: '/api/screens/users/list', permission: 'administration.users.read' }); // A screen plural with no matching in-scope section is dropped, not force-gated on a fake perm. expect(eps.some((e) => e.route.includes('ghosts'))).toBe(false); }); it('a non-CRUD verb resolves to its DECLARED action — never the verb→action guess', () => { // The historical failure: POST …/approve gated `.Approve` was planned as // `.create` — the verdict computed for the WRONG permission. const parsed: ParsedEndpoint[] = [ { controller: 'OrdersController', method: 'POST', route: '/api/commandes/orders/approve', navRoute: 'commandes.orders', permissionExprs: ['CommandesPermissions.Orders.Approve'] }, ]; const perms = new Set(['crm.commandes.orders.approve', 'crm.commandes.orders.create']); const eps = correlateEndpoints(parsed, perms, 'crm.commandes', [], 'crm'); expect(eps).toHaveLength(1); expect(eps[0].permission).toBe('crm.commandes.orders.approve'); expect(eps[0].permissionSource).toBe('declared'); }); it('requalifies the APP-LESS integration [NavRoute] into the app-rooted scope (3-level client app)', () => { // Client integration controllers carry [NavRoute("{module}.{section}")] — // no app segment — while the scope prefix is app-rooted: the dot-boundary // match alone dropped the WHOLE integration stratum. const parsed: ParsedEndpoint[] = [ { controller: 'OpportunitesController', method: 'GET', route: '/api/pipeline/opportunites', navRoute: 'pipeline.opportunites', permissionExprs: ['PipelinePermissions.Opportunites.Read'] }, ]; const perms = new Set(['crm.pipeline.opportunites.read']); const eps = correlateEndpoints(parsed, perms, 'crm.pipeline', [], 'crm'); expect(eps).toHaveLength(1); expect(eps[0].navRoute).toBe('crm.pipeline.opportunites'); expect(eps[0].permission).toBe('crm.pipeline.opportunites.read'); }); it('flags an endpoint with NO gate as ungated — never re-gated by a guess', () => { const warnings: string[] = []; const parsed: ParsedEndpoint[] = [ { controller: 'UsersController', method: 'GET', route: '/api/administration/users', navRoute: 'administration.users' }, ]; const eps = correlateEndpoints(parsed, new Set(['administration.users.read']), 'administration', [], '', warnings); expect(eps).toHaveLength(1); expect(eps[0].ungated).toBe(true); expect(eps[0].permission).toBeUndefined(); expect(warnings.some((w) => w.includes('DEV-API-033'))).toBe(true); }); it('a declared permission absent from the live set stays gated as declared-unseeded, loudly', () => { const warnings: string[] = []; const parsed: ParsedEndpoint[] = [ { controller: 'UsersController', method: 'POST', route: '/api/administration/users/export', navRoute: 'administration.users', permissionExprs: ['AdministrationPermissions.Users.Export'] }, ]; const eps = correlateEndpoints(parsed, new Set(['administration.users.read']), 'administration', [], '', warnings); expect(eps[0].permission).toBe('administration.users.export'); expect(eps[0].permissionSource).toBe('declared-unseeded'); expect(warnings.some((w) => w.includes('NO live permission row'))).toBe(true); }); it('the lookup dual gate resolves to its minimal grant (ANY semantics, first resolvable arg)', () => { const parsed: ParsedEndpoint[] = [ { controller: 'UsersController', method: 'GET', route: '/api/administration/users/lookup', navRoute: 'administration.users', permissionExprs: ['AdministrationPermissions.Users.Lookup', 'AdministrationPermissions.Users.Read'] }, ]; const perms = new Set(['administration.users.lookup', 'administration.users.read']); const eps = correlateEndpoints(parsed, perms, 'administration'); expect(eps[0].permission).toBe('administration.users.lookup'); }); it('drops screen plurals shared by two sections (ambiguous) rather than mis-map them', () => { const navRoutes = [ { componentKey: 'hr.settings', baseRoute: '/hr/settings', navKind: 'section' as const, views: ['list' as const] }, { componentKey: 'crm.settings', baseRoute: '/crm/settings', navKind: 'section' as const, views: ['list' as const] }, ]; const parsed: ParsedEndpoint[] = [ { controller: 'SettingsScreenController', method: 'GET', route: '/api/screens/settings/list', navRoute: 'screens.settings' }, ]; const eps = correlateEndpoints(parsed, new Set(['hr.settings.read']), '', navRoutes); expect(eps).toHaveLength(0); }); });