/** * audit-fixes.test.ts — the three defects the post-delivery audit of the 3 lots * found, each locked by a case: `read.all` counted as read on the node, the * upstream-trace step on op=modify, and the same-app actor condition. */ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { loadScopeCorpus } from '../corpus.js' import { executeChangeImpact } from '../execute.js' import { ChangeImpactInputSchema, type ChangeImpactInput } from '../types.js' import { validateSpec } from '../validate.js' import { writeChangeCorpus } from './fixture.js' let tmp: string let root: string beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'ba-change-audit-')) root = join(tmp, 'ba') }) afterEach(() => rmSync(tmp, { recursive: true, force: true })) function impact(over: Partial & { kind: ChangeImpactInput['kind'] }) { const spec = ChangeImpactInputSchema.parse({ baRoot: root, app: 'CRM', module: 'PIPELINE', ...over }) const v = validateSpec(spec) if (!v.valid || !v.scope) throw new Error(v.errors.join('; ')) return executeChangeImpact(spec, loadScopeCorpus(v.scope)) } describe('audit fix 1 — a `read.all` row IS a read on the node', () => { it('hasRead true through the scope tier, action listed as read.all not all', () => { writeChangeCorpus(root) const rbac = join(root, 'CRM', 'PIPELINE', 'rbac.md') writeFileSync( rbac, readFileSync(rbac, 'utf8').replace( '| BA-001-AC-002 (Manager commercial) | `pipeline.opportunites.approve` | équipe |\n', '| BA-001-AC-002 (Manager commercial) | `pipeline.opportunites.approve` | équipe |\n| BA-001-AC-002 (Manager commercial) | `pipeline.opportunites.read.all` | toutes |\n', ), 'utf8', ) const r = impact({ kind: 'permission', target: { actor: 'BA-001-AC-002', permissionPath: 'pipeline.opportunites.export', portee: 'toutes' } }) const reach = r.impact.find((s) => s.gateRules.includes('RBAC-002'))! expect(reach.data).toMatchObject({ hasRead: true, actorActionsOnNode: ['approve', 'read.all'] }) }) }) describe('audit fix 2 — the upstream trace is never a gate on op=modify', () => { it('entity modify (no trace computed) → skipped; attribute modify with a trace → skipped; without → conditional, never required', () => { writeChangeCorpus(root) const entity = impact({ kind: 'entity', op: 'modify', target: { code: 'ENT-001' } }) expect(entity.blocked).toEqual([]) expect(entity.impact[0]!.status).toBe('skipped') expect(entity.impact[0]!.skipReason).toMatch(/op=modify/) const traced = impact({ kind: 'attribute', op: 'modify', target: { entity: 'Opportunity', attribute: 'Amount' } }) // « montant » is not « amount » — the fixture never names Amount upstream: a modify stays conditional, not required expect(['skipped', 'conditional']).toContain(traced.impact[0]!.status) expect(traced.impact[0]!.status).not.toBe('required') const add = impact({ kind: 'attribute', target: { entity: 'Opportunity', attribute: 'Amount' } }) expect(add.blocked.map((b) => b.code)).toEqual(['upstream-trace-missing']) }) }) describe('audit fix 3 — an actor that already exists in THIS application is a modify, not a Périmètre line', () => { it('same app → « op=modify » condition; other app → reuse with a Périmètre line', () => { writeChangeCorpus(root) const here = impact({ kind: 'actor', target: { title: 'Commercial' } }) expect(here.impact[0]!.status).toBe('required') expect(here.impact[0]!.condition).toMatch(/THIS application.*op=modify/) const elsewhere = impact({ kind: 'actor', target: { title: 'Responsable RH' } }) expect(elsewhere.impact[0]!.condition).toMatch(/another application.*Périmètre/) }) }) describe('audit — a use case whose actor is not named yet keeps the RBAC check conditional', () => { it('no actor → conditional with a naming condition', () => { writeChangeCorpus(root) const r = impact({ kind: 'use-case', section: 'opportunites', target: { title: 'Relancer un prospect' } }) const rbac = r.impact.find((s) => s.gateRules.includes('XD-004'))! expect(rbac.status).toBe('conditional') expect(rbac.condition).toMatch(/name the actor first/) }) }) describe('audit fix 5 — a rule pinned on a section without its own rules doc is owned by the module doc', () => { it('clients has no règles-métier.md → owner = module doc, a warning says so, allocation stays module-wide, no first-pass block', () => { writeChangeCorpus(root) const r = impact({ kind: 'business-rule', section: 'clients', target: { title: 'Client unique' } }) expect(r.blocked).toEqual([]) expect(r.owner.file).toBe('CRM/PIPELINE/règles-métier.md') expect(r.owner.subWorkflow).toMatch(/MODULE doc is the deepest existing scope/) expect(r.existing.count).toBe(2) expect(r.allocation!.next).toBe('BR-004') expect(r.warnings.some((w) => /clients has no règles-métier\.md of its own/.test(w))).toBe(true) // a section WITH its own doc keeps it expect(impact({ kind: 'business-rule', section: 'opportunites' }).owner.file).toBe('CRM/PIPELINE/opportunites/règles-métier.md') }) })