/** * 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, extractPersonSources, extractStructureKinds, findPersonSourceViolationsInFile, findStructureKindViolationsInFile, 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; `, ); mkdirSync(join(root, 'apps/portal-bff/src/structures'), { recursive: true }); writeFileSync( join(root, 'apps/portal-bff/src/structures/structure-kind.ts'), `export const STRUCTURE_KINDS = [ 'medico_social', 'antenne', 'siege', ] as const; export type StructureKind = (typeof STRUCTURE_KINDS)[number]; `, ); mkdirSync(join(root, 'apps/portal-bff/src/users'), { recursive: true }); writeFileSync( join(root, 'apps/portal-bff/src/users/person-source.ts'), `export const PERSON_SOURCES = [ 'self-signin', 'admin-ui', 'seed', ] as const; export type PersonSource = (typeof PERSON_SOURCES)[number]; `, ); 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('extractStructureKinds', () => { it('reads the STRUCTURE_KINDS catalogue from structure-kind.ts', () => { const root = makeFixture({ files: {} }); const kinds = extractStructureKinds( join(root, 'apps/portal-bff/src/structures/structure-kind.ts'), ); assert.deepEqual([...kinds].sort(), ['antenne', 'medico_social', 'siege']); }); it('throws when the constant is missing', () => { const root = mkdtempSync(join(tmpdir(), 'drift-fixture-')); mkdirSync(join(root, 'apps/portal-bff/src/structures'), { recursive: true }); writeFileSync( join(root, 'apps/portal-bff/src/structures/structure-kind.ts'), `export const NOT_STRUCTURE_KINDS = ['foo'] as const;\n`, ); assert.throws( () => extractStructureKinds(join(root, 'apps/portal-bff/src/structures/structure-kind.ts')), /STRUCTURE_KINDS/, ); }); }); describe('findStructureKindViolationsInFile', () => { const validKinds = new Set(['medico_social', 'antenne', 'siege']); it('skips files that do not import structure-kind (would false-positive)', () => { // `kind: 'whatever'` is a common pattern on unrelated objects. // Without the import filter, every such literal would be flagged. const text = ` const event = { kind: 'somenoise', payload: {} }; `; const violations = findStructureKindViolationsInFile('virtual.ts', validKinds, text); assert.equal(violations.length, 0); }); it('returns no violations when every kind literal is in the catalogue', () => { const text = ` import type { StructureKind } from '../structures/structure-kind'; const a = { kind: 'medico_social' as StructureKind }; const b = { kind: 'siege' as StructureKind }; `; const violations = findStructureKindViolationsInFile('virtual.ts', validKinds, text); assert.equal(violations.length, 0); }); it('flags every kind literal not in the catalogue', () => { const text = ` import { STRUCTURE_KINDS } from '../structures/structure-kind'; const a = { kind: 'medico_social' }; const b = { kind: 'medical_social' }; const c = { kind: 'sanitaire' }; `; const violations = findStructureKindViolationsInFile('virtual.ts', validKinds, text); assert.equal(violations.length, 2); const values = violations.map((v) => v.value).sort(); assert.deepEqual(values, ['medical_social', 'sanitaire']); assert.equal(violations[0].callee, 'Structure.kind'); assert.equal(violations[0].catalogue, 'STRUCTURE_KINDS'); }); it('skips non-literal initialisers (variable indirection)', () => { // The TypeScript signature catches this at compile time when the // target type is `StructureKind` — the gate is best-effort here. const text = ` import { STRUCTURE_KINDS } from '../structures/structure-kind'; const k = 'something'; const a = { kind: k }; `; const violations = findStructureKindViolationsInFile('virtual.ts', validKinds, text); assert.equal(violations.length, 0); }); it('reports line and column of the offending literal', () => { const text = [ `import { STRUCTURE_KINDS } from '../structures/structure-kind';`, `const x = {`, ` kind: 'rogue-kind',`, `};`, ].join('\n'); const violations = findStructureKindViolationsInFile('virtual.ts', validKinds, text); assert.equal(violations.length, 1); assert.equal(violations[0].line, 3); // 'rogue-kind' literal starts at column 9 (0-indexed 8, +1 in the report). assert.equal(violations[0].column, 9); }); }); describe('extractPersonSources', () => { it('reads the PERSON_SOURCES catalogue from person-source.ts', () => { const root = makeFixture({ files: {} }); const sources = extractPersonSources(join(root, 'apps/portal-bff/src/users/person-source.ts')); assert.deepEqual([...sources].sort(), ['admin-ui', 'seed', 'self-signin']); }); it('throws when the constant is missing', () => { const root = mkdtempSync(join(tmpdir(), 'drift-fixture-')); mkdirSync(join(root, 'apps/portal-bff/src/users'), { recursive: true }); writeFileSync( join(root, 'apps/portal-bff/src/users/person-source.ts'), `export const NOT_PERSON_SOURCES = ['foo'] as const;\n`, ); assert.throws( () => extractPersonSources(join(root, 'apps/portal-bff/src/users/person-source.ts')), /PERSON_SOURCES/, ); }); }); describe('findPersonSourceViolationsInFile', () => { const validSources = new Set(['self-signin', 'admin-ui', 'seed']); it('skips files that do not import person-source (would false-positive)', () => { // `source: 'X'` is an extremely common pattern (event sources, log // sources, etc.). Without the import filter, every such literal // would be flagged. const text = ` const evt = { source: 'logger', message: 'hello' }; `; const violations = findPersonSourceViolationsInFile('virtual.ts', validSources, text); assert.equal(violations.length, 0); }); it('returns no violations when every source literal is in the catalogue', () => { const text = ` import type { PersonSource } from '../users/person-source'; const a = { source: 'self-signin' as PersonSource }; const b = { source: 'seed' as PersonSource }; `; const violations = findPersonSourceViolationsInFile('virtual.ts', validSources, text); assert.equal(violations.length, 0); }); it('flags every source literal not in the catalogue', () => { const text = ` import { PERSON_SOURCES } from '../users/person-source'; const a = { source: 'self-signin' }; const b = { source: 'pleiades' }; const c = { source: 'rogue-source' }; `; const violations = findPersonSourceViolationsInFile('virtual.ts', validSources, text); assert.equal(violations.length, 2); const values = violations.map((v) => v.value).sort(); assert.deepEqual(values, ['pleiades', 'rogue-source']); assert.equal(violations[0].callee, 'Person.source'); assert.equal(violations[0].catalogue, 'PERSON_SOURCES'); }); it('skips non-literal initialisers (variable indirection)', () => { const text = ` import { PERSON_SOURCES } from '../users/person-source'; const s = 'something'; const a = { source: s }; `; const violations = findPersonSourceViolationsInFile('virtual.ts', validSources, text); assert.equal(violations.length, 0); }); it('reports line and column of the offending literal', () => { const text = [ `import { PERSON_SOURCES } from '../users/person-source';`, `const x = {`, ` source: 'rogue-source',`, `};`, ].join('\n'); const violations = findPersonSourceViolationsInFile('virtual.ts', validSources, text); assert.equal(violations.length, 1); assert.equal(violations[0].line, 3); // 'rogue-source' literal starts at column 11 (0-indexed 10, +1 in the report). assert.equal(violations[0].column, 11); }); }); 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); }); it('aggregates decorator + Structure.kind + Person.source violations together', () => { const root = makeFixture({ files: { 'apps/portal-bff/src/foo.ts': ` import { STRUCTURE_KINDS } from './structures/structure-kind'; import { PERSON_SOURCES } from './users/person-source'; @RequirePrivilege('Portal.Ghost') class Bad {} const s = { kind: 'phantom_kind' }; const p = { source: 'rogue-source' }; `, }, }); const { violations } = scanWorkspace(root); assert.equal(violations.length, 3); const values = violations.map((v) => v.value).sort(); assert.deepEqual(values, ['Portal.Ghost', 'phantom_kind', 'rogue-source']); }); it('returns the structureKinds and personSources sets alongside catalogues', () => { const root = makeFixture({ files: {} }); const { structureKinds, personSources } = scanWorkspace(root); assert.deepEqual([...structureKinds].sort(), ['antenne', 'medico_social', 'siege']); assert.deepEqual([...personSources].sort(), ['admin-ui', 'seed', 'self-signin']); }); });