import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, statSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { deriveSensitivitySpecs, reconcilePagespecKey } from '../derive.js' import { validate } from '../validate.js' // derive-sensitivity-specs — the deterministic deriver of the data-sensitivity // seam: from the `- **Sensibilité**` lines of entité.md (lib/ba-sensitivity // ladder) it emits the scaffolder fragments and reconciles the pagespec // `sensitivity` key (single source of truth = entité.md; ABSENT = interne). let root: string let moduleRoot: string beforeEach(() => { root = mkdtempSync(path.join(tmpdir(), 'sssensitivity-')) moduleRoot = path.join(root, 'RH', 'PAIE') mkdirSync(path.join(moduleRoot, 'pagespecs'), { recursive: true }) }) afterEach(() => rmSync(root, { recursive: true, force: true })) function writeEntite(body: string): void { writeFileSync(path.join(moduleRoot, 'entité.md'), body) } function writePagespec(name: string, block: Record): string { const p = path.join(moduleRoot, 'pagespecs', name) writeFileSync(p, `# ${name}\n\n\`\`\`json\n${JSON.stringify(block, null, 2)}\n\`\`\`\n`) return p } const readBlock = (p: string): Record => JSON.parse(/```json\n([\s\S]*?)\n```/.exec(readFileSync(p, 'utf-8'))![1]!) as Record const DOC = ` ### ENT-001 — FichePaie (agrégat racine) - **Préfixe table** : \`paie_\` - **Sensibilité** : confidentiel — financier, rh - **Affichage** : Reference | Attribut | Type | Contraintes | Calculé | |---|---|---|---| | Id | Guid | PK | — | | Reference | string(50) | requis | — | | Montant | decimal(18,2) | — | — | ### ENT-002 — LignePaie (composant) - **Préfixe table** : \`paie_\` | Attribut | Type | Contraintes | Calculé | |---|---|---|---| | Id | Guid | PK | — | | Label | string(100) | requis | — | - **Relations** : LignePaie *→1 FichePaie — FK FichePaieId, scope same-module, onDelete cascade. ### ENT-003 — Periode (lookup) - **Préfixe table** : \`paie_\` | Attribut | Type | Contraintes | Calculé | |---|---|---|---| | Id | Guid | PK | — | | Label | string(50) | requis | — | ### ENT-004 — Catalogue (agrégat racine) - **Sensibilité** : public | Attribut | Type | Contraintes | Calculé | |---|---|---|---| | Id | Guid | PK | — | | Name | string(50) | requis | — | ` describe('derive-sensitivity-specs — resolution + scaffolder fragments', () => { it('resolves the declared entity, the composant through its root, skips interne, keeps public; the fragments exist only ≥ confidentiel', () => { writeEntite(DOC) const r = deriveSensitivitySpecs(validate({ moduleRoot, mode: 'check' }).spec!) expect(r.entities.map((e) => [e.entity, e.sensitivity.level, e.source, e.displayField])).toEqual([ ['Catalogue', 'public', 'entity', 'Name'], ['FichePaie', 'confidentiel', 'entity', 'Reference'], ['LignePaie', 'confidentiel', 'root', 'Label'], ]) const fiche = r.entities.find((e) => e.entity === 'FichePaie')! expect(fiche.scaffoldBusiness).toEqual({ sensitivity: { level: 'confidentiel', categories: ['financier', 'rh'] } }) expect(fiche.scaffoldComponent).toEqual({ sensitivity: { level: 'confidentiel', categories: ['financier', 'rh'] } }) expect(fiche.extensionSearch).toEqual({ level: 'confidentiel', displayField: 'Reference' }) const cat = r.entities.find((e) => e.entity === 'Catalogue')! expect(cat.scaffoldBusiness).toBeNull() expect(cat.extensionSearch).toBeNull() expect(r.totals).toMatchObject({ sensitive: 2, restricted: 0, public: 1, blocked: 0 }) }) it('a sensitive entity with no neutral label, or whose label IS a sensitive attribute, is BLOCKED — never improvised; a person projection is fine', () => { writeEntite( [ '### ENT-001 — Virement (agrégat racine)', '- **Sensibilité** : restreint — financier', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Id | Guid | PK | — |', '| Montant | decimal(18,2) | — | — |', '', '### ENT-002 — Compte (agrégat racine)', '- **Sensibilité** : confidentiel — financier', '- **Affichage** : Iban', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Id | Guid | PK | — |', '| Iban | string(34) | requis | — |', '', '### ENT-003 — Salarie (agrégat racine)', '- **Sensibilité** : confidentiel — rh', '- **Personne** : mandatory — identité via auth_Users (FirstName, LastName, Email)', '', '| Attribut | Type | Contraintes | Calculé |', '|---|---|---|---|', '| Id | Guid | PK | — |', '| Salary | decimal(18,2) | — | — |', '', ].join('\n'), ) const r = deriveSensitivitySpecs(validate({ moduleRoot, mode: 'check' }).spec!) const by = Object.fromEntries(r.entities.map((e) => [e.entity, e])) expect(by['Virement']!.blocked[0]).toContain('no neutral label') expect(by['Virement']!.scaffoldBusiness).toBeNull() expect(by['Compte']!.blocked[0]).toContain('« Iban » IS a sensitive attribute') expect(by['Salarie']!.blocked).toEqual([]) expect(by['Salarie']!.displayField).toBeNull() expect(by['Salarie']!.scaffoldBusiness).not.toBeNull() expect(by['Salarie']!.extensionSearch).toBeNull() // no stored label to SearchIn — the projection is the generator's business expect(r.totals.blocked).toBe(2) expect(r.warnings[0]).toContain('2 sensitive entities without a safe label') }) }) describe('derive-sensitivity-specs — pagespec key reconciliation', () => { it('stamps the key on every pagespec of a non-interne entity, leaves interne ones untouched (and unwritten), says parity in check', () => { writeEntite(DOC) const list = writePagespec('FichePaie.list.md', { entity: 'FichePaie', view: 'list' }) const detail = writePagespec('FichePaie.detail.md', { entity: 'FichePaie', view: 'detail' }) const ligne = writePagespec('LignePaie.form.md', { entity: 'LignePaie', view: 'form' }) const periode = writePagespec('Periode.list.md', { entity: 'Periode', view: 'list' }) const cat = writePagespec('Catalogue.list.md', { entity: 'Catalogue', view: 'list', pwa: { support: 'adapted' } }) const before = statSync(periode).mtimeMs const periodeContent = readFileSync(periode, 'utf-8') const check = deriveSensitivitySpecs(validate({ moduleRoot, mode: 'check' }).spec!) expect(check.drift.map((d) => [d.pagespec, d.status])).toEqual([ ['Catalogue.list.md', 'missing'], ['FichePaie.detail.md', 'missing'], ['FichePaie.list.md', 'missing'], ['LignePaie.form.md', 'missing'], ]) const r = deriveSensitivitySpecs(validate({ moduleRoot }).spec!) expect(r.totals).toMatchObject({ added: 4, staleRemoved: 0, drift: 0 }) expect(readBlock(list).sensitivity).toEqual({ level: 'confidentiel', categories: ['financier', 'rh'] }) expect(readBlock(detail).sensitivity).toEqual({ level: 'confidentiel', categories: ['financier', 'rh'] }) expect(readBlock(ligne).sensitivity).toEqual({ level: 'confidentiel', categories: ['financier', 'rh'] }) expect(readBlock(cat)).toEqual({ entity: 'Catalogue', view: 'list', pwa: { support: 'adapted' }, sensitivity: { level: 'public', categories: [] } }) expect(readBlock(periode).sensitivity).toBeUndefined() expect(readFileSync(periode, 'utf-8')).toBe(periodeContent) expect(statSync(periode).mtimeMs).toBe(before) // Idempotent: a second derive changes nothing. const again = deriveSensitivitySpecs(validate({ moduleRoot }).spec!) expect(again.totals).toMatchObject({ added: 0, staleRemoved: 0, drift: 0 }) expect(again.pagespecs.every((p) => p.status === 'already')).toBe(true) expect(deriveSensitivitySpecs(validate({ moduleRoot, mode: 'check' }).spec!).drift).toEqual([]) }) it('an entity back to interne loses its key (stale-removed); a changed level is healed; unknown slots survive', () => { writeEntite(DOC) const list = writePagespec('FichePaie.list.md', { entity: 'FichePaie', sensitivity: { level: 'restreint', categories: ['financier'], note: 'kept' } }) const periode = writePagespec('Periode.list.md', { entity: 'Periode', sensitivity: { level: 'confidentiel', categories: [] } }) const r = deriveSensitivitySpecs(validate({ moduleRoot }).spec!) expect(r.pagespecs.map((p) => [p.pagespec, p.status])).toEqual([ ['FichePaie.list.md', 'healed'], ['Periode.list.md', 'stale-removed'], ]) expect(readBlock(list).sensitivity).toEqual({ level: 'confidentiel', categories: ['financier', 'rh'], note: 'kept' }) expect(readBlock(periode).sensitivity).toBeUndefined() }) it('reconcilePagespecKey is pure and byte-identical when nothing changes', () => { const md = '# x\n\n```json\n{\n "entity": "A"\n}\n```\n' const out = reconcilePagespecKey(md, 'A.list.md', new Map(), false) expect(out).toEqual({ md, outcome: null }) }) it('the entity filter restricts both the resolution and the pagespecs touched', () => { writeEntite(DOC) const list = writePagespec('FichePaie.list.md', { entity: 'FichePaie' }) const cat = writePagespec('Catalogue.list.md', { entity: 'Catalogue' }) const r = deriveSensitivitySpecs(validate({ moduleRoot, entity: 'Catalogue' }).spec!) expect(r.entities.map((e) => e.entity)).toEqual(['Catalogue']) expect(readBlock(cat).sensitivity).toEqual({ level: 'public', categories: [] }) expect(readBlock(list).sensitivity).toBeUndefined() }) })