/** * lib/ba-sensitivity — the closed DATA SENSITIVITY vocabulary at the entity * grain: the bullet parser, the resolution ladder (entity → aggregate root → * document → default), the Personne implication (category only, never a * level), the whole-token triggers and the canonical renderers. */ import { describe, expect, it } from 'vitest' import { DEFAULT_SENSITIVITY_LEVEL, EntitySensitivitySchema, SENSITIVITY_CATEGORY_KEYS, SENSITIVITY_LEVELS, formatSensitivity, isPublic, isRestricted, isSensitive, isSensitivityLine, matchSensitiveAttribute, matchSensitiveEntityName, parseSensitivityValue, rankOf, renderSensitivityLine, resolveEntitySensitivity, isReferenceAttribute, sensitiveAttributesOf, type SensitivityEntityLike, } from '../ba-sensitivity.js' function entity(partial: Partial & { name: string }): SensitivityEntityLike { return { classification: undefined, sensitivityRaw: null, sensitivity: null, sensitivitySource: null, person: null, relations: [], ...partial, } } function declared(name: string, rhs: string, source: 'entity' | 'document' = 'entity', extra: Partial = {}): SensitivityEntityLike { return entity({ name, sensitivityRaw: rhs, sensitivity: parseSensitivityValue(rhs), sensitivitySource: source, ...extra }) } describe('ba-sensitivity — vocabulary', () => { it('four ranked levels, interne is the default', () => { expect([...SENSITIVITY_LEVELS]).toEqual(['public', 'interne', 'confidentiel', 'restreint']) expect(DEFAULT_SENSITIVITY_LEVEL).toBe('interne') expect(SENSITIVITY_LEVELS.map(rankOf)).toEqual([0, 1, 2, 3]) }) it('eight closed categories', () => { expect([...SENSITIVITY_CATEGORY_KEYS]).toEqual(['personnel', 'personnel-sensible', 'sante', 'juridique', 'rh', 'financier', 'commercial', 'securite']) }) it('isSensitive is the ≥ confidentiel threshold; isRestricted / isPublic are exact', () => { expect(isSensitive('public')).toBe(false) expect(isSensitive('interne')).toBe(false) expect(isSensitive('confidentiel')).toBe(true) expect(isSensitive({ level: 'restreint' })).toBe(true) expect(isSensitive(null)).toBe(false) expect(isRestricted('restreint')).toBe(true) expect(isRestricted('confidentiel')).toBe(false) expect(isPublic({ level: 'public' })).toBe(true) }) it('the Zod SSOT accepts the pagespec shape, defaults categories, refuses an unknown level', () => { expect(EntitySensitivitySchema.parse({ level: 'confidentiel' })).toEqual({ level: 'confidentiel', categories: [] }) expect(EntitySensitivitySchema.parse({ level: 'restreint', categories: ['sante'], extra: 1 })).toMatchObject({ level: 'restreint', categories: ['sante'], extra: 1 }) expect(EntitySensitivitySchema.safeParse({ level: 'secret' }).success).toBe(false) }) }) describe('parseSensitivityValue', () => { it('reads level, categories and the dated décision client', () => { const p = parseSensitivityValue('confidentiel — rh, financier — décision client du 2026-10-03 : paie externalisée') expect(p.level).toBe('confidentiel') expect(p.categories).toEqual(['rh', 'financier']) expect(p.unknownCategories).toEqual([]) expect(p.decision).toEqual({ date: '2026-10-03', reason: 'paie externalisée' }) expect(p.decisionUndated).toBe(false) }) it('a level alone is valid; FR/EN aliases and accents fold', () => { expect(parseSensitivityValue('interne').level).toBe('interne') expect(parseSensitivityValue('Restricted — Santé, Légal').categories).toEqual(['sante', 'juridique']) expect(parseSensitivityValue('Confidential — HR').level).toBe('confidentiel') expect(parseSensitivityValue('publique').level).toBe('public') }) it('an unknown level yields level null (the near-miss pair: raw present, typed null)', () => { const p = parseSensitivityValue('secret — rh') expect(p.level).toBeNull() expect(p.levelRaw).toBe('secret') expect(p.categories).toEqual(['rh']) }) it('an unknown category is surfaced, never dropped', () => { expect(parseSensitivityValue('confidentiel — rh, marketing').unknownCategories).toEqual(['marketing']) }) it('an undated « décision client » is flagged (an undated decision is a habit)', () => { const p = parseSensitivityValue('interne — financier — décision client : on garde le salaire ici') expect(p.decision).toEqual({ date: null, reason: 'on garde le salaire ici' }) expect(p.decisionUndated).toBe(true) }) it('isSensitivityLine accepts the bullet in either label, never a table cell', () => { expect(isSensitivityLine('- **Sensibilité** : confidentiel — rh')).toBe(true) expect(isSensitivityLine(' * **Sensibilite** : interne')).toBe(true) expect(isSensitivityLine('- **Sensitivity** : public')).toBe(true) expect(isSensitivityLine('| Sensibilité | confidentiel |')).toBe(false) expect(isSensitivityLine('- **Description** : la sensibilité du capteur')).toBe(false) }) }) describe('resolveEntitySensitivity — the ladder', () => { it('nothing declared → interne, source default, not declared', () => { expect(resolveEntitySensitivity(entity({ name: 'Order' }))).toEqual({ level: 'interne', categories: [], source: 'default', declared: false, inheritedFrom: null, decision: null, nearMiss: false, }) }) it('an entity line wins over everything', () => { const r = resolveEntitySensitivity(declared('Payslip', 'restreint — financier, rh')) expect(r).toMatchObject({ level: 'restreint', categories: ['financier', 'rh'], source: 'entity', declared: true }) }) it('a document line is inherited (declared, source document)', () => { const r = resolveEntitySensitivity(declared('Order', 'confidentiel — commercial', 'document')) expect(r).toMatchObject({ level: 'confidentiel', source: 'document', declared: true }) }) it('a composant inherits its aggregate root ENTITY line — over the document default', () => { const root = declared('Payslip', 'confidentiel — financier', 'entity', { classification: 'agrégat racine' }) const line = declared('PayslipLine', 'interne', 'document', { classification: 'composant', relations: [{ targetEntity: 'Payslip', cardinality: '*→1', scope: 'same-module' }], }) const r = resolveEntitySensitivity(line, [root, line]) expect(r).toMatchObject({ level: 'confidentiel', categories: ['financier'], source: 'root', declared: false, inheritedFrom: 'Payslip' }) }) it('a composant whose root declares nothing falls back to the document, then to interne', () => { const root = entity({ name: 'Order', classification: 'agrégat racine' }) const line = entity({ name: 'OrderLine', classification: 'composant', relations: [{ targetEntity: 'Order', cardinality: '*→1', scope: 'same-module' }] }) expect(resolveEntitySensitivity(line, [root, line]).source).toBe('default') const lineDoc = declared('OrderLine', 'public', 'document', { classification: 'composant', relations: line.relations }) expect(resolveEntitySensitivity(lineDoc, [root, lineDoc])).toMatchObject({ level: 'public', source: 'document' }) }) it('a cross-module or 1→* relation never carries an inheritance', () => { const root = declared('Payslip', 'restreint — financier', 'entity', { classification: 'agrégat racine' }) const other = entity({ name: 'Note', classification: 'composant', relations: [{ targetEntity: 'Payslip', cardinality: '*→1', scope: 'cross-module' }] }) expect(resolveEntitySensitivity(other, [root, other]).level).toBe('interne') }) it('**Personne** mandatory/optional implies the category personnel — NEVER a level', () => { const emp = entity({ name: 'Employee', person: 'mandatory — identité via auth_Users (FirstName, LastName, Email)' }) expect(resolveEntitySensitivity(emp)).toMatchObject({ level: 'interne', categories: ['personnel'], source: 'default' }) const decl = declared('Candidate', 'confidentiel — rh', 'entity', { person: 'optional — FK UserId nullable' }) expect(resolveEntitySensitivity(decl).categories).toEqual(['rh', 'personnel']) const none = entity({ name: 'Supplier', person: 'none — décision client : annuaire externe' }) expect(resolveEntitySensitivity(none).categories).toEqual([]) }) it('a line with an unknown level resolves to interne AND says nearMiss', () => { const r = resolveEntitySensitivity(declared('X', 'secret — rh')) expect(r).toMatchObject({ level: 'interne', nearMiss: true, declared: true }) }) }) describe('triggers — whole-token, never substring', () => { it('attribute triggers: Salary/IBAN/Diagnostic hit, Secretary/Tokenizer/Bonus do not', () => { expect(matchSensitiveAttribute('SalaryGrade')?.word).toBe('salary') expect(matchSensitiveAttribute('MonthlySalaire')?.category).toBe('financier') expect(matchSensitiveAttribute('Iban')?.category).toBe('financier') expect(matchSensitiveAttribute('BankAccountNumber')?.word).toBe('bankaccount') expect(matchSensitiveAttribute('DiagnosticCode')?.minLevel).toBe('restreint') expect(matchSensitiveAttribute('PasswordHash')?.category).toBe('securite') expect(matchSensitiveAttribute('Secretary')).toBeUndefined() expect(matchSensitiveAttribute('Tokenizer')).toBeUndefined() expect(matchSensitiveAttribute('Bonus')).toBeUndefined() expect(matchSensitiveAttribute('DateOfBirth')).toBeUndefined() expect(matchSensitiveAttribute('Email')).toBeUndefined() }) it('entity-name triggers: compounds, plurals and accents, never SalesOrder', () => { expect(matchSensitiveEntityName('EmployeeSalaries')?.word).toBe('Salary') expect(matchSensitiveEntityName('DossierMedical')?.category).toBe('sante') expect(matchSensitiveEntityName('Rémunération')?.category).toBe('financier') expect(matchSensitiveEntityName('ContratDeTravail')?.category).toBe('rh') expect(matchSensitiveEntityName('SalesOrder')).toBeUndefined() expect(matchSensitiveEntityName('Evaluation')).toBeUndefined() expect(matchSensitiveEntityName('Contract')).toBeUndefined() }) it('sensitiveAttributesOf lists each hit with its trigger', () => { const hits = sensitiveAttributesOf([{ name: 'Id' }, { name: 'Salary' }, { name: 'Label' }, { name: 'Iban' }]) expect(hits.map((h) => h.attribute)).toEqual(['Salary', 'Iban']) }) it('a REFERENCE column never trips the scan — a Guid FK named after a sensitive entity stores a Guid, not the data', () => { // Guid + `…Id` name, a FK / → constraint, or the FK a relation declares (any type). expect(isReferenceAttribute({ name: 'FichePaieId', type: 'Guid' })).toBe(true) expect(isReferenceAttribute({ name: 'SalaryGradeId', type: 'Guid?' })).toBe(true) expect(isReferenceAttribute({ name: 'PayrollRef', type: 'string(20)', constraints: 'FK → Payroll' })).toBe(true) expect(isReferenceAttribute({ name: 'IbanId', type: 'int', constraints: 'requis' }, new Set(['ibanid']))).toBe(true) // The data itself, whatever the type, is never a reference; the PK is not a FK. expect(isReferenceAttribute({ name: 'Iban', type: 'string(34)', constraints: 'requis, unique' })).toBe(false) expect(isReferenceAttribute({ name: 'Salary', type: 'decimal(10,2)' })).toBe(false) expect(isReferenceAttribute({ name: 'Id', type: 'Guid', constraints: 'PK' })).toBe(false) const hits = sensitiveAttributesOf( [ { name: 'FichePaieId', type: 'Guid', constraints: 'requis, FK' }, { name: 'SalaryGradeId', type: 'Guid', constraints: 'requis' }, { name: 'ContratTravailId', type: 'int', constraints: 'requis' }, { name: 'Iban', type: 'string(34)', constraints: 'requis' }, ], ['ContratTravailId'], ) expect(hits.map((h) => h.attribute)).toEqual(['Iban']) }) }) describe('renderers', () => { it('renderSensitivityLine is the ONE remedy wording, categories in vocabulary order, decision optional', () => { expect(renderSensitivityLine('confidentiel', ['financier', 'rh'])).toBe('- **Sensibilité** : confidentiel — rh, financier') expect(renderSensitivityLine('interne')).toBe('- **Sensibilité** : interne') expect(renderSensitivityLine('interne', ['financier'], { date: '2026-10-03', reason: 'le salaire reste sur Employee' })) .toBe('- **Sensibilité** : interne — financier — décision client du 2026-10-03 : le salaire reste sur Employee') }) it('round-trips render → parse', () => { const line = renderSensitivityLine('restreint', ['sante', 'personnel-sensible'], { date: '2026-10-03', reason: 'médecin du travail' }) const p = parseSensitivityValue(line.replace(/^- \*\*Sensibilité\*\* : /, '')) expect(p.level).toBe('restreint') expect(p.categories).toEqual(['personnel-sensible', 'sante']) expect(p.decision).toEqual({ date: '2026-10-03', reason: 'médecin du travail' }) }) it('formatSensitivity is the short human form', () => { expect(formatSensitivity({ level: 'confidentiel', categories: ['rh', 'personnel'] })).toBe('confidentiel — rh, personnel') expect(formatSensitivity({ level: 'interne', categories: [] })).toBe('interne') }) })