From ba4cdcee7a79e094508e290b70d8a0e12e0d11b2 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Tue, 26 May 2026 11:15:09 +0200 Subject: [PATCH] feat(structures): add Region/Delegation/Structure schema + seed (ADR-0027 PR 1) (#228) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary First implementation PR for [ADR-0027](docs/decisions/0027-portal-side-organisational-hierarchy.md) (`Region` / `Delegation` / `Structure` portal-side organisational hierarchy). **Schema + seed + drift-gate extension only** — no consumer code yet (the `PrismaScopeResolver` that dereferences `Structure.code` from `UserScope.value` lives in ADR-0026 PR 2, which depends on this PR landing first). Independent of ADR-0026 PR 1 at the schema level — both can ship in parallel; ADR-0026 PR 2 depends on both. ## What lands | File | Change | | --- | --- | | `apps/portal-bff/prisma/schema.prisma` | **+3 models**. `Region` (INSEE code PK + name + delegations[]). `Delegation` (dept code PK + name + regionCode FK + structures[]). `Structure` (portal code PK + name + kind discriminator + nullable unique finess/siret/codePaie + nullable delegationCode FK). All in the `public` schema; matches the ADR-0027 schema sketch verbatim. | | `apps/portal-bff/prisma/migrations/20260526143000_add_org_hierarchy/migration.sql` | **New** hand-written migration. DDL for the 3 tables. `CHECK ("kind" IN (...))` constraint mirroring `STRUCTURE_KINDS`. Indexes (FK columns + kind + uniques). Inline INSERT seed: Région Nouvelle-Aquitaine (75), Délégation Gironde (33), structures `0330800013` + `0330800021` (médico-social, FINESS = code) + `siege` (APF national, no delegation/finess). | | `apps/portal-bff/src/structures/structure-kind.ts` | **New**. Closed-set catalogue: `STRUCTURE_KINDS = [medico_social, antenne, dispositif, entreprise_adaptee, mouvement, administratif, siege] as const`. `type StructureKind` derived from the union, `isStructureKind` type guard. | | `apps/portal-bff/src/structures/structure-kind.spec.ts` | **New**. Jest spec — catalogue content, no duplicates, type guard true for catalogue values + false for typos / cascade-only values / empty, type narrowing at call site. | | `scripts/check-catalogue-drift.mjs` | **Extend**. New `extractStructureKinds(path)` + `findStructureKindViolationsInFile(path, validKinds, sourceText?)`. Property-literal scanner: detects `kind: 'X'` in object literals, restricted to files that import from `structure-kind.ts` (cheap text pre-filter — `kind` is a common property name on unrelated objects and we'd false-positive everywhere otherwise). Integrated into `scanWorkspace`. Error-message formatter switched on callee shape (`@Foo('x')` for decorators, `kind: 'x'` for property literals). Closing hint updated to reference both ADR-0025 and ADR-0027 catalogue locations. | | `scripts/check-catalogue-drift.spec.mjs` | **Extend**. Fixture writes a synthesised `structure-kind.ts` alongside `authorization.types.ts`. New tests: extract STRUCTURE_KINDS, throw on missing constant, skip files without the import, no-violation on catalogue values, flag off-catalogue values, skip non-literal initialisers, line/column tracking, scanWorkspace aggregation (decorator + Structure.kind together), scanWorkspace exposes the structureKinds set. **22 tests total, all passing.** | ## Defense in depth Three layers stack for `Structure.kind`, deliberately: 1. **TypeScript type union** `StructureKind` — compile-time check at every typed assignment. 2. **Postgres `CHECK` constraint** in the migration — runtime enforcement at INSERT / UPDATE, defends raw SQL / casts / untrusted API input. 3. **`scripts/check-catalogue-drift.mjs`** — CI gate asserting every `kind: 'X'` literal in structure-context files is in the catalogue. The `Privilege` / `FunctionalRole` catalogues from ADR-0025 only have layer 1 + layer 3 (no DB enforcement — those values aren't persisted as schema-checked columns). ADR-0027's `Structure.kind` is persisted, so layer 2 was practical to add — bulletproof against any code path that bypasses the type system. ## Seed scope (and what's deliberately NOT in it) Just what the 19 test-tenant personas reference per `notes/test-tenant-role-assignments.md`: - `Region` 75 Nouvelle-Aquitaine (only region the personas exercise) - `Delegation` 33 Gironde (only delegation) - `Structure` 0330800013 (APF Bordeaux, medico-social, FINESS = code) - `Structure` 0330800021 (Complexe Mérignac, medico-social) - `Structure` `siege` (APF national, kind=siege, no FK to a delegation, no FINESS) **No** placeholder `entreprise_adaptee`, `antenne`, or `dispositif` row — those kinds are valid per the catalogue but the test tenant doesn't exercise them, and adding gold-plate seed data would be misleading ("what is this row used for?"). The full APF inventory lands with [ADR-0029](#)'s cascade sync; this seed is **superseded** (not extended) by that sync. ## Notes for the reviewer - **Naming conflict with the existing `User` model.** The current `schema.prisma` already has a `User` model — but it's the ADR-0020 user-directory cache (Entra `oid` as PK, written by `UserDirectoryService.recordSignIn`). ADR-0026 PR 1 introduces a different `User` (UUID PK, FK to `Person`, `lastSignInAt`). **Out of scope here** — ADR-0027 PR 1 doesn't touch `User`. Flagging now so ADR-0026 PR 1 can plan the migration path (likely: rename the existing `User` to `UserDirectoryEntry` or fold it into the new Person + User pair). - **Migration is hand-written**, matching the style of the two existing migrations (`init_audit_schema`, `users_directory`). Timestamp `20260526143000` chosen so it sorts after the existing `20260514192014_users_directory`. - **`@@schema("public")`** required on every new model because the audit log uses `multiSchema` (per ADR-0013) — the public/audit split is configured at the datasource. - **Drift gate error-message formatter** now switches on callee shape — decorator violations still print as `@Foo('x')`, property-literal violations print as `kind: 'x'` to match the offending code shape. - **No `pnpm ci:check` impact expected** at the bff level beyond the new spec; `pnpm ci:catalogue-drift` continues to report clean (`catalogues: 4 privileges, 24 roles, 7 structure kinds`). ## Test plan - [x] `node scripts/check-catalogue-drift.mjs` — clean (4 / 24 / 7). - [x] `node --test scripts/check-catalogue-drift.spec.mjs` — 22 tests passing. - [x] `pnpm exec prettier --check` clean on the touched files. - [ ] **On the dev VM**: `pnpm prisma migrate dev` applies the migration cleanly against a fresh `infra/local/dev.compose.yml` postgres. `pnpm prisma studio` shows the seeded Region / Delegation / Structure rows. - [ ] **On the dev VM**: `pnpm exec nx test portal-bff` runs the new `structure-kind.spec.ts` green. - [ ] **Review focus** — the `Structure` model shape (kind discriminator, nullable unique columns, FK to Delegation), the inline seed values vs `notes/test-tenant-role-assignments.md`, the drift-gate property-literal scanner's restriction to files importing from `structure-kind.ts`. ## What's next Per [ADR-0027 §"Phasing"](docs/decisions/0027-portal-side-organisational-hierarchy.md): 1. **This PR** — Region / Delegation / Structure schema + seed + drift gate. ✅ 2. **ADR-0026 PR 1** — `Person` / `User` / `UserScope` schema + `PersonAndUserProvisioner` + drift gate extension for `Person.source` + updated `PrincipalBuilder`. Independent of (1), can ship in parallel. **Needs to resolve the existing-`User`-name collision.** 3. **ADR-0026 PR 2** — `PrismaScopeResolver` replacing `StubScopeResolver` + `/admin/users/:id/scopes` admin screen + `prisma/seed.ts` populating the 19 personas' `user_scopes` rows pointing at this PR's `Structure.code` values. **Depends on both (1) and (2).** 4. **ADR-0029** (future) — Pléiades + Acteurs+ + cascade syncs + facet schemas + `Pole` / `Service` / per-source enrichment extensions to this PR's hierarchy. --------- Co-authored-by: Julien Gautier Reviewed-on: https://git.unespace.com/julien/apf_portal/pulls/228 --- .../migration.sql | 117 +++++++++++++++++ apps/portal-bff/prisma/schema.prisma | 88 +++++++++++++ .../src/structures/structure-kind.spec.ts | 45 +++++++ .../src/structures/structure-kind.ts | 36 +++++ scripts/check-catalogue-drift.mjs | 114 ++++++++++++++-- scripts/check-catalogue-drift.spec.mjs | 124 ++++++++++++++++++ 6 files changed, 515 insertions(+), 9 deletions(-) create mode 100644 apps/portal-bff/prisma/migrations/20260526143000_add_org_hierarchy/migration.sql create mode 100644 apps/portal-bff/src/structures/structure-kind.spec.ts create mode 100644 apps/portal-bff/src/structures/structure-kind.ts diff --git a/apps/portal-bff/prisma/migrations/20260526143000_add_org_hierarchy/migration.sql b/apps/portal-bff/prisma/migrations/20260526143000_add_org_hierarchy/migration.sql new file mode 100644 index 0000000..49c5cc2 --- /dev/null +++ b/apps/portal-bff/prisma/migrations/20260526143000_add_org_hierarchy/migration.sql @@ -0,0 +1,117 @@ +-- Organisational hierarchy (per ADR-0027). +-- +-- Region → Delegation → Structure. The portal's ADR-0025 scope-axis +-- dereferences these three layers — the BFF guard +-- `principalCoversResource` walks etablissement → delegation → region +-- to decide whether a scope covers a resource. The schema lives in +-- the public schema; ADR-0029 will populate the full APF inventory +-- via the cascade sync, additively into the columns defined here. +-- +-- Inline seed at the bottom of this file: just the codes the +-- test tenant exercises (Région Nouvelle-Aquitaine, Délégation +-- Gironde, two médico-social structures + the APF national siège). +-- Superseded — not extended — by ADR-0029's cascade sync once it +-- ships. + +-- CreateTable +CREATE TABLE "regions" ( + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "regions_pkey" PRIMARY KEY ("code") +); + +-- CreateTable +CREATE TABLE "delegations" ( + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "region_code" TEXT NOT NULL, + + CONSTRAINT "delegations_pkey" PRIMARY KEY ("code") +); + +-- Closed-set CHECK on Structure.kind. Mirrors STRUCTURE_KINDS in +-- apps/portal-bff/src/structures/structure-kind.ts. The CI drift gate +-- (scripts/check-catalogue-drift.mjs) asserts the TS constant matches +-- the values used in code; this CHECK constraint provides the +-- equivalent guarantee at the DB layer for any code path that +-- bypasses the type system. +CREATE TABLE "structures" ( + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "finess" TEXT, + "siret" TEXT, + "code_paie" TEXT, + "delegation_code" TEXT, + + CONSTRAINT "structures_pkey" PRIMARY KEY ("code"), + CONSTRAINT "structures_kind_check" CHECK ( + "kind" IN ( + 'medico_social', + 'antenne', + 'dispositif', + 'entreprise_adaptee', + 'mouvement', + 'administratif', + 'siege' + ) + ) +); + +-- CreateIndex +CREATE INDEX "delegations_region_code_idx" ON "delegations"("region_code"); + +-- CreateIndex +CREATE UNIQUE INDEX "structures_finess_key" ON "structures"("finess"); + +-- CreateIndex +CREATE UNIQUE INDEX "structures_siret_key" ON "structures"("siret"); + +-- CreateIndex +CREATE UNIQUE INDEX "structures_code_paie_key" ON "structures"("code_paie"); + +-- CreateIndex +CREATE INDEX "structures_kind_idx" ON "structures"("kind"); + +-- CreateIndex +CREATE INDEX "structures_delegation_code_idx" ON "structures"("delegation_code"); + +-- AddForeignKey +ALTER TABLE "delegations" ADD CONSTRAINT "delegations_region_code_fkey" + FOREIGN KEY ("region_code") REFERENCES "regions"("code") + ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "structures" ADD CONSTRAINT "structures_delegation_code_fkey" + FOREIGN KEY ("delegation_code") REFERENCES "delegations"("code") + ON DELETE RESTRICT ON UPDATE CASCADE; + +-- ------------------------------------------------------------------ +-- Test-tenant reference seed (per ADR-0027 §"Seeding posture"). +-- +-- Region Nouvelle-Aquitaine + Délégation Gironde + the structures +-- referenced by the 19 personas in notes/test-tenant-role-assignments.md: +-- - 0330800013 (APF Bordeaux, FINESS = code) +-- - 0330800021 (Complexe Mérignac, FINESS = code) +-- - 'siege' (APF national headquarters) +-- +-- Cascade-sync (ADR-0029) supersedes this seed entirely when it +-- ships — the cleanup migration responsible for that truncates and +-- repopulates from cascade's authoritative inventory. +-- ------------------------------------------------------------------ + +INSERT INTO "regions" ("code", "name") VALUES + ('75', 'Nouvelle-Aquitaine'); + +INSERT INTO "delegations" ("code", "name", "region_code") VALUES + ('33', 'Gironde', '75'); + +INSERT INTO "structures" ("code", "name", "kind", "finess", "delegation_code") VALUES + ('0330800013', 'APF Bordeaux', 'medico_social', '0330800013', '33'), + ('0330800021', 'Complexe Mérignac', 'medico_social', '0330800021', '33'); + +-- Siège has no delegation parent and no FINESS/SIRET/codePaie (those +-- columns stay NULL — the unique indexes tolerate multiple NULLs). +INSERT INTO "structures" ("code", "name", "kind") VALUES + ('siege', 'Siège APF France handicap', 'siege'); diff --git a/apps/portal-bff/prisma/schema.prisma b/apps/portal-bff/prisma/schema.prisma index ddfa345..4fce951 100644 --- a/apps/portal-bff/prisma/schema.prisma +++ b/apps/portal-bff/prisma/schema.prisma @@ -100,6 +100,94 @@ model User { @@index([username]) } +// ============================================================ +// Organisational hierarchy (per ADR-0027) +// ============================================================ +// +// Region → Delegation → Structure. The portal's scope-axis +// dereferences these three layers — the BFF guard +// `principalCoversResource` walks etablissement → delegation → +// region to decide whether a scope covers a resource (per +// ADR-0025). +// +// Population in v1: a small inline seed in the +// `add_org_hierarchy` migration (the codes the test tenant +// exercises). The full APF inventory ships with ADR-0029's +// cascade sync, which writes additively into these columns — +// no schema churn at sync time. + +model Region { + // INSEE region code (2 digits — '75' Nouvelle-Aquitaine, + // '11' Île-de-France). Externally meaningful and stable + // across reorgs; doubles as primary key. + code String @id + name String + + delegations Delegation[] + + @@map("regions") + @@schema("public") +} + +model Delegation { + // French department code (2-3 chars — '33' Gironde, '2A', + // '971'). Same rationale as Region.code. + code String @id + name String + + regionCode String @map("region_code") + region Region @relation(fields: [regionCode], references: [code]) + + structures Structure[] + + @@map("delegations") + @@schema("public") + @@index([regionCode]) +} + +model Structure { + // Portal-internal stable code, externally meaningful. For + // medico-social structures: code = FINESS (9 digits). For + // non-medico-social: APF-internal slug ('siege', + // 'apf-bdx-merignac', 'ea-toulouse', …). Opaque at the type + // level; matching is string equality, not parsing. + code String @id + name String + + // Closed set, drift-gated. Legal values are tracked in + // `apps/portal-bff/src/structures/structure-kind.ts` and + // mirrored by a Postgres CHECK constraint on this column + // (see the migration). `scripts/check-catalogue-drift.mjs` + // asserts the TS constant matches the values used in code. + kind String + + // FINESS (9 digits). NULL for non-medico-social structures. + // Unique when present. Cascade's StructureSourceFiness is + // the long-term authoritative carrier; the portal denormalises + // it inline here for v1 scope-axis checks. ADR-0029's sync + // owns the write path. + finess String? @unique + + // SIRET (14 chars: 9 SIREN + 5 NIC). NULL when the structure + // is not SIRENE-registered (most antennes, dispositifs). + // Unique when present. + siret String? @unique + + // Pléiades payroll code (6 chars). NULL in v1 — populated by + // ADR-0029's Pléiades sync once it ships. + codePaie String? @unique @map("code_paie") + + // Parent delegation. NULL for structures not attached to one + // (siège, mouvement national, …). + delegationCode String? @map("delegation_code") + delegation Delegation? @relation(fields: [delegationCode], references: [code]) + + @@map("structures") + @@schema("public") + @@index([kind]) + @@index([delegationCode]) +} + model AuditEvent { id String @id @default(uuid()) @db.Uuid createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) diff --git a/apps/portal-bff/src/structures/structure-kind.spec.ts b/apps/portal-bff/src/structures/structure-kind.spec.ts new file mode 100644 index 0000000..55e21e9 --- /dev/null +++ b/apps/portal-bff/src/structures/structure-kind.spec.ts @@ -0,0 +1,45 @@ +import { isStructureKind, STRUCTURE_KINDS, type StructureKind } from './structure-kind'; + +describe('STRUCTURE_KINDS', () => { + it('contains the seven cascade-aligned kinds per ADR-0027', () => { + expect(STRUCTURE_KINDS).toEqual([ + 'medico_social', + 'antenne', + 'dispositif', + 'entreprise_adaptee', + 'mouvement', + 'administratif', + 'siege', + ]); + }); + + it('has no duplicate entries', () => { + expect(new Set(STRUCTURE_KINDS).size).toBe(STRUCTURE_KINDS.length); + }); +}); + +describe('isStructureKind', () => { + it('returns true for every catalogue value', () => { + for (const kind of STRUCTURE_KINDS) { + expect(isStructureKind(kind)).toBe(true); + } + }); + + it('returns false for values outside the catalogue', () => { + // common typos / cascade kinds not adopted by the portal + expect(isStructureKind('medical_social')).toBe(false); + expect(isStructureKind('sanitaire')).toBe(false); // cascade has it, portal doesn't ship it in v1 + expect(isStructureKind('autre')).toBe(false); // escape hatch deliberately excluded + expect(isStructureKind('')).toBe(false); + expect(isStructureKind('Medico_Social')).toBe(false); // case-sensitive + }); + + it('narrows the type at the call site', () => { + const candidate: string = 'medico_social'; + if (!isStructureKind(candidate)) { + throw new Error('unreachable — candidate is a known kind'); + } + const narrowed: StructureKind = candidate; + expect(narrowed).toBe('medico_social'); + }); +}); diff --git a/apps/portal-bff/src/structures/structure-kind.ts b/apps/portal-bff/src/structures/structure-kind.ts new file mode 100644 index 0000000..c703c12 --- /dev/null +++ b/apps/portal-bff/src/structures/structure-kind.ts @@ -0,0 +1,36 @@ +/** + * Closed-set catalogue for `Structure.kind` per + * [ADR-0027 §"Confirmation"](../../../../../docs/decisions/0027-portal-side-organisational-hierarchy.md). + * + * Three enforcement layers stack here (defense-in-depth, same posture + * as ADR-0025's `Privilege` / `FunctionalRole` catalogues): + * + * 1. TypeScript type union `StructureKind` — compile-time check at + * every assignment / function call that uses the typed reference. + * 2. Postgres `CHECK` constraint in the `add_org_hierarchy` migration + * — runtime enforcement at INSERT / UPDATE time, defends against + * any code path that bypasses the type system (raw SQL, casts, + * future API endpoints with untrusted input). + * 3. `scripts/check-catalogue-drift.mjs` — CI gate asserting every + * `kind: 'X'` literal in files that import this module is in the + * catalogue. + * + * Cascade's `Structure.type` discriminator is the source of the + * vocabulary; the portal preserves cascade's spelling so the future + * cascade sync (ADR-0029) writes additively without translation. + */ +export const STRUCTURE_KINDS = [ + 'medico_social', + 'antenne', + 'dispositif', + 'entreprise_adaptee', + 'mouvement', + 'administratif', + 'siege', +] as const; + +export type StructureKind = (typeof STRUCTURE_KINDS)[number]; + +export function isStructureKind(value: string): value is StructureKind { + return (STRUCTURE_KINDS as readonly string[]).includes(value); +} diff --git a/scripts/check-catalogue-drift.mjs b/scripts/check-catalogue-drift.mjs index 23e32c5..2fa8d47 100644 --- a/scripts/check-catalogue-drift.mjs +++ b/scripts/check-catalogue-drift.mjs @@ -47,6 +47,16 @@ const __dirname = dirname(__filename); const WORKSPACE_ROOT = resolve(__dirname, '..'); const CATALOGUE_PATH = join(WORKSPACE_ROOT, 'libs/shared/auth/src/lib/authorization.types.ts'); +const STRUCTURE_KIND_PATH = 'apps/portal-bff/src/structures/structure-kind.ts'; + +/** + * A file is only scanned for `Structure.kind` violations if it + * imports from the structure-kind module. Outside that context + * `kind` is a common property name on unrelated objects and the + * scanner would false-positive. The check is text-level (cheap) + * and runs before any AST work — files that fail it short-circuit. + */ +const STRUCTURE_KIND_IMPORT_PATTERN = /\bfrom\s+['"][^'"]*structure-kind['"]/; /** * Decorator name → catalogue array name. Adding a third decorator @@ -131,6 +141,84 @@ export function extractCatalogues(sourceFilePath = CATALOGUE_PATH) { return out; } +/** + * Parse `structure-kind.ts` and extract the `STRUCTURE_KINDS` + * constant as `Set`. Mirror of `extractCatalogues` for the + * ADR-0027 `Structure.kind` catalogue — same `as const` array + * literal pattern, single declaration per file. Exported for the spec. + */ +export function extractStructureKinds(sourceFilePath = join(WORKSPACE_ROOT, STRUCTURE_KIND_PATH)) { + const text = readFileSync(sourceFilePath, 'utf8'); + const sourceFile = ts.createSourceFile(sourceFilePath, text, ts.ScriptTarget.Latest, true); + + for (const stmt of sourceFile.statements) { + if (!ts.isVariableStatement(stmt)) continue; + for (const decl of stmt.declarationList.declarations) { + if (!ts.isIdentifier(decl.name) || decl.name.text !== 'STRUCTURE_KINDS') continue; + let init = decl.initializer; + if (init && ts.isAsExpression(init)) init = init.expression; + if (!init || !ts.isArrayLiteralExpression(init)) continue; + + const values = new Set(); + for (const el of init.elements) { + if (ts.isStringLiteral(el) || ts.isNoSubstitutionTemplateLiteral(el)) { + values.add(el.text); + } + } + return values; + } + } + + throw new Error( + `Failed to extract STRUCTURE_KINDS from ${sourceFilePath} — ` + + `the script expected a top-level "export const STRUCTURE_KINDS = [...] as const" declaration.`, + ); +} + +/** + * Find every `kind: 'X'` property literal whose X is not in the + * STRUCTURE_KINDS catalogue. Restricted to files that import from + * the structure-kind module (heuristic — `kind` is a common + * property name on unrelated objects; without the restriction the + * scanner would false-positive everywhere). Exported for the spec. + */ +export function findStructureKindViolationsInFile(filePath, validKinds, sourceText) { + const text = sourceText ?? readFileSync(filePath, 'utf8'); + if (!STRUCTURE_KIND_IMPORT_PATTERN.test(text)) return []; + + const sourceFile = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true); + const violations = []; + + function visit(node) { + if (ts.isPropertyAssignment(node)) { + const name = node.name; + const isKindKey = + (ts.isIdentifier(name) && name.text === 'kind') || + (ts.isStringLiteral(name) && name.text === 'kind'); + if (isKindKey) { + const init = node.initializer; + if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) { + if (!validKinds.has(init.text)) { + const { line, character } = sourceFile.getLineAndCharacterOfPosition(init.getStart()); + violations.push({ + file: filePath, + line: line + 1, + column: character + 1, + callee: 'Structure.kind', + value: init.text, + catalogue: 'STRUCTURE_KINDS', + }); + } + } + } + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return violations; +} + /** * Walk `dir` recursively and yield every `.ts` file path * (excluding declaration files and the dirs listed in @@ -213,24 +301,27 @@ export function scanWorkspace(workspaceRoot = WORKSPACE_ROOT) { const catalogues = extractCatalogues( join(workspaceRoot, 'libs/shared/auth/src/lib/authorization.types.ts'), ); + const structureKinds = extractStructureKinds(join(workspaceRoot, STRUCTURE_KIND_PATH)); const out = []; for (const dirName of ['apps', 'libs']) { const dir = join(workspaceRoot, dirName); for (const file of walkTsFiles(dir, workspaceRoot)) { out.push(...findViolationsInFile(file, catalogues)); + out.push(...findStructureKindViolationsInFile(file, structureKinds)); } } - return { violations: out, catalogues }; + return { violations: out, catalogues, structureKinds }; } function main() { - const { violations, catalogues } = scanWorkspace(); + const { violations, catalogues, structureKinds } = scanWorkspace(); if (violations.length === 0) { const privCount = catalogues.PRIVILEGES.size; const roleCount = catalogues.FUNCTIONAL_ROLES.size; + const kindCount = structureKinds.size; console.log( - `catalogue-drift: clean (catalogues: ${privCount} privileges, ${roleCount} roles).`, + `catalogue-drift: clean (catalogues: ${privCount} privileges, ${roleCount} roles, ${kindCount} structure kinds).`, ); process.exit(0); } @@ -249,16 +340,21 @@ function main() { const rel = relative(WORKSPACE_ROOT, file).replaceAll('\\', '/'); console.error(` ${rel}`); for (const v of list) { - console.error( - ` ${v.line}:${v.column} @${v.callee}('${v.value}') — not in ${v.catalogue}`, - ); + // Decorator-style violations are printed as `@Callee('value')`; + // property-literal violations (Structure.kind) are printed as + // `kind: 'value'` to match the offending code shape. + const display = v.callee.includes('.') + ? `${v.callee.split('.').pop()}: '${v.value}'` + : `@${v.callee}('${v.value}')`; + console.error(` ${v.line}:${v.column} ${display} — not in ${v.catalogue}`); } } console.error(''); console.error( - `Catalogues are closed in v1 per ADR-0025. To add a value, amend ` + - `the ADR and the corresponding constant in ` + - `libs/shared/auth/src/lib/authorization.types.ts.`, + `Catalogues are closed in v1 per ADR-0025 (PRIVILEGES / FUNCTIONAL_ROLES) ` + + `and ADR-0027 (STRUCTURE_KINDS). To add a value, amend the ADR and the ` + + `corresponding constant in libs/shared/auth/src/lib/authorization.types.ts ` + + `or apps/portal-bff/src/structures/structure-kind.ts.`, ); process.exit(1); } diff --git a/scripts/check-catalogue-drift.spec.mjs b/scripts/check-catalogue-drift.spec.mjs index e3452a5..55062eb 100644 --- a/scripts/check-catalogue-drift.spec.mjs +++ b/scripts/check-catalogue-drift.spec.mjs @@ -15,6 +15,8 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { extractCatalogues, + extractStructureKinds, + findStructureKindViolationsInFile, findViolationsInFile, scanWorkspace, } from './check-catalogue-drift.mjs'; @@ -46,6 +48,17 @@ function makeFixture({ files }) { ] 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]; + `, + ); for (const [relativePath, contents] of Object.entries(files)) { const full = join(root, relativePath); mkdirSync(join(full, '..'), { recursive: true }); @@ -160,6 +173,95 @@ describe('findViolationsInFile', () => { }); }); +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('scanWorkspace', () => { it('returns 0 violations on a clean fixture', () => { const root = makeFixture({ @@ -230,4 +332,26 @@ describe('scanWorkspace', () => { const { violations } = scanWorkspace(root); assert.equal(violations.length, 1); }); + + it('aggregates decorator + Structure.kind violations together', () => { + const root = makeFixture({ + files: { + 'apps/portal-bff/src/foo.ts': ` + import { STRUCTURE_KINDS } from './structures/structure-kind'; + @RequirePrivilege('Portal.Ghost') class Bad {} + const s = { kind: 'phantom_kind' }; + `, + }, + }); + const { violations } = scanWorkspace(root); + assert.equal(violations.length, 2); + const values = violations.map((v) => v.value).sort(); + assert.deepEqual(values, ['Portal.Ghost', 'phantom_kind']); + }); + + it('returns the structureKinds set alongside catalogues', () => { + const root = makeFixture({ files: {} }); + const { structureKinds } = scanWorkspace(root); + assert.deepEqual([...structureKinds].sort(), ['antenne', 'medico_social', 'siege']); + }); });