/** * extract-doc / access-roles — the « Accès & rôles » deterministic join. * * The contract under test (truth model, user-decided): * - the CODE-extracted permissions are the ONLY join keys — a state/rbac.md * grant matching no code permission is excluded + warned (drift), never * published; * - the core-seed state contributes the roles, rbac.md only enriches matched * rows (labels, portée); * - non-endpoint actions (`access`, the `read.all` tier) and grants on other * sections never raise drift warnings; * - absence of sources degrades to 'none' without ever failing. */ import { describe, it, expect } from 'vitest' import { buildAccessRoles, deriveModuleCode, normalizePermissionPath } from '../access-roles.js' import type { RbacRow } from '../../../../lib/ba-rbac-rows.js' const STATE = { application: 'CRM', roles: [ { code: 'crm-admin', name: 'Administrateur CRM', isDefault: false }, { code: 'commercial', name: 'Commercial', isDefault: true }, ], rolePermissions: [ { roleCode: 'crm-admin', permissionPath: 'crm.pipeline.opportunites.read' }, { roleCode: 'crm-admin', permissionPath: 'crm.pipeline.opportunites.create' }, { roleCode: 'crm-admin', permissionPath: 'crm.pipeline.opportunites.delete' }, // Menu-visibility lock — never endpoint-enforced, must NOT count as drift. { roleCode: 'crm-admin', permissionPath: 'crm.pipeline.opportunites.access' }, { roleCode: 'commercial', permissionPath: 'crm.pipeline.opportunites.read' }, // Declared but NOT implemented (no approve endpoint) — drift warning. { roleCode: 'commercial', permissionPath: 'crm.pipeline.opportunites.approve' }, // Another section of the module — out of this controller's scope, ignored. { roleCode: 'commercial', permissionPath: 'crm.pipeline.devis.read' }, ], } const RBAC_ROWS: RbacRow[] = [ // Case-insensitive actor↔role identity (label 'commercial' vs role name 'Commercial'). { actorCode: 'BA-001-AC-001', actorLabel: 'commercial', path: 'pipeline.opportunites.read', portee: 'toutes' }, // App-qualified human row — normalization strips the app segment. { actorCode: 'BA-001-AC-002', actorLabel: 'Administrateur CRM', path: 'crm.pipeline.opportunites.create', portee: 'toutes' }, // Actor matching no seeded role — its portée cannot be attached → warning. { actorCode: 'BA-001-AC-003', actorLabel: 'Inconnu', path: 'pipeline.opportunites.read', portee: 'équipe' }, ] const CODE_PERMS = [ 'crm.pipeline.opportunites.read', 'crm.pipeline.opportunites.create', 'crm.pipeline.opportunites.delete', // Enforced by an endpoint but granted to NO role → unmapped caveat. 'crm.pipeline.opportunites.export', ] describe('access-roles / normalizePermissionPath', () => { it('strips a leading app segment case-insensitively and lowercases', () => { expect(normalizePermissionPath('CRM.Pipeline.Opportunites.Read', ['crm'])).toBe('pipeline.opportunites.read') }) it('leaves a module-scoped path untouched (no app segment to strip)', () => { expect(normalizePermissionPath('pipeline.opportunites.read', ['crm'])).toBe('pipeline.opportunites.read') }) it('preserves the read.all scope tier while stripping the app', () => { expect(normalizePermissionPath('crm.pipeline.opportunites.read.all', ['crm'])).toBe( 'pipeline.opportunites.read.all', ) }) }) describe('access-roles / deriveModuleCode', () => { it('returns the majority first segment of the normalized permissions', () => { expect(deriveModuleCode(CODE_PERMS, ['crm'])).toBe('pipeline') }) it('returns null when nothing is usable', () => { expect(deriveModuleCode([], ['crm'])).toBeNull() expect(deriveModuleCode(['read'], [])).toBeNull() }) }) describe('access-roles / buildAccessRoles — state+ba', () => { const report = buildAccessRoles({ codePermissions: CODE_PERMS, appCodes: ['crm'], state: STATE, rbacRows: RBAC_ROWS, }) it('sources state+ba and keys strictly on the code permissions', () => { expect(report.source).toBe('state+ba') expect(report.codePermissions).toEqual([ 'pipeline.opportunites.create', 'pipeline.opportunites.delete', 'pipeline.opportunites.export', 'pipeline.opportunites.read', ]) }) it('builds one row per granted role, sorted by label, actions in canonical order', () => { expect(report.rows.map((r) => r.role)).toEqual(['Administrateur CRM', 'Commercial']) // Canonical PERMISSION_ACTIONS order: read < create < delete. expect(report.rows[0]).toMatchObject({ roleCode: 'crm-admin', actions: ['read', 'create', 'delete'], }) expect(report.rows[1]).toMatchObject({ roleCode: 'commercial', actions: ['read'] }) }) it('attaches portée from rbac.md via case-insensitive actor↔role identity', () => { expect(report.rows[1].portee).toBe('toutes') expect(report.rows[1].porteeByAction).toEqual({ read: 'toutes' }) // Admin: only `create` carries a matched rbac row (app-qualified, stripped). expect(report.rows[0].porteeByAction).toEqual({ create: 'toutes' }) }) it('warns on drift (declared approve, no endpoint) but never on access / other sections', () => { const drift = report.warnings.filter((w) => w.includes('drift')) expect(drift).toHaveLength(1) expect(drift[0]).toContain('pipeline.opportunites.approve') expect(report.warnings.join('\n')).not.toContain('.access') expect(report.warnings.join('\n')).not.toContain('devis') }) it('warns on an rbac.md actor matching no seeded role', () => { expect(report.warnings.some((w) => w.includes('BA-001-AC-003 (Inconnu)'))).toBe(true) }) it('surfaces a code permission held by no role as unmapped (never dropped)', () => { expect(report.unmappedCodePermissions).toEqual(['pipeline.opportunites.export']) expect(report.warnings.some((w) => w.includes('pipeline.opportunites.export'))).toBe(true) }) }) describe('access-roles / buildAccessRoles — degraded sources', () => { it('state alone → source state, portée null', () => { const report = buildAccessRoles({ codePermissions: CODE_PERMS, appCodes: ['crm'], state: STATE, rbacRows: null, }) expect(report.source).toBe('state') expect(report.rows[1].portee).toBeNull() expect(report.rows[1].porteeByAction).toBeNull() }) it('rbac.md alone → source ba, declarative caveat, roleCode null', () => { const report = buildAccessRoles({ codePermissions: CODE_PERMS, appCodes: ['crm'], state: null, rbacRows: RBAC_ROWS, }) expect(report.source).toBe('ba') expect(report.rows.map((r) => r.role).sort()).toEqual(['Administrateur CRM', 'Inconnu', 'commercial'].sort()) expect(report.rows.every((r) => r.roleCode === null)).toBe(true) expect(report.warnings.some((w) => w.includes('non vérifiés contre le seed'))).toBe(true) }) it('no source at all → none, no noise', () => { const report = buildAccessRoles({ codePermissions: CODE_PERMS, appCodes: ['crm'], state: null, rbacRows: null, }) expect(report).toMatchObject({ source: 'none', rows: [], warnings: [] }) expect(report.codePermissions).toHaveLength(4) }) it('no code permission → none WITH a warning, even when sources exist', () => { const report = buildAccessRoles({ codePermissions: [], appCodes: ['crm'], state: STATE, rbacRows: RBAC_ROWS }) expect(report.source).toBe('none') expect(report.warnings.some((w) => w.includes('No code-extracted permission'))).toBe(true) }) it('unresolved (.unknown) and empty permissions never become join keys', () => { const report = buildAccessRoles({ codePermissions: ['', 'crm.pipeline.opportunites.unknown'], appCodes: ['crm'], state: STATE, rbacRows: RBAC_ROWS, }) expect(report.source).toBe('none') }) }) describe('access-roles / mixed portée', () => { it('reports null portee + full porteeByAction when the values differ per action', () => { const rbac: RbacRow[] = [ { actorCode: 'BA-001-AC-001', actorLabel: 'Commercial', path: 'pipeline.opportunites.read', portee: 'toutes' }, { actorCode: 'BA-001-AC-001', actorLabel: 'Commercial', path: 'pipeline.opportunites.create', portee: 'les siennes' }, ] const state = { application: 'CRM', roles: [{ code: 'commercial', name: 'Commercial', isDefault: true }], rolePermissions: [ { roleCode: 'commercial', permissionPath: 'crm.pipeline.opportunites.read' }, { roleCode: 'commercial', permissionPath: 'crm.pipeline.opportunites.create' }, ], } const report = buildAccessRoles({ codePermissions: ['crm.pipeline.opportunites.read', 'crm.pipeline.opportunites.create'], appCodes: ['crm'], state, rbacRows: rbac, }) expect(report.rows).toHaveLength(1) expect(report.rows[0].portee).toBeNull() expect(report.rows[0].porteeByAction).toEqual({ read: 'toutes', create: 'les siennes' }) }) })