/** * scaffold-business × data sensitivity (lib/ba-sensitivity). * - ≥ confidentiel: the list search LIKEs the DISPLAY FIELD ONLY (golden * predicate), preceded by a `// Sensitivity:` trace line; a display field * that IS a sensitive attribute is refused (validate + generate). * - no `sensitivity` → byte-identical output (the no-op guarantee). */ import { describe, it, expect } from 'vitest' import { generate } from '../generate.js' import { validate, validateSensitivity } from '../validate.js' import type { ScaffoldBusinessInput } from '../types.js' function fixture(overrides: Partial = {}): ScaffoldBusinessInput { return { name: 'Payslip', pluralName: 'Payslips', module: 'paie', appCode: 'TestV2', applicationCode: 'rh', namespace: 'TestV2', fields: [ { name: 'Reference', type: 'string', required: true, isKey: false }, { name: 'Comment', type: 'string', required: false, isKey: false }, { name: 'Amount', type: 'decimal', required: true, isKey: false }, ], businessRules: [], customActions: [], projectPath: '/tmp/project', displayNameExpr: 'Reference', ...overrides, } } const SENSITIVE = { level: 'confidentiel' as const, categories: ['financier' as const, 'rh' as const] } const service = (spec: ScaffoldBusinessInput): string => generate(spec).find((f) => f.path.endsWith('Services/Rh/Paie/PayslipService.cs'))!.content describe('scaffold-business — sensitive entity', () => { it('golden: the list search LIKEs the display field ONLY, with the trace line; the untagged twin LIKEs every string column', () => { const svc = service(fixture({ sensitivity: SENSITIVE })) expect(svc).toContain(' // Sensitivity: confidentiel — financier, rh — list search restricted to the display field (lib/ba-sensitivity).\n if (!string.IsNullOrEmpty(query.Search))') expect(svc).toContain('q = q.Where(x => EF.Functions.Like(x.Reference, $"%{query.Search}%"));') expect(svc).not.toMatch(/EF\.Functions\.Like\(x\.Comment/) const plain = service(fixture()) expect(plain).not.toContain('// Sensitivity:') expect(plain).toMatch(/EF\.Functions\.Like\(x\.Reference, \$"%\{query\.Search\}%"\) \|\| EF\.Functions\.Like\(x\.Comment, \$"%\{query\.Search\}%"\)/) }) it('the lookup, the DTOs and the sort are unchanged — the protection is the split, never a masking', () => { const svc = service(fixture({ sensitivity: SENSITIVE })) const plain = service(fixture()) expect(svc).toContain('new PayslipRefDto(x.Id, x.Reference)') expect(svc).toContain('new PayslipListDto(x.Id, x.Reference, x.Comment, x.Amount, x.CreatedAt)') expect(svc).toContain('"comment" => asc ? q.OrderBy(x => x.Comment) : q.OrderByDescending(x => x.Comment)') // Everything else is byte-identical: strip the two sensitive differences and compare. const normalise = (s: string): string => s .replace(/^ {8}\/\/ Sensitivity:.*\r?\n/m, '') .replace(/EF\.Functions\.Like\(x\.Reference, \$"%\{query\.Search\}%"\) \|\| EF\.Functions\.Like\(x\.Comment, \$"%\{query\.Search\}%"\)/, 'EF.Functions.Like(x.Reference, $"%{query.Search}%")') expect(normalise(svc)).toBe(normalise(plain)) }) it('a display field that IS a sensitive attribute is refused — by validate (actionable) and by generate (fail-closed)', () => { const spec = fixture({ sensitivity: SENSITIVE, displayNameExpr: 'Iban', fields: [{ name: 'Iban', type: 'string', required: true, isKey: false }, { name: 'Amount', type: 'decimal', required: true, isKey: false }], }) const v = validateSensitivity(spec) expect(v.valid).toBe(false) expect(v.errors[0]).toContain("displayNameExpr 'Iban' is a sensitive attribute (financier)") expect(validate(spec).errors.some((e) => e.includes('sensitive attribute'))).toBe(true) expect(() => generate(spec)).toThrow(/lookup label 'Iban' is a sensitive attribute \(financier\)/) // The GUID opt-out is accepted, and so is a neutral label without an explicit displayNameExpr. expect(validateSensitivity(fixture({ sensitivity: SENSITIVE, displayNameExpr: 'Id' })).valid).toBe(true) expect(() => generate(fixture({ sensitivity: SENSITIVE, displayNameExpr: 'Id' }))).not.toThrow() expect(() => generate(fixture({ sensitivity: SENSITIVE, displayNameExpr: undefined }))).not.toThrow() }) it('the cascade label of a sensitive entity is guarded too (no explicit displayNameExpr)', () => { // `Reference` sits in the display cascade and is neutral; a cascade that lands on a trigger word is refused. const spec = fixture({ sensitivity: SENSITIVE, displayNameExpr: undefined, fields: [{ name: 'Name', type: 'string', required: true, isKey: false }], }) expect(() => generate(spec)).not.toThrow() }) it('a public or interne level changes nothing', () => { expect(service(fixture({ sensitivity: { level: 'public', categories: [] } }))).toBe(service(fixture())) expect(service(fixture({ sensitivity: { level: 'interne', categories: [] } }))).toBe(service(fixture())) }) })