/** * DM-036..038 — data sensitivity at the ENTITY grain (lib/ba-sensitivity, * doctrine 2026-10-03). * DM-036 the declaration is due where the data looks sensitive (warn, err * under --strict); an unreadable line is an err (near-miss pair); * a sensitive entity without category is a warn * DM-037 the split doctrine: a sensitive attribute inside a DECLARED * `interne` entity (warn, a dated décision client silences); * `public` + sensitive attribute or **Personne** = err * DM-038 **API externe** × sensitivity: restreint never (err), confidentiel * only with a dated décision client on that line (err otherwise) * DM-034 the judgment question carries both sides' levels */ import { mkdirSync, mkdtempSync, 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 { DM_RULES } from '../rules/dm.js' import type { Finding, JudgmentItem } from '../types.js' const block = (code: string, name: string, cls: string, bullets: string[], attrs: string[], tail: string[] = [], rawRows: string[] = []): string => [ `### ${code} — ${name} (${cls})`, `- **Préfixe table** : \`${name.slice(0, 3).toLowerCase()}_\``, `- **Description** : ${name} — description.`, `- **Périmètre** : ${name} — périmètre.`, ...bullets, '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Id | Guid | PK | — |', ...attrs.map((a) => `| ${a} | string(50) | requis | — |`), ...rawRows, '', ...tail, '', ].join('\n') const PAIE_DOC = [ // Declared confidentiel + publishes read WITHOUT a dated decision → DM-038 err. block('ENT-001', 'FichePaie', 'agrégat racine', ['- **Sensibilité** : confidentiel — financier, rh', '- **API externe** : read'], ['Montant', 'Periode']), // Person entity with a Salary attribute and NO declaration → DM-036 warn (strict: err); Personne implies the category only. block('ENT-002', 'Employe', 'agrégat racine', ['- **Personne** : mandatory — identité via auth_Users (FirstName, LastName, Email)'], ['HiredAt', 'Salary'], [ '- **Relations** : Employe *→1 User — FK UserId, scope core (auth_Users), onDelete restrict.', ]), // Composant of FichePaie without a line → inherits confidentiel → no DM-036 despite Iban. block('ENT-003', 'CompteBancaire', 'composant', [], ['Iban'], [ '- **Relations** : CompteBancaire *→1 FichePaie — FK FichePaieId, scope same-module, onDelete cascade.', ]), // Unknown level → DM-036 err (near-miss pair). block('ENT-004', 'Badge', 'agrégat racine', ['- **Sensibilité** : secret — rh'], ['Numero']), // public + Salary → DM-037 err. block('ENT-005', 'Export', 'agrégat racine', ['- **Sensibilité** : public'], ['Salary']), // interne + Iban kept by a DATED decision → DM-037 silent. block('ENT-006', 'Contact', 'agrégat racine', ['- **Sensibilité** : interne — personnel — décision client du 2026-10-03 : l’IBAN de remboursement reste sur le contact'], ['Iban']), // interne + Diagnostic, no decision → DM-037 warn (split doctrine). block('ENT-007', 'Dossier', 'agrégat racine', ['- **Sensibilité** : interne'], ['Diagnostic']), // restreint + API externe WITH a decision → still err (never exposable). block('ENT-008', 'Tarif', 'agrégat racine', ['- **Sensibilité** : restreint — commercial', '- **API externe** : read — décision client du 2026-10-03 : partenaire'], ['Prix']), // public + API externe → ok, no decision needed. block('ENT-009', 'Catalogue', 'agrégat racine', ['- **Sensibilité** : public', '- **API externe** : read'], ['Libelle']), // confidentiel + API externe with a dated decision → ok. block('ENT-010', 'Contrat', 'agrégat racine', ['- **Sensibilité** : confidentiel — rh', '- **API externe** : read — décision client du 2026-10-03 : prestataire paie'], ['Reference']), // Name trigger, no declaration → DM-036 warn naming the NAME. block('ENT-011', 'Sanction', 'agrégat racine', [], ['Motif']), // Undated décision client → DM-036 err. block('ENT-012', 'Prime', 'agrégat racine', ['- **Sensibilité** : interne — financier — décision client : on verra'], ['Montant']), // confidentiel without a category → DM-036 warn (uncategorised). block('ENT-013', 'Archive', 'agrégat racine', ['- **Sensibilité** : confidentiel'], ['Chemin']), ].join('\n') let dir: string let findings: Finding[] let strictFindings: Finding[] let judgments: JudgmentItem[] const of = (rule: string, mod: string, from: Finding[] = findings): Finding[] => from.filter((f) => f.ruleId === rule && f.scope.module === mod) beforeAll(() => { dir = mkdtempSync(join(tmpdir(), 'ss-dm-sensitivity-')) const w = (rel: string, content: string): void => { const p = join(dir, rel) mkdirSync(join(p, '..'), { recursive: true }) writeFileSync(p, content, 'utf8') } w('RH/index.md', '# RH\n') w('RH/PAIE/index.md', '# PAIE\n') w('RH/PAIE/entité.md', PAIE_DOC) // Document-level default: every entity of the module is confidentiel — Salary needs no entity line. w('RH/CARRIERE/index.md', '# CARRIERE\n') w('RH/CARRIERE/entité.md', ['- **Sensibilité** : confidentiel — rh', '', block('ENT-001', 'Evolution', 'agrégat racine', [], ['Salary', 'Date'])].join('\n')) // Another application re-declaring FichePaie → DM-034 judgment carrying both levels. w('PLANNING/index.md', '# PLANNING\n') w('PLANNING/EQUIPE/index.md', '# EQUIPE\n') w('PLANNING/EQUIPE/entité.md', block('ENT-001', 'FichePaie', 'agrégat racine', ['- **Sensibilité** : confidentiel — rh'], ['Montant', 'Periode'])) // The CONSUMER of a sensitive entity: a Guid FK named after it is a reference, never the data (the // 2026-10-03 end-to-end replay — DM-036 accused PLANNING/AFFECT of carrying payroll). w('PLANNING/AFFECT/index.md', '# AFFECT\n') w( 'PLANNING/AFFECT/entité.md', [ // Undeclared + `FichePaieId` Guid FK (relation + constraint) → DM-036 silent. block('ENT-001', 'Affectation', 'agrégat racine', [], ['Libelle'], ['- **Relations** : Affectation *→1 FichePaie — FK FichePaieId, scope cross-module (RH/PAIE), onDelete restrict.'], ['| FichePaieId | Guid | requis, FK | — |']), // Declared interne + a Guid `…Id` with no relation line → DM-037 silent (nothing to split). block('ENT-002', 'Planning', 'agrégat racine', ['- **Sensibilité** : interne'], ['Semaine'], [], ['| SalaryGradeId | Guid | requis | — |']), ].join('\n'), ) const model = loadCorpus(dir) const out = runRules(DM_RULES, model, { strict: false }) findings = out.findings judgments = out.judgments strictFindings = runRules(DM_RULES, model, { strict: true }).findings }) afterAll(() => rmSync(dir, { recursive: true, force: true })) describe('DM-036 — the declaration is due where the data looks sensitive', () => { it('an undeclared entity with a Salary attribute or a sensitive NAME → warn, naming the trigger and the ONE remedy wording', () => { const f = of('DM-036', 'PAIE') const missing = f.find((x) => x.message.includes('non déclarée'))! expect(missing.severity).toBe('warn') expect(missing.evidence!.some((e) => e.startsWith('ENT-002 Employe — attribut Salary (financier)') && e.includes('`- **Sensibilité** : confidentiel — financier`'))).toBe(true) expect(missing.evidence!.some((e) => e.startsWith('ENT-011 Sanction — nom « Sanction » (juridique)') && e.includes('restreint — juridique'))).toBe(true) // The composant inherits its root → not listed; declared entities are DM-037's business → not listed. expect(missing.evidence!.some((e) => e.includes('CompteBancaire'))).toBe(false) expect(missing.evidence!.some((e) => e.includes('Dossier') || e.includes('Contact'))).toBe(false) }) it('--strict turns the missing declaration into an err', () => { expect(of('DM-036', 'PAIE', strictFindings).find((x) => x.message.includes('non déclarée'))!.severity).toBe('err') }) it('an unknown level or an undated décision client → err (the near-miss pair)', () => { const nm = of('DM-036', 'PAIE').find((x) => x.message.includes('illisible'))! expect(nm.severity).toBe('err') expect(nm.evidence).toEqual([ 'ENT-004 Badge — **Sensibilité** « secret » : niveau inconnu — attendu public | interne | confidentiel | restreint', 'ENT-012 Prime — **Sensibilité** : « décision client » sans date — attendu `décision client du AAAA-MM-JJ : ` (une décision non datée est une habitude, pas une décision)', ]) }) it('a sensitive entity without category → warn', () => { const un = of('DM-036', 'PAIE').find((x) => x.message.includes('sans catégorie'))! expect(un.severity).toBe('warn') expect(un.evidence).toEqual(['ENT-013 Archive — confidentiel sans catégorie : dis QUEL régime (personnel, personnel-sensible, sante, juridique, rh, financier, commercial, securite)']) }) it('a document-level line covers the whole module → ok, even with a Salary attribute', () => { expect(of('DM-036', 'CARRIERE').map((x) => x.severity)).toEqual(['ok']) }) it('a Guid FK named after a sensitive entity is a REFERENCE — the consumer module stays silent (the reach is CODE-009 / RBAC-012)', () => { expect(of('DM-036', 'AFFECT').map((x) => x.severity)).toEqual(['ok']) expect(of('DM-036', 'AFFECT', strictFindings).map((x) => x.severity)).toEqual(['ok']) }) }) describe('DM-037 — the split doctrine', () => { it('a DECLARED interne entity carrying a Diagnostic attribute → warn offering the three exits; a dated décision client silences', () => { const f = of('DM-037', 'PAIE') const split = f.find((x) => x.message.includes('scission'))! expect(split.severity).toBe('warn') expect(split.evidence).toHaveLength(1) expect(split.evidence![0]).toContain('ENT-007 Dossier (interne) — Diagnostic (sante)') expect(split.evidence![0]).toContain('`- **Sensibilité** : restreint — sante`') expect(split.evidence![0]).toContain('composant dédié') expect(split.evidence![0]).toContain('décision client du AAAA-MM-JJ') }) it('public + a sensitive attribute → err', () => { const contra = of('DM-037', 'PAIE').find((x) => x.message.includes('contradictoire'))! expect(contra.severity).toBe('err') expect(contra.evidence).toEqual(['ENT-005 Export — `public` avec Salary (financier) : une donnée exposable sans décision ne porte pas d’attribut sensible']) }) it('public on a **Personne** entity → err', () => { const solo = mkdtempSync(join(tmpdir(), 'ss-dm-public-person-')) try { mkdirSync(join(solo, 'APP', 'MOD'), { recursive: true }) writeFileSync(join(solo, 'APP', 'index.md'), '# APP\n') writeFileSync(join(solo, 'APP', 'MOD', 'index.md'), '# MOD\n') writeFileSync(join(solo, 'APP', 'MOD', 'entité.md'), block('ENT-001', 'Membre', 'agrégat racine', ['- **Sensibilité** : public', '- **Personne** : optional — FK UserId nullable'], ['Nom'], ['- **Relations** : Membre *→1 User — FK UserId, scope core (auth_Users), onDelete restrict.']), 'utf8') const out = runRules(DM_RULES, loadCorpus(solo), { strict: false }) const f = out.findings.find((x) => x.ruleId === 'DM-037')! expect(f.severity).toBe('err') expect(f.evidence![0]).toContain('`public` sur une entité **Personne**') } finally { rmSync(solo, { recursive: true, force: true }) } }) it('nothing declared interne/public with a sensitive attribute → ok', () => { expect(of('DM-037', 'CARRIERE').map((x) => x.severity)).toEqual(['ok']) }) it('a declared interne entity whose only sensitive-looking column is a Guid `…Id` → ok (a reference is nothing to split)', () => { expect(of('DM-037', 'AFFECT').map((x) => x.severity)).toEqual(['ok']) }) }) describe('DM-038 — the public API never carries a sensitive entity without the client’s dated decision', () => { it('restreint (even with a decision) and confidentiel without a dated decision → err; public and confidentiel + decision → sanctioned', () => { const f = of('DM-038', 'PAIE')[0]! expect(f.severity).toBe('err') expect(f.evidence!.some((e) => e.startsWith('ENT-001 FichePaie — `confidentiel` (financier, rh) publie read sans décision client datée') && e.includes('décision client du AAAA-MM-JJ : '))).toBe(true) expect(f.evidence!.some((e) => e.startsWith('ENT-008 Tarif — `restreint` (commercial) publie read'))).toBe(true) expect(f.evidence!.some((e) => e.includes('Catalogue') || e.includes('Contrat'))).toBe(false) }) it('a module publishing nothing → ok', () => { expect(of('DM-038', 'CARRIERE').map((x) => x.severity)).toEqual(['ok']) }) }) describe('DM-034 — the judgment carries both levels', () => { it('FichePaie vs PLANNING/EQUIPE FichePaie: the question shows [levels] and routes a sensitive reuse to the access request', () => { const j = judgments.find((x) => x.ruleId === 'DM-034' && x.scope.module === 'PAIE' && x.question.includes('ENT-001 FichePaie vs PLANNING/EQUIPE ENT-001 FichePaie'))! expect(j).toBeDefined() expect(j.question).toContain('[confidentiel — financier, rh / confidentiel — rh]') expect(j.question).toContain('demande d’accès') expect(j.question).toContain('CODE-009') }) })