/** * lib/ba-rbac-rows — shared rbac.md HUMAN-row parser. * * The contract under test: * - parseRbacRows yields the `(actor × permission × portée)` triples of the * HUMAN matrix only — the machine-owned derived-lookups block is stripped * first (its rows share the `| BA-… |` shape and would double-count); * - loadModuleRbacRows is null-safe (`exists: false` when the file is absent) * and resolves its APP/MODULE folders case-insensitively; * - resolveBaModuleDir joins lowercase nav codes onto the UPPERCASE BA * folders case-insensitively and returns the ACTUAL folder names. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { parseRbacRows, loadModuleRbacRows, resolveBaModuleDir, stripDerivedBlock, DERIVED_BLOCK_BEGIN, DERIVED_BLOCK_END, } from '../ba-rbac-rows.js' const RBAC_MD = ` # RBAC — CRM / PIPELINE | Acteur | Permission (\`module.section[.resource].action\`) | Portée | |--------|------------------------------------------|--------| | BA-001-AC-001 (Commercial) | \`pipeline.opportunites.read\` | toutes | | BA-001-AC-001 (Commercial) | \`pipeline.opportunites.create\` | les siennes | | BA-001-AC-002 | \`pipeline.opportunites.approve\` | équipe | ${DERIVED_BLOCK_BEGIN} | Acteur | Permission (\`app.module.section.lookup\`) | Portée | Justification | |--------|-------------------------------------------|--------|---------------| | BA-001-AC-001 (Commercial) | \`crm.ventes.clients.lookup\` | toutes | FK Opportunity.clientId | ${DERIVED_BLOCK_END} ` describe('ba-rbac-rows / parseRbacRows', () => { it('parses actor code, optional label, backtick path and verbatim portée', () => { const rows = parseRbacRows(RBAC_MD) expect(rows).toEqual([ { 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' }, { actorCode: 'BA-001-AC-002', actorLabel: undefined, path: 'pipeline.opportunites.approve', portee: 'équipe' }, ]) }) it('strips the machine-owned derived-lookups block before matching', () => { // The derived row (crm.ventes.clients.lookup) shares the `| BA-… |` shape — // without the strip it would pollute the human matrix. const rows = parseRbacRows(RBAC_MD) expect(rows.some((r) => r.path.endsWith('.lookup'))).toBe(false) expect(stripDerivedBlock(RBAC_MD)).not.toContain('crm.ventes.clients.lookup') }) it('returns [] on a content with no matrix rows', () => { expect(parseRbacRows('# RBAC — vide\n\nRien.\n')).toEqual([]) }) }) describe('ba-rbac-rows / loadModuleRbacRows + resolveBaModuleDir', () => { let baRoot: string beforeAll(() => { baRoot = mkdtempSync(join(tmpdir(), 'ba-rbac-rows-')) mkdirSync(join(baRoot, 'CRM', 'PIPELINE'), { recursive: true }) writeFileSync(join(baRoot, 'CRM', 'PIPELINE', 'rbac.md'), RBAC_MD, 'utf8') }) afterAll(() => { rmSync(baRoot, { recursive: true, force: true }) }) it('loads and parses an existing module rbac.md', () => { const res = loadModuleRbacRows(baRoot, 'CRM', 'PIPELINE') expect(res.exists).toBe(true) expect(res.rows).toHaveLength(3) }) it('is null-safe when the module has no rbac.md', () => { expect(loadModuleRbacRows(baRoot, 'CRM', 'ABSENT')).toEqual({ exists: false, rows: [] }) }) it('resolves the module folder CASE-INSENSITIVELY', () => { // Callers hand over codes typed by a human: derive-lookup-grants passes the // `(APP/MODULE)` of an entité.md cross-module relation. A case-sensitive // join returned [] on Linux, which the lookup derivation reads as "this // actor holds nothing" — and emits a redundant grant. for (const [app, mod] of [ ['crm', 'pipeline'], ['Crm', 'Pipeline'], ['CRM', 'pipeline'], ] as const) { const res = loadModuleRbacRows(baRoot, app, mod) expect(res.exists, `${app}/${mod}`).toBe(true) expect(res.rows, `${app}/${mod}`).toHaveLength(3) } }) it('resolves lowercase nav codes onto the UPPERCASE BA folders', () => { expect(resolveBaModuleDir(baRoot, 'crm', 'pipeline')).toEqual({ app: 'CRM', module: 'PIPELINE' }) }) it('returns null when the app or module folder is absent', () => { expect(resolveBaModuleDir(baRoot, 'crm', 'nope')).toBeNull() expect(resolveBaModuleDir(baRoot, 'nope', 'pipeline')).toBeNull() expect(resolveBaModuleDir(join(baRoot, 'missing-root'), 'crm', 'pipeline')).toBeNull() }) })