import { describe, expect, it } from 'vitest' import { DERIVED_BLOCK_BEGIN, DERIVED_BLOCK_END, checkLookupDrift, extractBlockRowLines, extractDerivedBlock, renderDerivedBlock, spliceDerivedBlock, stripDerivedBlock, } from '../block.js' import { parseRbacRows } from '../rbac-rows.js' import type { LookupGrantRow, SkippedGrant, UnresolvedProducer } from '../types.js' const ROW: LookupGrantRow = { actorCode: 'BA-001-AC-001', actorLabel: 'Gestionnaire', path: 'crm.referentiel.clients.lookup', portee: 'toutes', justification: 'FK Commande.ClientId → Client', } const UNRESOLVED: UnresolvedProducer = { reason: 'producer-section-unresolved', consumerEntity: 'Commande', fk: 'StatutId', targetEntity: 'Statut', producerModule: 'VENTES/COMMANDES', detail: 'aucun écran liste ni section « statuts » au menu', } const HUMAN_RBAC = ` # RBAC — VENTES / COMMANDES | Acteur | Permission | Portée | |--------|------------|--------| | BA-001-AC-001 (Gestionnaire) | \`commandes.liste.create\` | toutes | ` describe('renderDerivedBlock', () => { it('renders markers, the app-qualified row and the FK justification', () => { const block = renderDerivedBlock([ROW], []) expect(block.startsWith(DERIVED_BLOCK_BEGIN)).toBe(true) expect(block.endsWith(DERIVED_BLOCK_END)).toBe(true) expect(block).toContain( '| BA-001-AC-001 (Gestionnaire) | `crm.referentiel.clients.lookup` | toutes | FK Commande.ClientId → Client |', ) }) it('renders the explicit empty marker and the needsResolution warnings', () => { const block = renderDerivedBlock([], [UNRESOLVED]) expect(block).toContain('_Aucun grant `lookup` dérivé') expect(block).toContain('FK Commande.StatutId → Statut (VENTES/COMMANDES)') }) }) describe('spliceDerivedBlock — idempotent machine-owned splice', () => { it('appends when no markers exist, replaces in place afterwards, and human rows survive', () => { const block1 = renderDerivedBlock([ROW], []) const withBlock = spliceDerivedBlock(HUMAN_RBAC, block1) expect(withBlock).not.toBeNull() expect(withBlock!).toContain('`commandes.liste.create`') expect(withBlock!).toContain('`crm.referentiel.clients.lookup`') // Re-splicing the SAME block → null (already up to date, no rewrite). expect(spliceDerivedBlock(withBlock!, block1)).toBeNull() // A different derivation replaces the block WITHOUT duplicating markers. const block2 = renderDerivedBlock([], [UNRESOLVED]) const replaced = spliceDerivedBlock(withBlock!, block2) expect(replaced).not.toBeNull() expect(replaced!.match(/ba:rbac-derived-lookups BEGIN/g)).toHaveLength(1) expect(replaced!).not.toContain('`crm.referentiel.clients.lookup`') expect(replaced!).toContain('`commandes.liste.create`') }) it('extract/strip/rows round-trip — and the human parser never reads machine rows', () => { const withBlock = spliceDerivedBlock(HUMAN_RBAC, renderDerivedBlock([ROW], []))! const extracted = extractDerivedBlock(withBlock) expect(extracted).not.toBeNull() expect(extractBlockRowLines(extracted!)).toEqual([ '| BA-001-AC-001 (Gestionnaire) | `crm.referentiel.clients.lookup` | toutes | FK Commande.ClientId → Client |', ]) const stripped = stripDerivedBlock(withBlock) expect(stripped).not.toContain('ba:rbac-derived-lookups') expect(stripped).toContain('`commandes.liste.create`') // parseRbacRows works on the STRIPPED content — the derived lookup row is // machine-owned and must never feed grantee/skip logic as a human row. const rows = parseRbacRows(withBlock) expect(rows.some((r) => r.path === 'crm.referentiel.clients.lookup')).toBe(false) expect(rows.some((r) => r.path === 'commandes.liste.create')).toBe(true) }) }) describe('renderDerivedBlock — the empty block tells the TRUTH about why', () => { const SKIPPED: SkippedGrant[] = [ { actorCode: 'BA-001-AC-001', path: 'crm.referentiel.clients.lookup', reason: 'holds-read' }, { actorCode: 'BA-001-AC-002', path: 'crm.referentiel.clients.lookup', reason: 'holds-lookup' }, ] it('still says "aucune FK" when there genuinely is none', () => { const block = renderDerivedBlock([], []) expect(block).toContain('aucune FK vers une autre section') }) it('says "unique candidat" in the singular', () => { expect(renderDerivedBlock([], [], [SKIPPED[0]!])).toContain('l’unique candidat est déjà couvert') }) it('does NOT claim "aucune FK" when every candidate was skipped as redundant', () => { // The Demo-GestionFlotte reading trap: FKs existed, every grant was // suppressed, and the block asserted the opposite. A client had to read the // CLI's stdout JSON to find out why their block was empty. const block = renderDerivedBlock([], [], SKIPPED) expect(block).not.toContain('aucune FK vers une autre section') expect(block).toContain('les 2 candidats sont déjà couverts') }) it('lists the suppressions and names which grant already covers them', () => { const block = renderDerivedBlock([], [], SKIPPED) expect(block).toContain('> - BA-001-AC-001 → `crm.referentiel.clients.lookup` (détient `read`)') expect(block).toContain('> - BA-001-AC-002 → `crm.referentiel.clients.lookup` (détient `lookup`)') }) it('keeps the suppression note OUT of the drift unit', () => { // Blockquote lines are not table rows: adding this note must not make every // existing rbac.md look stale to RBAC-008. const block = renderDerivedBlock([ROW], [], SKIPPED) expect(extractBlockRowLines(block)).toEqual([ '| BA-001-AC-001 (Gestionnaire) | `crm.referentiel.clients.lookup` | toutes | FK Commande.ClientId → Client |', ]) }) }) describe('checkLookupDrift — the ONE check layer', () => { const core = (rows: LookupGrantRow[], fkCandidates: number) => ({ rows, totals: { fkCandidates } }) it('reports a missing row when the block lacks a derived grant', () => { const withEmpty = spliceDerivedBlock(HUMAN_RBAC, renderDerivedBlock([], []))! const drift = checkLookupDrift(withEmpty, core([ROW], 1)) expect(drift.upToDate).toBe(false) expect(drift.missingRows).toHaveLength(1) expect(drift.staleRows).toEqual([]) }) it('reports a stale row when the block carries a grant the derivation dropped', () => { const withRow = spliceDerivedBlock(HUMAN_RBAC, renderDerivedBlock([ROW], []))! const drift = checkLookupDrift(withRow, core([], 1)) expect(drift.upToDate).toBe(false) expect(drift.staleRows).toHaveLength(1) expect(drift.missingRows).toEqual([]) }) it('is upToDate when a rendered block matches its own derivation', () => { const withRow = spliceDerivedBlock(HUMAN_RBAC, renderDerivedBlock([ROW], []))! expect(checkLookupDrift(withRow, core([ROW], 1))).toEqual({ missingBlock: false, staleRows: [], missingRows: [], upToDate: true, }) }) it('a missing block is drift ONLY when the derivation had something to say', () => { // No block + no FK candidate is not drift: the module simply has nothing to // derive yet. This is the semantics RBAC-008 documents, and the CLI used to // disagree with it (`missingBlock: current === null`, unconditional). expect(checkLookupDrift(HUMAN_RBAC, core([], 0))).toEqual({ missingBlock: false, staleRows: [], missingRows: [], upToDate: true, }) expect(checkLookupDrift(HUMAN_RBAC, core([ROW], 1)).missingBlock).toBe(true) }) })