/** * lot2-actor-permission.test.ts — the people & access kinds: impact decisions, * fail-closed blockers, verify mode. */ 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, executeVerify } 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-lot2-')) root = join(tmp, 'ba') }) afterEach(() => rmSync(tmp, { recursive: true, force: true })) const specOf = (over: Partial & { kind: ChangeImpactInput['kind'] }): ChangeImpactInput => ChangeImpactInputSchema.parse({ baRoot: root, app: 'CRM', module: 'PIPELINE', ...over }) function impact(over: Partial & { kind: ChangeImpactInput['kind'] }) { const spec = specOf(over) const v = validateSpec(spec) if (!v.valid || !v.scope) throw new Error(v.errors.join('; ')) return executeChangeImpact(spec, loadScopeCorpus(v.scope, spec.projectPath)) } function verify(over: Partial & { kind: ChangeImpactInput['kind'] }) { const spec = specOf({ mode: 'verify', ...over }) const v = validateSpec(spec) if (!v.valid || !v.scope) throw new Error(v.errors.join('; ')) return executeVerify(spec, loadScopeCorpus(v.scope)) } describe('actor — impact', () => { it('an existing label anywhere makes the precondition REQUIRED (reuse), a fresh one skips it; allocation is project-wide', () => { writeChangeCorpus(root) const reuse = impact({ kind: 'actor', target: { title: 'Responsable RH' } }) expect(reuse.blocked).toEqual([]) expect(reuse.impact[0]!.status).toBe('required') expect(reuse.impact[0]!.condition).toMatch(/Périmètre/) const fresh = impact({ kind: 'actor', target: { title: 'Assistante commerciale' } }) expect(fresh.impact[0]!.status).toBe('skipped') expect(fresh.allocation!.next).toBe('BA-001-AC-006') expect(fresh.impact.find((s) => s.phase === 'prd')!.status).toBe('skipped') expect(fresh.owner).toMatchObject({ file: 'CRM/acteur.md', exists: true, placeholder: false, writeDiscipline: 'full-rewrite', machineBlocksHash: 'none' }) }) it('developed → the seed-parity check runs derive-rbac-grants with apps[] + projectPath; not developed → skipped', () => { writeChangeCorpus(root, { withSnapshot: true }) const hot = impact({ kind: 'actor', target: { title: 'Assistante commerciale' } }) const parity = hot.impact.find((s) => s.cli?.cliPath.includes('derive-rbac-grants'))! expect(parity.status).toBe('required') expect(parity.cli!.spec).toMatchObject({ apps: ['CRM'], mode: 'check' }) expect(parity.cli!.spec['app']).toBeUndefined() expect(parity.cli!.writes).toBe(false) rmSync(tmp, { recursive: true, force: true }) writeChangeCorpus(root) const cold = impact({ kind: 'actor', target: { title: 'Assistante commerciale' } }) expect(cold.impact.find((s) => s.cli?.cliPath.includes('derive-rbac-grants'))!.status).toBe('skipped') }) it('op=modify allocates nothing and carries the rename → derive-seed-delta warning', () => { writeChangeCorpus(root) const r = impact({ kind: 'actor', op: 'modify', target: { code: 'BA-001-AC-001' } }) expect(r.blocked).toEqual([]) expect(r.allocation).toBeNull() expect(r.existing.resolved?.code).toBe('BA-001-AC-001') expect(r.impact.some((s) => /derive-seed-delta/.test(s.title))).toBe(true) }) it('an unknown code on modify is target-not-found', () => { writeChangeCorpus(root) expect(impact({ kind: 'actor', op: 'modify', target: { code: 'BA-001-AC-042' } }).blocked[0]!.code).toBe('target-not-found') }) }) describe('permission — impact', () => { it('a complete valid tuple skips the precondition and reports grain, action, floor membership and reach', () => { writeChangeCorpus(root) const r = impact({ kind: 'permission', target: { actor: 'BA-001-AC-001', permissionPath: 'pipeline.opportunites.export', portee: 'toutes' } }) expect(r.blocked).toEqual([]) expect(r.allocation).toBeNull() expect(r.owner).toMatchObject({ file: 'CRM/PIPELINE/rbac.md', writeDiscipline: 'table-row-append' }) expect(r.owner.machineBlocksHash).toMatch(/^[0-9a-f]{64}$/) const pre = r.impact[0]! expect(pre.status).toBe('skipped') expect(pre.data).toMatchObject({ grain: 'section', action: 'export', inFloor: false }) const reach = r.impact.find((s) => s.gateRules.includes('RBAC-002'))! expect(reach.data).toMatchObject({ hasAccess: false, hasRead: true, actorActionsOnNode: ['create', 'read'] }) expect(r.impact.filter((s) => s.phase === 'derive').map((s) => s.cli!.spec['mode'])).toEqual(['check', 'check']) }) it('the same tuple already present is reported; a floor action is said to be in the floor', () => { writeChangeCorpus(root) const r = impact({ kind: 'permission', target: { actor: 'Commercial', permissionPath: 'pipeline.opportunites.read', portee: 'toutes' } }) expect(r.existing.exact.map((m) => m.reason)).toEqual(['same-tuple']) expect(r.impact[0]!.data).toMatchObject({ inFloor: true }) expect(r.warnings.some((w) => /same title|identity/.test(w))).toBe(true) }) it('op=modify resolves the row and adds the revocation → derive-seed-delta step', () => { writeChangeCorpus(root) const r = impact({ kind: 'permission', op: 'modify', target: { actor: 'BA-001-AC-002', permissionPath: 'pipeline.opportunites.approve', portee: 'toutes' } }) expect(r.blocked).toEqual([]) expect(r.existing.resolved?.reason).toBe('same-actor-path') expect(r.impact.some((s) => /revoked grant/.test(s.title))).toBe(true) }) }) describe('permission — blocked, fail-closed', () => { it('unknown actor → actor-not-found routed to kind=actor', () => { writeChangeCorpus(root) const r = impact({ kind: 'permission', target: { actor: 'Stagiaire', permissionPath: 'pipeline.opportunites.read' } }) expect(r.blocked.map((b) => b.code)).toEqual(['actor-not-found']) expect(r.blocked[0]!.routeTo).toEqual({ skill: 'ba-change', kind: 'actor' }) expect(r.impact).toEqual([]) }) it('an app-prefixed path is refused with the corrected path', () => { writeChangeCorpus(root) const r = impact({ kind: 'permission', target: { actor: 'BA-001-AC-001', permissionPath: 'crm.pipeline.opportunites.read' } }) expect(r.blocked[0]!.code).toBe('permission-path-app-prefixed') expect(r.blocked[0]!.reason).toContain('`pipeline.opportunites.read`') }) it('an unknown action, a bare app-grain path and an unknown Portée are refused', () => { writeChangeCorpus(root) expect(impact({ kind: 'permission', target: { actor: 'BA-001-AC-001', permissionPath: 'pipeline.opportunites.validate' } }).blocked[0]!.code).toBe('permission-path-invalid') expect(impact({ kind: 'permission', target: { actor: 'BA-001-AC-001', permissionPath: 'read' } }).blocked[0]!.code).toBe('permission-path-invalid') expect(impact({ kind: 'permission', target: { actor: 'BA-001-AC-001', permissionPath: 'pipeline.opportunites.read', portee: 'mine' } }).blocked[0]!.code).toBe('portee-unknown') }) it('the read.all tier on a section is a valid human row path', () => { writeChangeCorpus(root) const r = impact({ kind: 'permission', target: { actor: 'BA-001-AC-002', permissionPath: 'pipeline.opportunites.read.all', portee: 'toutes' } }) expect(r.blocked).toEqual([]) expect(r.impact[0]!.data).toMatchObject({ action: 'read.all', grain: 'section' }) }) it('op=modify without the tuple, and verify without the actor, are usage errors', () => { writeChangeCorpus(root) expect(validateSpec({ baRoot: root, app: 'CRM', module: 'PIPELINE', kind: 'permission', op: 'modify', target: { actor: 'BA-001-AC-001' } }).errors[0]).toMatch(/target\.actor AND target\.permissionPath/) expect(validateSpec({ baRoot: root, app: 'CRM', module: 'PIPELINE', kind: 'permission', mode: 'verify', verify: { expectCode: 'x', baselineCount: 0 } }).errors[0]).toMatch(/target\.actor/) }) }) describe('verify — actor and permission', () => { it('an appended actor block → found, delta +1, ok; a near-miss heading is lost', () => { writeChangeCorpus(root) const base = impact({ kind: 'actor', target: { title: 'Assistante commerciale' } }) const crm = join(root, 'CRM', 'acteur.md') writeFileSync(crm, `${readFileSync(crm, 'utf8')}\n### BA-001-AC-006 — Assistante commerciale\n- **Type** : internal\n- **Description** : Assiste.\n- **Origine** : conversation\n`, 'utf8') const ok = verify({ kind: 'actor', verify: { expectCode: 'BA-001-AC-006', baselineCount: base.existing.count, machineBlocksHash: base.owner.machineBlocksHash } }) expect(ok.verify).toMatchObject({ found: true, count: 3, delta: 1, lost: [], duplicates: [], machineBlocksIntact: true, ok: true }) writeFileSync(crm, `${readFileSync(crm, 'utf8')}\n### BA-001-AC-007\n- **Type** : internal\n`, 'utf8') const ko = verify({ kind: 'actor', verify: { expectCode: 'BA-001-AC-006', baselineCount: base.existing.count } }) expect(ko.verify!.lost.some((l) => /near-miss/.test(l))).toBe(true) expect(ko.verify!.ok).toBe(false) }) it('an appended row → found, delta +1, machine blocks intact; a touched machine block or a near-miss row fails', () => { writeChangeCorpus(root) const base = impact({ kind: 'permission', target: { actor: 'BA-001-AC-001', permissionPath: 'pipeline.opportunites.export', portee: 'toutes' } }) const rbac = join(root, 'CRM', 'PIPELINE', 'rbac.md') const original = readFileSync(rbac, 'utf8') const approveRow = '| BA-001-AC-002 (Manager commercial) | `pipeline.opportunites.approve` | équipe |\n' writeFileSync(rbac, original.replace(approveRow, `${approveRow}| BA-001-AC-001 (Commercial) | \`pipeline.opportunites.export\` | toutes |\n`), 'utf8') const spec = { kind: 'permission' as const, target: { actor: 'BA-001-AC-001' }, verify: { expectCode: 'pipeline.opportunites.export', baselineCount: base.existing.count, machineBlocksHash: base.owner.machineBlocksHash }, } const ok = verify(spec) expect(ok.verify).toMatchObject({ found: true, count: 4, delta: 1, machineBlocksIntact: true, sourcesCited: 'not-required', ok: true }) // the derived block edited by hand → intact false writeFileSync(rbac, readFileSync(rbac, 'utf8').replace('FK ClientId', 'FK Client'), 'utf8') expect(verify(spec).verify).toMatchObject({ machineBlocksIntact: false, ok: false }) // a row that does not parse (missing backticks) → lost, not found writeFileSync(rbac, `${original}\n| BA-001-AC-001 (Commercial) | pipeline.opportunites.export | toutes |\n`, 'utf8') const lost = verify(spec) expect(lost.verify!.found).toBe(false) expect(lost.verify!.lost.some((l) => /near-miss row/.test(l))).toBe(true) }) })