/** * extract-doc / access-roles — loadAccessSources (read-only FS loader). * * The contract under test: * - the core-seed state is located by matching the PARSED `application` field * (case-insensitive) against the app-code candidates — never by file name; * - with no candidate and exactly ONE state file, that file is used * (single-app project); * - the BA rbac.md resolves case-insensitively (lowercase nav codes vs * UPPERCASE BA folders); * - every absence degrades to null + a note in warnings, NEVER a throw. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { loadAccessSources } from '../access-roles.js' const STATE_JSON = JSON.stringify({ $schema: 'smartstack/core-seed-state', version: 2, application: 'CRM', specHash: 'deadbeefdeadbeefdeadbeefdeadbeef', navigation: [], roles: [{ code: 'commercial', name: 'Commercial', isDefault: true }], permissions: [], rolePermissions: [{ roleCode: 'commercial', permissionPath: 'crm.pipeline.opportunites.read' }], }) const RBAC_MD = ` | Acteur | Permission | Portée | |--------|------------|--------| | BA-001-AC-001 (Commercial) | \`pipeline.opportunites.read\` | toutes | ` describe('access-roles / loadAccessSources', () => { let root: string beforeAll(() => { root = mkdtempSync(join(tmpdir(), 'access-io-')) mkdirSync(join(root, '.smartstack', 'core-seed'), { recursive: true }) writeFileSync(join(root, '.smartstack', 'core-seed', 'crm.state.json'), STATE_JSON, 'utf8') mkdirSync(join(root, '.smartstack', 'ba', 'CRM', 'PIPELINE'), { recursive: true }) writeFileSync(join(root, '.smartstack', 'ba', 'CRM', 'PIPELINE', 'rbac.md'), RBAC_MD, 'utf8') }) afterAll(() => { rmSync(root, { recursive: true, force: true }) }) it('finds both sources from lowercase nav codes', async () => { const warnings: string[] = [] const { state, rbacRows } = await loadAccessSources(root, ['crm'], 'pipeline', warnings) expect(state?.application).toBe('CRM') expect(rbacRows).toHaveLength(1) expect(warnings).toEqual([]) }) it('uses the single state file when no app-code candidate is known', async () => { const warnings: string[] = [] const { state } = await loadAccessSources(root, [], 'pipeline', warnings) expect(state?.application).toBe('CRM') }) it('warns (and returns null state) when no state matches the app code', async () => { const warnings: string[] = [] const { state } = await loadAccessSources(root, ['hr'], 'pipeline', warnings) expect(state).toBeNull() expect(warnings.some((w) => w.includes('none matches the app code'))).toBe(true) }) it('warns (and returns null rows) when the module has no rbac.md', async () => { const warnings: string[] = [] const { rbacRows } = await loadAccessSources(root, ['crm'], 'absent-module', warnings) expect(rbacRows).toBeNull() expect(warnings.some((w) => w.includes('No BA rbac.md'))).toBe(true) }) it('degrades to nulls + notes on a project with neither source (never throws)', async () => { const bare = mkdtempSync(join(tmpdir(), 'access-io-bare-')) try { const warnings: string[] = [] const { state, rbacRows } = await loadAccessSources(bare, ['crm'], 'pipeline', warnings) expect(state).toBeNull() expect(rbacRows).toBeNull() expect(warnings.some((w) => w.includes('core-seed'))).toBe(true) expect(warnings.some((w) => w.includes('.smartstack/ba'))).toBe(true) } finally { rmSync(bare, { recursive: true, force: true }) } }) })