import { describe, expect, it } from 'vitest' import { parseEntities } from '../../../../../lib/ba-relations.js' import type { ParsedScreen } from '../../../../../lib/ba-screens.js' import { deriveLookupGrants, navCodeOf, type DeriveSources } from '../derive.js' import { parseRbacRows } from '../rbac-rows.js' // --------------------------------------------------------------------------- // Fixtures — real grammars end-to-end (entité.md + rbac.md parsed by the // production parsers; only the screen registry is constructed literally). // --------------------------------------------------------------------------- const CONSUMER_ENTITE = ` # Modèle de données — VENTES / COMMANDES ### ENT-001 — Commande (agrégat racine) - Relations : - Commande *→1 Client — FK ClientId, scope cross-module (CRM/REFERENTIEL), onDelete restrict - Commande *→1 Statut — FK StatutId, scope same-module, onDelete restrict - Commande *→1 User — FK OwnerId, scope core (auth_Users) - Commande 1→* LigneCommande — FK CommandeId, scope same-module ### ENT-002 — Devis (agrégat racine) - Relations : - Devis *→1 Client — FK ClientId, scope cross-module (CRM/REFERENTIEL), onDelete restrict ` const CONSUMER_RBAC = ` # RBAC — VENTES / COMMANDES | Acteur | Permission | Portée | |--------|------------|--------| | BA-001-AC-001 (Gestionnaire) | \`commandes.liste.access\` | toutes | | BA-001-AC-001 (Gestionnaire) | \`commandes.liste.read\` | toutes | | BA-001-AC-001 (Gestionnaire) | \`commandes.liste.create\` | toutes | | BA-001-AC-002 (Lecteur) | \`commandes.liste.read\` | toutes | ` function graphOf(entiteByModule: Record) { return parseEntities(new Map(Object.entries(entiteByModule))) } function listScreen(entity: string, module: string, section: string, code: string): ParsedScreen { return { code, title: `Liste ${entity}`, screenType: 'SmartListView', file: `X/${module}/${section}/screen.md`, module, section, entity, declaredTabs: [], } } function sourcesOf(overrides: Partial = {}): DeriveSources { return { graph: graphOf({ 'VENTES/COMMANDES': CONSUMER_ENTITE }), consumerRows: parseRbacRows(CONSUMER_RBAC), producerRowsOf: () => [], screensOf: () => [], sectionsOf: (app, mod) => (app === 'CRM' && mod === 'REFERENTIEL' ? ['clients'] : []), ...overrides, } } // --------------------------------------------------------------------------- describe('deriveLookupGrants — FK → derived lookup rows', () => { it('grants the producer section lookup to create/update actors, app-qualified, portée toutes', () => { const report = deriveLookupGrants('VENTES', 'COMMANDES', sourcesOf()) const clientRows = report.rows.filter((r) => r.path === 'crm.referentiel.clients.lookup') expect(clientRows).toHaveLength(1) expect(clientRows[0].actorCode).toBe('BA-001-AC-001') expect(clientRows[0].actorLabel).toBe('Gestionnaire') expect(clientRows[0].portee).toBe('toutes') // AC-002 is read-only (no create/update) — never a grantee. expect(report.rows.some((r) => r.actorCode === 'BA-001-AC-002')).toBe(false) expect(report.totals.grantees).toBe(1) }) it('ignores scope core and non-*→1/1→1 cardinalities entirely', () => { const report = deriveLookupGrants('VENTES', 'COMMANDES', sourcesOf()) // ENT-001: ClientId + StatutId (core OwnerId and 1→* excluded) + ENT-002 ClientId. expect(report.totals.fkCandidates).toBe(3) expect(report.rows.some((r) => r.path.includes('.users.'))).toBe(false) expect(report.rows.some((r) => r.path.includes('lignecommande'))).toBe(false) }) it('dedupes several FKs onto one producer section into ONE row with a joined justification', () => { const report = deriveLookupGrants('VENTES', 'COMMANDES', sourcesOf()) const clientRows = report.rows.filter((r) => r.path === 'crm.referentiel.clients.lookup') expect(clientRows).toHaveLength(1) expect(clientRows[0].justification).toBe( 'FK Commande.ClientId → Client, FK Devis.ClientId → Client', ) }) it('skips an actor already holding the producer section read (3-seg row in the producer matrix)', () => { const producerRows = parseRbacRows( '| BA-001-AC-001 (Gestionnaire) | `referentiel.clients.read` | toutes |\n', ) const report = deriveLookupGrants( 'VENTES', 'COMMANDES', sourcesOf({ producerRowsOf: () => producerRows }), ) expect(report.rows.some((r) => r.path === 'crm.referentiel.clients.lookup')).toBe(false) expect(report.skipped).toContainEqual({ actorCode: 'BA-001-AC-001', path: 'crm.referentiel.clients.lookup', reason: 'holds-read', }) }) it('skips on an app-qualified 4-seg lookup row authored in the CONSUMER matrix', () => { const consumerRows = parseRbacRows( CONSUMER_RBAC + '| BA-001-AC-001 (Gestionnaire) | `crm.referentiel.clients.lookup` | toutes |\n', ) const report = deriveLookupGrants('VENTES', 'COMMANDES', sourcesOf({ consumerRows })) expect(report.rows.some((r) => r.path === 'crm.referentiel.clients.lookup')).toBe(false) expect(report.skipped).toContainEqual({ actorCode: 'BA-001-AC-001', path: 'crm.referentiel.clients.lookup', reason: 'holds-lookup', }) }) it('module-grain read does NOT satisfy the section-grain gate (no false skip)', () => { const producerRows = parseRbacRows( '| BA-001-AC-001 (Gestionnaire) | `referentiel.read` | toutes |\n', ) const report = deriveLookupGrants( 'VENTES', 'COMMANDES', sourcesOf({ producerRowsOf: () => producerRows }), ) expect(report.rows.some((r) => r.path === 'crm.referentiel.clients.lookup')).toBe(true) }) it('resolves the producer section via its list screen first (same-module FK)', () => { const report = deriveLookupGrants( 'VENTES', 'COMMANDES', sourcesOf({ screensOf: (app) => app === 'VENTES' ? [listScreen('Statut', 'COMMANDES', 'statuts', 'SCR-VENTES-COMMANDES-LIST-002')] : [], }), ) const statutRows = report.rows.filter((r) => r.path === 'ventes.commandes.statuts.lookup') expect(statutRows).toHaveLength(1) expect(statutRows[0].justification).toBe('FK Commande.StatutId → Statut') }) it('falls back to the kebab-plural menu section, and NEVER guesses when both fail', () => { // Statut: no screen, no section folder → needsResolution, no row. const report = deriveLookupGrants('VENTES', 'COMMANDES', sourcesOf()) expect(report.rows.some((r) => r.path.endsWith('.statuts.lookup'))).toBe(false) const unresolved = report.needsResolution.filter((n) => n.targetEntity === 'Statut') expect(unresolved).toHaveLength(1) expect(unresolved[0].reason).toBe('producer-section-unresolved') expect(unresolved[0].producerModule).toBe('VENTES/COMMANDES') // Client resolved via the menu fallback (sectionsOf returns ['clients']). expect(report.rows.some((r) => r.path === 'crm.referentiel.clients.lookup')).toBe(true) }) it('flags a cross-module relation lacking its (APP/MODULE) scope detail', () => { const graph = graphOf({ 'VENTES/COMMANDES': `### ENT-001 — Commande (agrégat racine) - Relations : - Commande *→1 Client — FK ClientId, scope cross-module, onDelete restrict `, }) const report = deriveLookupGrants('VENTES', 'COMMANDES', sourcesOf({ graph })) expect(report.rows).toHaveLength(0) expect(report.needsResolution).toHaveLength(1) expect(report.needsResolution[0].reason).toBe('cross-module-scope-detail-missing') }) it('output is stably sorted (actor, then path) — byte-identical re-runs', () => { const rbac = CONSUMER_RBAC + '| BA-001-AC-003 (Assistant) | `commandes.liste.update` | les siennes |\n' const sources = sourcesOf({ consumerRows: parseRbacRows(rbac), screensOf: (app) => app === 'VENTES' ? [listScreen('Statut', 'COMMANDES', 'statuts', 'SCR-VENTES-COMMANDES-LIST-002')] : [], }) const a = deriveLookupGrants('VENTES', 'COMMANDES', sources) const b = deriveLookupGrants('VENTES', 'COMMANDES', sources) expect(a.rows).toEqual(b.rows) expect(a.rows.map((r) => `${r.actorCode}|${r.path}`)).toEqual( [...a.rows.map((r) => `${r.actorCode}|${r.path}`)].sort(), ) expect(a.totals.grantees).toBe(2) }) }) describe('navCodeOf', () => { it('lowercases and maps underscores to hyphens (HR_PORTAL → hr-portal)', () => { expect(navCodeOf('HR_PORTAL')).toBe('hr-portal') expect(navCodeOf('CRM')).toBe('crm') }) }) // --------------------------------------------------------------------------- // Portée and the skip rule — a DECISION, pinned. // // `holdsProducerSectionGrant` deliberately ignores `row.portee`. That looks // like a bug and is not one: the row-level perimeter is an EF global query // filter whose only lift is `{path}.read.all` (DataScopePolicy.ScopeAllPermission // — see development/backend/data-layer/references/data-scopes.md). A derived // `.lookup` grant does NOT lift it. So for a scoped producer the dropdown is // filtered identically whether the grant is emitted or not: conditioning the // skip on Portée would emit permissions that change NOTHING at runtime, and // re-introduce exactly the redundancy the skip exists to remove. // // If you are here to "fix" the missing Portée check — this is why you should // not. The real gap for a scoped producer is a missing `.read.all`, not a // missing lookup grant. // --------------------------------------------------------------------------- describe('deriveLookupGrants — Portée does not change the skip rule', () => { const producerRead = (portee: string) => parseRbacRows(`| BA-001-AC-001 (Gestionnaire) | \`referentiel.clients.read\` | ${portee} | `) const reportWith = (portee: string) => deriveLookupGrants('VENTES', 'COMMANDES', sourcesOf({ producerRowsOf: () => producerRead(portee) })) for (const portee of ['toutes', 'les siennes', 'les attribuées', 'équipe', 'personnalisée : agence du user']) { it(`skips on a producer section read whatever the Portée (« ${portee} »)`, () => { const report = reportWith(portee) expect(report.rows.some((r) => r.path === 'crm.referentiel.clients.lookup')).toBe(false) expect(report.skipped).toContainEqual({ actorCode: 'BA-001-AC-001', path: 'crm.referentiel.clients.lookup', reason: 'holds-read', }) }) } it('an out-of-vocabulary Portée is RBAC-007 business, and never turns the skip off', () => { // « département » is not in the closed vocabulary — RBAC-007 errs on it. // It must not silently turn the skip off. const report = reportWith('département') expect(report.rows.some((r) => r.path === 'crm.referentiel.clients.lookup')).toBe(false) }) it('a lone `.read.all` row does NOT satisfy the gate — the grant is still emitted', () => { // The extracted action of `referentiel.clients.read.all` is `all`, which is // not in SATISFYING_ACTIONS. That is correct: `.read.all` is the scope TIER, // it does not by itself carry the `read` capability the dual gate wants. const producerRows = parseRbacRows( '| BA-001-AC-001 (Gestionnaire) | `referentiel.clients.read.all` | toutes |', ) const report = deriveLookupGrants('VENTES', 'COMMANDES', sourcesOf({ producerRowsOf: () => producerRows })) expect(report.rows.some((r) => r.path === 'crm.referentiel.clients.lookup')).toBe(true) expect(report.skipped).toEqual([]) }) })