/** * XD-009 — a 360 related tab onto a ≥ confidentiel entity is DECLARED: * backticked permission + `→ SCR-…` list target (unless `affichage summary`). * The target's sensitivity is read from the FULL tree (the other application * under a module-scoped run included). */ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { loadCorpus } from '../corpus/model.js' import { runRules } from '../engine.js' import { XD_RULES } from '../rules/xd.js' import type { Finding } from '../types.js' const RULE = XD_RULES.filter((r) => r.id === 'XD-009') const entity = (code: string, name: string, bullets: string[], attrs: string[]): string => [ `### ${code} — ${name} (agrégat racine)`, `- **Préfixe table** : \`${name.slice(0, 3).toLowerCase()}_\``, ...bullets, '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Id | Guid | PK | — |', ...attrs.map((a) => `| ${a} | string(50) | requis | — |`), '', ].join('\n') const idx = (t: string): string => `# ${t}\n\n## Contexte\n${t}.\n\n## Hors-périmètre\n_Aucune._\n` let dir: string let full: Finding[] beforeAll(() => { dir = mkdtempSync(join(tmpdir(), 'ss-xd009-')) const w = (rel: string, content: string): void => { const p = join(dir, rel) mkdirSync(join(p, '..'), { recursive: true }) writeFileSync(p, content, 'utf8') } // The sensitive producer, in ANOTHER application. w('RH/index.md', idx('RH')) w('RH/PAIE/index.md', idx('Paie')) w('RH/PAIE/entité.md', entity('ENT-001', 'FichePaie', ['- **Sensibilité** : confidentiel — financier'], ['Montant'])) w('RH/PAIE/fiches/index.md', idx('Fiches')) // The consumer: a client detail page with four related tabs. w('CRM/index.md', idx('CRM')) w('CRM/CLIENTS/index.md', idx('Clients')) w( 'CRM/CLIENTS/entité.md', [ entity('ENT-001', 'Client', [], ['Nom']), entity('ENT-002', 'Commande', [], ['Reference']), ].join('\n'), ) w('CRM/CLIENTS/clients/index.md', idx('Clients')) w( 'CRM/CLIENTS/clients/screen.md', [ '### SCR-CRM-CLIENTS-DETAIL-001 — Fiche client (SmartForm)', '- **Entité** : Client (ENT-001)', '- **Permission** : `clients.clients.read`', '- **Mode** : detail', // Fully declared → ok. '- **Onglet lié « Fiches de paie »** : entité FichePaie, FK clientId, app RH, affichage table → SCR-RH-PAIE-FICHES-LIST-001 (`paie.fiches.read`)', // Neither permission nor target → two err legs. '- **Onglet lié « Paies »** : entité FichePaie, FK clientId, app RH, affichage table', // Summary band with its permission, no target → ok (a cartouche needs no list). '- **Onglet lié « Résumé paie »** : entité FichePaie, FK clientId, app RH, affichage summary (`paie.fiches.read`)', // Interne target, nothing declared → silent (not XD-009's business). '- **Onglet lié « Commandes »** : entité Commande, FK clientId, affichage table', '', ].join('\n'), ) full = runRules(RULE, loadCorpus(dir), { strict: false }).findings }) afterAll(() => rmSync(dir, { recursive: true, force: true })) const of = (from: Finding[], mod: string): Finding => from.find((f) => f.ruleId === 'XD-009' && f.scope.module === mod)! describe('XD-009 — a 360 tab onto a sensitive entity is declared', () => { it('an under-declared tab onto a confidentiel entity → err naming the missing permission and the missing list target; the declared and summary tabs, and the interne target, are silent', () => { const f = of(full, 'CLIENTS') expect(f.severity).toBe('err') expect(f.evidence).toHaveLength(2) expect(f.evidence![0]).toContain('SCR-CRM-CLIENTS-DETAIL-001 — onglet « Paies » → FichePaie (RH/PAIE, confidentiel — financier) : permission backtickée absente') expect(f.evidence![1]).toContain('onglet « Paies » → FichePaie (RH/PAIE, confidentiel — financier) : cible `→ SCR-…` (écran liste) absente') // The remedy names its owner — the one cross-dimension rule routed to a skill (lib/remediation). for (const e of f.evidence!) expect(e).toContain('/ba-create-screen') const all = f.evidence!.join('\n') expect(all).not.toContain('Fiches de paie') expect(all).not.toContain('Résumé paie') expect(all).not.toContain('Commandes') }) it('the producer module (no tab) → ok « aucun onglet »', () => { expect(of(full, 'PAIE')).toMatchObject({ severity: 'ok', message: 'Aucun onglet lié déclaré dans le module.' }) }) it('a MODULE-scoped run still sees the other application’s sensitivity (full-tree resolution) — same verdict', () => { const scoped = runRules(RULE, loadCorpus(dir, { app: 'CRM', module: 'CLIENTS' }), { strict: false }).findings const f = of(scoped, 'CLIENTS') expect({ sev: f.severity, ev: f.evidence }).toEqual({ sev: of(full, 'CLIENTS').severity, ev: of(full, 'CLIENTS').evidence }) }) it('when every sensitive tab is declared → ok with the count', () => { const screen = join(dir, 'CRM', 'CLIENTS', 'clients', 'screen.md') const original = readFileSync(screen, 'utf8') try { writeFileSync(screen, original.replace('- **Onglet lié « Paies »** : entité FichePaie, FK clientId, app RH, affichage table\n', ''), 'utf8') const f = of(runRules(RULE, loadCorpus(dir), { strict: false }).findings, 'CLIENTS') expect(f.severity).toBe('ok') expect(f.message).toBe('2 onglet(s) lié(s) sur une entité sensible, permission et cible déclarées.') } finally { writeFileSync(screen, original, 'utf8') } }) })