import { describe, expect, it } from 'vitest' import { parseActors, type BaActor } from '../../../../../lib/ba-actors.js' import { parseRbacRows } from '../../../../../lib/ba-rbac-rows.js' import { deriveAccessGrants } from '../../../../../development/backend/core-seed/cli/scaffold-core-seed/build-spec.js' import { deriveRbacGrants, parseDerivedRows, type GrantSources } from '../derive.js' // --------------------------------------------------------------------------- // Fixtures — real grammars end-to-end: acteur.md via parseActors, rbac.md via // the production parsers (human rows AND derived block). // --------------------------------------------------------------------------- const ACTEUR_CRM = ` # Acteurs — CRM ### BA-001-AC-001 — Commercial - **Type** : internal - **Catégorie** : contributeur ### BA-001-AC-002 — Manager commercial - **Type** : internal - **Catégorie** : gestionnaire ` const RBAC_PIPELINE = ` # RBAC — CRM / PIPELINE | Acteur | Permission (\`module.section[.resource].action\`) | Portée | |--------|--------------------------------------------------|--------| | BA-001-AC-001 (Commercial) | \`pipeline.opportunites.read\` | les siennes | | BA-001-AC-001 (Commercial) | \`pipeline.opportunites.create\` | toutes | | BA-001-AC-002 (Manager commercial) | \`pipeline.opportunites.read.all\` | toutes | | BA-001-AC-002 (Manager commercial) | \`pipeline.access\` | toutes | | BA-001-AC-003 (Fantôme) | \`pipeline.opportunites.delete\` | toutes | | BA-001-AC-001 (Commercial) | \`pipeline.foo\` | toutes | ### Grants \`lookup\` dérivés — bloc machine (ne pas éditer à la main) | Acteur | Permission (\`app.module.section.lookup\`) | Portée | Justification | |--------|-------------------------------------------|--------|---------------| | BA-001-AC-001 (Commercial) | \`crm.referentiel.clients.lookup\` | toutes | FK Opportunite.ClientId → Client | | BA-001-AC-001 (Commercial) | \`administration.users.lookup\` | toutes | FK Opportunite.OwnerId → User | ` function makeSources(overrides: Partial = {}): GrantSources { const actors = parseActors(ACTEUR_CRM).actors return { appFolders: ['CRM'], actorsOf: (app) => (app === 'CRM' ? actors : null), modulesOf: (app) => (app === 'CRM' ? ['PIPELINE', 'REFERENTIEL'] : []), humanRowsOf: (app, module) => app === 'CRM' && module === 'PIPELINE' ? parseRbacRows(RBAC_PIPELINE) : null, derivedRowsOf: (app, module) => app === 'CRM' && module === 'PIPELINE' ? parseDerivedRows(RBAC_PIPELINE) : [], sectionsOf: (app, module) => { if (module === 'PIPELINE') return ['opportunites'] if (module === 'REFERENTIEL') return ['clients'] return [] }, resourcesOf: () => [], ...overrides, } } const grantSet = (grants: { roleCode: string; permissionPath: string }[]): Set => new Set(grants.map((g) => `${g.roleCode}|${g.permissionPath}`)) describe('deriveRbacGrants — transcription rules', () => { const result = deriveRbacGrants(makeSources()) const crm = result.apps[0] it('derives roles from acteur.md: slug code, verbatim name, taxonomy category', () => { expect(crm.actors).toEqual([ { code: 'commercial', name: 'Commercial', applicationCode: 'crm', category: 'Contributor', baCode: 'BA-001-AC-001', }, { code: 'manager-commercial', name: 'Manager commercial', applicationCode: 'crm', category: 'Manager', baCode: 'BA-001-AC-002', }, ]) }) it('app-prefixes module-scoped human rows (2/3-seg + read.all tier)', () => { const grants = grantSet(crm.rolePermissions) expect(grants).toContain('commercial|crm.pipeline.opportunites.read') expect(grants).toContain('commercial|crm.pipeline.opportunites.create') expect(grants).toContain('manager-commercial|crm.pipeline.opportunites.read.all') expect(grants).toContain('manager-commercial|crm.pipeline.access') }) it('transports derived-block rows VERBATIM — cross-app + platform included, never re-prefixed', () => { const grants = grantSet(crm.rolePermissions) expect(grants).toContain('commercial|crm.referentiel.clients.lookup') expect(grants).toContain('commercial|administration.users.lookup') // No accidental crm.crm.… double prefix anywhere. expect([...grants].some((g) => g.includes('crm.crm.'))).toBe(false) }) it('grandfathers the v3.62 `.access` rows — but never from a lookup grant', () => { const grants = grantSet(crm.rolePermissions) expect(grants).toContain('commercial|crm.access') expect(grants).toContain('commercial|crm.pipeline.access') expect(grants).toContain('commercial|crm.pipeline.opportunites.access') // The lookup on referentiel.clients must NOT open the producer menu: expect(grants).not.toContain('commercial|crm.referentiel.access') expect(grants).not.toContain('commercial|crm.referentiel.clients.access') // read.all tier grandfathers like read: expect(grants).toContain('manager-commercial|crm.access') expect(grants).toContain('manager-commercial|crm.pipeline.opportunites.access') }) it('excludes floor paths from authored permissions[], keeps non-floor extras', () => { // read/create/access/lookup at their grains are floor rows (by construction); // the only authored extra of the fixture is the read.all scope tier. expect(crm.permissions).toEqual([ { path: 'crm.pipeline.opportunites.read.all', roles: ['manager-commercial'] }, ]) }) it('never guesses: unknown actor + unparsable path land in needsResolution, not in grants', () => { const kinds = result.needsResolution.map((n) => n.kind).sort() expect(kinds).toEqual(['invalid-path', 'unknown-actor']) const grants = grantSet(crm.rolePermissions) expect([...grants].some((g) => g.includes('BA-001-AC-003') || g.includes('fantome'))).toBe(false) expect([...grants].some((g) => g.endsWith('.foo'))).toBe(false) }) it('warns on the platform path (permission row left to its owner) — grant still transported', () => { expect(result.warnings.some((w) => w.includes('administration'))).toBe(true) expect(crm.permissions.some((p) => p.path.startsWith('administration.'))).toBe(false) }) it('is deterministic: sorted actors, permissions and grants', () => { const again = deriveRbacGrants(makeSources()) expect(again).toEqual(result) const sortedGrants = [...crm.rolePermissions].sort( (a, b) => a.roleCode.localeCompare(b.roleCode) || a.permissionPath.localeCompare(b.permissionPath), ) expect(crm.rolePermissions).toEqual(sortedGrants) }) it('totals count each grant family separately', () => { expect(result.totals.humanGrants).toBe(4) expect(result.totals.derivedLookupGrants).toBe(2) expect(result.totals.accessGrandfathered).toBeGreaterThan(0) expect(result.totals.needsResolution).toBe(2) }) }) describe('deriveRbacGrants — edge cases', () => { it('reports a role-code collision instead of silently merging two actors', () => { const actors: BaActor[] = [ { code: 'BA-001-AC-001', label: 'Gestionnaire de budget' }, { code: 'BA-001-AC-002', label: 'Le gestionnaire du budget' }, ] const result = deriveRbacGrants( makeSources({ actorsOf: () => actors, humanRowsOf: () => [], derivedRowsOf: () => [] }), ) const collisions = result.needsResolution.filter((n) => n.kind === 'role-code-collision') expect(collisions).toHaveLength(1) expect(collisions[0].detail).toContain('gestionnaire-budget') }) it('flags a module with matrix rows but no acteur.md (missing-acteur), transports nothing', () => { const result = deriveRbacGrants(makeSources({ actorsOf: () => null })) expect(result.needsResolution.some((n) => n.kind === 'missing-acteur')).toBe(true) expect(result.apps[0].rolePermissions).toEqual([]) }) it('transports an already-app-qualified human row verbatim with a warning (prohibition #10)', () => { const rows = parseRbacRows( '| BA-001-AC-001 (Commercial) | `crm.pipeline.opportunites.export` | toutes |\n', ) const result = deriveRbacGrants(makeSources({ humanRowsOf: () => rows, derivedRowsOf: () => [] })) expect(grantSet(result.apps[0].rolePermissions)).toContain('commercial|crm.pipeline.opportunites.export') expect(result.warnings.some((w) => w.includes('already app-qualified'))).toBe(true) }) it('warns when a row roots at a foreign module but still transports it', () => { const rows = parseRbacRows( '| BA-001-AC-001 (Commercial) | `referentiel.clients.read` | toutes |\n', ) const result = deriveRbacGrants(makeSources({ humanRowsOf: () => rows, derivedRowsOf: () => [] })) expect(grantSet(result.apps[0].rolePermissions)).toContain('commercial|crm.referentiel.clients.read') expect(result.warnings.some((w) => w.includes('instead of "pipeline"'))).toBe(true) }) }) describe('drift — access grandfathering mirrors scaffold-core-seed deriveAccessGrants', () => { it('produces the same access set as the real build-spec function on the fixture grants', () => { const result = deriveRbacGrants(makeSources()) const crm = result.apps[0] // Base (pre-grandfather) grants of the fixture, as build-spec would see them. const base = crm.rolePermissions.filter( (g) => !g.permissionPath.endsWith('.access') || g.permissionPath === 'crm.pipeline.access', ) const floorAccess = new Set([ 'crm.access', 'crm.pipeline.access', 'crm.referentiel.access', 'crm.pipeline.opportunites.access', 'crm.referentiel.clients.access', ]) const expected = deriveAccessGrants(floorAccess, base) const ours = grantSet(crm.rolePermissions) for (const g of expected) { expect(ours, `grandfathered ${g.roleCode}|${g.permissionPath} must be in the fragment`).toContain( `${g.roleCode}|${g.permissionPath}`, ) } // BIDIRECTIONAL (conformity-audit hardening): the drift lock was // `expected ⊆ ours` only — an OVER-generation of `.access` grants // relative to build-spec would have passed. Every access grant we emit // beyond the base must be one build-spec would derive too. const baseSet = new Set(base.map((g) => `${g.roleCode}|${g.permissionPath}`)) const expectedSet = new Set(expected.map((g) => `${g.roleCode}|${g.permissionPath}`)) for (const g of crm.rolePermissions) { const key = `${g.roleCode}|${g.permissionPath}` if (!g.permissionPath.endsWith('.access') || baseSet.has(key)) continue expect(expectedSet, `over-generated access grant ${key} — build-spec would NOT derive it`).toContain(key) } }) }) describe('parseDerivedRows', () => { it('reads only the machine block; parseRbacRows strips it — no double counting', () => { const derived = parseDerivedRows(RBAC_PIPELINE) expect(derived.map((r) => r.path)).toEqual([ 'crm.referentiel.clients.lookup', 'administration.users.lookup', ]) const human = parseRbacRows(RBAC_PIPELINE) expect(human.some((r) => r.path.endsWith('.lookup'))).toBe(false) }) it('returns [] when the block is absent', () => { expect(parseDerivedRows('# RBAC\n| BA-001-AC-001 | `a.b.read` | toutes |\n')).toEqual([]) }) })