/** * Tests for `check-catalogue-drift.mjs`. Run via: * node --test scripts/check-catalogue-drift.spec.mjs * * Uses Node's built-in test runner (no Vitest / Jest dependency) * because the script itself has no per-app project hosting it; * shipping a third test runner just for this one file would be * overkill. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { extractCatalogues, findViolationsInFile, scanWorkspace, } from './check-catalogue-drift.mjs'; /** * Build a self-contained workspace fixture with: * - a synthesised authorization.types.ts file mirroring the real * catalogue declarations, * - a bag of .ts files under apps/ + libs/ exercising the * decorators with whichever values the caller asks for. */ function makeFixture({ files }) { const root = mkdtempSync(join(tmpdir(), 'drift-fixture-')); mkdirSync(join(root, 'libs/shared/auth/src/lib'), { recursive: true }); writeFileSync( join(root, 'libs/shared/auth/src/lib/authorization.types.ts'), `export const PRIVILEGES = [ 'Portal.Admin', 'Portal.Auditor', ] as const; export const FUNCTIONAL_ROLES = [ 'collaborateur', 'rh', 'dpo', ] as const; export const SCOPE_KINDS = [ 'self', 'unrestricted', ] as const; `, ); for (const [relativePath, contents] of Object.entries(files)) { const full = join(root, relativePath); mkdirSync(join(full, '..'), { recursive: true }); writeFileSync(full, contents); } return root; } describe('extractCatalogues', () => { it('reads the three closed catalogues from authorization.types.ts', () => { const root = makeFixture({ files: {} }); const cataloguePath = join(root, 'libs/shared/auth/src/lib/authorization.types.ts'); const cats = extractCatalogues(cataloguePath); assert.deepEqual([...cats.PRIVILEGES].sort(), ['Portal.Admin', 'Portal.Auditor']); assert.deepEqual([...cats.FUNCTIONAL_ROLES].sort(), ['collaborateur', 'dpo', 'rh']); }); it('throws when the catalogue file omits an expected declaration', () => { const root = mkdtempSync(join(tmpdir(), 'drift-fixture-')); mkdirSync(join(root, 'libs/shared/auth/src/lib'), { recursive: true }); writeFileSync( join(root, 'libs/shared/auth/src/lib/authorization.types.ts'), `export const PRIVILEGES = ['Portal.Admin'] as const;\n`, ); assert.throws( () => extractCatalogues(join(root, 'libs/shared/auth/src/lib/authorization.types.ts')), /FUNCTIONAL_ROLES/, ); }); }); describe('findViolationsInFile', () => { const catalogues = { PRIVILEGES: new Set(['Portal.Admin', 'Portal.Auditor']), FUNCTIONAL_ROLES: new Set(['collaborateur', 'rh', 'dpo']), }; it('returns no violations on a file that uses only catalogue values', () => { const text = ` class Foo { @RequirePrivilege('Portal.Admin') @RequireRole('rh', 'collaborateur') method() {} } `; const violations = findViolationsInFile('virtual.ts', catalogues, text); assert.equal(violations.length, 0); }); it('flags every off-catalogue privilege value', () => { const text = ` class Foo { @RequirePrivilege('Portal.Admin', 'Portal.Bogus') method() {} } `; const violations = findViolationsInFile('virtual.ts', catalogues, text); assert.equal(violations.length, 1); assert.equal(violations[0].callee, 'RequirePrivilege'); assert.equal(violations[0].value, 'Portal.Bogus'); assert.equal(violations[0].catalogue, 'PRIVILEGES'); }); it('flags every off-catalogue role value', () => { const text = ` class Foo { @RequireRole('rh', 'rogue-role') method() {} } `; const violations = findViolationsInFile('virtual.ts', catalogues, text); assert.equal(violations.length, 1); assert.equal(violations[0].callee, 'RequireRole'); assert.equal(violations[0].value, 'rogue-role'); assert.equal(violations[0].catalogue, 'FUNCTIONAL_ROLES'); }); it('skips non-literal arguments (variable indirection)', () => { // A real-world escape hatch — TypeScript catches this at // compile time via the decorator signature, the drift gate is // best-effort here. const text = ` const slug = 'rogue-role'; class Foo { @RequireRole(slug) method() {} } `; const violations = findViolationsInFile('virtual.ts', catalogues, text); assert.equal(violations.length, 0); }); it('also detects the literal when the decorator is invoked as a function call (non-decorator usage)', () => { // A test fixture might call RequirePrivilege as a function to // produce a decorator value for a manual UseGuards pattern; the // call site shape is identical so the script catches it. const text = ` const dec = RequirePrivilege('Portal.Bogus'); `; const violations = findViolationsInFile('virtual.ts', catalogues, text); assert.equal(violations.length, 1); assert.equal(violations[0].value, 'Portal.Bogus'); }); it('reports line and column of the offending literal', () => { const text = ['line1', 'line2', " @RequireRole('rogue-role')"].join('\n'); const violations = findViolationsInFile('virtual.ts', catalogues, text); assert.equal(violations.length, 1); assert.equal(violations[0].line, 3); // The literal starts at column 16 (0-indexed 15, +1 in the report). assert.equal(violations[0].column, 16); }); }); describe('scanWorkspace', () => { it('returns 0 violations on a clean fixture', () => { const root = makeFixture({ files: { 'apps/x/src/main.ts': ` class Foo { @RequirePrivilege('Portal.Admin') @RequireRole('rh', 'collaborateur') m() {} } `, }, }); const { violations } = scanWorkspace(root); assert.equal(violations.length, 0); }); it('aggregates violations across multiple files', () => { const root = makeFixture({ files: { 'apps/x/src/a.ts': ` @RequirePrivilege('Portal.Ghost') class A {} `, 'libs/y/src/b.ts': ` @RequireRole('phantom-role') class B {} `, }, }); const { violations } = scanWorkspace(root); assert.equal(violations.length, 2); const values = violations.map((v) => v.value).sort(); assert.deepEqual(values, ['Portal.Ghost', 'phantom-role']); }); it('skips dist/ and node_modules/ folders even when they contain decorator calls', () => { const root = makeFixture({ files: { 'apps/x/dist/built.ts': `@RequireRole('rogue-role') class X {}`, 'apps/x/node_modules/whatever/foo.ts': `@RequireRole('also-rogue') class Y {}`, }, }); const { violations } = scanWorkspace(root); assert.equal(violations.length, 0); }); it('skips the generated gRPC stubs (different `roles` semantics)', () => { const root = makeFixture({ files: { 'apps/portal-bff/src/grpc/gen/apf-ai/common.ts': ` // Codegen output: this is NOT an ADR-0025 decorator call, // it just happens to share an identifier. @RequireRole('not-a-real-role') class FromGen {} `, }, }); const { violations } = scanWorkspace(root); assert.equal(violations.length, 0); }); it('does NOT skip spec files (deliberate decorator usage in tests must stay in sync)', () => { const root = makeFixture({ files: { 'libs/y/src/y.spec.ts': ` @RequireRole('rogue-role') class TestSubject {} `, }, }); const { violations } = scanWorkspace(root); assert.equal(violations.length, 1); }); });