diff --git a/.dependency-cruiser.base.js b/.dependency-cruiser.base.js index ff945cd..b6f1e0b 100644 --- a/.dependency-cruiser.base.js +++ b/.dependency-cruiser.base.js @@ -100,9 +100,9 @@ module.exports = function buildConfig(contextAllowed, appName, tsConfigFileName) { name: 'apiclient-infrastructure-only', comment: - 'The generated ApiClient is a value only inside infrastructure/ (+ shared/upload); elsewhere type-only.', + 'The generated ApiClient is a value only inside infrastructure/; elsewhere type-only.', severity: 'error', - from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' }, + from: { pathNot: '/infrastructure/' }, to: { path: '^libs/shared/src/infrastructure/api-client\\.ts$', dependencyTypesNot: ['type-only'], diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 011aab4..21fedab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -202,6 +202,11 @@ jobs: # run manually against backend/openzaak/ (see its README), never in CI. - run: dotnet test backend/BigRegister.slnx --filter "Category!=Integration" if: needs.changes.outputs.backend == 'true' + # RB-14/BIO-016: `npm audit --omit=dev` covers only the frontend; the .NET dependency + # tree was entirely unscanned. The script — not a bare `dotnet list` — is the gate, + # because `dotnet list package --vulnerable` exits 0 even on a High advisory. + - run: ./scripts/dotnet-audit.sh + if: needs.changes.outputs.backend == 'true' e2e: needs: changes diff --git a/.gitignore b/.gitignore index 10477d4..022d93b 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,7 @@ backend/openzaak/seeded.env # WP-55: render-prod-secrets.sh's output — the real client secret, never committed backend/openzaak/setup_configuration/data.prod.yaml + +# Agent git worktrees (Claude Code `isolation: "worktree"`) — full checkouts of +# this repo nested inside it; never commit one. +.claude/worktrees/ diff --git a/.prettierignore b/.prettierignore index deed558..07cb7ce 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,6 +4,11 @@ storybook-static*/ coverage/ .angular/ +# Agent git worktrees — full checkouts of this repo nested inside it, so an +# unignored `prettier --check .` walks into every one of them (and reports the +# vendored CIBG files that the top-level ignore already excludes). +.claude/worktrees/ + # Lockfile package-lock.json @@ -21,3 +26,7 @@ plop-templates/ # Backend is formatted by `dotnet format`, not prettier backend/ + +# Agent prompts — their exact wording is the input, reflowing markdown edits the prompt +docs/project/refactor-backlog-setup/agents/ +docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/ diff --git a/CLAUDE.md b/CLAUDE.md index 88fa614..3c7401a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,8 +124,10 @@ than hardcoding one app's content — the two apps' primary nav genuinely differ should be **composition of existing blocks** — adding building blocks is the exception, not the default. Atoms are thin wrappers over CIBG Huisstijl (Bootstrap 5.2) CSS classes (`btn`, `form-control`, `card`, …); we own only a small typed `input()` API, -the design system does the visuals. (Where CIBG lacks a class — e.g. `alert` — the atom is a -small hand-rolled surface built from the token bridge; see ADR-0003.) +the design system does the visuals. (Where CIBG lacks a class — e.g. `skeleton`, +`spinner` — the atom is a small hand-rolled surface built from the token bridge and carries a +`// CIBG-GAP EXTENSION:` marker; see ADR-0003. `alert` is **not** such a case: it wraps the +vendored `.feedback feedback-*` classes.) ### 3. State: make illegal states unrepresentable @@ -175,8 +177,14 @@ herregistratie eligibility) or _config value_ (server sends threshold, FE applie for instant feedback, server re-validates as authority — e.g. scholing threshold). FE keeps only **format** validation, never as authority. -DTO lives in `contracts/`; a hand-written `parse*`/`toDomain` in `infrastructure/` -validates the untrusted shape and maps DTO → domain. Wiring a real .NET backend +The generated client +(`libs/shared/src/infrastructure/api-client.ts`, `npm run gen:api`, drift-checked in CI) **is** +the wire contract — consume its types directly, as 19 of the 20 adapters do. A hand-written +`contracts/*.dto.ts` is the exception, only where codegen does not reach the endpoint or types +it too loosely (the four survivors are all the latter — the generator emits every property as +optional and flattens unions); such a file must still import nothing. Either way a hand-written +`parse*`/`toDomain` in `infrastructure/` validates the untrusted shape and maps DTO → domain — +**a generated type is a compile-time claim about the wire, not a runtime guarantee.** Wiring a real .NET backend touches only `infrastructure/` + `contracts/` (see ARCHITECTURE §6). Server-owned rules live **only** on the server, with no FE mirror to drift from it — the FE may mirror a server-supplied _value_ (a threshold, a bound) for instant feedback, but @@ -185,8 +193,12 @@ never reimplements the _algorithm_. **Business-tunable reference data ("stamdata") is config-as-code, not a DB.** Tables the business controls (profession↔diploma map, thresholds, policy-question text) live as typed C# in `backend/.../Stamdata/`, validated at build by `StamdataValidationTests` (a bad edit -fails CI, never prod) — never runtime-editable. Org-templates are the deliberate exception -(operational per-org config in SQLite). UI copy is `$localize`. See ADR-0004. +fails CI, never prod) — never runtime-editable. Operational configuration is the deliberate +exception, and ADR-0004 states it as a four-part test rather than a list: the catalog lives in +code, an unknown key fails closed, the value is operational rather than a shared business rule, +and writes are admin-capability-gated **and** audited. Two surfaces pass it today — +`OrgTemplateStore` (per-org letterhead) and `FeatureFlagStore` (rollout switches), both in +SQLite. A third surface must pass the same test, not argue by analogy. UI copy is `$localize`. See ADR-0004. ### 5. Testing @@ -214,6 +226,17 @@ regardless of which atomic layer it is (a context organism doesn't get its own - **Naming:** shared/reusable UI is **English** (language-agnostic: `button`, `wizard-shell`); domain contexts are **Dutch** (`registratie`, `herregistratie`, `*.machine.ts`). Pick the language by which side of the seam the code is on. +- **English prose uses Simplified Technical English (STE).** This covers documentation, + code comments, commit messages, ADRs, and the backlog notes. One idea per sentence; + 20 words or fewer in a procedure, 25 in a description. Active voice, present tense. + One word for one meaning — pick a term and repeat it, do not vary it for style. Keep + articles ("the test fails"). Three nouns together at most. No idioms and no humour. + Six sentences per paragraph at most. Write a procedure as numbered steps, one action + per step. + **STE governs form, not content.** Split a long sentence; never drop a caveat, a + measurement, or a precise term to make it shorter. + **STE does not apply to** Dutch identifiers, `$localize` copy, quoted output, or + existing documents you are not already editing. - **User-facing copy = `$localize`.** Every user-visible string is wrapped in Angular's first-party `$localize` (no third-party i18n lib), with a stable custom id (`` $localize`:@@context.key:Tekst` ``). Source locale is `nl`; a second locale is a diff --git a/angular.json b/angular.json index 30dde9d..2e55d08 100644 --- a/angular.json +++ b/angular.json @@ -106,7 +106,7 @@ "**/*.spec.ts", "**/*.stories.ts", "**/contracts/**", - "libs/shared/src/infrastructure/api-client.ts", + "**/infrastructure/api-client.ts", "apps/ssp/src/main.ts", "**/*.testing.ts", "**/*.d.ts" @@ -235,7 +235,7 @@ "**/*.spec.ts", "**/*.stories.ts", "**/contracts/**", - "libs/shared/src/infrastructure/api-client.ts", + "**/infrastructure/api-client.ts", "apps/behandelportal/src/main.ts", "**/*.testing.ts", "**/*.d.ts" @@ -293,7 +293,7 @@ "**/*.spec.ts", "**/*.stories.ts", "**/contracts/**", - "src/infrastructure/api-client.ts", + "**/infrastructure/api-client.ts", "src/test-entry.ts", "**/*.testing.ts", "**/*.d.ts" @@ -332,6 +332,7 @@ "**/*.spec.ts", "**/*.stories.ts", "**/contracts/**", + "**/infrastructure/api-client.ts", "src/test-entry.ts", "**/*.testing.ts", "**/*.d.ts" diff --git a/apps/behandelportal/src/app/auth/application/session.store.ts b/apps/behandelportal/src/app/auth/application/session.store.ts index 47374d3..d865c3a 100644 --- a/apps/behandelportal/src/app/auth/application/session.store.ts +++ b/apps/behandelportal/src/app/auth/application/session.store.ts @@ -1,58 +1,50 @@ import { Injectable, computed, effect, inject, signal } from '@angular/core'; -import { Result } from '@shared/kernel/fp'; -import { Session } from '../domain/session'; -import { DigidAdapter } from '../infrastructure/digid.adapter'; +import { Principal, parseStoredPrincipal } from '../domain/principal'; +import { MedewerkerAdapter } from '../infrastructure/medewerker.adapter'; const STORAGE_KEY = 'session-v1'; -/** Restore a persisted session (best-effort; corrupt entry → logged out). - G2: validate the shape before trusting it. G1: the BSN is never persisted - (see the effect below), so a restored session carries an empty one — it is - unused after login; only `naam` is shown in the chrome. */ -function restore(): Session | null { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return null; - const parsed = JSON.parse(raw) as Partial; - return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null; - } catch { - return null; - } +/** Restore a persisted principal (best-effort; corrupt entry → logged out). + The shape validation (G2 — there is no BSN here, so no G1 to enforce) lives in + `parseStoredPrincipal` (`../domain/principal`) — pure, spec'd, and testable + without stubbing `localStorage`; this just supplies the raw value. */ +function restore(): Principal | null { + return parseStoredPrincipal(localStorage.getItem(STORAGE_KEY)); } /** - * Holds the current session for the whole app. Because it is providedIn:'root' - * there is exactly one instance — every component that injects it sees the same - * session signal, so logging in is instantly visible everywhere (the guard, the - * header, etc.). The session is mirrored to localStorage so a refresh, a deep-link, - * or the full-page navigation the language switch performs (nl at `/` ⇄ en at `/en/`, - * separate bundles) keeps you logged in. ponytail: localStorage, not sessionStorage — - * sessionStorage's per-tab clearing dropped the login on the cross-bundle language - * switch. Trade-off: the demo session now survives tab close; a real portal keeps auth - * in an httpOnly cookie/token, not web storage. + * Holds the current medewerker principal for the whole backoffice app. One + * `providedIn: 'root'` instance, so logging in is instantly visible everywhere + * (the guard, the header). Persisted to localStorage — a refresh or the + * cross-bundle language switch (nl at `/` ⇄ en at `/en/`) keeps you logged in — + * which is safe to do verbatim here: a medewerker principal carries no BSN or + * other national identifier, unlike the SSP's `SessionStore`, whose equivalent + * comment explains why *that* app strips a field before writing. A real + * deployment keeps auth in an httpOnly cookie/token, not web storage, regardless. */ @Injectable({ providedIn: 'root' }) export class SessionStore { - private digid = inject(DigidAdapter); - private _session = signal(restore()); + private medewerker = inject(MedewerkerAdapter); + private _session = signal(restore()); readonly session = this._session.asReadonly(); readonly isAuthenticated = computed(() => this._session() !== null); constructor() { effect(() => { - const s = this._session(); - // G1: persist only `naam` — never write the BSN (national ID) to storage. - if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam })); + const p = this._session(); + if (p) localStorage.setItem(STORAGE_KEY, JSON.stringify(p)); else localStorage.removeItem(STORAGE_KEY); }); } - /** Effectful command: authenticate, then store the session on success. */ - async login(bsn: string): Promise> { - const r = await this.digid.authenticate(bsn); - if (r.ok) this._session.set(r.value); - return r; + /** Effectful command: authenticate via the SSO stand-in, then store the + resulting principal. No credential to pass in, and nothing that can fail + today — see `MedewerkerAdapter`. */ + async login(): Promise { + const p = await this.medewerker.authenticate(); + this._session.set(p); + return p; } logout() { diff --git a/apps/behandelportal/src/app/auth/auth.guard.ts b/apps/behandelportal/src/app/auth/auth.guard.ts index 3eadd8f..e3def66 100644 --- a/apps/behandelportal/src/app/auth/auth.guard.ts +++ b/apps/behandelportal/src/app/auth/auth.guard.ts @@ -1,34 +1,10 @@ -import { inject } from '@angular/core'; -import { CanActivateFn, Router } from '@angular/router'; -import { AccessStore } from '@shared/application/access.store'; -import { Capability } from '@shared/domain/capability'; -import { SessionStore } from './application/session.store'; - -/** Route guard: only let authenticated users in; otherwise redirect to /login. */ -export const authGuard: CanActivateFn = () => { - const store = inject(SessionStore); - const router = inject(Router); - return store.isAuthenticated() ? true : router.createUrlTree(['/login']); -}; - /** - * Route guard factory (PRD-0002 §6): authenticated AND holding `capability`, else - * redirect. Used by the admin pages (`/brief/huisstijl`, `/beheer/stamdata`). + * The route guards live in `libs/shared` (ADR-C-006) — they are actor-agnostic, reading + * only `SESSION_PORT` and `AccessStore`, so both apps share one copy and one spec. + * Re-exported here so `app.routes.ts` keeps importing them from `@auth/auth.guard`: + * routing asks the auth context for its guards, which is the right direction to read. * - * **Async on purpose:** `can()` is deny-by-default, so it must not be read while `/me` - * is still loading — it would deny an entitled admin and bounce them. We await - * `AccessStore.whenReady()` (caps resolved) before deciding. An unauthenticated user - * goes to `/login`; an authenticated-but-unentitled user goes to `/dashboard` (they're - * logged in, just not allowed here — no re-login loop). The backend re-enforces - * regardless (403); this guard is the UX pre-gate. + * ADR-0002 §3's "auth stays duplicated" still holds for what it actually scopes — + * `Principal`, the login flow, `SessionStore`. A guard is neither. */ -export function capabilityGuard(capability: Capability): CanActivateFn { - return async () => { - const session = inject(SessionStore); - const access = inject(AccessStore); - const router = inject(Router); - if (!session.isAuthenticated()) return router.createUrlTree(['/login']); - await access.whenReady(); - return access.can(capability) ? true : router.createUrlTree(['/dashboard']); - }; -} +export { authGuard, capabilityGuard } from '@shared/application/auth.guard'; diff --git a/apps/behandelportal/src/app/auth/domain/principal.spec.ts b/apps/behandelportal/src/app/auth/domain/principal.spec.ts new file mode 100644 index 0000000..3cd76d8 --- /dev/null +++ b/apps/behandelportal/src/app/auth/domain/principal.spec.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { isAuthenticated, parseRollen, parseStoredPrincipal, Principal } from './principal'; + +const principal: Principal = { + kind: 'medewerker', + medewerkerId: 'medewerker-1', + naam: 'Test', + rollen: ['behandelaar'], +}; + +describe('isAuthenticated', () => { + it('narrows a present principal to Principal', () => { + expect(isAuthenticated(principal)).toBe(true); + }); + + it('reports no principal as not authenticated', () => { + expect(isAuthenticated(null)).toBe(false); + }); +}); + +describe('parseStoredPrincipal', () => { + it('returns null when nothing is stored', () => { + expect(parseStoredPrincipal(null)).toBeNull(); + }); + + it('returns null for a non-JSON string', () => { + expect(parseStoredPrincipal('not json')).toBeNull(); + }); + + it('returns null when the stored shape is wrong (no naam)', () => { + expect( + parseStoredPrincipal(JSON.stringify({ kind: 'medewerker', medewerkerId: 'medewerker-1' })), + ).toBeNull(); + }); + + it('returns null when kind is not medewerker', () => { + expect( + parseStoredPrincipal( + JSON.stringify({ + kind: 'zorgverlener', + medewerkerId: 'medewerker-1', + naam: 'Test', + rollen: [], + }), + ), + ).toBeNull(); + }); + + it('returns null when rollen holds an unrecognized token', () => { + expect( + parseStoredPrincipal( + JSON.stringify({ + kind: 'medewerker', + medewerkerId: 'medewerker-1', + naam: 'Test', + rollen: ['geen'], + }), + ), + ).toBeNull(); + }); + + it('restores a well-shaped stored principal as-is (no BSN to strip)', () => { + const restored = parseStoredPrincipal(JSON.stringify(principal)); + expect(restored).toEqual(principal); + }); +}); + +describe('parseRollen', () => { + it('parses a single recognized rol', () => { + expect(parseRollen('behandelaar')).toEqual(['behandelaar']); + }); + + it('is case-insensitive and trims whitespace', () => { + expect(parseRollen(' Behandelaar , behandelaar ')).toEqual(['behandelaar', 'behandelaar']); + }); + + it('drops unrecognized tokens (the deny-path toggle, e.g. ?rollen=geen)', () => { + expect(parseRollen('geen')).toEqual([]); + }); + + it('returns an empty list for an empty string', () => { + expect(parseRollen('')).toEqual([]); + }); +}); diff --git a/apps/behandelportal/src/app/auth/domain/principal.ts b/apps/behandelportal/src/app/auth/domain/principal.ts new file mode 100644 index 0000000..fd68bb2 --- /dev/null +++ b/apps/behandelportal/src/app/auth/domain/principal.ts @@ -0,0 +1,71 @@ +/** + * Who is logged in. Framework-free domain type. + * + * The `medewerker` variant of ADR-0002 §3's `Principal` union — the backoffice has + * exactly one actor kind (an employee, authenticated via SSO), so this app's own copy + * of the union only ever holds this one member. Unlike the SSP's `zorgverlener` + * variant, there is no BSN: a Behandelaar is not a citizen, and §3 names this + * unrepresentable-by-construction distinction as the whole point of the union. + * `rollen` is the FE-visible echo of the same dev stand-in `medewerker.interceptor.ts` + * already stamps onto every backend request — it does not itself grant anything; + * `AccessStore`/`GET /me` (server-resolved capabilities) is still the sole authority + * on what this principal may do (ADR-0001, ADR-0002 §3). + */ +export type Rol = 'behandelaar'; + +const ROLLEN: readonly Rol[] = ['behandelaar']; +export const isRol = (v: unknown): v is Rol => typeof v === 'string' && ROLLEN.includes(v as Rol); + +export interface Principal { + readonly kind: 'medewerker'; + readonly medewerkerId: string; + readonly naam: string; + readonly rollen: readonly Rol[]; +} + +export function isAuthenticated(p: Principal | null): p is Principal { + return p !== null; +} + +/** + * Turn the raw `X-Rollen` stand-in value (`medewerker.ts`'s `currentRollen()`) into + * typed `Rol[]`, mirroring the backend's own `StubIdentityProvider.ParseRollen`: + * comma-separated, case-insensitive, unrecognized tokens dropped — so + * `?rollen=geen` (the deny-path toggle) yields an empty list here too, rather than + * a fabricated recognized role. Pure so `MedewerkerAdapter` (infrastructure) can + * stay a thin wire-up instead of holding logic of its own. + */ +export function parseRollen(raw: string): Rol[] { + return raw + .split(',') + .map((t) => t.trim().toLowerCase()) + .filter(isRol); +} + +/** + * Parse a persisted principal out of a raw `localStorage` string (best-effort; + * anything that isn't a well-shaped record → logged out). G2: validate the shape + * before trusting it. Unlike the zorgverlener variant there is no G1 field to strip + * — a medewerker carries no national identifier — so a well-shaped record is + * restored as-is rather than reconstructed field-by-field. + */ +export function parseStoredPrincipal(raw: string | null): Principal | null { + try { + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + return parsed?.kind === 'medewerker' && + typeof parsed.medewerkerId === 'string' && + typeof parsed.naam === 'string' && + Array.isArray(parsed.rollen) && + parsed.rollen.every(isRol) + ? { + kind: 'medewerker', + medewerkerId: parsed.medewerkerId, + naam: parsed.naam, + rollen: parsed.rollen, + } + : null; + } catch { + return null; + } +} diff --git a/apps/behandelportal/src/app/auth/domain/session.spec.ts b/apps/behandelportal/src/app/auth/domain/session.spec.ts deleted file mode 100644 index c46343d..0000000 --- a/apps/behandelportal/src/app/auth/domain/session.spec.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { isAuthenticated, Session } from './session'; - -const session: Session = { bsn: '19012345601', naam: 'Test' }; - -describe('isAuthenticated', () => { - it('narrows a present session to Session', () => { - expect(isAuthenticated(session)).toBe(true); - }); - - it('reports no session as not authenticated', () => { - expect(isAuthenticated(null)).toBe(false); - }); -}); diff --git a/apps/behandelportal/src/app/auth/domain/session.ts b/apps/behandelportal/src/app/auth/domain/session.ts deleted file mode 100644 index 486dc85..0000000 --- a/apps/behandelportal/src/app/auth/domain/session.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** Who is logged in. Framework-free domain type. */ -export interface Session { - readonly bsn: string; - readonly naam: string; -} - -export function isAuthenticated(s: Session | null): s is Session { - return s !== null; -} diff --git a/apps/behandelportal/src/app/auth/infrastructure/digid.adapter.ts b/apps/behandelportal/src/app/auth/infrastructure/digid.adapter.ts deleted file mode 100644 index a4956d0..0000000 --- a/apps/behandelportal/src/app/auth/infrastructure/digid.adapter.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Injectable } from '@angular/core'; -import { Result, ok } from '@shared/kernel/fp'; -import { parseBsn } from '@shared/kernel/bsn'; -import { Session } from '../domain/session'; - -/** Infrastructure: talks to the (mock) DigiD identity provider. */ -@Injectable({ providedIn: 'root' }) -export class DigidAdapter { - // ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity. - // Real BSN validation (parseBsn, WP-40) is the trust boundary; swap the fixed identity - // for a real OIDC redirect flow when there's an IdP. - async authenticate(bsn: string): Promise> { - const r = parseBsn(bsn); - return r.ok ? ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r; - } -} diff --git a/apps/behandelportal/src/app/auth/infrastructure/medewerker.adapter.ts b/apps/behandelportal/src/app/auth/infrastructure/medewerker.adapter.ts new file mode 100644 index 0000000..85cc7ee --- /dev/null +++ b/apps/behandelportal/src/app/auth/infrastructure/medewerker.adapter.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@angular/core'; +import { Principal, parseRollen } from '../domain/principal'; +import { MEDEWERKER_ID, currentRollen } from './medewerker'; + +/** + * Infrastructure: resolves the current medewerker identity into a `Principal` + * (ADR-C-004/RB-13). Stands in for a real employee-SSO redirect flow (ADR-0002 §3, + * "out of scope here") — there is no credential to enter and, unlike `DigidAdapter`'s + * BSN check, no format to reject, so `authenticate()` takes no input and returns the + * `Principal` directly rather than a `Result` with an error variant that can never + * actually occur. A real SSO callback (which *can* fail — session expired, access + * denied) swaps in behind this same method; that is the point where this return + * type would gain a `Result`, not before. + * + * Resolves the same `MEDEWERKER_ID` + `currentRollen()` the dev-only + * `medewerkerInterceptor` already stamps onto every backend request as + * `X-Medewerker`/`X-Rollen` — this only makes that identity visible on the + * frontend (the guard, the header, `SessionStore`'s persisted principal), it does + * not change what the backend resolves or authorizes. + */ +@Injectable({ providedIn: 'root' }) +export class MedewerkerAdapter { + // ponytail: fake employee SSO — a fixed medewerker, no credential exchange. + async authenticate(): Promise { + return { + kind: 'medewerker', + medewerkerId: MEDEWERKER_ID, + naam: 'H. (Hassan) Bakker', + rollen: parseRollen(currentRollen()), + }; + } +} diff --git a/apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts b/apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts index d11c5d3..6d9b5ce 100644 --- a/apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts +++ b/apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts @@ -1,50 +1,27 @@ import { Component, output } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { FormFieldComponent } from '@shared/ui/form-field/form-field.component'; -import { TextInputComponent } from '@shared/ui/text-input/text-input.component'; import { ButtonComponent } from '@shared/ui/button/button.component'; -/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */ +/** + * Organism: employee-SSO-style mock login (ADR-C-004/RB-13). No real auth — and, + * unlike the SSP's DigiD form, no credential to enter at all: a Behandelaar has no + * BSN, and this app has no password of its own to check either way. There is + * nothing to compose beyond one button, which is itself evidence for the ADR — the + * two apps' login flows are meant to look this different. + */ @Component({ selector: 'app-login-form', - imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent], + imports: [ButtonComponent], template: ` -
-
-
- * verplichte velden -
-
- - - - - - - - - - Inloggen met DigiD -
+
+

+ U meldt zich aan via de SSO van uw organisatie — er is geen wachtwoord nodig. +

+ + Inloggen met SSO + +
`, }) export class LoginFormComponent { - bsn = ''; - password = ''; - submitted = output(); + submitted = output(); } diff --git a/apps/behandelportal/src/app/auth/ui/login.page.ts b/apps/behandelportal/src/app/auth/ui/login.page.ts index 59671f9..aca9da5 100644 --- a/apps/behandelportal/src/app/auth/ui/login.page.ts +++ b/apps/behandelportal/src/app/auth/ui/login.page.ts @@ -1,36 +1,35 @@ -import { Component, inject, signal } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { Router } from '@angular/router'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; -import { AlertComponent } from '@shared/ui/alert/alert.component'; import { LoginFormComponent } from '@auth/ui/login-form/login-form.component'; import { SessionStore } from '@auth/application/session.store'; +/** + * No error alert here — unlike the SSP's DigiD form, `SessionStore.login()` has + * nothing to fail on (see `MedewerkerAdapter`). A real SSO integration is where + * this page would grow one back. + */ @Component({ selector: 'app-login-page', - imports: [PageShellComponent, AlertComponent, LoginFormComponent], + imports: [PageShellComponent, LoginFormComponent], template: ` - @if (error()) { - {{ error() }} - } - + `, }) export class LoginPage { private store = inject(SessionStore); private router = inject(Router); - error = signal(''); - async login(bsn: string) { - const r = await this.store.login(bsn); - if (r.ok) this.router.navigate(['/dashboard']); - else this.error.set(r.error); + async login() { + await this.store.login(); + this.router.navigate(['/dashboard']); } } diff --git a/apps/behandelportal/src/app/behandeling/domain/besluit.machine.spec.ts b/apps/behandelportal/src/app/behandeling/domain/besluit.machine.spec.ts index b9d2f09..a7fff87 100644 --- a/apps/behandelportal/src/app/behandeling/domain/besluit.machine.spec.ts +++ b/apps/behandelportal/src/app/behandeling/domain/besluit.machine.spec.ts @@ -1,12 +1,7 @@ import { describe, it, expect } from 'vitest'; import { expectTag } from '@shared/testing/expect-tag'; -import { BesluitState, reduce, initial } from './besluit.machine'; - -const editingWith = (besluit: string, toelichting = ''): BesluitState => ({ - tag: 'Editing', - draft: { besluit, toelichting }, - errors: {}, -}); +import { reduce, initial } from './besluit.machine'; +import { givenBesluit } from './besluit.testing'; describe('besluit reduce', () => { it('SetField updates the draft while editing', () => { @@ -15,17 +10,23 @@ describe('besluit reduce', () => { }); it('Submit with no besluit chosen stays Editing and reports a field error', () => { - const s = reduce(editingWith(''), { tag: 'Submit' }); + const s = reduce(initial, { tag: 'Submit' }); expect(expectTag(s, 'Editing').errors.besluit).toBeTruthy(); }); it('Submit Afwijzen without a toelichting stays Editing and reports a field error', () => { - const s = reduce(editingWith('Afwijzen'), { tag: 'Submit' }); + const editingAfwijzen = givenBesluit({ tag: 'SetField', key: 'besluit', value: 'Afwijzen' }); + const s = reduce(editingAfwijzen, { tag: 'Submit' }); expect(expectTag(s, 'Editing').errors.toelichting).toBeTruthy(); }); it('Submit Goedkeuren with no toelichting moves to Submitting (optional there)', () => { - const s = reduce(editingWith('Goedkeuren'), { tag: 'Submit' }); + const editingGoedkeuren = givenBesluit({ + tag: 'SetField', + key: 'besluit', + value: 'Goedkeuren', + }); + const s = reduce(editingGoedkeuren, { tag: 'Submit' }); expect(expectTag(s, 'Submitting').data).toEqual({ besluit: 'Goedkeuren', toelichting: undefined, @@ -33,7 +34,11 @@ describe('besluit reduce', () => { }); it('Submit Afwijzen with a toelichting moves to Submitting with the trimmed value', () => { - const s = reduce(editingWith('Afwijzen', ' niet erkend '), { tag: 'Submit' }); + const editingAfwijzenWithToelichting = givenBesluit( + { tag: 'SetField', key: 'besluit', value: 'Afwijzen' }, + { tag: 'SetField', key: 'toelichting', value: ' niet erkend ' }, + ); + const s = reduce(editingAfwijzenWithToelichting, { tag: 'Submit' }); expect(expectTag(s, 'Submitting').data).toEqual({ besluit: 'Afwijzen', toelichting: 'niet erkend', @@ -41,24 +46,36 @@ describe('besluit reduce', () => { }); it('SubmitConfirmed maps Submitting to Submitted', () => { - const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' }); + const submitting = givenBesluit( + { tag: 'SetField', key: 'besluit', value: 'Goedkeuren' }, + { tag: 'Submit' }, + ); expect(reduce(submitting, { tag: 'SubmitConfirmed' }).tag).toBe('Submitted'); }); it('SubmitFailed maps Submitting to Failed with the error', () => { - const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' }); + const submitting = givenBesluit( + { tag: 'SetField', key: 'besluit', value: 'Goedkeuren' }, + { tag: 'Submit' }, + ); const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' }); expect(failed).toMatchObject({ tag: 'Failed', error: 'boom' }); }); it('Retry re-submits a failure', () => { - const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' }); + const submitting = givenBesluit( + { tag: 'SetField', key: 'besluit', value: 'Goedkeuren' }, + { tag: 'Submit' }, + ); const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' }); expect(reduce(failed, { tag: 'Retry' }).tag).toBe('Submitting'); }); it('Reset returns to the initial editing state', () => { - const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' }); + const submitting = givenBesluit( + { tag: 'SetField', key: 'besluit', value: 'Goedkeuren' }, + { tag: 'Submit' }, + ); expect(reduce(submitting, { tag: 'Reset' })).toEqual(initial); }); }); diff --git a/apps/behandelportal/src/app/behandeling/domain/besluit.testing.ts b/apps/behandelportal/src/app/behandeling/domain/besluit.testing.ts new file mode 100644 index 0000000..e268c38 --- /dev/null +++ b/apps/behandelportal/src/app/behandeling/domain/besluit.testing.ts @@ -0,0 +1,7 @@ +import { given } from '@shared/testing/machine'; +import { reduce, initial } from './besluit.machine'; + +/** Replay real `BesluitMsg`s through the real `reduce`, starting from `initial`. + Pure TS only (no Angular) — domain/ stays framework-free (dependency-cruiser + `domain-is-pure`). See `libs/shared/src/testing/machine.ts`. */ +export const givenBesluit = given(reduce, initial); diff --git a/apps/behandelportal/src/locale/messages.en.xlf b/apps/behandelportal/src/locale/messages.en.xlf index a4dddc2..e538c31 100644 --- a/apps/behandelportal/src/locale/messages.en.xlf +++ b/apps/behandelportal/src/locale/messages.en.xlf @@ -26,65 +26,33 @@ 27 - - * verplichte velden - * required fields + + U meldt zich aan via de SSO van uw organisatie — er is geen wachtwoord nodig. + You sign in through your organization's SSO — no password is needed. src/app/auth/ui/login-form/login-form.component.ts - 15,18 - - - src/app/registratie/ui/change-request-form/change-request-form.component.ts - 44,46 - - - src/app/shared/layout/wizard-shell/wizard-shell.component.ts - 90,92 - - - - BSN - BSN - - src/app/auth/ui/login-form/login-form.component.ts - 22,23 - - - - 9-cijferig BSN, elfproef-geldig (demo: 123456782) - 9-digit BSN, valid eleven-test checksum (demo: 123456782) - - src/app/auth/ui/login-form/login-form.component.ts - 25,28 - - - - Wachtwoord - Password - - src/app/auth/ui/login-form/login-form.component.ts - 36,37 + 17,19 - Inloggen met DigiD - Log in with DigiD + Inloggen met SSO + Log in with SSO src/app/auth/ui/login-form/login-form.component.ts - 41,43 + 20,21 - Inloggen - Log in + Inloggen bij het behandelportal + Log in to the treatment portal src/app/auth/ui/login.page.ts 14,16 - Log in op uw persoonlijke BIG-register omgeving. - Log in to your personal BIG register environment. + Voor medewerkers die aanvragen beoordelen. + For staff who assess applications. src/app/auth/ui/login.page.ts 17,19 @@ -3878,6 +3846,10 @@ De functievlaggen konden niet worden geladen. The feature flags could not be loaded. + + De functievlag kon niet worden opgeslagen. + The feature flag could not be saved. + Opnieuw proberen Try again diff --git a/apps/ssp/src/app/auth/application/session.store.ts b/apps/ssp/src/app/auth/application/session.store.ts index 47374d3..dd891b1 100644 --- a/apps/ssp/src/app/auth/application/session.store.ts +++ b/apps/ssp/src/app/auth/application/session.store.ts @@ -1,55 +1,50 @@ import { Injectable, computed, effect, inject, signal } from '@angular/core'; import { Result } from '@shared/kernel/fp'; -import { Session } from '../domain/session'; +import { Principal, parseStoredPrincipal } from '../domain/principal'; import { DigidAdapter } from '../infrastructure/digid.adapter'; const STORAGE_KEY = 'session-v1'; -/** Restore a persisted session (best-effort; corrupt entry → logged out). - G2: validate the shape before trusting it. G1: the BSN is never persisted - (see the effect below), so a restored session carries an empty one — it is - unused after login; only `naam` is shown in the chrome. */ -function restore(): Session | null { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return null; - const parsed = JSON.parse(raw) as Partial; - return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null; - } catch { - return null; - } +/** Restore a persisted principal (best-effort; corrupt entry → logged out). + The parse + shape validation (G1/G2) lives in `parseStoredPrincipal` + (`../domain/principal`) — pure, spec'd, and testable without stubbing + `localStorage`; this just supplies the raw value. */ +function restore(): Principal | null { + return parseStoredPrincipal(localStorage.getItem(STORAGE_KEY)); } /** - * Holds the current session for the whole app. Because it is providedIn:'root' - * there is exactly one instance — every component that injects it sees the same - * session signal, so logging in is instantly visible everywhere (the guard, the - * header, etc.). The session is mirrored to localStorage so a refresh, a deep-link, - * or the full-page navigation the language switch performs (nl at `/` ⇄ en at `/en/`, - * separate bundles) keeps you logged in. ponytail: localStorage, not sessionStorage — - * sessionStorage's per-tab clearing dropped the login on the cross-bundle language - * switch. Trade-off: the demo session now survives tab close; a real portal keeps auth - * in an httpOnly cookie/token, not web storage. + * Holds the current zorgverlener principal for the whole SSP. One + * `providedIn: 'root'` instance, so logging in is instantly visible everywhere + * (the guard, the header). Persisted to localStorage — a refresh or the + * cross-bundle language switch (nl at `/` ⇄ en at `/en/`) keeps you logged in — + * but never the BSN itself (G1 in the `effect` below): this principal carries a + * citizen's national identifier, which the behandelportal's equivalent store does + * not have to guard against, because its `medewerker` principal has no BSN. + * ponytail: localStorage, not sessionStorage — sessionStorage's per-tab clearing + * dropped the login on the cross-bundle language switch. Trade-off: the demo + * session now survives tab close; a real portal keeps auth in an httpOnly + * cookie/token, not web storage. */ @Injectable({ providedIn: 'root' }) export class SessionStore { private digid = inject(DigidAdapter); - private _session = signal(restore()); + private _session = signal(restore()); readonly session = this._session.asReadonly(); readonly isAuthenticated = computed(() => this._session() !== null); constructor() { effect(() => { - const s = this._session(); + const p = this._session(); // G1: persist only `naam` — never write the BSN (national ID) to storage. - if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam })); + if (p) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: p.naam })); else localStorage.removeItem(STORAGE_KEY); }); } - /** Effectful command: authenticate, then store the session on success. */ - async login(bsn: string): Promise> { + /** Effectful command: authenticate, then store the principal on success. */ + async login(bsn: string): Promise> { const r = await this.digid.authenticate(bsn); if (r.ok) this._session.set(r.value); return r; diff --git a/apps/ssp/src/app/auth/auth.guard.spec.ts b/apps/ssp/src/app/auth/auth.guard.spec.ts deleted file mode 100644 index dfc3fb5..0000000 --- a/apps/ssp/src/app/auth/auth.guard.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { TestBed } from '@angular/core/testing'; -import { Router } from '@angular/router'; -import { describe, it, expect, vi } from 'vitest'; -import { AccessStore } from '@shared/application/access.store'; -import { SessionStore } from './application/session.store'; -import { authGuard, capabilityGuard } from './auth.guard'; - -type Opts = { - authed: boolean; - can?: (c: string) => boolean; - whenReady?: () => Promise; -}; - -function setup({ authed, can = () => false, whenReady = () => Promise.resolve() }: Opts) { - const createUrlTree = vi.fn((cmds: string[]) => ({ tree: cmds })); - const readySpy = vi.fn(whenReady); - TestBed.configureTestingModule({ - providers: [ - { provide: SessionStore, useValue: { isAuthenticated: () => authed } }, - { provide: AccessStore, useValue: { whenReady: readySpy, can } }, - { provide: Router, useValue: { createUrlTree } }, - ], - }); - return { createUrlTree, readySpy }; -} - -// The guards ignore their (route, state) args; cast to call with none. -const call = (fn: unknown) => TestBed.runInInjectionContext(() => (fn as () => T)()); - -describe('authGuard', () => { - it('allows an authenticated user', () => { - setup({ authed: true }); - expect(call(authGuard)).toBe(true); - }); - - it('redirects an anonymous user to /login', () => { - const { createUrlTree } = setup({ authed: false }); - expect(call(authGuard)).toEqual({ tree: ['/login'] }); - expect(createUrlTree).toHaveBeenCalledWith(['/login']); - }); -}); - -describe('capabilityGuard', () => { - const guard = () => capabilityGuard('stamdata:edit'); - - it('waits for /me, then allows an entitled admin', async () => { - const { readySpy } = setup({ authed: true, can: (c) => c === 'stamdata:edit' }); - await expect(call>(guard())).resolves.toBe(true); - expect(readySpy).toHaveBeenCalledOnce(); // it awaited caps before deciding - }); - - it('sends an authenticated-but-unentitled user to /dashboard (not a login loop)', async () => { - setup({ authed: true, can: () => false }); - await expect(call>(guard())).resolves.toEqual({ tree: ['/dashboard'] }); - }); - - it('redirects an anonymous user to /login without waiting for caps', async () => { - const { readySpy } = setup({ authed: false, can: () => true }); - await expect(call>(guard())).resolves.toEqual({ tree: ['/login'] }); - expect(readySpy).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/ssp/src/app/auth/auth.guard.ts b/apps/ssp/src/app/auth/auth.guard.ts index 3eadd8f..e3def66 100644 --- a/apps/ssp/src/app/auth/auth.guard.ts +++ b/apps/ssp/src/app/auth/auth.guard.ts @@ -1,34 +1,10 @@ -import { inject } from '@angular/core'; -import { CanActivateFn, Router } from '@angular/router'; -import { AccessStore } from '@shared/application/access.store'; -import { Capability } from '@shared/domain/capability'; -import { SessionStore } from './application/session.store'; - -/** Route guard: only let authenticated users in; otherwise redirect to /login. */ -export const authGuard: CanActivateFn = () => { - const store = inject(SessionStore); - const router = inject(Router); - return store.isAuthenticated() ? true : router.createUrlTree(['/login']); -}; - /** - * Route guard factory (PRD-0002 §6): authenticated AND holding `capability`, else - * redirect. Used by the admin pages (`/brief/huisstijl`, `/beheer/stamdata`). + * The route guards live in `libs/shared` (ADR-C-006) — they are actor-agnostic, reading + * only `SESSION_PORT` and `AccessStore`, so both apps share one copy and one spec. + * Re-exported here so `app.routes.ts` keeps importing them from `@auth/auth.guard`: + * routing asks the auth context for its guards, which is the right direction to read. * - * **Async on purpose:** `can()` is deny-by-default, so it must not be read while `/me` - * is still loading — it would deny an entitled admin and bounce them. We await - * `AccessStore.whenReady()` (caps resolved) before deciding. An unauthenticated user - * goes to `/login`; an authenticated-but-unentitled user goes to `/dashboard` (they're - * logged in, just not allowed here — no re-login loop). The backend re-enforces - * regardless (403); this guard is the UX pre-gate. + * ADR-0002 §3's "auth stays duplicated" still holds for what it actually scopes — + * `Principal`, the login flow, `SessionStore`. A guard is neither. */ -export function capabilityGuard(capability: Capability): CanActivateFn { - return async () => { - const session = inject(SessionStore); - const access = inject(AccessStore); - const router = inject(Router); - if (!session.isAuthenticated()) return router.createUrlTree(['/login']); - await access.whenReady(); - return access.can(capability) ? true : router.createUrlTree(['/dashboard']); - }; -} +export { authGuard, capabilityGuard } from '@shared/application/auth.guard'; diff --git a/apps/ssp/src/app/auth/domain/principal.spec.ts b/apps/ssp/src/app/auth/domain/principal.spec.ts new file mode 100644 index 0000000..386111a --- /dev/null +++ b/apps/ssp/src/app/auth/domain/principal.spec.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { isAuthenticated, parseStoredPrincipal, Principal } from './principal'; + +const principal: Principal = { kind: 'zorgverlener', bsn: '19012345601', naam: 'Test' }; + +describe('isAuthenticated', () => { + it('narrows a present principal to Principal', () => { + expect(isAuthenticated(principal)).toBe(true); + }); + + it('reports no principal as not authenticated', () => { + expect(isAuthenticated(null)).toBe(false); + }); +}); + +describe('parseStoredPrincipal', () => { + it('returns null when nothing is stored', () => { + expect(parseStoredPrincipal(null)).toBeNull(); + }); + + it('returns null for a non-JSON string', () => { + expect(parseStoredPrincipal('not json')).toBeNull(); + }); + + it('returns null when the stored shape is wrong (no naam)', () => { + expect(parseStoredPrincipal(JSON.stringify({ bsn: '19012345601' }))).toBeNull(); + }); + + it('G1: a stored bsn is never restored, even if present in the raw value', () => { + const restored = parseStoredPrincipal(JSON.stringify({ bsn: '19012345601', naam: 'Test' })); + expect(restored).toEqual({ kind: 'zorgverlener', bsn: '', naam: 'Test' }); + }); +}); diff --git a/apps/ssp/src/app/auth/domain/principal.ts b/apps/ssp/src/app/auth/domain/principal.ts new file mode 100644 index 0000000..3672e07 --- /dev/null +++ b/apps/ssp/src/app/auth/domain/principal.ts @@ -0,0 +1,39 @@ +/** + * Who is logged in. Framework-free domain type. + * + * The `zorgverlener` variant of ADR-0002 §3's `Principal` union — the SSP has exactly + * one actor kind (a citizen, authenticated via DigiD/BSN), so this app's own copy of + * the union only ever holds this one member. `kind` is still a discriminant, not + * decoration: it is what makes `apps/behandelportal`'s `medewerker` variant a + * genuinely different type rather than a same-shaped coincidence, and what a future + * third actor (§4 — admin/auditor/institution-rep) would add a member to. + */ +export interface Principal { + readonly kind: 'zorgverlener'; + readonly bsn: string; + readonly naam: string; +} + +export function isAuthenticated(p: Principal | null): p is Principal { + return p !== null; +} + +/** + * Parse a persisted principal out of a raw `localStorage` string (best-effort; + * anything that isn't a well-shaped record → logged out). G2: validate the + * shape before trusting it. G1: even if a stored entry carries a `bsn`, the + * restored principal's `bsn` is always `''` — the BSN is never persisted (see + * the `SessionStore` effect that writes it), so a legacy or tampered entry + * cannot resurrect one. + */ +export function parseStoredPrincipal(raw: string | null): Principal | null { + try { + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + return typeof parsed?.naam === 'string' + ? { kind: 'zorgverlener', bsn: '', naam: parsed.naam } + : null; + } catch { + return null; + } +} diff --git a/apps/ssp/src/app/auth/domain/session.spec.ts b/apps/ssp/src/app/auth/domain/session.spec.ts deleted file mode 100644 index c46343d..0000000 --- a/apps/ssp/src/app/auth/domain/session.spec.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { isAuthenticated, Session } from './session'; - -const session: Session = { bsn: '19012345601', naam: 'Test' }; - -describe('isAuthenticated', () => { - it('narrows a present session to Session', () => { - expect(isAuthenticated(session)).toBe(true); - }); - - it('reports no session as not authenticated', () => { - expect(isAuthenticated(null)).toBe(false); - }); -}); diff --git a/apps/ssp/src/app/auth/domain/session.ts b/apps/ssp/src/app/auth/domain/session.ts deleted file mode 100644 index 486dc85..0000000 --- a/apps/ssp/src/app/auth/domain/session.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** Who is logged in. Framework-free domain type. */ -export interface Session { - readonly bsn: string; - readonly naam: string; -} - -export function isAuthenticated(s: Session | null): s is Session { - return s !== null; -} diff --git a/apps/ssp/src/app/auth/infrastructure/digid.adapter.ts b/apps/ssp/src/app/auth/infrastructure/digid.adapter.ts index a4956d0..652622c 100644 --- a/apps/ssp/src/app/auth/infrastructure/digid.adapter.ts +++ b/apps/ssp/src/app/auth/infrastructure/digid.adapter.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { Result, ok } from '@shared/kernel/fp'; import { parseBsn } from '@shared/kernel/bsn'; -import { Session } from '../domain/session'; +import { Principal } from '../domain/principal'; /** Infrastructure: talks to the (mock) DigiD identity provider. */ @Injectable({ providedIn: 'root' }) @@ -9,8 +9,8 @@ export class DigidAdapter { // ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity. // Real BSN validation (parseBsn, WP-40) is the trust boundary; swap the fixed identity // for a real OIDC redirect flow when there's an IdP. - async authenticate(bsn: string): Promise> { + async authenticate(bsn: string): Promise> { const r = parseBsn(bsn); - return r.ok ? ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r; + return r.ok ? ok({ kind: 'zorgverlener', bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r; } } diff --git a/apps/ssp/src/app/brief/application/brief.store.spec.ts b/apps/ssp/src/app/brief/application/brief.store.spec.ts index 145994a..2a493f8 100644 --- a/apps/ssp/src/app/brief/application/brief.store.spec.ts +++ b/apps/ssp/src/app/brief/application/brief.store.spec.ts @@ -1,9 +1,15 @@ import { TestBed } from '@angular/core/testing'; import { describe, it, expect, vi, afterEach } from 'vitest'; import { Result } from '@shared/kernel/fp'; +import { BLOB_PRESENTER, BlobPresenter } from '@shared/application/blob-presenter'; import { Brief, BriefDecisions, CaseContext, LetterBlock } from '@brief/domain/brief'; import { OrgTemplate } from '@brief/domain/org-template'; -import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter'; +import { + BRIEF_LOAD_FAILED, + BriefAdapter, + BriefLoadFailure, + BriefView, +} from '@brief/infrastructure/brief.adapter'; import { LetterPreviewAdapter, PREVIEW_FAILED } from '@brief/infrastructure/letter-preview.adapter'; import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter'; import { BriefStore } from './brief.store'; @@ -48,8 +54,26 @@ const caseContext: CaseContext = { const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext }; -function setup(adapter: Partial): BriefStore { - TestBed.configureTestingModule({ providers: [{ provide: BriefAdapter, useValue: adapter }] }); +/** A recording fake of BLOB_PRESENTER (RB-28/TE-006) — records every call instead of + touching the DOM, so a spec can assert a command's success path directly. */ +function fakeBlobPresenter() { + const opened: Blob[] = []; + const presenter: BlobPresenter = { + open: (blob) => opened.push(blob), + download: () => { + throw new Error('not used by BriefStore'); + }, + }; + return { presenter, opened }; +} + +function setup(adapter: Partial, blobPresenter?: BlobPresenter): BriefStore { + TestBed.configureTestingModule({ + providers: [ + { provide: BriefAdapter, useValue: adapter }, + ...(blobPresenter ? [{ provide: BLOB_PRESENTER, useValue: blobPresenter }] : []), + ], + }); return TestBed.inject(BriefStore); } @@ -60,7 +84,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => { brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } }, }; const store = setup({ - load: (): Promise> => Promise.resolve({ ok: true, value: view }), + load: (): Promise> => + Promise.resolve({ ok: true, value: view }), save: (): Promise> => Promise.resolve({ ok: true, value: view }), approve: (): Promise> => Promise.resolve({ ok: true, value: approved }), @@ -79,7 +104,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => { brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } }, }; const store = setup({ - load: (): Promise> => Promise.resolve({ ok: true, value: view }), + load: (): Promise> => + Promise.resolve({ ok: true, value: view }), save: (): Promise> => Promise.resolve({ ok: true, value: view }), approve: (): Promise> => Promise.resolve({ ok: true, value: approved }), @@ -93,7 +119,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => { it('goes Busy then Failed on a failing transition, surfacing the error', async () => { const store = setup({ - load: (): Promise> => Promise.resolve({ ok: true, value: view }), + load: (): Promise> => + Promise.resolve({ ok: true, value: view }), save: (): Promise> => Promise.resolve({ ok: true, value: view }), approve: (): Promise> => Promise.resolve({ ok: false, error: 'niet toegestaan' }), @@ -108,7 +135,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => { it('a subsequent successful transition clears a prior Failed state', async () => { let approveResult: Result = { ok: false, error: 'eerste poging mislukt' }; const store = setup({ - load: (): Promise> => Promise.resolve({ ok: true, value: view }), + load: (): Promise> => + Promise.resolve({ ok: true, value: view }), save: (): Promise> => Promise.resolve({ ok: true, value: view }), approve: (): Promise> => Promise.resolve(approveResult), }); @@ -156,8 +184,10 @@ function loadedBrief(store: BriefStore): Brief { } async function loadedStore(over: Partial = {}): Promise { - const ok = (v: BriefView): Promise> => - Promise.resolve({ ok: true, value: v }); + // Untyped return (inferred as the narrow `{ ok: true; value }` literal) so this one + // helper satisfies both `load` (error channel `BriefLoadFailure`) and `save` (error + // channel `string`) — it only ever produces the `ok: true` branch. + const ok = (v: BriefView) => Promise.resolve({ ok: true, value: v } as const); const store = setup({ load: () => ok(filledView), save: () => ok(filledView), ...over }); await store.load(); return store; @@ -255,8 +285,7 @@ describe('BriefStore rejection diff', () => { ...filledBrief, status: { tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'nee' }, }; - const ok = (v: BriefView): Promise> => - Promise.resolve({ ok: true, value: v }); + const ok = (v: BriefView) => Promise.resolve({ ok: true, value: v } as const); const store = setup({ load: () => ok({ ...filledView, brief: submitted }), save: () => ok(filledView), @@ -277,41 +306,46 @@ describe('BriefStore rejection diff', () => { }); describe('BriefStore.previewLetter', () => { - // vi.spyOn reuses an existing spy (and its call history) if one is already on - // the property — window.open/URL.createObjectURL must be restored between tests. afterEach(() => vi.restoreAllMocks()); - it('opens the composed letter in a new tab on success', async () => { - const store = setup({ - load: (): Promise> => Promise.resolve({ ok: true, value: view }), - }); + it('opens the composed letter via BLOB_PRESENTER on success (RB-28)', async () => { + const { presenter, opened } = fakeBlobPresenter(); + const store = setup( + { + load: (): Promise> => + Promise.resolve({ ok: true, value: view }), + }, + presenter, + ); await store.load(); const blob = new Blob([''], { type: 'text/html' }); - vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock'); - const open = vi.spyOn(window, 'open').mockImplementation(() => null); vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({ ok: true, value: blob, }); await store.previewLetter(); - expect(open).toHaveBeenCalledWith('blob:mock', '_blank'); + expect(opened).toEqual([blob]); expect(store.lastError()).toBeNull(); }); it('surfaces the error without opening a tab on failure', async () => { - const store = setup({ - load: (): Promise> => Promise.resolve({ ok: true, value: view }), - }); + const { presenter, opened } = fakeBlobPresenter(); + const store = setup( + { + load: (): Promise> => + Promise.resolve({ ok: true, value: view }), + }, + presenter, + ); await store.load(); - const open = vi.spyOn(window, 'open').mockImplementation(() => null); vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({ ok: false, error: PREVIEW_FAILED, }); await store.previewLetter(); - expect(open).not.toHaveBeenCalled(); + expect(opened).toHaveLength(0); expect(store.lastError()).toBe(PREVIEW_FAILED); }); }); @@ -377,3 +411,42 @@ describe('BriefStore.flushPending (CanDeactivate guard / beforeunload)', () => { expect(save).not.toHaveBeenCalled(); }); }); + +// --- RB-22 (CQ-007 expand half): a 404 from GET /brief tolerates by calling the +// existing reset() command, exactly once. Today's backend never 404s (RB-23 adds +// that); this fake adapter is what exercises the branch until then. --- + +describe('BriefStore.load — 404 tolerance (RB-22)', () => { + const notFound: Result = { ok: false, error: { tag: 'notFound' } }; + const resetOk: Result = { ok: true, value: view }; + + it('a 404 drives exactly one reset(), which populates the store', async () => { + // Given GET /brief 404s (no brief exists yet) and reset() succeeds. + const load = vi.fn(() => Promise.resolve(notFound)); + const reset = vi.fn(() => Promise.resolve(resetOk)); + const store = setup({ load, reset }); + + // When the store loads... + await store.load(); + + // Then reset() ran exactly once, and the store ends up loaded from its result. + expect(reset).toHaveBeenCalledTimes(1); + expect(store.model().tag).toBe('loaded'); + }); + + it('a second 404 does not drive a second reset()', async () => { + // Given every load() attempt 404s (e.g. the brief still fails to appear). + const load = vi.fn(() => Promise.resolve(notFound)); + const reset = vi.fn(() => Promise.resolve(resetOk)); + const store = setup({ load, reset }); + + // When the store loads twice... + await store.load(); + await store.load(); + + // Then reset() ran exactly once — the once-only bound holds across calls, not + // just within one — and the second 404 surfaces as an ordinary load failure. + expect(reset).toHaveBeenCalledTimes(1); + expect(store.model()).toEqual({ tag: 'failed', reason: BRIEF_LOAD_FAILED }); + }); +}); diff --git a/apps/ssp/src/app/brief/application/brief.store.ts b/apps/ssp/src/app/brief/application/brief.store.ts index 46e9210..80ab564 100644 --- a/apps/ssp/src/app/brief/application/brief.store.ts +++ b/apps/ssp/src/app/brief/application/brief.store.ts @@ -16,11 +16,12 @@ import { import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine'; import { BlockDiffKind, changedBlocks, diffBlocks } from '@brief/domain/brief-diff'; import { OrgTemplate } from '@brief/domain/org-template'; -import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter'; +import { BRIEF_LOAD_FAILED, BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter'; import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter'; import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter'; -import { uploadContentUrl } from '@shared/upload/upload.adapter'; +import { uploadContentUrl } from '@shared/infrastructure/upload.adapter'; import { PendingSave, registerPendingSave } from '@shared/application/pending-saves'; +import { BLOB_PRESENTER } from '@shared/application/blob-presenter'; /** * Root singleton for the letter: the Elm store (Model + dispatch), the derived @@ -35,6 +36,7 @@ export class BriefStore implements PendingSave { private adapter = inject(BriefAdapter); private previewAdapter = inject(LetterPreviewAdapter); private revealAdapter = inject(RevealBigNummerAdapter); + private blobPresenter = inject(BLOB_PRESENTER); private store = createStore(initial, reduce); readonly model = this.store.model; @@ -119,13 +121,40 @@ export class BriefStore implements PendingSave { return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics()); }); + /** True once a 404-triggered recovery has been attempted (RB-22, CQ-007's expand + half — see `recoverFromMissingBrief`). This is the structural once-only bound: + a repeated 404 falls straight to the `error` branch below and can never reach + `adapter.reset()` a second time, regardless of how many times `load()` runs. */ + private hasRecoveredFromMissingBrief = false; + async load() { const r = await this.adapter.load(); if (r.ok) { - this.orgTemplate.set(r.value.orgTemplate); - this.caseContext.set(r.value.caseContext); - this.history.clear(); - this.store.dispatch({ tag: 'BriefLoaded', ...r.value }); + this.applyLoadedView(r.value); + } else if (r.error.tag === 'notFound' && !this.hasRecoveredFromMissingBrief) { + this.hasRecoveredFromMissingBrief = true; + await this.recoverFromMissingBrief(); + } else { + const reason = r.error.tag === 'notFound' ? BRIEF_LOAD_FAILED : r.error.reason; + this.store.dispatch({ tag: 'BriefLoadFailed', reason }); + } + } + + private applyLoadedView(view: BriefView) { + this.orgTemplate.set(view.orgTemplate); + this.caseContext.set(view.caseContext); + this.history.clear(); + this.store.dispatch({ tag: 'BriefLoaded', ...view }); + } + + /** `GET /brief` 404'd — no brief exists yet for this owner. Recover by calling the + existing `reset()` command directly (the same POST `resetDemo()` uses) and + applying whatever it returns; this NEVER calls `load()` again, so a second 404 + (e.g. `reset()` itself failing) cannot loop back into this method. */ + private async recoverFromMissingBrief() { + const r = await this.adapter.reset(); + if (r.ok) { + this.applyLoadedView(r.value); } else { this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error }); } @@ -217,8 +246,8 @@ export class BriefStore implements PendingSave { send = () => this.transition(() => this.adapter.send()); /** Explicit action, never a live re-render (PRD §8): opens the server-composed - letter in a new tab. ponytail: the blob URL is never revoked — it's cheap and - the tab outlives this call; not worth a teardown hook for a POC. */ + letter in a new tab via `BLOB_PRESENTER.open` — see its doc comment for why the + object URL is never revoked. */ async previewLetter() { this.actionState.set({ tag: 'Busy' }); const r = await this.previewAdapter.preview(); @@ -227,15 +256,18 @@ export class BriefStore implements PendingSave { return; } this.actionState.set({ tag: 'Idle' }); - window.open(URL.createObjectURL(r.value), '_blank'); + this.blobPresenter.open(r.value); } /** Reveal the masked case BIG-nummer (PRD-0002 §5c). Server re-checks the capability + step-up and audits the attempt; on success we swap the masked value in the already-loaded caseContext (a field update, not a reload). The step-up gesture - itself is the UI's concern — this command just runs the audited server call. */ + itself is the UI's concern (`behandel-scherm.component.ts`'s `onReveal()` confirm) + — this command is only reachable once that gesture has happened, so it is the one + that tells the adapter to send `X-Step-Up` (BIO-006a: the adapter itself no longer + hardcodes the header). */ async revealBigNummer() { - const r = await this.revealAdapter.reveal(); + const r = await this.revealAdapter.reveal(true); if (!r.ok) { this.actionState.set({ tag: 'Failed', error: r.error }); return; diff --git a/apps/ssp/src/app/brief/application/org-template.store.spec.ts b/apps/ssp/src/app/brief/application/org-template.store.spec.ts new file mode 100644 index 0000000..f5decef --- /dev/null +++ b/apps/ssp/src/app/brief/application/org-template.store.spec.ts @@ -0,0 +1,120 @@ +import { TestBed } from '@angular/core/testing'; +import { describe, it, expect } from 'vitest'; +import { Result, ok } from '@shared/kernel/fp'; +import { BLOB_PRESENTER, BlobPresenter } from '@shared/application/blob-presenter'; +import { UploadAdapter } from '@shared/infrastructure/upload.adapter'; +import { UploadShellService } from '@shared/application/upload-shell.service'; +import { OrgTemplate, OrgTemplateAdminView, SubOrgSummary } from '@brief/domain/org-template'; +import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter'; +import { OrgTemplateStore } from './org-template.store'; + +const template: OrgTemplate = { + subOrgId: 'cibg-registers', + orgName: 'CIBG — Registers', + returnAddress: 'Postbus 00000\n2500 AA Den Haag', + footerContact: 'info@voorbeeld.example', + footerLegal: 'KvK 00000000', + signatureName: 'A. de Vries', + signatureRole: 'Hoofd Registratie', + signatureClosing: 'Met vriendelijke groet,', + margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 }, + version: 1, +}; + +const view: OrgTemplateAdminView = { + draft: template, + publishedVersion: 1, + history: [], + unsentBriefs: 0, +}; + +const subOrgs: SubOrgSummary[] = [ + { subOrgId: 'cibg-registers', orgName: 'CIBG', publishedVersion: 1 }, +]; + +/** A recording fake of BLOB_PRESENTER (RB-28/TE-006) — records every call instead of + touching the DOM, so a spec can assert a command's success path directly. */ +function fakeBlobPresenter() { + const opened: Blob[] = []; + const presenter: BlobPresenter = { + open: (blob) => opened.push(blob), + download: () => { + throw new Error('not used by OrgTemplateStore'); + }, + }; + return { presenter, opened }; +} + +/** A no-op categories resource: the logo-upload sub-state is untouched by these + tests, so 'idle' (never resolved) keeps the constructor effect from dispatching. */ +function fakeCategoriesResource(): ReturnType { + const fake = { status: () => 'idle' as const, value: () => undefined }; + return fake as unknown as ReturnType; +} + +function setup( + adapter: Partial, + blobPresenter: BlobPresenter, +): OrgTemplateStore { + const uploadAdapter: Partial = { + categoriesResource: () => fakeCategoriesResource(), + }; + TestBed.configureTestingModule({ + providers: [ + { provide: OrgTemplateAdapter, useValue: adapter }, + { provide: UploadAdapter, useValue: uploadAdapter }, + { provide: UploadShellService, useValue: {} }, + { provide: BLOB_PRESENTER, useValue: blobPresenter }, + ], + }); + return TestBed.inject(OrgTemplateStore); +} + +// --- RB-28 (TE-006): proefbrief() ends in BLOB_PRESENTER.open, not a raw +// window.open(URL.createObjectURL(...)) call, so both outcomes are assertable. --- + +describe('OrgTemplateStore.proefbrief (RB-28)', () => { + it('opens the rendered proefbrief via BLOB_PRESENTER on success', async () => { + // Given a loaded sub-org template. + const { presenter, opened } = fakeBlobPresenter(); + const blob = new Blob([''], { type: 'text/html' }); + const store = setup( + { + list: (): Promise> => Promise.resolve(ok(subOrgs)), + load: (): Promise> => Promise.resolve(ok(view)), + proefbrief: (): Promise> => Promise.resolve(ok(blob)), + }, + presenter, + ); + await store.load(); + + // When proefbrief() is called... + await store.proefbrief(); + + // Then the presenter receives exactly the rendered blob, and no error surfaces. + expect(opened).toEqual([blob]); + expect(store.lastError()).toBeNull(); + }); + + it('surfaces the error without opening a tab on failure', async () => { + // Given a loaded sub-org template whose proefbrief call fails server-side. + const { presenter, opened } = fakeBlobPresenter(); + const store = setup( + { + list: (): Promise> => Promise.resolve(ok(subOrgs)), + load: (): Promise> => Promise.resolve(ok(view)), + proefbrief: (): Promise> => + Promise.resolve({ ok: false, error: 'mislukt' }), + }, + presenter, + ); + await store.load(); + + // When proefbrief() is called... + await store.proefbrief(); + + // Then the presenter is never reached and the error is surfaced. + expect(opened).toHaveLength(0); + expect(store.lastError()).toBe('mislukt'); + }); +}); diff --git a/apps/ssp/src/app/brief/application/org-template.store.ts b/apps/ssp/src/app/brief/application/org-template.store.ts index 6135944..67bb1fe 100644 --- a/apps/ssp/src/app/brief/application/org-template.store.ts +++ b/apps/ssp/src/app/brief/application/org-template.store.ts @@ -3,9 +3,9 @@ import { createStore } from '@shared/application/store'; import { ActionState, SaveState } from '@shared/application/action-state'; import { createDebouncedSave } from '@shared/application/debounced-save'; import { machineRemoteData } from '@shared/application/machine-remote-data'; -import { UploadAdapter } from '@shared/upload/upload.adapter'; -import { UploadShellService } from '@shared/upload/upload-shell.service'; -import { UploadMsg, initialUpload, rejectReason } from '@shared/upload/upload.machine'; +import { UploadAdapter, uploadContentUrl } from '@shared/infrastructure/upload.adapter'; +import { UploadShellService } from '@shared/application/upload-shell.service'; +import { UploadMsg, initialUpload, rejectReason } from '@shared/domain/upload.machine'; import { MARGIN_MAX_MM, MARGIN_MIN_MM, @@ -20,6 +20,7 @@ import { } from '@brief/domain/org-template.machine'; import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter'; import { PendingSave, registerPendingSave } from '@shared/application/pending-saves'; +import { BLOB_PRESENTER } from '@shared/application/blob-presenter'; type LoadedState = Extract; @@ -38,6 +39,7 @@ export class OrgTemplateStore implements PendingSave { private adapter = inject(OrgTemplateAdapter); private uploadAdapter = inject(UploadAdapter); private shell = inject(UploadShellService); + private blobPresenter = inject(BLOB_PRESENTER); private store = createStore(initial, reduce); readonly model = this.store.model; @@ -72,6 +74,9 @@ export class OrgTemplateStore implements PendingSave { return id ? this.uploadAdapter.contentUrl(id) : null; }); + /** Preview/download link for any completed upload in the editor's document list. */ + readonly previewUrlFor = (documentId: string): string | undefined => uploadContentUrl(documentId); + /** Client-side mirror of the server rules (`OrgTemplateRules`) for instant feedback; the server re-validates and stays the authority — publish is gated on this. */ readonly draftValid = computed(() => { @@ -214,7 +219,7 @@ export class OrgTemplateStore implements PendingSave { return; } this.actionState.set({ tag: 'Idle' }); - window.open(URL.createObjectURL(r.value), '_blank'); + this.blobPresenter.open(r.value); } // --- logo upload (reuses the shared upload transport; single `org-logo` file) --- diff --git a/apps/ssp/src/app/brief/domain/brief.machine.spec.ts b/apps/ssp/src/app/brief/domain/brief.machine.spec.ts index e37c46b..5ce6a1d 100644 --- a/apps/ssp/src/app/brief/domain/brief.machine.spec.ts +++ b/apps/ssp/src/app/brief/domain/brief.machine.spec.ts @@ -3,6 +3,7 @@ import { Besluit, Brief, BriefDecisions, BriefStatus, LibraryPassage } from './b import { RichTextBlock } from '@shared/kernel/rich-text'; import { PlaceholderDef } from './placeholders'; import { BriefState, reduce } from './brief.machine'; +import { givenBrief } from './brief.testing'; const placeholders: PlaceholderDef[] = [ { key: 'naam', label: 'Naam', autoResolvable: true }, @@ -64,15 +65,15 @@ const decisions: BriefDecisions = { canRevealBigNummer: true, }; -const loaded = ( - status: BriefStatus = { tag: 'draft' }, - sections?: Brief['sections'], -): BriefState => ({ - tag: 'loaded', - brief: briefWith(status, sections), - availablePassages: lib, - decisions, -}); +// Replays a real `BriefLoaded` message through the real `reduce` (ADR-0006 §2) +// instead of hand-assembling the 'loaded' state directly. +const loaded = (status: BriefStatus = { tag: 'draft' }, sections?: Brief['sections']): BriefState => + givenBrief({ + tag: 'BriefLoaded', + brief: briefWith(status, sections), + availablePassages: lib, + decisions, + }); const sectionBlocks = (s: BriefState, key: string) => s.tag === 'loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : []; @@ -128,12 +129,12 @@ describe('brief.machine reduce', () => { it('BesluitSelected deep-copies content — later library mutation does not leak in', () => { const passage = libPassage('intro', 'kern'); // shared → offered for any besluit - const st: BriefState = { - tag: 'loaded', + const st = givenBrief({ + tag: 'BriefLoaded', brief: briefWith({ tag: 'draft' }), availablePassages: [passage], decisions, - }; + }); const s = reduce(st, besluit('positief')); // Mutate the source passage object after composition. (passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED'; diff --git a/apps/ssp/src/app/brief/domain/brief.testing.ts b/apps/ssp/src/app/brief/domain/brief.testing.ts new file mode 100644 index 0000000..56af8ca --- /dev/null +++ b/apps/ssp/src/app/brief/domain/brief.testing.ts @@ -0,0 +1,7 @@ +import { given } from '@shared/testing/machine'; +import { reduce, initial } from './brief.machine'; + +/** Replay real `BriefMsg`s through the real `reduce`, starting from `initial`. + Pure TS only (no Angular) — domain/ stays framework-free (dependency-cruiser + `domain-is-pure`). See `libs/shared/src/testing/machine.ts`. */ +export const givenBrief = given(reduce, initial); diff --git a/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts b/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts index fb223fa..1b8f24b 100644 --- a/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts +++ b/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { expectTag } from '@shared/testing/expect-tag'; import { OrgTemplate, OrgTemplateAdminView } from './org-template'; import { OrgTemplateState, reduce } from './org-template.machine'; -import { DocumentCategory } from '@shared/upload/upload.machine'; +import { DocumentCategory } from '@shared/domain/upload.machine'; const template: OrgTemplate = { subOrgId: 'cibg-registers', diff --git a/apps/ssp/src/app/brief/domain/org-template.machine.ts b/apps/ssp/src/app/brief/domain/org-template.machine.ts index de90a36..7932941 100644 --- a/apps/ssp/src/app/brief/domain/org-template.machine.ts +++ b/apps/ssp/src/app/brief/domain/org-template.machine.ts @@ -1,6 +1,6 @@ import { assertNever } from '@shared/kernel/fp'; import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from './org-template'; -import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/upload/upload.machine'; +import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/domain/upload.machine'; /** * The admin org-template editor as one Elm-style machine (WP-26, PRD Brief v2 §5) — diff --git a/apps/ssp/src/app/brief/infrastructure/brief.adapter.ts b/apps/ssp/src/app/brief/infrastructure/brief.adapter.ts index 9bef9c2..02d3fad 100644 --- a/apps/ssp/src/app/brief/infrastructure/brief.adapter.ts +++ b/apps/ssp/src/app/brief/infrastructure/brief.adapter.ts @@ -1,6 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { Result, ok, err } from '@shared/kernel/fp'; import { runSubmit } from '@shared/application/submit'; +import { problemDetail } from '@shared/infrastructure/api-error'; import { ApiClient, BriefDecisionsDto, @@ -33,8 +34,13 @@ import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/ric * The only place brief HTTP lives (ADR-0001 anti-corruption boundary). The wire * uses FLAT unions (a `type`/`tag` string + nullable fields, the repo convention); * the `parse*` boundary narrows them into the domain's proper discriminated unions - * and rejects malformed shapes. Mutations go through `runSubmit` (ProblemDetails → - * error string), then parse the returned brief. + * and rejects malformed shapes. Every mutation folds through `runSubmit` + * (ProblemDetails → error string, plus the Idempotency-Key mint), then parses the + * returned brief. `load` (the only read) does its own try/catch instead of the + * shared `runResult` fold, because it needs one extra bit `runResult` throws away: + * whether the failure was an HTTP 404 (see `BriefLoadFailure` — RB-22, CQ-007's + * expand half). Today's backend never 404s `GET /brief` (RB-23 adds that), so the + * `notFound` branch is unreached until RB-23 ships; this adapter is ready in advance. */ export interface BriefView { @@ -45,16 +51,39 @@ export interface BriefView { readonly caseContext: CaseContext; } +/** + * Why `load()` did not return a brief. `notFound` is a bare HTTP 404 — kept + * distinct from every other failure so `BriefStore.load()` can tolerate it (call + * `reset()` instead of showing an error banner) without conflating it with a real + * failure. See the class docstring above. + */ +export type BriefLoadFailure = + { readonly tag: 'notFound' } | { readonly tag: 'error'; readonly reason: string }; + export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`; export const BRIEF_ACTION_FAILED = $localize`:@@brief.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`; +/** True when the thrown value carries an HTTP 404 status — matches both the + generic `SwaggerException` (today's shape, since `GET /brief` declares no 404 + response yet) and a parsed `ProblemDetails` (RFC 7807 `status`, the shape once + RB-23 gives the endpoint a documented 404 response). */ +function isHttpNotFound(e: unknown): boolean { + return !!e && typeof e === 'object' && (e as { status?: unknown }).status === 404; +} + @Injectable({ providedIn: 'root' }) export class BriefAdapter { private client = inject(ApiClient); - async load(): Promise> { - const r = await runSubmit(() => this.client.briefGET(), BRIEF_LOAD_FAILED); - return r.ok ? parseBriefView(r.value) : r; + async load(): Promise> { + try { + const dto = await this.client.briefGET(); + const parsed = parseBriefView(dto); + return parsed.ok ? ok(parsed.value) : err({ tag: 'error', reason: parsed.error }); + } catch (e) { + if (isHttpNotFound(e)) return err({ tag: 'notFound' }); + return err({ tag: 'error', reason: problemDetail(e, BRIEF_LOAD_FAILED) }); + } } async save(sections: readonly LetterSection[]): Promise> { diff --git a/apps/ssp/src/app/brief/infrastructure/letter-preview.adapter.spec.ts b/apps/ssp/src/app/brief/infrastructure/letter-preview.adapter.spec.ts new file mode 100644 index 0000000..7c52ca7 --- /dev/null +++ b/apps/ssp/src/app/brief/infrastructure/letter-preview.adapter.spec.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { errorMessage, PREVIEW_FAILED, LetterPreviewAdapter } from './letter-preview.adapter'; + +// Minimal Response stand-in — errorMessage only calls `.json()`. Avoids stubbing +// globalThis.fetch to reach this trust boundary (TE-002). +const fakeResponse = (body: unknown): Response => + ({ json: () => Promise.resolve(body) }) as unknown as Response; + +describe('errorMessage (TE-002 trust boundary)', () => { + it('surfaces the ProblemDetails detail when present', async () => { + expect(await errorMessage(fakeResponse({ detail: 'Geen toegang.', status: 403 }))).toBe( + 'Geen toegang.', + ); + }); + + it('falls back to PREVIEW_FAILED when the body has no detail', async () => { + expect(await errorMessage(fakeResponse({ status: 500 }))).toBe(PREVIEW_FAILED); + }); + + it('falls back to PREVIEW_FAILED when the body is not JSON', async () => { + const res = { json: () => Promise.reject(new Error('not json')) } as unknown as Response; + expect(await errorMessage(res)).toBe(PREVIEW_FAILED); + }); +}); + +// isDevMode() reads the `ngDevMode` global the Angular CLI defines away in a +// production build. There is no ambient type for it in app code, so this is +// accessed through an untyped bag rather than a `declare const`. +const globals = globalThis as Record; +const originalNgDevMode = globals['ngDevMode']; +const setDevMode = (on: boolean) => { + globals['ngDevMode'] = on; +}; + +describe('LetterPreviewAdapter.preview (BIO-012)', () => { + const okResponse = () => + ({ ok: true, blob: () => Promise.resolve(new Blob()) }) as unknown as Response; + + afterEach(() => { + globals['ngDevMode'] = originalNgDevMode; + vi.unstubAllGlobals(); + history.pushState({}, '', '/'); + sessionStorage.clear(); + }); + + it('sends no X-Role/X-Subject headers outside isDevMode()', async () => { + setDevMode(false); + history.pushState({}, '', '/?subject=111222333'); + const fetchSpy = vi.fn().mockResolvedValue(okResponse()); + vi.stubGlobal('fetch', fetchSpy); + + await new LetterPreviewAdapter().preview(); + + expect(fetchSpy.mock.calls[0][1].headers).toEqual({}); + }); + + it('sends X-Role (and X-Subject when known) under isDevMode()', async () => { + setDevMode(true); + history.pushState({}, '', '/?subject=111222333'); + const fetchSpy = vi.fn().mockResolvedValue(okResponse()); + vi.stubGlobal('fetch', fetchSpy); + + await new LetterPreviewAdapter().preview(); + + const headers = fetchSpy.mock.calls[0][1].headers as Record; + expect(headers['X-Role']).toBeDefined(); + expect(headers['X-Subject']).toBe('111222333'); + }); +}); diff --git a/apps/ssp/src/app/brief/infrastructure/letter-preview.adapter.ts b/apps/ssp/src/app/brief/infrastructure/letter-preview.adapter.ts index 7fc1f31..d777d7d 100644 --- a/apps/ssp/src/app/brief/infrastructure/letter-preview.adapter.ts +++ b/apps/ssp/src/app/brief/infrastructure/letter-preview.adapter.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { Injectable, isDevMode } from '@angular/core'; import { Result, ok, err } from '@shared/kernel/fp'; import { currentRole } from '@shared/infrastructure/role'; import { currentSubject } from '@shared/infrastructure/subject'; @@ -15,7 +15,10 @@ export const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning * hand-written fetch, not the `ApiClient`. That also means it bypasses `HttpClient`'s * `roleInterceptor` AND `subjectInterceptor`, so both `X-Role` and `X-Subject` are set * here explicitly (WP-74 — without `X-Subject` this always previewed - * `DocumentStore.DemoOwner`'s letter regardless of who was actually logged in). + * `DocumentStore.DemoOwner`'s letter regardless of who was actually logged in). Both are + * dev-only identity stand-ins (`role.ts`/`subject.ts`) and are sent only under + * `isDevMode()`, mirroring how the interceptors themselves are only registered in dev + * (`app.config.ts`) — a production build sends neither header from this call (BIO-012). * * `cache: 'no-store'` (WP-74): the endpoint has no `Cache-Control`, only a CORS-driven * `Vary: Origin`, and its content changes at the SAME URL as the letter moves @@ -43,7 +46,9 @@ export class LetterPreviewAdapter { const subject = currentSubject(); res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/preview`, { cache: 'no-store', - headers: { 'X-Role': currentRole(), ...(subject ? { 'X-Subject': subject } : {}) }, + headers: isDevMode() + ? { 'X-Role': currentRole(), ...(subject ? { 'X-Subject': subject } : {}) } + : {}, }); } catch { return err(PREVIEW_FAILED); @@ -53,7 +58,9 @@ export class LetterPreviewAdapter { } } -async function errorMessage(res: Response): Promise { +/** Trust boundary (TE-002): maps a non-OK response to a message. Exported so a spec + can call it directly instead of stubbing `globalThis.fetch`. */ +export async function errorMessage(res: Response): Promise { try { return problemDetail(await res.json(), PREVIEW_FAILED); } catch { diff --git a/apps/ssp/src/app/brief/infrastructure/org-template.adapter.spec.ts b/apps/ssp/src/app/brief/infrastructure/org-template.adapter.spec.ts index ac7d221..b102a90 100644 --- a/apps/ssp/src/app/brief/infrastructure/org-template.adapter.spec.ts +++ b/apps/ssp/src/app/brief/infrastructure/org-template.adapter.spec.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from 'vitest'; import { OrgTemplateAdminViewDto, OrgTemplateDto } from '@shared/infrastructure/api-client'; -import { parseOrgTemplateAdminView } from './org-template.adapter'; +import { + parseOrgTemplateAdminView, + proefbriefErrorMessage, + PROEFBRIEF_FAILED, +} from './org-template.adapter'; const draft: OrgTemplateDto = { subOrgId: 'cibg-registers', @@ -54,3 +58,25 @@ describe('parseOrgTemplateAdminView', () => { expect(r.ok).toBe(false); }); }); + +// Minimal Response stand-in — proefbriefErrorMessage only calls `.json()`. Avoids +// stubbing globalThis.fetch to reach this trust boundary (TE-002). +const fakeResponse = (body: unknown): Response => + ({ json: () => Promise.resolve(body) }) as unknown as Response; + +describe('proefbriefErrorMessage (TE-002 trust boundary)', () => { + it('surfaces the ProblemDetails detail when present', async () => { + expect( + await proefbriefErrorMessage(fakeResponse({ detail: 'Niet gevonden.', status: 404 })), + ).toBe('Niet gevonden.'); + }); + + it('falls back to PROEFBRIEF_FAILED when the body has no detail', async () => { + expect(await proefbriefErrorMessage(fakeResponse({ status: 500 }))).toBe(PROEFBRIEF_FAILED); + }); + + it('falls back to PROEFBRIEF_FAILED when the body is not JSON', async () => { + const res = { json: () => Promise.reject(new Error('not json')) } as unknown as Response; + expect(await proefbriefErrorMessage(res)).toBe(PROEFBRIEF_FAILED); + }); +}); diff --git a/apps/ssp/src/app/brief/infrastructure/org-template.adapter.ts b/apps/ssp/src/app/brief/infrastructure/org-template.adapter.ts index 9477030..339c0c2 100644 --- a/apps/ssp/src/app/brief/infrastructure/org-template.adapter.ts +++ b/apps/ssp/src/app/brief/infrastructure/org-template.adapter.ts @@ -1,6 +1,6 @@ -import { Injectable, inject } from '@angular/core'; +import { Injectable, inject, isDevMode } from '@angular/core'; import { Result, ok, err } from '@shared/kernel/fp'; -import { runSubmit } from '@shared/application/submit'; +import { runResult, runSubmit } from '@shared/application/submit'; import { currentRole } from '@shared/infrastructure/role'; import { problemDetail } from '@shared/infrastructure/api-error'; import { environment } from '@shared/environments/environment'; @@ -26,17 +26,20 @@ import { parseOrgTemplate } from '@brief/infrastructure/brief.adapter'; * rollback go through the generated client (X-Role added by `roleInterceptor`); * `parse*` narrows the untrusted wire shape. The proefbrief is `text/html` and * `ExcludeFromDescription`'d — a hand-written fetch, same seam as `letter-preview.adapter`. + * `X-Role` there is a dev-only identity stand-in (`role.ts`) and is sent only under + * `isDevMode()`, mirroring `roleInterceptor`'s own dev-only registration — a production + * build never sends it from this hand-written call either (BIO-012). */ const FAILED = $localize`:@@orgTemplate.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`; -const PROEFBRIEF_FAILED = $localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`; +export const PROEFBRIEF_FAILED = $localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`; @Injectable({ providedIn: 'root' }) export class OrgTemplateAdapter { private client = inject(ApiClient); async list(): Promise> { - const r = await runSubmit(() => this.client.orgTemplates(), FAILED); + const r = await runResult(() => this.client.orgTemplates(), FAILED); if (!r.ok) return r; const out: SubOrgSummary[] = []; for (const s of r.value ?? []) { @@ -48,7 +51,7 @@ export class OrgTemplateAdapter { } async load(subOrgId: string): Promise> { - const r = await runSubmit(() => this.client.orgTemplateGET(subOrgId), FAILED); + const r = await runResult(() => this.client.orgTemplateGET(subOrgId), FAILED); return r.ok ? parseAdminView(r.value) : r; } @@ -76,22 +79,26 @@ export class OrgTemplateAdapter { try { res = await fetch( `${environment.apiBaseUrl}/api/v1/admin/org-template/${encodeURIComponent(subOrgId)}/preview`, - { headers: { 'X-Role': currentRole() } }, + { headers: isDevMode() ? { 'X-Role': currentRole() } : {} }, ); } catch { return err(PROEFBRIEF_FAILED); } - if (!res.ok) { - try { - return err(problemDetail(await res.json(), PROEFBRIEF_FAILED)); - } catch { - return err(PROEFBRIEF_FAILED); - } - } + if (!res.ok) return err(await proefbriefErrorMessage(res)); return ok(await res.blob()); } } +/** Trust boundary (TE-002): maps a non-OK proefbrief response to a message. Exported + so a spec can call it directly instead of stubbing `globalThis.fetch`. */ +export async function proefbriefErrorMessage(res: Response): Promise { + try { + return problemDetail(await res.json(), PROEFBRIEF_FAILED); + } catch { + return PROEFBRIEF_FAILED; + } +} + // --- parse: wire → domain, validating at the boundary --- function parseSubOrg(dto: SubOrgSummaryDto): Result { diff --git a/apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.spec.ts b/apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.spec.ts new file mode 100644 index 0000000..c5ba514 --- /dev/null +++ b/apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.spec.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { parseRevealed, REVEAL_FAILED, RevealBigNummerAdapter } from './reveal-bignummer.adapter'; + +describe('parseRevealed (TE-002 trust boundary)', () => { + it('accepts a well-formed body', () => { + const r = parseRevealed({ bigNummer: '12345678' }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.value).toBe('12345678'); + }); + + // The finding's own named case: a numeric bigNummer must be rejected, not + // coerced — this is a PII reveal, not a display formatter. + it('rejects a bigNummer sent as a number', () => { + const r = parseRevealed({ bigNummer: 42 }); + expect(r).toEqual({ ok: false, error: REVEAL_FAILED }); + }); + + it('rejects a missing bigNummer field', () => { + expect(parseRevealed({}).ok).toBe(false); + }); + + it('rejects null and non-object bodies', () => { + expect(parseRevealed(null).ok).toBe(false); + expect(parseRevealed(undefined).ok).toBe(false); + expect(parseRevealed('12345678').ok).toBe(false); + expect(parseRevealed(42).ok).toBe(false); + }); +}); + +// isDevMode() reads the `ngDevMode` global the Angular CLI defines away in a +// production build. There is no ambient type for it in app code, so this is +// accessed through an untyped bag rather than a `declare const`. +const globals = globalThis as Record; +const originalNgDevMode = globals['ngDevMode']; +const setDevMode = (on: boolean) => { + globals['ngDevMode'] = on; +}; + +describe('RevealBigNummerAdapter.reveal (BIO-006a + BIO-012)', () => { + const okResponse = () => + ({ ok: true, json: () => Promise.resolve({ bigNummer: '12345678' }) }) as unknown as Response; + + beforeEach(() => setDevMode(true)); + afterEach(() => { + globals['ngDevMode'] = originalNgDevMode; + vi.unstubAllGlobals(); + }); + + it('sends X-Step-Up only when the caller passes stepUp: true', async () => { + const fetchSpy = vi.fn().mockResolvedValue(okResponse()); + vi.stubGlobal('fetch', fetchSpy); + + await new RevealBigNummerAdapter().reveal(false); + const headersWithoutStepUp = fetchSpy.mock.calls[0][1].headers as Record; + expect(headersWithoutStepUp['X-Step-Up']).toBeUndefined(); + + await new RevealBigNummerAdapter().reveal(true); + const headersWithStepUp = fetchSpy.mock.calls[1][1].headers as Record; + expect(headersWithStepUp['X-Step-Up']).toBe('true'); + }); + + it('sends X-Role only under isDevMode()', async () => { + const fetchSpy = vi.fn().mockResolvedValue(okResponse()); + vi.stubGlobal('fetch', fetchSpy); + + setDevMode(false); + await new RevealBigNummerAdapter().reveal(true); + const prodHeaders = fetchSpy.mock.calls[0][1].headers as Record; + expect(prodHeaders['X-Role']).toBeUndefined(); + expect(prodHeaders['X-Step-Up']).toBe('true'); // step-up is not a dev-only hatch + + setDevMode(true); + await new RevealBigNummerAdapter().reveal(true); + const devHeaders = fetchSpy.mock.calls[1][1].headers as Record; + expect(devHeaders['X-Role']).toBeDefined(); + }); +}); diff --git a/apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.ts b/apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.ts index 1b722e9..60d721b 100644 --- a/apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.ts +++ b/apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.ts @@ -1,47 +1,62 @@ -import { Injectable } from '@angular/core'; +import { Injectable, isDevMode } from '@angular/core'; import { Result, ok, err } from '@shared/kernel/fp'; import { currentRole } from '@shared/infrastructure/role'; import { problemDetail } from '@shared/infrastructure/api-error'; import { environment } from '@shared/environments/environment'; -const REVEAL_FAILED = $localize`:@@brief.reveal.failed:Het BIG-nummer kon niet worden getoond.`; +/** Exported so specs can assert against the same message id instead of retyping the + Dutch sentence (matches `letter-preview.adapter.ts`'s `PREVIEW_FAILED`). */ +export const REVEAL_FAILED = $localize`:@@brief.reveal.failed:Het BIG-nummer kon niet worden getoond.`; /** * Field-level PII reveal (PRD-0002 §5c). The case screen ships the BIG-nummer masked; * this unmasks it, gated server-side by the reveal capability AND a step-up. The - * step-up is stubbed as the `X-Step-Up` header — the caller sends it only after the - * user's confirm gesture, so a plain call (or a role without the capability) 403s. + * step-up is stubbed as the `X-Step-Up` header, sent only when the caller passes + * `stepUp: true` — `BriefStore.revealBigNummer()` is the only caller and it is only + * ever reachable after `behandel-scherm.component.ts`'s `onReveal()` confirm gesture, + * so the header now reflects that gesture instead of being a constant baked into this + * adapter (BIO-006a — a call that skips confirmation sends no step-up at all). * * Hand-written fetch (not the `ApiClient`) because the call needs a per-request header; * `.ExcludeFromDescription()` on the endpoint keeps the generated client JSON-only, the - * same seam as `/brief/preview` and uploads — which also means `X-Role` is set here. + * same seam as `/brief/preview` and uploads. `X-Role` is a dev-only identity stand-in + * (see `role.ts`) and is therefore only sent under `isDevMode()`, mirroring the + * `roleInterceptor` registration in `app.config.ts` — a production build never sends it + * from this hand-written call either (BIO-012). */ @Injectable({ providedIn: 'root' }) export class RevealBigNummerAdapter { - async reveal(): Promise> { + async reveal(stepUp: boolean): Promise> { let res: Response; try { res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/reveal-bignummer`, { method: 'POST', - headers: { 'X-Role': currentRole(), 'X-Step-Up': 'true' }, + headers: { + ...(isDevMode() ? { 'X-Role': currentRole() } : {}), + ...(stepUp ? { 'X-Step-Up': 'true' } : {}), + }, }); } catch { return err(REVEAL_FAILED); } if (!res.ok) return err(await errorMessage(res)); - const body: unknown = await res.json().catch(() => null); - // Trust boundary: validate the shape before handing back a plain string. - if ( - typeof body === 'object' && - body !== null && - typeof (body as { bigNummer?: unknown }).bigNummer === 'string' - ) { - return ok((body as { bigNummer: string }).bigNummer); - } - return err(REVEAL_FAILED); + return parseRevealed(await res.json().catch(() => null)); } } +/** Trust boundary: validate the untrusted response shape before handing back a plain + string (TE-002) — exported so a spec can call it without stubbing `globalThis.fetch`. */ +export function parseRevealed(body: unknown): Result { + if ( + typeof body === 'object' && + body !== null && + typeof (body as { bigNummer?: unknown }).bigNummer === 'string' + ) { + return ok((body as { bigNummer: string }).bigNummer); + } + return err(REVEAL_FAILED); +} + async function errorMessage(res: Response): Promise { try { return problemDetail(await res.json(), REVEAL_FAILED); diff --git a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts index b9b8c98..2690e0f 100644 --- a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts +++ b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts @@ -5,7 +5,7 @@ import { ButtonComponent } from '@shared/ui/button/button.component'; import { AlertComponent } from '@shared/ui/alert/alert.component'; import { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component'; import { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.component'; -import { UploadState } from '@shared/upload/upload.machine'; +import { UploadState } from '@shared/domain/upload.machine'; import { Brief } from '@brief/domain/brief'; import { MARGIN_MAX_MM, diff --git a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.stories.ts b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.stories.ts index 2b4d987..11fca36 100644 --- a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.stories.ts +++ b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.stories.ts @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from '@storybook/angular'; import { OrgTemplateEditorComponent } from './org-template-editor.component'; import { OrgTemplate, OrgTemplateVersion, SubOrgSummary } from '@brief/domain/org-template'; -import { UploadState, initialUpload } from '@shared/upload/upload.machine'; +import { UploadState, initialUpload } from '@shared/domain/upload.machine'; const draft: OrgTemplate = { subOrgId: 'cibg-registers', diff --git a/apps/ssp/src/app/brief/ui/org-template.page.ts b/apps/ssp/src/app/brief/ui/org-template.page.ts index f28b7b4..697f19d 100644 --- a/apps/ssp/src/app/brief/ui/org-template.page.ts +++ b/apps/ssp/src/app/brief/ui/org-template.page.ts @@ -4,7 +4,6 @@ import { AlertComponent } from '@shared/ui/alert/alert.component'; import { ButtonComponent } from '@shared/ui/button/button.component'; import { ASYNC } from '@shared/ui/async/async.component'; import { AccessStore } from '@shared/application/access.store'; -import { UploadAdapter } from '@shared/upload/upload.adapter'; import { OrgTemplateStore } from '@brief/application/org-template.store'; import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component'; @@ -86,10 +85,9 @@ import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-te export class OrgTemplatePage { protected store = inject(OrgTemplateStore); protected access = inject(AccessStore); - private uploadAdapter = inject(UploadAdapter); protected canEdit = computed(() => this.access.can('orgtemplate:edit')); - protected previewUrlFor = (documentId: string) => this.uploadAdapter.contentUrl(documentId); + protected previewUrlFor = this.store.previewUrlFor; protected heading = $localize`:@@orgTemplate.page.heading:Huisstijl beheren`; protected intro = $localize`:@@orgTemplate.page.intro:Beheer per organisatieonderdeel het uiterlijk van de brief: logo, afzender, ondertekening, voettekst en marges.`; diff --git a/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts b/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts index 2c457a7..ffed921 100644 --- a/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts +++ b/apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts @@ -7,7 +7,7 @@ import { reduceUpload, requiredCategoriesSatisfied, deliveryRefs, -} from '@shared/upload/upload.machine'; +} from '@shared/domain/upload.machine'; /** What the user is typing (raw, possibly invalid). */ export interface Draft { diff --git a/apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts b/apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts index c5360f3..9987b85 100644 --- a/apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts +++ b/apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts @@ -2,7 +2,6 @@ import { describe, it, expect } from 'vitest'; import { ok, err } from '@shared/kernel/fp'; import { expectTag } from '@shared/testing/expect-tag'; import { - Answers, initial, STEPS, lageUren, @@ -15,14 +14,7 @@ import { reduce, IntakeState, } from './intake.machine'; - -const answering = (answers: Answers, cursor = 0, scholingThreshold = 1000): IntakeState => ({ - tag: 'Answering', - answers, - cursor, - errors: {}, - scholingThreshold, -}); +import { givenIntake } from './intake.testing'; describe('STEPS (fixed) and inline questions', () => { it('always has the same three steps', () => { @@ -31,12 +23,12 @@ describe('STEPS (fixed) and inline questions', () => { it('reveals the buitenland detail questions inline only when worked abroad', () => { // No new step; instead these fields become required within the buitenland step. - expect(next(answering({ buitenlandGewerkt: 'ja' })).tag).toBe('Answering'); // land/uren missing -> blocked - expect( - expectTag(next(answering({ buitenlandGewerkt: 'ja' })), 'Answering').errors.land, - ).toBeTruthy(); - expect(next(answering({ buitenlandGewerkt: 'nee' })).tag).toBe('Answering'); // valid, advances (cursor moves) - expect(expectTag(next(answering({ buitenlandGewerkt: 'nee' })), 'Answering').cursor).toBe(1); + const abroad = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' }); + expect(next(abroad).tag).toBe('Answering'); // land/uren missing -> blocked + expect(expectTag(next(abroad), 'Answering').errors.land).toBeTruthy(); + const domestic = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }); + expect(next(domestic).tag).toBe('Answering'); // valid, advances (cursor moves) + expect(expectTag(next(domestic), 'Answering').cursor).toBe(1); }); it('reveals the scholing question only when NL-hours are below the threshold', () => { @@ -49,9 +41,13 @@ describe('STEPS (fixed) and inline questions', () => { expect(lageUren({ uren: '1500' }, 1000)).toBe(false); expect(lageUren({ uren: '1500' }, 2000)).toBe(true); // And the threshold from state flows through submit: - const lowThreshold = submit( - answering({ buitenlandGewerkt: 'nee', uren: '1500', punten: '200' }, 0, 2000), + const lowThresholdState = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '1500' }, + { tag: 'SetAnswer', key: 'punten', value: '200' }, + { tag: 'SetPolicy', scholingThreshold: 2000 }, ); + const lowThreshold = submit(lowThresholdState); expect(lowThreshold.tag).toBe('Answering'); // scholing now required (1500 < 2000), unanswered → blocked expect(expectTag(lowThreshold, 'Answering').errors.scholingGevolgd).toBeTruthy(); }); @@ -65,18 +61,21 @@ describe('navigation', () => { }); it('Next advances once the step is valid', () => { - const s = expectTag(next(answering({ buitenlandGewerkt: 'nee' })), 'Answering'); + const domestic = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }); + const s = expectTag(next(domestic), 'Answering'); expect(s.cursor).toBe(1); expect(currentStep(s)).toBe('werk'); }); it('editing an answer leaves the cursor fixed (steps never collapse)', () => { + const atWerk = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' }, + { tag: 'SetAnswer', key: 'land', value: 'België' }, + { tag: 'SetAnswer', key: 'buitenlandseUren', value: '300' }, + { tag: 'Next' }, // buitenland step valid -> cursor 0 -> 1 + ); const edited = expectTag( - reduce(answering({ buitenlandGewerkt: 'ja' }, 1), { - tag: 'SetAnswer', - key: 'buitenlandGewerkt', - value: 'nee', - }), + reduce(atWerk, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }), 'Answering', ); expect(edited.cursor).toBe(1); // cursor untouched; only inline questions change @@ -87,57 +86,86 @@ describe('navigation', () => { }); it('gaNaarStap jumps back to an earlier step, clearing errors', () => { - const s = answering({ buitenlandGewerkt: 'nee' }, 2); + const s = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'Next' }, // cursor 0 -> 1 + { tag: 'SetAnswer', key: 'uren', value: '4160' }, + { tag: 'Next' }, // cursor 1 -> 2 + ); expect(expectTag(gaNaarStap(s, 0), 'Answering').cursor).toBe(0); }); it('gaNaarStap ignores a same/forward jump and jumps outside Answering', () => { - const s = answering({ buitenlandGewerkt: 'nee' }, 1); + const s = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'Next' }, // cursor 0 -> 1 + ); expect(gaNaarStap(s, 1)).toBe(s); // same step -> no-op expect(gaNaarStap(s, 2)).toBe(s); // forward -> no-op - const submitting = submit(answering({ buitenlandGewerkt: 'nee', uren: '4160' }, 2)); + const atReview = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'Next' }, // cursor 0 -> 1 + { tag: 'SetAnswer', key: 'uren', value: '4160' }, + { tag: 'Next' }, // cursor 1 -> 2 + ); + const submitting = submit(atReview); expect(gaNaarStap(submitting, 0)).toBe(submitting); // not Answering -> no-op }); }); describe('submit', () => { // High hours: no scholing question, so no punten is asked or collected. - const complete: Answers = { buitenlandGewerkt: 'nee', uren: '4160' }; + const highUren = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '4160' }, + ); it('reaches Submitting ONLY with valid answers', () => { // Bad punten only blocks when scholing was followed (otherwise punten is ignored). - expect( - submit( - answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: 'x' }), - ).tag, - ).toBe('Answering'); - const good = expectTag(submit(answering(complete)), 'Submitting'); + const badPunten = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '500' }, + { tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' }, + { tag: 'SetAnswer', key: 'punten', value: 'x' }, + ); + expect(submit(badPunten).tag).toBe('Answering'); + const good = expectTag(submit(highUren), 'Submitting'); expect(good.data.uren).toBe(4160); expect(good.data.punten).toBeUndefined(); // not collected without scholing }); it('punten is required only when aanvullende scholing was gevolgd', () => { // scholing = ja but punten missing -> blocked on punten. - const missing = expectTag( - submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja' })), - 'Answering', + const scholingJaNoPunten = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '500' }, + { tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' }, ); + const missing = expectTag(submit(scholingJaNoPunten), 'Answering'); expect(missing.errors.punten).toBeTruthy(); // scholing = nee -> punten not required, submits without it. - expect( - submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'nee' })).tag, - ).toBe('Submitting'); + const scholingNee = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '500' }, + { tag: 'SetAnswer', key: 'scholingGevolgd', value: 'nee' }, + ); + expect(submit(scholingNee).tag).toBe('Submitting'); }); it('low hours requires the scholing answer before submit', () => { - const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500' })); - expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered - const withScholing = expectTag( - submit( - answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: '200' }), - ), - 'Submitting', + const lowUrenNoScholing = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '500' }, ); + const noScholing = submit(lowUrenNoScholing); + expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered + const lowUrenWithScholing = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '500' }, + { tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' }, + { tag: 'SetAnswer', key: 'punten', value: '200' }, + ); + const withScholing = expectTag(submit(lowUrenWithScholing), 'Submitting'); expect(withScholing.data.aanvullendeScholing).toBe(true); expect(withScholing.data.punten).toBe(200); }); @@ -145,38 +173,36 @@ describe('submit', () => { it('does not require punten for a hidden question (WP-69 §6)', () => { // scholingGevolgd is a stale 'ja' from when uren was low, but uren is now above // threshold — the template hides the question, so punten must not be required either. - const good = expectTag( - submit(answering({ buitenlandGewerkt: 'nee', uren: '1500', scholingGevolgd: 'ja' })), - 'Submitting', + const staleScholingNoPunten = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '1500' }, + { tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' }, ); + const good = expectTag(submit(staleScholingNoPunten), 'Submitting'); expect(good.data.aanvullendeScholing).toBeUndefined(); }); it('drops punten when raising uren hides the question (WP-69 §6)', () => { // Same stale answer, but this time punten was also filled in while uren was low. - const good = expectTag( - submit( - answering({ - buitenlandGewerkt: 'nee', - uren: '1500', - scholingGevolgd: 'ja', - punten: '150', - }), - ), - 'Submitting', + const staleScholingWithPunten = givenIntake( + { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }, + { tag: 'SetAnswer', key: 'uren', value: '1500' }, + { tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' }, + { tag: 'SetAnswer', key: 'punten', value: '150' }, ); + const good = expectTag(submit(staleScholingWithPunten), 'Submitting'); // ValidIntake stays honest: neither the stale 'ja' nor its punten leak through. expect(good.data.aanvullendeScholing).toBeUndefined(); expect(good.data.punten).toBeUndefined(); }); it('resolve maps Submitting to Submitted on a successful submit', () => { - const submitting = submit(answering(complete)); + const submitting = submit(highUren); expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted'); }); it('resolve maps Submitting to Failed on a failed submit', () => { - const submitting = submit(answering(complete)); + const submitting = submit(highUren); expect(resolve(submitting, err('boom')).tag).toBe('Failed'); }); }); diff --git a/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts b/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts index f2cc4ad..986dae0 100644 --- a/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts +++ b/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts @@ -23,9 +23,8 @@ import { } from '@herregistratie/domain/herregistratie.machine'; import { createDraftSync } from '@registratie/application/draft-sync'; import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component'; -import { createUploadController } from '@shared/upload/upload-controller'; -import { UploadAdapter } from '@shared/upload/upload.adapter'; -import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine'; +import { createUploadController } from '@shared/application/upload-controller'; +import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.machine'; /** Organism: multi-step herregistratie wizard. ALL state lives in one signal driven by the pure `reduce` function (see herregistratie.machine.ts) via an @@ -149,13 +148,13 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload. }) export class HerregistratieWizardComponent { private profile = inject(BigProfileStore); - private uploadAdapter = inject(UploadAdapter); private store = createStore(initial, reduce); - /** Preview/download link for a completed upload; dev-simulation `demo-*` ids have - no stored bytes, so they get no link. */ + /** Preview/download link for a completed upload; delegates to the upload + controller (application layer), which knows the dev-simulation `demo-*` ids + have no stored bytes and returns no link for them. */ protected previewUrlFor = (documentId: string): string | undefined => - documentId.startsWith('demo-') ? undefined : this.uploadAdapter.contentUrl(documentId); + this.uploadCtl.previewUrlFor(documentId); /** Optional seed so Storybook / the showcase can mount any state directly. */ seed = input(initial); diff --git a/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts b/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts index f876c5e..4a632a3 100644 --- a/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts +++ b/apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.stories.ts @@ -4,7 +4,7 @@ import { provideHttpClient } from '@angular/common/http'; import { provideApiClient } from '@shared/infrastructure/api-client.provider'; import { HerregistratieWizardComponent } from './herregistratie-wizard.component'; import { WizardState } from '@herregistratie/domain/herregistratie.machine'; -import { initialUpload } from '@shared/upload/upload.machine'; +import { initialUpload } from '@shared/domain/upload.machine'; import { Uren } from '@registratie/domain/value-objects/uren'; const validData = { uren: 4160 as Uren, jaren: 5, punten: 200, documents: [] }; diff --git a/apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts b/apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts index 4b757dd..ffab1b2 100644 --- a/apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts +++ b/apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts @@ -1,5 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { describe, it, expect, vi } from 'vitest'; +import { SUBMIT_FAILED } from '@shared/application/submit'; import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter'; import { AdminCasesStore } from './admin-cases.store'; @@ -43,7 +44,10 @@ describe('AdminCasesStore', () => { expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['b']); }); - it('rolls back the removal when the delete fails', async () => { + // RB-20: a failed delete must not be silent — the row rolls back AND the store + // surfaces the error the page renders. Before RB-20 this only rolled back + // (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever. + it('rolls back the removal and surfaces the error when the delete fails', async () => { const deleteAny = vi.fn().mockRejectedValue(new Error('boom')); const store = setup({ listAll: () => Promise.resolve([summary('a')]), deleteAny }); await store.load(); @@ -51,5 +55,24 @@ describe('AdminCasesStore', () => { await store.delete('a'); const s = store.cases(); expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['a']); // reappears + expect(store.lastError()).toBe(SUBMIT_FAILED); + }); + + it('clears a stale error on the next delete attempt', async () => { + const deleteAny = vi + .fn() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce(undefined); + const store = setup({ + listAll: () => Promise.resolve([summary('a'), summary('b')]), + deleteAny, + }); + await store.load(); + + await store.delete('a'); + expect(store.lastError()).toBe(SUBMIT_FAILED); + + await store.delete('b'); + expect(store.lastError()).toBeNull(); }); }); diff --git a/apps/ssp/src/app/registratie/application/admin-cases.store.ts b/apps/ssp/src/app/registratie/application/admin-cases.store.ts index 943dcb4..b4580b9 100644 --- a/apps/ssp/src/app/registratie/application/admin-cases.store.ts +++ b/apps/ssp/src/app/registratie/application/admin-cases.store.ts @@ -1,5 +1,6 @@ import { Injectable, inject, signal } from '@angular/core'; import { RemoteData } from '@shared/application/remote-data'; +import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit'; import { Aanvraag } from '@registratie/domain/aanvraag'; import { ApplicationsAdapter, @@ -12,8 +13,9 @@ type Err = Error | undefined; * Admin view of ALL cases across owners (WP-36; `cases:manage`) — the back-office * counterpart of the user-facing `ApplicationsStore`. Same shape: one root singleton * owns the list as a writable RemoteData signal, delete removes the row synchronously - * (optimistic) and rolls back on error. Admin delete removes any case (any owner, - * submitted or not — the server enforces the capability). + * (optimistic), goes through `runSubmit`, and rolls back plus surfaces `lastError` on + * failure (RB-20). Admin delete removes any case (any owner, submitted or not — the + * server enforces the capability). */ @Injectable({ providedIn: 'root' }) export class AdminCasesStore { @@ -22,6 +24,11 @@ export class AdminCasesStore { private state = signal>({ tag: 'Loading' }); readonly cases = this.state.asReadonly(); + /** Set on a failed delete (RB-20): the optimistic removal already rolled back by + then, this is only the message for the alert the page renders above the list. */ + private error = signal(null); + readonly lastError = this.error.asReadonly(); + /** Fetch + parse at the trust boundary, then publish as RemoteData. Keeps the last-good value on a resync (only shows Loading on the first load). */ async load() { @@ -42,16 +49,18 @@ export class AdminCasesStore { void this.load(); } - /** Delete a case: drop it now (synchronous), then confirm the DELETE; roll back on error. */ + /** Delete a case: drop it now (synchronous), then confirm the DELETE; roll back on error + AND surface it (RB-20) — a silent reappearance leaves the admin guessing why. */ async delete(id: string) { const before = this.state(); if (before.tag === 'Success') { this.state.set({ tag: 'Success', value: before.value.filter((a) => a.id !== id) }); } - try { - await this.adapter.deleteAny(id); - } catch { + this.error.set(null); + const r = await runSubmit(() => this.adapter.deleteAny(id), SUBMIT_FAILED); + if (!r.ok) { this.state.set(before); // roll back: the row reappears + this.error.set(r.error); } } } diff --git a/apps/ssp/src/app/registratie/application/applications.store.spec.ts b/apps/ssp/src/app/registratie/application/applications.store.spec.ts new file mode 100644 index 0000000..f8860af --- /dev/null +++ b/apps/ssp/src/app/registratie/application/applications.store.spec.ts @@ -0,0 +1,77 @@ +import { TestBed } from '@angular/core/testing'; +import { describe, it, expect, vi } from 'vitest'; +import { SUBMIT_FAILED } from '@shared/application/submit'; +import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter'; +import { ApplicationsStore } from './applications.store'; + +const summary = (id: string) => ({ + id, + type: 'registratie', + status: { tag: 'Concept', stepIndex: 0, stepCount: 3 }, + documentIds: [], + createdAt: '2026-07-23T10:00:00Z', + updatedAt: '2026-07-23T10:00:00Z', +}); + +function setup(adapter: Partial): ApplicationsStore { + TestBed.configureTestingModule({ + providers: [{ provide: ApplicationsAdapter, useValue: adapter }], + }); + // The store's own constructor kicks off `load()` (dashboard revisit refresh) — + // give every test a `list` so that initial call has something to resolve. + return TestBed.inject(ApplicationsStore); +} + +describe('ApplicationsStore', () => { + it('loads and parses the list', async () => { + const store = setup({ list: () => Promise.resolve([summary('a'), summary('b')]) }); + await store.load(); + const s = store.applications(); + expect(s.tag).toBe('Success'); + expect(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['a', 'b']); + }); + + it('cancels optimistically and confirms via the DELETE endpoint', async () => { + const cancel = vi.fn().mockResolvedValue(undefined); + const store = setup({ + list: () => Promise.resolve([summary('a'), summary('b')]), + cancel, + }); + await store.load(); + + await store.cancel('a'); + expect(cancel).toHaveBeenCalledWith('a'); + const s = store.applications(); + expect(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['b']); + expect(store.lastError()).toBeNull(); + }); + + // RB-20: a failed cancel must not be silent — the row rolls back AND the store + // surfaces the error the page renders. Before RB-20 this only rolled back + // (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever. + it('rolls back the removal and surfaces the error when the cancel fails', async () => { + const cancel = vi.fn().mockRejectedValue(new Error('boom')); + const store = setup({ list: () => Promise.resolve([summary('a')]), cancel }); + await store.load(); + + await store.cancel('a'); + const s = store.applications(); + expect(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['a']); // reappears + expect(store.lastError()).toBe(SUBMIT_FAILED); + }); + + it('clears a stale error on the next cancel attempt', async () => { + const cancel = vi + .fn() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce(undefined); + const store = setup({ list: () => Promise.resolve([summary('a'), summary('b')]), cancel }); + await store.load(); + + await store.cancel('a'); + expect(store.lastError()).toBe(SUBMIT_FAILED); + + await store.cancel('b'); + expect(store.lastError()).toBeNull(); + }); +}); diff --git a/apps/ssp/src/app/registratie/application/applications.store.ts b/apps/ssp/src/app/registratie/application/applications.store.ts index db96201..5157dad 100644 --- a/apps/ssp/src/app/registratie/application/applications.store.ts +++ b/apps/ssp/src/app/registratie/application/applications.store.ts @@ -1,5 +1,6 @@ import { Injectable, inject, signal } from '@angular/core'; import { RemoteData } from '@shared/application/remote-data'; +import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit'; import { Aanvraag } from '@registratie/domain/aanvraag'; import { ApplicationsAdapter, @@ -15,7 +16,8 @@ type Err = Error | undefined; * the row SYNCHRONOUSLY, so the block disappears deterministically — no dependence on * change-detection timing, HTTP caching, or a resource `reload()`. `reload()` re-fetches * so a page revisit reflects auto-approval (Concept → In behandeling → Goedgekeurd is - * computed server-side on read). + * computed server-side on read). Cancel goes through `runSubmit` and rolls back plus + * surfaces `lastError` on failure (RB-20). */ @Injectable({ providedIn: 'root' }) export class ApplicationsStore { @@ -24,6 +26,11 @@ export class ApplicationsStore { private state = signal>({ tag: 'Loading' }); readonly applications = this.state.asReadonly(); + /** Set on a failed cancel (RB-20): the optimistic removal already rolled back by + then, this is only the message for the alert the page renders above the list. */ + private error = signal(null); + readonly lastError = this.error.asReadonly(); + constructor() { void this.load(); } @@ -50,16 +57,19 @@ export class ApplicationsStore { } /** Cancel a Concept: drop it now (synchronous, guaranteed), then confirm the DELETE. - No resync — the delete succeeded, so the optimistic removal is authoritative. */ + No resync — the delete succeeded, so the optimistic removal is authoritative. On + failure, roll back AND surface the error (RB-20) — a silent reappearance leaves the + user guessing why the block came back. */ async cancel(id: string) { const before = this.state(); if (before.tag === 'Success') { this.state.set({ tag: 'Success', value: before.value.filter((a) => a.id !== id) }); } - try { - await this.adapter.cancel(id); - } catch { + this.error.set(null); + const r = await runSubmit(() => this.adapter.cancel(id), SUBMIT_FAILED); + if (!r.ok) { this.state.set(before); // roll back: the block reappears + this.error.set(r.error); } } } diff --git a/apps/ssp/src/app/registratie/application/draft-sync.ts b/apps/ssp/src/app/registratie/application/draft-sync.ts index ff533dd..eec4426 100644 --- a/apps/ssp/src/app/registratie/application/draft-sync.ts +++ b/apps/ssp/src/app/registratie/application/draft-sync.ts @@ -8,10 +8,8 @@ import type { SubmitApplicationResponse, } from '@shared/infrastructure/api-client'; import { AanvraagType } from '@registratie/domain/aanvraag'; -import { - ApplicationsAdapter, - parseApplications, -} from '@registratie/infrastructure/applications.adapter'; +import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter'; +import { findConcept, loadConcept } from './find-concept'; /** What a wizard persists per step: the opaque machine snapshot + progress + docs. */ export interface DraftSnapshot { @@ -70,7 +68,7 @@ export function createDraftSync(deps: DraftSyncDeps) { // server's guard (409) — recover by adopting the existing Concept instead of // erroring. Only recover when one actually exists; otherwise surface the failure. .catch(async (e) => { - const existing = await findConcept(); + const existing = await findConcept(adapter, deps.type); if (existing) return existing; throw e; }) @@ -140,32 +138,14 @@ export function createDraftSync(deps: DraftSyncDeps) { // (submitted/gone) id is treated as fresh so it can't reopen as an editable draft. const load = (linked: string): Promise => { id = linked; - return adapter - .detail(linked) - .then((dto) => { - if (dto.status && dto.status.tag !== 'Concept') { - id = undefined; - applyResume(null); - return; - } - applyResume(dto.draft ?? null); - }) - .catch(() => { + return loadConcept(adapter, linked).then((result) => { + if (result.tag === 'not-concept') { id = undefined; - applyResume(null); // unknown/deleted id → start fresh - }); - }; - - // Find the user's existing Concept of this type (at most one), if any. - const findConcept = async (): Promise => { - try { - const parsed = parseApplications(await adapter.list()); - return parsed.ok - ? parsed.value.find((a) => a.type === deps.type && a.status.tag === 'Concept')?.id - : undefined; - } catch { - return undefined; - } + applyResume(null); + return; + } + applyResume(result.draft); + }); }; return { @@ -189,7 +169,7 @@ export function createDraftSync(deps: DraftSyncDeps) { await load(linked); return; } - const existing = await findConcept(); + const existing = await findConcept(adapter, deps.type); if (existing) { await load(existing); // Stamp the id into the URL so a reload resumes the same Concept. diff --git a/apps/ssp/src/app/registratie/application/find-concept.spec.ts b/apps/ssp/src/app/registratie/application/find-concept.spec.ts new file mode 100644 index 0000000..c617ad0 --- /dev/null +++ b/apps/ssp/src/app/registratie/application/find-concept.spec.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest'; +import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter'; +import { findConcept, loadConcept } from './find-concept'; + +// Free functions taking the adapter as a parameter (no inject()) — a plain fake +// object is enough, no Angular TestBed needed. +function fakeAdapter(overrides: Partial): ApplicationsAdapter { + return overrides as ApplicationsAdapter; +} + +describe('findConcept', () => { + it('returns the id of the existing Concept of the given type', async () => { + const adapter = fakeAdapter({ + list: async () => [ + { + id: 'a1', + type: 'registratie', + status: { tag: 'Concept', stepIndex: 0, stepCount: 3 }, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + ], + }); + + await expect(findConcept(adapter, 'registratie')).resolves.toBe('a1'); + }); + + it('returns undefined when the list has no application of the given type', async () => { + const adapter = fakeAdapter({ list: async () => [] }); + + await expect(findConcept(adapter, 'registratie')).resolves.toBeUndefined(); + }); + + it('returns undefined when the matching type is not a Concept', async () => { + const adapter = fakeAdapter({ + list: async () => [ + { + id: 'a1', + type: 'registratie', + status: { tag: 'Ingediend', referentie: 'R1' }, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + ], + }); + + await expect(findConcept(adapter, 'registratie')).resolves.toBeUndefined(); + }); + + it('returns undefined when adapter.list() resolves with an unparsable shape', async () => { + const adapter = fakeAdapter({ list: async () => 'not-an-array' as unknown as [] }); + + await expect(findConcept(adapter, 'registratie')).resolves.toBeUndefined(); + }); + + it('returns undefined when adapter.list() rejects', async () => { + const adapter = fakeAdapter({ + list: async () => { + throw new Error('network down'); + }, + }); + + await expect(findConcept(adapter, 'registratie')).resolves.toBeUndefined(); + }); +}); + +describe('loadConcept', () => { + it('reads the draft off a Concept', async () => { + const adapter = fakeAdapter({ + detail: async () => ({ + id: 'a1', + status: { tag: 'Concept', stepIndex: 1, stepCount: 3 }, + draft: { step: 1 }, + }), + }); + + await expect(loadConcept(adapter, 'a1')).resolves.toEqual({ + tag: 'concept', + draft: { step: 1 }, + }); + }); + + it('reports a missing draft as null', async () => { + const adapter = fakeAdapter({ + detail: async () => ({ id: 'a1', status: { tag: 'Concept', stepIndex: 0, stepCount: 3 } }), + }); + + await expect(loadConcept(adapter, 'a1')).resolves.toEqual({ tag: 'concept', draft: null }); + }); + + it('reports not-concept when the id has moved past Concept (submitted)', async () => { + const adapter = fakeAdapter({ + detail: async () => ({ id: 'a1', status: { tag: 'Ingediend', referentie: 'R1' } }), + }); + + await expect(loadConcept(adapter, 'a1')).resolves.toEqual({ tag: 'not-concept' }); + }); + + it('reports not-concept when the id is unknown or deleted (detail rejects)', async () => { + const adapter = fakeAdapter({ + detail: async () => { + throw new Error('404'); + }, + }); + + await expect(loadConcept(adapter, 'gone')).resolves.toEqual({ tag: 'not-concept' }); + }); +}); diff --git a/apps/ssp/src/app/registratie/application/find-concept.ts b/apps/ssp/src/app/registratie/application/find-concept.ts new file mode 100644 index 0000000..6b0cf75 --- /dev/null +++ b/apps/ssp/src/app/registratie/application/find-concept.ts @@ -0,0 +1,48 @@ +import { AanvraagType } from '@registratie/domain/aanvraag'; +import { + ApplicationsAdapter, + parseApplications, +} from '@registratie/infrastructure/applications.adapter'; + +/** + * Read half of the Concept lookup that `createDraftSync` (`draft-sync.ts`) needs + * before it can start writing (RB-21 / CQ-001). Free functions that take the adapter + * as a parameter, not `inject()`, so they get a direct spec without Angular TestBed. + * `createDraftSync` keeps the closure state (`id`, `resumeGate`) and the write path; + * these two functions only read. + */ + +/** Find the user's existing Concept of a given type (at most one), if any. */ +export async function findConcept( + adapter: ApplicationsAdapter, + type: AanvraagType, +): Promise { + try { + const parsed = parseApplications(await adapter.list()); + return parsed.ok + ? parsed.value.find((a) => a.type === type && a.status.tag === 'Concept')?.id + : undefined; + } catch { + return undefined; + } +} + +/** Outcome of loading one Concept by id: its draft (or null when it has none), or + `not-concept` when the id is not an editable Concept (submitted/gone) or the + lookup failed (unknown/deleted id) — the caller treats both the same way, as + "start fresh". */ +export type LoadedConcept = { tag: 'concept'; draft: unknown | null } | { tag: 'not-concept' }; + +/** Load a specific Concept by id and report whether it is still editable. */ +export async function loadConcept( + adapter: ApplicationsAdapter, + id: string, +): Promise { + try { + const dto = await adapter.detail(id); + if (dto.status && dto.status.tag !== 'Concept') return { tag: 'not-concept' }; + return { tag: 'concept', draft: dto.draft ?? null }; + } catch { + return { tag: 'not-concept' }; + } +} diff --git a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts index 695b314..f137d23 100644 --- a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts +++ b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts @@ -1,9 +1,8 @@ import { describe, it, expect } from 'vitest'; import { ok, err } from '@shared/kernel/fp'; -import { initialUpload } from '@shared/upload/upload.machine'; +import { given } from '@shared/testing/machine'; import { expectTag } from '@shared/testing/expect-tag'; import { - Draft, RegistratieState, STEPS, initial, @@ -21,28 +20,50 @@ import { resolve, reduce, } from './registratie-wizard.machine'; +import { givenRegistratieWizard } from './registratie-wizard.testing'; -const invullen = (draft: Partial, cursor = 0): RegistratieState => ({ - tag: 'Invullen', - draft: { antwoorden: {}, ...draft }, - cursor, - errors: {}, - upload: initialUpload, -}); +/** + * Every fixture below is built by replaying real `RegistratieMsg`s through the + * real `reduce` (ADR-0006 §2) — never a hand-assembled `RegistratieState` + * literal. Each helper reaches a named point in the wizard one transition at a + * time, so a spec can only assert on a state the reducer can actually produce. + */ +const toAdresValid = (): RegistratieState => + givenRegistratieWizard( + { + tag: 'PrefillAdres', + straat: 'Lange Voorhout 9', + postcode: '2514 EA', + woonplaats: 'Den Haag', + }, + { tag: 'SetCorrespondentie', value: 'post' }, + ); -const validAdres = { - straat: 'Lange Voorhout 9', - postcode: '2514 EA', - woonplaats: 'Den Haag', - correspondentie: 'post' as const, - adresHerkomst: 'brp' as const, -}; -const validDraft: Partial = { - ...validAdres, - diplomaId: 'd1', - beroep: 'Arts', - diplomaHerkomst: 'duo', -}; +const toBeroepStep = (): RegistratieState => reduce(toAdresValid(), { tag: 'Next' }); // cursor 0 -> 1, no diploma yet + +const toBeroepStepWithDiploma = (): RegistratieState => + reduce(toBeroepStep(), { tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] }); + +const toControleStep = (): RegistratieState => reduce(toBeroepStepWithDiploma(), { tag: 'Next' }); // cursor 1 -> 2 + +const toIndienen = (): RegistratieState => reduce(toControleStep(), { tag: 'Submit' }); + +// A complete, valid draft assembled WITHOUT ever advancing the cursor. Setting a +// field or choosing a diploma is never gated by cursor position, so this is a +// real, reachable 'Invullen' state at cursor 0 — matching what `submit()` +// (which validates the whole draft regardless of cursor) is exercised against +// in the tests below. +const toFullDraftAtCursor0 = (): RegistratieState => + givenRegistratieWizard( + { + tag: 'PrefillAdres', + straat: 'Lange Voorhout 9', + postcode: '2514 EA', + woonplaats: 'Den Haag', + }, + { tag: 'SetCorrespondentie', value: 'post' }, + { tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] }, + ); describe('STEPS (fixed)', () => { it('always has the same three steps', () => { @@ -60,46 +81,55 @@ describe('navigation', () => { }); it('Next advances once the adres step is valid', () => { - const s = expectTag(next(invullen(validAdres)), 'Invullen'); + const s = expectTag(next(toAdresValid()), 'Invullen'); expect(s.cursor).toBe(1); expect(currentStep(s)).toBe('beroep'); }); it('requires a valid e-mail only when the channel is email', () => { - const bad = expectTag(next(invullen({ ...validAdres, correspondentie: 'email' })), 'Invullen'); + const withEmailChannel = givenRegistratieWizard( + { + tag: 'PrefillAdres', + straat: 'Lange Voorhout 9', + postcode: '2514 EA', + woonplaats: 'Den Haag', + }, + { tag: 'SetCorrespondentie', value: 'email' }, + ); + const bad = expectTag(next(withEmailChannel), 'Invullen'); expect(bad.errors.email).toBeTruthy(); const good = expectTag( - next(invullen({ ...validAdres, correspondentie: 'email', email: 'a@b.nl' })), + next(given(reduce, withEmailChannel)({ tag: 'SetField', key: 'email', value: 'a@b.nl' })), 'Invullen', ); expect(good.cursor).toBe(1); }); it('beroep step requires a chosen diploma', () => { - const noDiploma = expectTag(next(invullen(validAdres, 1)), 'Invullen'); + const noDiploma = expectTag(next(toBeroepStep()), 'Invullen'); expect(noDiploma.cursor).toBe(1); expect(noDiploma.errors.diploma).toBeTruthy(); - const withDiploma = expectTag(next(invullen(validDraft, 1)), 'Invullen'); + const withDiploma = expectTag(next(toBeroepStepWithDiploma()), 'Invullen'); expect(withDiploma.cursor).toBe(2); }); it('Back never goes below the first step and preserves the draft', () => { expect(back(initial)).toBe(initial); - const s = expectTag(back(invullen(validDraft, 2)), 'Invullen'); + const s = expectTag(back(toControleStep()), 'Invullen'); expect(s.cursor).toBe(1); expect(s.draft.beroep).toBe('Arts'); }); it('GaNaarStap only jumps backwards', () => { - expect(expectTag(gaNaarStap(invullen(validDraft, 2), 0), 'Invullen').cursor).toBe(0); - expect(expectTag(gaNaarStap(invullen(validDraft, 1), 2), 'Invullen').cursor).toBe(1); // forward jump rejected + expect(expectTag(gaNaarStap(toControleStep(), 0), 'Invullen').cursor).toBe(0); + expect(expectTag(gaNaarStap(toBeroepStepWithDiploma(), 2), 'Invullen').cursor).toBe(1); // forward jump rejected }); }); describe('adres origin (BRP vs handmatig)', () => { it('prefillAdres flags origin brp', () => { const s = expectTag( - prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag'), + prefillAdres(initial, 'Lange Voorhout 9', '2514 EA', 'Den Haag'), 'Invullen', ); expect(s.draft.adresHerkomst).toBe('brp'); @@ -107,43 +137,38 @@ describe('adres origin (BRP vs handmatig)', () => { }); it('editing a prefilled address field flips origin to handmatig', () => { - const prefilled = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag'); + const prefilled = prefillAdres(initial, 'Lange Voorhout 9', '2514 EA', 'Den Haag'); const edited = expectTag(setField(prefilled, 'woonplaats', 'Rotterdam'), 'Invullen'); expect(edited.draft.adresHerkomst).toBe('handmatig'); }); it('typing an address with no BRP prefill yields handmatig', () => { - const s = expectTag(setField(invullen({}), 'straat', 'Kerkstraat 1'), 'Invullen'); + const s = expectTag(setField(initial, 'straat', 'Kerkstraat 1'), 'Invullen'); expect(s.draft.adresHerkomst).toBe('handmatig'); }); it('editing the e-mail field does not change the address origin', () => { - const prefilled = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag'); + const prefilled = prefillAdres(initial, 'Lange Voorhout 9', '2514 EA', 'Den Haag'); const edited = expectTag(setField(prefilled, 'email', 'a@b.nl'), 'Invullen'); expect(edited.draft.adresHerkomst).toBe('brp'); }); it('a manually entered address still submits (only manual diploma is gated)', () => { - const s = submit( - invullen({ - straat: 'Kerkstraat 1', - postcode: '1234 AB', - woonplaats: 'Utrecht', - correspondentie: 'post', - adresHerkomst: 'handmatig', - diplomaId: 'd1', - beroep: 'Arts', - diplomaHerkomst: 'duo', - }), + const manualAdres = givenRegistratieWizard( + { tag: 'SetField', key: 'straat', value: 'Kerkstraat 1' }, + { tag: 'SetField', key: 'postcode', value: '1234 AB' }, + { tag: 'SetField', key: 'woonplaats', value: 'Utrecht' }, + { tag: 'SetCorrespondentie', value: 'post' }, + { tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] }, ); - const indienen = expectTag(s, 'Indienen'); + const indienen = expectTag(submit(manualAdres), 'Indienen'); expect(indienen.data.adresHerkomst).toBe('handmatig'); }); }); describe('kiesDiploma', () => { it('derives the beroep from the chosen diploma and flags origin duo', () => { - const s = expectTag(kiesDiploma(invullen({}), 'd9', 'Verpleegkundige', []), 'Invullen'); + const s = expectTag(kiesDiploma(initial, 'd9', 'Verpleegkundige', []), 'Invullen'); expect(s.draft.diplomaId).toBe('d9'); expect(s.draft.beroep).toBe('Verpleegkundige'); expect(s.draft.diplomaHerkomst).toBe('duo'); @@ -152,7 +177,7 @@ describe('kiesDiploma', () => { describe('policy questions (geldigheidsvragen)', () => { it('a diploma with questions blocks Next until they are answered', () => { - let s = kiesDiploma(invullen(validAdres, 1), 'd2', 'Arts', ['nl-taalvaardigheid']); + let s = kiesDiploma(toBeroepStep(), 'd2', 'Arts', ['nl-taalvaardigheid']); const blocked = expectTag(next(s), 'Invullen'); expect(blocked.cursor).toBe(1); expect(blocked.errors.antwoorden?.['nl-taalvaardigheid']).toBeTruthy(); @@ -161,7 +186,12 @@ describe('policy questions (geldigheidsvragen)', () => { }); it('validateAll keeps only the answers to the questions that applied', () => { - let s = kiesDiploma(invullen(validAdres, 2), 'd2', 'Arts', ['nl-taalvaardigheid']); + // DRIFT (see rb-31.md): the old literal put the wizard at cursor 2 before any + // diploma was chosen. That combination cannot occur in the real reducer — + // advancing past 'beroep' (cursor 1 -> 2) requires a diploma to already be + // set. Replayed here at cursor 1 instead; submit() validates the whole draft + // regardless of cursor, so the assertion below is unaffected. + let s = kiesDiploma(toBeroepStep(), 'd2', 'Arts', ['nl-taalvaardigheid']); s = setAntwoord(s, 'nl-taalvaardigheid', 'ja'); s = setAntwoord(s, 'stale', 'x'); // not in vraagIds const done = expectTag(submit(s), 'Indienen'); @@ -173,14 +203,16 @@ describe('manual diploma fallback', () => { const maxIds = ['nl-taalvaardigheid', 'diploma-erkend', 'toelichting']; it('KiesHandmatig flags handmatig with the maximal question set and no beroep yet', () => { - const s = expectTag(kiesHandmatig(invullen(validAdres, 1), maxIds), 'Invullen'); + const s = expectTag(kiesHandmatig(toBeroepStep(), maxIds), 'Invullen'); expect(s.draft.diplomaHerkomst).toBe('handmatig'); expect(s.draft.beroep).toBeUndefined(); expect(s.draft.vraagIds).toEqual(maxIds); }); it('requires a declared beroep + all maximal questions before submit', () => { - let s = kiesHandmatig(invullen(validAdres, 2), maxIds); + // DRIFT (see rb-31.md): same unreachable cursor-2-before-diploma combination + // as above. Replayed at cursor 1; submit() is cursor-agnostic. + let s = kiesHandmatig(toBeroepStep(), maxIds); expect(submit(s).tag).toBe('Invullen'); // no beroep declared s = declareerBeroep(s, 'Fysiotherapeut'); expect(submit(s).tag).toBe('Invullen'); // questions unanswered @@ -193,11 +225,11 @@ describe('manual diploma fallback', () => { describe('submit', () => { it('stays in Invullen when the draft is incomplete (no diploma)', () => { - expect(submit(invullen(validAdres)).tag).toBe('Invullen'); + expect(submit(toAdresValid()).tag).toBe('Invullen'); }); it('reaches Indienen with a complete, valid draft, carrying its data', () => { - const good = expectTag(submit(invullen(validDraft)), 'Indienen'); + const good = expectTag(submit(toFullDraftAtCursor0()), 'Indienen'); expect(good.data.beroep).toBe('Arts'); expect(good.data.adres.postcode).toBe('2514 EA'); expect(good.data.adresHerkomst).toBe('brp'); @@ -205,43 +237,18 @@ describe('submit', () => { it('resolve maps Indienen to Ingediend with the referentie', () => { const ingediend = expectTag( - resolve(submit(invullen(validDraft)), ok('BIG-2026-001')), + resolve(submit(toFullDraftAtCursor0()), ok('BIG-2026-001')), 'Ingediend', ); expect(ingediend.referentie).toBe('BIG-2026-001'); }); it('resolve maps Indienen to Mislukt on a failed submit', () => { - expect(resolve(submit(invullen(validDraft)), err('boom')).tag).toBe('Mislukt'); + expect(resolve(submit(toFullDraftAtCursor0()), err('boom')).tag).toBe('Mislukt'); }); }); describe('reduce (message-driven happy path)', () => { - // Each helper replays real messages through the real reducer up to the named - // point — no hand-assembled state literal — so each test below Givens its own - // starting point independently, one transition at a time. - const toBeroepStep = (): RegistratieState => { - let s: RegistratieState = initial; - s = reduce(s, { - tag: 'PrefillAdres', - straat: 'Lange Voorhout 9', - postcode: '2514 EA', - woonplaats: 'Den Haag', - }); - s = reduce(s, { tag: 'SetCorrespondentie', value: 'post' }); - return reduce(s, { tag: 'Next' }); - }; - const toControleStep = (): RegistratieState => { - const s = reduce(toBeroepStep(), { - tag: 'KiesDiploma', - diplomaId: 'd1', - beroep: 'Arts', - vraagIds: [], - }); - return reduce(s, { tag: 'Next' }); - }; - const toIndienen = (): RegistratieState => reduce(toControleStep(), { tag: 'Submit' }); - it('adres and correspondentie set, Next advances from adres to beroep', () => { // Given the initial wizard. // When the adres is prefilled, correspondentie chosen, and Next dispatched... @@ -279,7 +286,7 @@ describe('reduce (message-driven happy path)', () => { }); it('SubmitFailed moves Indienen to Mislukt', () => { - const s = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), { + const s = reduce(reduce(toFullDraftAtCursor0(), { tag: 'Submit' }), { tag: 'SubmitFailed', error: 'boom', }); @@ -287,7 +294,7 @@ describe('reduce (message-driven happy path)', () => { }); it('Retry returns Mislukt to Indienen with the same data', () => { - const mislukt = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), { + const mislukt = reduce(reduce(toFullDraftAtCursor0(), { tag: 'Submit' }), { tag: 'SubmitFailed', error: 'boom', }); @@ -310,7 +317,7 @@ describe('inline document upload (beroep step)', () => { it('routes Upload messages through the upload reducer', () => { const s = expectTag( - reduce(invullen(validDraft), { + reduce(toFullDraftAtCursor0(), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] }, }), @@ -320,7 +327,7 @@ describe('inline document upload (beroep step)', () => { }); it('blocks the beroep step until a required category is satisfied', () => { - let s = reduce(invullen(validDraft, 1), { + let s = reduce(toBeroepStepWithDiploma(), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] }, }); @@ -339,7 +346,7 @@ describe('inline document upload (beroep step)', () => { }); it('includes delivery refs in the submitted data', () => { - let s = reduce(invullen(validDraft), { + let s = reduce(toFullDraftAtCursor0(), { tag: 'Upload', msg: { type: 'CategoriesLoaded', categories: [cat] }, }); diff --git a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts index 73b96d4..069178a 100644 --- a/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts +++ b/apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts @@ -9,7 +9,7 @@ import { reduceUpload, requiredCategoriesSatisfied, deliveryRefs, -} from '@shared/upload/upload.machine'; +} from '@shared/domain/upload.machine'; /** * A FIXED 3-step registration wizard. The steps never change in number (always diff --git a/apps/ssp/src/app/registratie/domain/registratie-wizard.testing.ts b/apps/ssp/src/app/registratie/domain/registratie-wizard.testing.ts new file mode 100644 index 0000000..2949ccf --- /dev/null +++ b/apps/ssp/src/app/registratie/domain/registratie-wizard.testing.ts @@ -0,0 +1,7 @@ +import { given } from '@shared/testing/machine'; +import { reduce, initial } from './registratie-wizard.machine'; + +/** Replay real `RegistratieMsg`s through the real `reduce`, starting from + `initial`. Pure TS only (no Angular) — domain/ stays framework-free + (dependency-cruiser `domain-is-pure`). See `libs/shared/src/testing/machine.ts`. */ +export const givenRegistratieWizard = given(reduce, initial); diff --git a/apps/ssp/src/app/registratie/ui/admin-cases.page.ts b/apps/ssp/src/app/registratie/ui/admin-cases.page.ts index aa088a4..8f83fad 100644 --- a/apps/ssp/src/app/registratie/ui/admin-cases.page.ts +++ b/apps/ssp/src/app/registratie/ui/admin-cases.page.ts @@ -42,6 +42,9 @@ import { AdminCasesStore } from '@registratie/application/admin-cases.store'; } @else if (!canManage()) { {{ deniedText }} } @else { + @if (store.lastError(); as err) { + {{ err }} + } {{ failedText }} diff --git a/apps/ssp/src/app/registratie/ui/dashboard.page.ts b/apps/ssp/src/app/registratie/ui/dashboard.page.ts index 1e17309..86c5a35 100644 --- a/apps/ssp/src/app/registratie/ui/dashboard.page.ts +++ b/apps/ssp/src/app/registratie/ui/dashboard.page.ts @@ -51,6 +51,9 @@ import { tasksFromProfile } from '@registratie/domain/tasks'; intro="Welkom in uw persoonlijke omgeving van het BIG-register. Hier ziet u uw registratie en regelt u uw zaken." >
+ @if (cancelError(); as err) { + {{ err }} + } @if (aanvragen().length) {
@for (a of concepten(); track a.id) { @@ -260,6 +263,8 @@ export class DashboardPage { protected cancelAanvraag(a: Aanvraag) { void this.apps.cancel(a.id); } + /** RB-20: the message from a failed cancel, rendered above the list. */ + protected cancelError = computed(() => this.apps.lastError()); /** Server-computed eligibility (rendered, not recomputed). */ private readonly eligible = computed(() => { diff --git a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts index aa2b60a..25e3b19 100644 --- a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts +++ b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts @@ -37,9 +37,8 @@ import { } from '@registratie/domain/registratie-wizard.machine'; import { createDraftSync } from '@registratie/application/draft-sync'; import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component'; -import { createUploadController } from '@shared/upload/upload-controller'; -import { UploadAdapter } from '@shared/upload/upload.adapter'; -import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine'; +import { createUploadController } from '@shared/application/upload-controller'; +import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.machine'; const KANALEN = [ { value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` }, @@ -368,13 +367,13 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid'; }) export class RegistratieWizardComponent { private lookup = inject(RegistratieLookupStore); - private uploadAdapter = inject(UploadAdapter); private store = createStore(initial, reduce); - /** Preview/download link for a completed upload; the dev-simulation `demo-*` ids - have no stored bytes, so they get no link. */ + /** Preview/download link for a completed upload; delegates to the upload + controller (application layer), which knows the dev-simulation `demo-*` ids + have no stored bytes and returns no link for them. */ protected previewUrlFor = (documentId: string): string | undefined => - documentId.startsWith('demo-') ? undefined : this.uploadAdapter.contentUrl(documentId); + this.uploadCtl.previewUrlFor(documentId); /** Optional seed so Storybook / tests can mount any state directly. */ seed = input(initial); diff --git a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts index 5fd9ef4..29f31db 100644 --- a/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts +++ b/apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.stories.ts @@ -8,7 +8,7 @@ import { RegistratieState, ValidRegistratie, } from '@registratie/domain/registratie-wizard.machine'; -import { initialUpload } from '@shared/upload/upload.machine'; +import { initialUpload } from '@shared/domain/upload.machine'; import { Postcode } from '@registratie/domain/value-objects/postcode'; const adres: Partial = { diff --git a/apps/ssp/src/app/shell/debug-state/debug-state.component.ts b/apps/ssp/src/app/shell/debug-state/debug-state.component.ts index 8a6f190..b3faf88 100644 --- a/apps/ssp/src/app/shell/debug-state/debug-state.component.ts +++ b/apps/ssp/src/app/shell/debug-state/debug-state.component.ts @@ -1,7 +1,7 @@ import { Component, Injector, computed, inject, isDevMode, signal } from '@angular/core'; import { JsonPipe } from '@angular/common'; import { SessionStore } from '@auth/application/session.store'; -import { Session } from '@auth/domain/session'; +import { Principal } from '@auth/domain/principal'; import { BigProfileStore } from '@registratie/application/big-profile.store'; import { map } from '@shared/application/remote-data'; import { Role } from '@shared/domain/role'; @@ -172,6 +172,6 @@ export class DebugStateComponent { } } -function maskSession(s: Session | null): Session | null { - return s ? { ...s, bsn: maskBsn(s.bsn) } : null; +function maskSession(p: Principal | null): Principal | null { + return p ? { ...p, bsn: maskBsn(p.bsn) } : null; } diff --git a/apps/ssp/src/app/shell/debug-state/mask.spec.ts b/apps/ssp/src/app/shell/debug-state/mask.spec.ts new file mode 100644 index 0000000..92915a4 --- /dev/null +++ b/apps/ssp/src/app/shell/debug-state/mask.spec.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { BigProfile } from '@registratie/domain/big-profile'; +import { REDACTED } from '@shared/kernel/pii'; +import { redactProfile } from './mask'; + +const profile: BigProfile = { + registration: { + bigNummer: '12345678901', + naam: 'J. Jansen', + beroep: 'arts', + registratiedatum: '2015-03-01', + geboortedatum: '1980-06-12', + status: { tag: 'Geregistreerd', herregistratieDatum: '2027-03-01' }, + }, + person: { + naam: 'J. Jansen', + geboortedatum: '1980-06-12', + adres: { straat: 'Hoofdstraat 1', postcode: '1234AB', woonplaats: 'Utrecht' }, + }, +}; + +describe('redactProfile', () => { + const redacted = redactProfile(profile) as { + registration: { + bigNummer: string; + naam: string; + beroep: string; + registratiedatum: string; + geboortedatum: string; + status: unknown; + }; + person: { naam: string; geboortedatum: string; adres: string }; + }; + + it('masks the BIG-nummer to its last 3 digits', () => { + expect(redacted.registration.bigNummer).toBe('********901'); + }); + + it('redacts the name on both the registration and the person', () => { + expect(redacted.registration.naam).toBe(REDACTED); + expect(redacted.person.naam).toBe(REDACTED); + }); + + it('redacts every date of birth', () => { + expect(redacted.registration.geboortedatum).toBe(REDACTED); + expect(redacted.person.geboortedatum).toBe(REDACTED); + }); + + it('redacts the address', () => { + expect(redacted.person.adres).toBe(REDACTED); + }); + + it('keeps structural/decision-relevant fields untouched', () => { + expect(redacted.registration.beroep).toBe('arts'); + expect(redacted.registration.registratiedatum).toBe('2015-03-01'); + expect(redacted.registration.status).toEqual(profile.registration.status); + }); +}); diff --git a/apps/ssp/src/locale/messages.en.xlf b/apps/ssp/src/locale/messages.en.xlf index 9860f45..fa514b7 100644 --- a/apps/ssp/src/locale/messages.en.xlf +++ b/apps/ssp/src/locale/messages.en.xlf @@ -3738,6 +3738,10 @@ De functievlaggen konden niet worden geladen. The feature flags could not be loaded. + + De functievlag kon niet worden opgeslagen. + The feature flag could not be saved. + Opnieuw proberen Try again diff --git a/backend/src/BigRegister.Api/Contracts/Dtos.cs b/backend/src/BigRegister.Api/Contracts/Dtos.cs index 1a8c787..211b5c0 100644 --- a/backend/src/BigRegister.Api/Contracts/Dtos.cs +++ b/backend/src/BigRegister.Api/Contracts/Dtos.cs @@ -74,7 +74,6 @@ public sealed record DocumentRefDto(string CategoryId, string Channel, string? D // Submit requests carry only the fields the server re-validates (UX-only fields // stay on the client). ponytail: a real submit would carry the full application. -public sealed record RegistratieRequest(string DiplomaHerkomst, IReadOnlyList? Documents = null); public sealed record ChangeRequestRequest(string Telefoon); diff --git a/backend/src/BigRegister.Api/Contracts/Mappers.cs b/backend/src/BigRegister.Api/Contracts/Mappers.cs index d4af6fb..ae32125 100644 --- a/backend/src/BigRegister.Api/Contracts/Mappers.cs +++ b/backend/src/BigRegister.Api/Contracts/Mappers.cs @@ -70,8 +70,12 @@ public static class Mappers a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), SubmittedAtOf(a)); /// Admin summary — same shape plus the owner (WP-36; the user-facing list leaves Owner null). + /// The owner is a BSN, and both consumers of this mapper are cross-owner lists read by + /// someone who is not the subject (`/admin/cases`, `/werkvoorraad`), so it goes out masked + /// (RB-03/BIO-003). Masking here rather than at each endpoint means a third cross-owner + /// list cannot be added that forgets to. public static ApplicationSummaryDto ToAdminSummaryDto(this Aanvraag a, DateTimeOffset now) => - a.ToSummaryDto(now) with { Owner = a.Owner }; + a.ToSummaryDto(now) with { Owner = Pii.MaskTail(a.Owner, 3) }; public static ApplicationDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new( a.Id, a.Type, a.ToStatusDto(now), DraftOf(a), a.DocumentIds, diff --git a/backend/src/BigRegister.Api/Data/AppDbContext.cs b/backend/src/BigRegister.Api/Data/AppDbContext.cs index 876789a..08f109d 100644 --- a/backend/src/BigRegister.Api/Data/AppDbContext.cs +++ b/backend/src/BigRegister.Api/Data/AppDbContext.cs @@ -53,7 +53,7 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon modelBuilder.Entity(e => { e.HasKey(b => b.BriefId); - e.HasIndex(b => b.Owner).IsUnique(); // one demo brief per owner (GetOrCreate's invariant) + e.HasIndex(b => b.Owner).IsUnique(); // one demo brief per owner (ResetAndCreate's invariant) e.Property(b => b.Placeholders).HasConversion(Json>()); e.Property(b => b.Sections).HasConversion(Json>()); e.Property(b => b.Status).HasConversion(Json()); diff --git a/backend/src/BigRegister.Api/Data/BriefStore.cs b/backend/src/BigRegister.Api/Data/BriefStore.cs index 7a3f636..170b3fd 100644 --- a/backend/src/BigRegister.Api/Data/BriefStore.cs +++ b/backend/src/BigRegister.Api/Data/BriefStore.cs @@ -47,17 +47,15 @@ public static class BriefStore private static readonly object _gate = new(); - public static BriefEntity GetOrCreate(string owner) + /// Pure query (RB-23/CQ-007): no write. `GET /brief` 404s when this returns null — + /// the owner's first-ever draft is created only through the explicit `ResetAndCreate` + /// command (`POST /brief/reset`), never as a side effect of a read. + public static BriefEntity? Get(string owner) { lock (_gate) { using var db = Db.Create(); - var existing = db.Briefs.FirstOrDefault(e => e.Owner == owner); - if (existing is not null) return existing; - var created = BriefSeed.NewBrief(owner); - db.Briefs.Add(created); - db.SaveChanges(); - return created; + return db.Briefs.FirstOrDefault(e => e.Owner == owner); } } @@ -70,10 +68,10 @@ public static class BriefStore using var db = Db.Create(); var e = db.Briefs.FirstOrDefault(e => e.Owner == owner); if (e is null) return (Outcome.Conflict, null); - if (!isDrafter) return (Outcome.Forbidden, null); - if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null); + var outcome = BriefRules.CanSave(e.Status, isDrafter); + if (outcome != Outcome.Ok) return (outcome, null); e.Sections = sections.ToList(); - if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft"); + e.Status = BriefRules.StatusAfterSave(e.Status); db.SaveChanges(); return (Outcome.Ok, e); } @@ -86,8 +84,8 @@ public static class BriefStore using var db = Db.Create(); var e = db.Briefs.FirstOrDefault(e => e.Owner == owner); if (e is null) return (Outcome.Conflict, null); - if (!isDrafter) return (Outcome.Forbidden, null); - if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null); + var outcome = BriefRules.CanSubmit(e.Status, isDrafter, BriefRules.RequiredFilled(e.Sections)); + if (outcome != Outcome.Ok) return (outcome, null); e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at); db.SaveChanges(); return (Outcome.Ok, e); @@ -109,7 +107,8 @@ public static class BriefStore using var db = Db.Create(); var e = db.Briefs.FirstOrDefault(e => e.Owner == owner); if (e is null) return (Outcome.Conflict, null); - if (e.Status.Tag != "approved") return (Outcome.Conflict, null); + var outcome = BriefRules.CanSend(e.Status); + if (outcome != Outcome.Ok) return (outcome, null); e.Status = new BriefStatusDto("sent", SentAt: at); // Pin the org-template version the letter was sent with (WP-23): from here on // its appearance is frozen — republishing the template touches unsent briefs only. @@ -152,7 +151,7 @@ public static class BriefStore // from the drafter (a drafter cannot approve their own letter). The SoD check is // Authz.CanActOn — the SAME check the screen DTO's decision flags use — checked // BEFORE the status guard so Forbidden vs Conflict ordering matches the old - // inline check exactly. + // inline check exactly (BriefRules.CanDecide preserves that order). private static (Outcome, BriefEntity?) Review(string owner, Principal principal, BriefAction action, Func next) { lock (_gate) @@ -160,15 +159,13 @@ public static class BriefStore using var db = Db.Create(); var e = db.Briefs.FirstOrDefault(e => e.Owner == owner); if (e is null) return (Outcome.Conflict, null); - if (!Authz.CanActOn(action, principal, e.DrafterId)) return (Outcome.Forbidden, null); - if (e.Status.Tag != "submitted") return (Outcome.Conflict, null); + var outcome = BriefRules.CanDecide(action, e.Status, principal, e.DrafterId); + if (outcome != Outcome.Ok) return (outcome, null); e.Status = next(); db.SaveChanges(); return (Outcome.Ok, e); } } - - private static bool RequiredFilled(BriefEntity e) => e.Sections.All(s => !s.Required || s.Blocks.Count > 0); } /// Seeded template (sections + placeholder fields) and passage library. diff --git a/backend/src/BigRegister.Api/Data/DocumentStore.cs b/backend/src/BigRegister.Api/Data/DocumentStore.cs index 1f2faa0..82054f1 100644 --- a/backend/src/BigRegister.Api/Data/DocumentStore.cs +++ b/backend/src/BigRegister.Api/Data/DocumentStore.cs @@ -1,3 +1,5 @@ +using BigRegister.Domain.People; + namespace BigRegister.Api.Data; /// @@ -58,7 +60,7 @@ public static class DocumentStore db.Documents.Add(doc); db.SaveChanges(); } - Audit("upload", doc.DocumentId, categoryId, owner); + Audit("upload", doc.DocumentId, categoryId, Pii.MaskTail(owner, 3)); return doc; } @@ -73,13 +75,13 @@ public static class DocumentStore /// Status for the poll-on-return pattern: a known localId is "complete" (it /// arrived), an unknown one is still in flight / never started. - public static IReadOnlyList ByLocalIds(IEnumerable localIds) + public static IReadOnlyList ByLocalIds(IEnumerable localIds, string owner) { var set = localIds.ToHashSet(); lock (_gate) { using var db = Db.Create(); - return db.Documents.Where(d => set.Contains(d.LocalId)).ToList(); + return db.Documents.Where(d => set.Contains(d.LocalId) && d.Owner == owner).ToList(); } } @@ -156,7 +158,7 @@ public static class DocumentStore db.Documents.Remove(d); db.SaveChanges(); } - Audit("delete-user", documentId, categoryId, owner); + Audit("delete-user", documentId, categoryId, Pii.MaskTail(owner, 3)); return DeleteResult.Ok; } @@ -178,6 +180,12 @@ public static class DocumentStore return true; } + /// Append one metadata-only audit row. must arrive + /// **already redacted** (RB-04/BIO-005) — the two citizen call sites pass + /// of the owner BSN, `delete-admin` passes the literal + /// `"admin"`. The unmasked BSN lives only in , which is + /// the authorization key and stays untouched. Masking here instead would have to guess + /// which actors are BSNs and which are role names. public static void Audit(string action, string documentId, string categoryId, string actor) { lock (_gate) diff --git a/backend/src/BigRegister.Api/Domain/Authorization/IIdentityProvider.cs b/backend/src/BigRegister.Api/Domain/Authorization/IIdentityProvider.cs index 5d4f047..a69a16d 100644 --- a/backend/src/BigRegister.Api/Domain/Authorization/IIdentityProvider.cs +++ b/backend/src/BigRegister.Api/Domain/Authorization/IIdentityProvider.cs @@ -4,9 +4,14 @@ namespace BigRegister.Domain.Authorization; /// Resolves the acting for a request (WP-53) — one of the two actor /// kinds (WP-62, ADR-0002 §3): a zorgverlener (real DigiD claims in production) or a medewerker /// (real employee SSO/eHerkenning claims in production). is -/// the only implementation today. +/// the only implementation today, and is registered only in Development (Program.cs, +/// RB-09/BIO-002). /// public interface IIdentityProvider { - CallerIdentity Resolve(HttpContext ctx); + /// Null when the request carries no identity a real implementation can vouch for — + /// e.g. no credential at all. Returning null, rather than inventing a default, is what makes + /// "unauthenticated" representable; the identity-resolution middleware (Program.cs) + /// turns a null into a 401 instead of a silent identity substitution. + CallerIdentity? Resolve(HttpContext ctx); } diff --git a/backend/src/BigRegister.Api/Domain/Authorization/StubIdentityProvider.cs b/backend/src/BigRegister.Api/Domain/Authorization/StubIdentityProvider.cs index d3ba6ff..0a721cd 100644 --- a/backend/src/BigRegister.Api/Domain/Authorization/StubIdentityProvider.cs +++ b/backend/src/BigRegister.Api/Domain/Authorization/StubIdentityProvider.cs @@ -13,6 +13,11 @@ namespace BigRegister.Domain.Authorization; /// A real system builds this from verified DigiD claims (zorgverlener) / employee SSO claims /// (medewerker); every consumer of carries over unchanged once that /// swap happens. +/// +/// Registered only in Development (Program.cs, RB-09/BIO-002) — it always invents a +/// caller for a request with no credential, which is a deliberate developer convenience, not +/// something a production build may do. Its own return type stays non-nullable: unlike +/// , this stub never has "no identity" to report. /// public sealed class StubIdentityProvider : IIdentityProvider { diff --git a/backend/src/BigRegister.Api/Domain/Letters/BriefRules.cs b/backend/src/BigRegister.Api/Domain/Letters/BriefRules.cs new file mode 100644 index 0000000..a5bea7c --- /dev/null +++ b/backend/src/BigRegister.Api/Domain/Letters/BriefRules.cs @@ -0,0 +1,63 @@ +using BigRegister.Api.Contracts; +using BigRegister.Api.Data; +using BigRegister.Domain.Authorization; + +namespace BigRegister.Domain.Letters; + +/// +/// SERVER-OWNED brief state-transition and authorization rules (RB-30, TE-008). Each +/// method is a pure decision over (status tag, actor role, entity completeness) — +/// extracted out of 's lock-held, DB-opening methods so the +/// decision can be unit-tested without a booted host or a real SQLite file. Callers +/// pass the values the rule needs, never the entity, so this stays pure. +/// +/// Returns — that type already exists as the domain +/// concept the whole brief flow reports through (`BriefResult` in Program.cs switches +/// on it directly), so this reuses it rather than inventing a second result shape. +/// +public static class BriefRules +{ + /// Save is drafter-only, and only while the letter is editable (draft/rejected). + /// Order matches the store's original inline check: role before status, so a + /// non-drafter always sees Forbidden even against a non-editable status. + public static BriefStore.Outcome CanSave(BriefStatusDto status, bool isDrafter) + { + if (!isDrafter) return BriefStore.Outcome.Forbidden; + if (status.Tag is not ("draft" or "rejected")) return BriefStore.Outcome.Conflict; + return BriefStore.Outcome.Ok; + } + + /// A save on a rejected letter reopens it to draft (mirrors the FE reducer); a save + /// on a draft leaves the status untouched. + public static BriefStatusDto StatusAfterSave(BriefStatusDto status) => + status.Tag == "rejected" ? new BriefStatusDto("draft") : status; + + /// Every required section needs at least one block before a letter is submittable. + public static bool RequiredFilled(IReadOnlyList sections) => + sections.All(s => !s.Required || s.Blocks.Count > 0); + + /// Submit is drafter-only, only from draft, and only once every required section + /// is filled. + public static BriefStore.Outcome CanSubmit(BriefStatusDto status, bool isDrafter, bool requiredFilled) + { + if (!isDrafter) return BriefStore.Outcome.Forbidden; + if (status.Tag != "draft" || !requiredFilled) return BriefStore.Outcome.Conflict; + return BriefStore.Outcome.Ok; + } + + /// Send only from approved — sending is a mechanical dispatch step, not role-gated + /// (Authz.CanActOn already returns true unconditionally for BriefAction.Send). + public static BriefStore.Outcome CanSend(BriefStatusDto status) => + status.Tag == "approved" ? BriefStore.Outcome.Ok : BriefStore.Outcome.Conflict; + + /// Approve/Reject share this guard: the caller must be entitled to act on the letter + /// (four-eyes/SoD, via the existing ), and the letter must + /// be submitted. The entitlement check runs BEFORE the status check — Forbidden takes + /// priority over Conflict, matching the store's original order exactly. + public static BriefStore.Outcome CanDecide(BriefAction action, BriefStatusDto status, Principal principal, string drafterId) + { + if (!Authz.CanActOn(action, principal, drafterId)) return BriefStore.Outcome.Forbidden; + if (status.Tag != "submitted") return BriefStore.Outcome.Conflict; + return BriefStore.Outcome.Ok; + } +} diff --git a/backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs b/backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs index fffb309..8ddc879 100644 --- a/backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs +++ b/backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs @@ -56,7 +56,7 @@ public static class LetterHtml { sb.Append("

").Append(Enc(section.Title)).Append("

"); foreach (var block in section.Blocks) - RenderParagraphs(sb, block.Content.Paragraphs, defs); + RenderParagraphs(sb, block.Content.Paragraphs, defs, at); sb.Append("
"); } sb.Append("
"); @@ -89,7 +89,8 @@ public static class LetterHtml private const string RecipientPlaceholder = "Adres van de geadresseerde\n(wordt ingevuld bij verzending)"; private static void RenderParagraphs( - StringBuilder sb, IReadOnlyList paragraphs, IReadOnlyDictionary defs) + StringBuilder sb, IReadOnlyList paragraphs, IReadOnlyDictionary defs, + string at) { string? openList = null; foreach (var para in paragraphs) @@ -101,14 +102,14 @@ public static class LetterHtml openList = para.List; } sb.Append(openList is null ? "

" : "

  • "); - foreach (var node in para.Nodes) RenderNode(sb, node, defs); + foreach (var node in para.Nodes) RenderNode(sb, node, defs, at); sb.Append(openList is null ? "

    " : "
  • "); } if (openList is not null) sb.Append(openList == "bullet" ? "" : ""); } private static void RenderNode( - StringBuilder sb, RichTextNodeDto node, IReadOnlyDictionary defs) + StringBuilder sb, RichTextNodeDto node, IReadOnlyDictionary defs, string at) { switch (node.Type) { @@ -122,7 +123,7 @@ public static class LetterHtml var key = node.Key ?? ""; var def = defs.GetValueOrDefault(key); var label = def?.Label ?? key; - sb.Append(def is { AutoResolvable: true } ? Enc(ResolveAuto(key, label)) : Enc($"[NOG IN TE VULLEN: {label}]")); + sb.Append(def is { AutoResolvable: true } ? Enc(ResolveAuto(key, label, at)) : Enc($"[NOG IN TE VULLEN: {label}]")); break; } } @@ -131,11 +132,11 @@ public static class LetterHtml // single demo applicant (SeedData.Registration — no per-brief resolved value is // ever stored, see the class doc above). Falls back to the label itself for any // other auto-resolvable key, mirroring the FE canvas' own `sampleFor` fallback. - private static string ResolveAuto(string key, string label) => key switch + private static string ResolveAuto(string key, string label, string at) => key switch { "naam_zorgverlener" => SeedData.Registration.Naam, "big_nummer" => SeedData.Registration.BigNummer, - "datum" => FormatDatumNl(DateTimeOffset.UtcNow.ToString("o")), + "datum" => FormatDatumNl(at), _ => label, }; diff --git a/backend/src/BigRegister.Api/Domain/People/Pii.cs b/backend/src/BigRegister.Api/Domain/People/Pii.cs new file mode 100644 index 0000000..c98b620 --- /dev/null +++ b/backend/src/BigRegister.Api/Domain/People/Pii.cs @@ -0,0 +1,18 @@ +namespace BigRegister.Domain.People; + +/// +/// One redaction rule for identifiers that must not leave the server in full (BSN, +/// BIG-nummer). Lives in Domain/ because three layers need it — the DTO mappers +/// (Contracts/Mappers.cs), the audit writes (Data/DocumentStore.cs) and the +/// endpoints themselves — and a second hand-rolled copy is exactly how one of them drifts +/// into leaking. Mirrors the FE maskTail (libs/shared/src/ui/debug-state/mask.ts) +/// so wire redaction and the dev panel agree on what a masked value looks like. +/// +public static class Pii +{ + /// Keep the last characters, mask the rest. Idempotent: masking an + /// already-masked value is a no-op, so a defence-in-depth second call is harmless. + public static string MaskTail(string value, int keep) => + value.Length <= keep ? new string('*', value.Length) + : new string('*', value.Length - keep) + value[^keep..]; +} diff --git a/backend/src/BigRegister.Api/Domain/Submissions/SubmissionRules.cs b/backend/src/BigRegister.Api/Domain/Submissions/SubmissionRules.cs index 1db366a..c9ee980 100644 --- a/backend/src/BigRegister.Api/Domain/Submissions/SubmissionRules.cs +++ b/backend/src/BigRegister.Api/Domain/Submissions/SubmissionRules.cs @@ -9,12 +9,6 @@ namespace BigRegister.Domain.Submissions; /// public static class SubmissionRules { - // RULE: a manually entered diploma cannot be auto-verified. - public static string? RejectRegistratie(string diplomaHerkomst) => - diplomaHerkomst == "handmatig" - ? "Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling." - : null; - // RULE: an application reporting zero worked hours is rejected. public static string? RejectZeroUren(int uren) => uren == 0 ? "Aanvraag afgewezen: geen gewerkte uren geregistreerd." : null; diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index 5dd830d..f0f6ae8 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -12,6 +12,7 @@ using BigRegister.Domain.Documents; using BigRegister.Domain.Features; using BigRegister.Domain.Intake; using BigRegister.Domain.Letters; +using BigRegister.Domain.People; using BigRegister.Domain.Registrations; using BigRegister.Domain.Submissions; using BigRegister.Api.Zgw; @@ -50,7 +51,22 @@ Db.ConnectionString = builder.Configuration.GetConnectionString("AppDb") ?? Db.C // every store call site that used to hardcode DocumentStore.DemoOwner. Stub today (X-Role/ // X-Subject for a zorgverlener, X-Medewerker/X-Rollen for a medewerker); a real // DigiD/employee-SSO provider swaps in without touching a consumer. -builder.Services.AddSingleton(); +// +// RB-09/BIO-002: StubIdentityProvider invents a citizen identity for any request with no +// credential at all — a production behandelportal build sends no X-Medewerker header, so it +// used to authenticate every request as the seeded citizen (open on that citizen's own rights, +// including CanRevealBigNummer). Registering the stub only in Development, and failing to +// start in Production rather than falling through to a per-request 401, means a misconfigured +// deploy never serves a single request. The real DigiD/employee-SSO provider is out of scope +// for this POC (BIO-002's remediation says so explicitly) — until one exists, Production simply +// cannot start, which is the correct fail-closed behaviour for "no identity provider available". +if (builder.Environment.IsDevelopment()) + builder.Services.AddSingleton(); +else if (builder.Environment.IsProduction()) + throw new InvalidOperationException( + "No IIdentityProvider is registered for a Production environment. StubIdentityProvider " + + "is Development-only (RB-09/BIO-002); there is no real DigiD/employee-SSO provider in " + + "this POC yet. Register one before deploying to Production."); // WP-49: the cases (zaken) READ path goes through IZaakSource so a real ZGW backend // (OpenZaak) can replace the local SQLite store behind the same DTO contract — the FE never @@ -110,16 +126,37 @@ app.Use(async (ctx, next) => // WP-53: resolve the acting citizen once per request, right after correlation — everything // downstream (Authz.ResolvePrincipal, the endpoints below) reads it via ctx.Caller() instead of -// re-deriving "who" itself. +// re-deriving "who" itself. RB-09/BIO-002: a null resolution is "no identity", not "the seeded +// citizen" — this is the one place that turns it into a response (401) rather than letting it +// flow downstream as a silent identity substitution. var identityProvider = app.Services.GetRequiredService(); app.Use(async (ctx, next) => { - ctx.SetCaller(identityProvider.Resolve(ctx)); + var identity = identityProvider.Resolve(ctx); + if (identity is null) + { + ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + ctx.SetCaller(identity); await next(ctx); }); -app.UseSwagger(); -app.UseSwaggerUI(); +// RB-15/BIO-015: the OpenAPI document + its UI are a genuine attack-surface reduction to +// gate — they enumerate every route, request/response shape and (via SwaggerUI's "Try it +// out") let a caller fire requests straight from the browser. Development-only, like the +// dev-role/scenario-toggle hatches this POC already keeps out of production builds +// (docker-compose.prod.yml runs Production; only docker-compose.yml's dev image runs +// Development). `dotnet swagger tofile` (npm run gen:api) is unaffected: Swashbuckle's CLI +// resolves ISwaggerProvider straight out of the DI container to build swagger.json — it +// never sends an HTTP request through this pipeline, so it never touches this middleware at +// all, gated or not. Verified empirically (see rb-15.md) rather than assumed, per RB-09's +// note that this exact file has already broken that tool once. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} app.UseCors(SpaCors); // Liveness/readiness for orchestrators (k8s probes, load balancers). No data, no PII. @@ -164,6 +201,7 @@ api.MapGet("/intake/policy", () => new IntakePolicyDto(IntakePolicy.ScholingThre api.MapGet("/stamdata", (HttpContext ctx) => StamdataAdmin(ctx, () => Results.Ok(StamdataCatalog.All.Select(t => new StamdataTableSummaryDto(t.Id, t.Label, t.Columns.Select(ToColumnDto).ToList(), t.Temporal)).ToList()))) +.Gate("StamdataAdmin") .WithName("stamdataTables") .Produces>() .ProducesProblem(StatusCodes.Status403Forbidden); @@ -174,21 +212,29 @@ api.MapGet("/stamdata/{table}", (string table, string? peildatum, HttpContext ct { var t = StamdataCatalog.Find(table); if (t is null) return Results.NotFound(); - var rows = peildatum is { Length: > 0 } p ? t.RowsOn(DateOnly.Parse(p)) : t.Rows(); + DateOnly? peildatumWaarde = null; + // RB-16/BIO-019: DateOnly.Parse threw FormatException on unparseable input, surfacing as + // an unhandled 500 (and, in Development, an exception detail leaked to the caller) — an + // admin-gated but still user-supplied string needs the same 400 path every other bad-input + // check in this file uses, not a crash. + if (peildatum is { Length: > 0 } p) + { + if (!DateOnly.TryParse(p, out var parsed)) + return Results.Problem(detail: $"Ongeldige peildatum '{p}'.", statusCode: StatusCodes.Status400BadRequest); + peildatumWaarde = parsed; + } + var rows = peildatumWaarde is { } d ? t.RowsOn(d) : t.Rows(); return Results.Ok(new StamdataTableDto(t.Id, t.Label, t.Columns.Select(ToColumnDto).ToList(), t.Temporal, rows)); })) +.Gate("StamdataAdmin") .WithName("stamdataTable") .Produces() +.ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); // --- POST: submits. The server is the authority; it re-validates and decides. --- -api.MapPost("/registrations", (RegistratieRequest req, HttpContext ctx) => - Submit(ctx, "registratie", SubmissionRules.RejectRegistratie(req.DiplomaHerkomst), req.Documents)) -.Produces() -.ProducesProblem(StatusCodes.Status422UnprocessableEntity); - api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) => Submit(ctx, "telefoonwijziging", SubmissionRules.RejectPhoneChange(req.Telefoon))) .Produces() @@ -196,10 +242,47 @@ api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) => // --- Document upload --- +// --- reads --- + // Server-owned category config per wizard. The FE renders these; it never hardcodes. api.MapGet("/uploads/categories", (string wizardId, string? diplomaHerkomst, string? taalvaardigheid) => new UploadCategoriesDto(DocumentRules.CategoriesFor(wizardId, diplomaHerkomst, taalvaardigheid).Select(c => c.ToDto()).ToList())); +// Serve stored bytes so a re-opened wizard can preview/download an upload. Inline +// for pdf/image (browser renders it), attachment otherwise (download). +// Scoped like DELETE on the same resource (RB-01/BIO-004): the owning citizen, or a +// behandelaar reading an aanvraag's linked documents. A foreign id 404s rather than +// 403s, so the endpoint never confirms that a document id exists. +api.MapGet("/uploads/{documentId}/content", (string documentId, HttpContext ctx) => +{ + var doc = DocumentStore.Get(documentId); + var allowed = ctx.Caller() switch + { + ZorgverlenerCaller z => doc?.Owner == z.Bsn, + var caller => Authz.CanBeoordelen(caller), + }; + if (doc is null || !allowed) return Results.NotFound(); + var inline = doc.ContentType == "application/pdf" || doc.ContentType.StartsWith("image/"); + return Results.File(doc.Content, doc.ContentType, fileDownloadName: inline ? null : doc.FileName); +}) +.Produces(StatusCodes.Status200OK) +.Produces(StatusCodes.Status404NotFound); + +// Poll-on-return: which of these client localIds have arrived at the BFF. +api.MapGet("/uploads/status", (string? localIds, HttpContext ctx) => +{ + var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + // Owner-scoped (RB-01/BIO-004): someone else's localId reads back as "unknown", the + // same answer an id that never existed gets. + var found = DocumentStore.ByLocalIds(ids, ctx.Zorgverlener().Bsn).ToDictionary(d => d.LocalId); + var results = ids.Select(id => found.TryGetValue(id, out var d) + ? new UploadStatusItemDto(id, "complete", d.DocumentId) + : new UploadStatusItemDto(id, "unknown", null)).ToList(); + return new UploadStatusDto(results); +}); + +// --- writes --- + // Multipart upload. Hand-written on the FE (XHR for progress), so it is excluded // from the OpenAPI doc to keep the NSwag-generated client JSON-only. Validates type // and size authoritatively; stores metadata only (no file bytes / PII held). @@ -226,29 +309,6 @@ api.MapPost("/uploads", async (HttpRequest request, HttpContext ctx, IDocumentSo }) .ExcludeFromDescription(); -// Serve stored bytes so a re-opened wizard can preview/download an upload. Inline -// for pdf/image (browser renders it), attachment otherwise (download). -api.MapGet("/uploads/{documentId}/content", (string documentId) => -{ - var doc = DocumentStore.Get(documentId); - if (doc is null) return Results.NotFound(); - var inline = doc.ContentType == "application/pdf" || doc.ContentType.StartsWith("image/"); - return Results.File(doc.Content, doc.ContentType, fileDownloadName: inline ? null : doc.FileName); -}) -.Produces(StatusCodes.Status200OK) -.Produces(StatusCodes.Status404NotFound); - -// Poll-on-return: which of these client localIds have arrived at the BFF. -api.MapGet("/uploads/status", (string? localIds) => -{ - var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - var found = DocumentStore.ByLocalIds(ids).ToDictionary(d => d.LocalId); - var results = ids.Select(id => found.TryGetValue(id, out var d) - ? new UploadStatusItemDto(id, "complete", d.DocumentId) - : new UploadStatusItemDto(id, "unknown", null)).ToList(); - return new UploadStatusDto(results); -}); - // User delete: owner-scoped; 409 once linked to a finalised submission. api.MapDelete("/uploads/{documentId}", (string documentId, HttpContext ctx) => DocumentStore.DeleteOwned(documentId, ctx.Zorgverlener().Bsn) switch @@ -263,17 +323,21 @@ api.MapDelete("/uploads/{documentId}", (string documentId, HttpContext ctx) => .ProducesProblem(StatusCodes.Status409Conflict) .Produces(StatusCodes.Status404NotFound); -// Admin delete (seam): a real system requires an admin role; here an X-Admin header -// stands in. Bypasses ownership, unlinks, and flags the submission for review. -api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx) => - !IsAdmin(ctx) ? Results.StatusCode(StatusCodes.Status403Forbidden) - : DocumentStore.AdminDelete(documentId, "admin") ? Results.NoContent() : Results.NotFound()) +// Admin delete: bypasses ownership, unlinks, and flags the submission for review. Gated +// by the same CasesAdmin wrapper (cases:manage) the other admin-cases endpoints use +// (RB-08/BIO-003) — it used to be gated by a standalone X-Admin header, outside Authz and +// unaudited; CasesAdmin gives it the missing AuthzAuditStore row for free (RB-07). +api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx) => CasesAdmin(ctx, () => + DocumentStore.AdminDelete(documentId, "admin") ? Results.NoContent() : Results.NotFound())) +.Gate("CasesAdmin") .Produces(StatusCodes.Status204NoContent) -.Produces(StatusCodes.Status403Forbidden) +.ProducesProblem(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); // --- Applications (aanvragen): the system of record the dashboard reads. --- +// --- reads --- + // WP-53: routed through IZaakSource (like /admin/cases already was) rather than calling // ApplicationStore directly — under Zgw:Enabled=true a citizen's own dashboard list comes from // OpenZaak (BSN-filtered) too, closing the last "reads a static store directly" gap @@ -288,6 +352,8 @@ api.MapGet("/applications/{id}", (string id, HttpContext ctx) => .Produces() .Produces(StatusCodes.Status404NotFound); +// --- writes --- + api.MapPost("/applications", (CreateApplicationRequest req, HttpContext ctx) => { // Feature flag (WP-47): self-service registration can be closed by an admin. @@ -422,11 +488,40 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re .Produces(StatusCodes.Status404NotFound); // --- Admin cases (WP-36): cross-owner list + admin delete, gated by `cases:manage`. --- + +// --- reads --- + api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ctx, () => Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow)))) +.Gate("CasesAdmin") .Produces>() .ProducesProblem(StatusCodes.Status403Forbidden); +// Queryable authz/PII-reveal audit trail (WP-41) — data-minimised, no PII. Admin-gated +// via the existing CasesAdmin (cases:manage); a dedicated audit:read cap is a later refinement. +api.MapGet("/admin/audit", (HttpContext ctx) => CasesAdmin(ctx, () => + Results.Ok(AuthzAuditStore.List() + .Select(a => new AuthzAuditDto(a.At.ToString("o"), a.Action, a.Resource, a.Decision, a.Role, a.CorrelationId)) + .ToList()))) +.Gate("CasesAdmin") +.Produces>() +.ProducesProblem(StatusCodes.Status403Forbidden); + +// --- writes --- + +// Admin delete removes ANY case (any owner, submitted or not) — unlike the user-facing +// DELETE /applications/{id}. A missing id is a 404. +api.MapDelete("/admin/cases/{id}", (string id, HttpContext ctx) => CasesAdmin(ctx, () => +{ + if (!ApplicationStore.DeleteAny(id)) return Results.NotFound(); + app.Logger.LogInformation("admin case delete id={Id}", id); + return Results.NoContent(); +})) +.Gate("CasesAdmin") +.Produces(StatusCodes.Status204NoContent) +.Produces(StatusCodes.Status404NotFound) +.ProducesProblem(StatusCodes.Status403Forbidden); + // --- Werkvoorraad (WP-64): the behandelportal's queue of aanvragen needing treatment. --- // Cross-owner like /admin/cases, but gated by the medewerker capability (`CanBeoordelen`, // WP-62) rather than the admin role, and pre-filtered to the two "still open" status tags — @@ -435,6 +530,7 @@ api.MapGet("/werkvoorraad", (HttpContext ctx, IZaakSource zaken) => Beoordelen(c Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow) .Where(c => c.Status.Tag is "Ingediend" or "InBehandeling") .ToList()))) +.Gate("Beoordelen") .Produces>() .ProducesProblem(StatusCodes.Status403Forbidden); @@ -450,13 +546,17 @@ api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken) if (c is null || c.Status.Tag == "Concept") return Results.NotFound(); var docs = DocumentStore.ByIds(c.DocumentIds) .Select(d => new BeoordelingDocumentDto(d.DocumentId, d.CategoryId, d.FileName)).ToList(); - var masked = c with { Owner = MaskTail(c.Owner!, 3) }; + // Belt and braces: ToAdminSummaryDto already masks the local source (RB-03) and + // MaskTail is idempotent, but IZaakSource has a second implementation whose Owner + // is mapped from OpenZaak, so this stays as the guarantee for this response. + var masked = c with { Owner = Pii.MaskTail(c.Owner!, 3) }; // WP-68 (F3): non-throwing — c.Status.Tag crosses the IZaakSource wire boundary, so an // unrecognised tag degrades to "cannot decide" instead of a 500. var canBesluiten = Enum.TryParse(c.Status.Tag, out var tag) && BeoordelingRules.CanDecide(tag); var decisions = new BeoordelingDecisionsDto(canBesluiten); return Results.Ok(new BeoordelingViewDto(masked, docs, decisions)); })) +.Gate("Beoordelen") .Produces() .ProducesProblem(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); @@ -497,6 +597,10 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H statusCode: StatusCodes.Status409Conflict); app.Logger.LogInformation("aanvraag besluit id={Id} besluit={Besluit}", a.Id, besluit); + // RB-07/BIO-007: the gate above records that a behandelaar was allowed to act; this + // records what they decided. Without it /beheer/audit cannot answer "who rejected this + // aanvraag", which is the question the trail exists for. + AuditAuthz(ctx, "aanvraag:besluit", $"aanvraag/{a.Id}/{besluit}", true, Authz.ResolvePrincipal(ctx)); // WP-60: the local decision above already committed — a ZGW failure here is caught and // flagged rather than allowed to diverge silently, same handling as submit's create-zaak @@ -513,6 +617,7 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H return Results.Ok(new RecordBesluitResponse(updated!.ToStatusDto(now))); })) +.Gate("Beoordelen") .Produces() .ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status403Forbidden) @@ -549,27 +654,6 @@ api.MapPost("/zgw/notificaties", (HttpContext ctx, NotificatieDto body) => // /uploads and /brief/reveal-bignummer. .ExcludeFromDescription(); -// Admin delete removes ANY case (any owner, submitted or not) — unlike the user-facing -// DELETE /applications/{id}. A missing id is a 404. -api.MapDelete("/admin/cases/{id}", (string id, HttpContext ctx) => CasesAdmin(ctx, () => -{ - if (!ApplicationStore.DeleteAny(id)) return Results.NotFound(); - app.Logger.LogInformation("admin case delete id={Id}", id); - return Results.NoContent(); -})) -.Produces(StatusCodes.Status204NoContent) -.Produces(StatusCodes.Status404NotFound) -.ProducesProblem(StatusCodes.Status403Forbidden); - -// Queryable authz/PII-reveal audit trail (WP-41) — data-minimised, no PII. Admin-gated -// via the existing CasesAdmin (cases:manage); a dedicated audit:read cap is a later refinement. -api.MapGet("/admin/audit", (HttpContext ctx) => CasesAdmin(ctx, () => - Results.Ok(AuthzAuditStore.List() - .Select(a => new AuthzAuditDto(a.At.ToString("o"), a.Action, a.Resource, a.Decision, a.Role, a.CorrelationId)) - .ToList()))) -.Produces>() -.ProducesProblem(StatusCodes.Status403Forbidden); - // PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks (NOT // tied to a specific brief's live status — see BriefDecisionsDto for that). // WP-64: `aanvraag:beoordelen` is caller-kind-derived (CanBeoordelen), not role-derived like @@ -589,23 +673,53 @@ api.MapGet("/flags", () => Results.Ok(FeatureFlagStore.All().Select(f => new FeatureFlagDto(f.Key, f.Description, f.Enabled)).ToList())) .Produces>(); -api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpContext ctx) => FlagsAdmin(ctx, () => - FeatureFlagStore.Set(key, req.Enabled) ? Results.NoContent() : Results.NotFound())) +api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpContext ctx) => + FlagsAdmin(ctx, $"feature-flags/{key}={req.Enabled}", () => + FeatureFlagStore.Set(key, req.Enabled) ? Results.NoContent() : Results.NotFound())) +.Gate("FlagsAdmin") .Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status404NotFound) .ProducesProblem(StatusCodes.Status403Forbidden); // --- Brief (letter composition). One demo brief per owner; the server owns the // status machine + authorization (Authz, PRD-0002 phase P1). Principal is a -// dev-only stand-in via X-Role (mirrors the X-Admin seam and the FE ?role= -// toggle) — no real identities in this POC. --- +// dev-only stand-in via X-Role (mirrors the FE ?role= toggle) — no real +// identities in this POC. --- + +// --- reads --- api.MapGet("/brief", (HttpContext ctx) => { - var e = BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn); - return ToView(ctx, e); + // RB-23/CQ-007: a read that used to allocate a row on first call. The owner's first + // draft now comes only from the explicit POST /brief/reset (BriefStore.ResetAndCreate) + // — this GET is a pure query and 404s when there is nothing to read yet. + var e = BriefStore.Get(ctx.Zorgverlener().Bsn); + if (e is null) return Results.NotFound(); + return Results.Ok(ToView(ctx, e)); }) -.Produces(); +.Produces() +.Produces(StatusCodes.Status404NotFound); + +// Server-rendered HTML preview (WP-25): "what you compose is what is sent" — the +// same LetterHtml.Render a sent brief archived. Hand-written on the FE (fetch → +// blob → new tab), so excluded from the OpenAPI doc, same seam as uploads. Sent +// letters serve their frozen archive; anything else renders live with a watermark. +api.MapGet("/brief/preview", (HttpContext ctx) => +{ + // RB-23: BriefStore.GetOrCreate is gone (split into Get + ResetAndCreate). This GET + // must not create a brief as a side effect either, so it 404s under the same + // precondition as GET /brief — in the running app the FE only reaches this endpoint + // from the brief page, which has already loaded (and, if needed, reset) a brief. + var e = BriefStore.Get(ctx.Zorgverlener().Bsn); + if (e is null) return Results.NotFound(); + if (e.Status.Tag == "sent" && e.ArchivedHtml is { } archived) + return Results.Content(archived, "text/html"); + var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, null); + return Results.Content(LetterHtml.Render(e, template, Now(), watermark: true), "text/html"); +}) +.ExcludeFromDescription(); + +// --- writes --- api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) => { @@ -620,7 +734,7 @@ api.MapPost("/brief/submit", (HttpContext ctx) => { var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter; var r = BriefStore.Submit(ctx.Zorgverlener().Bsn, isDrafter, Now()); - LogBrief("submit", r); + LogBrief(ctx, "submit", r); return BriefResult(ctx, r, "Alleen de opsteller mag indienen."); }) .WithName("briefSubmit") // distinct name so the generated client method isn't `submit2` @@ -631,7 +745,7 @@ api.MapPost("/brief/submit", (HttpContext ctx) => api.MapPost("/brief/approve", (HttpContext ctx) => { var r = BriefStore.Approve(ctx.Zorgverlener().Bsn, Authz.ResolvePrincipal(ctx), Now()); - LogBrief("approve", r); + LogBrief(ctx, "approve", r); return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn."); }) .Produces() @@ -641,7 +755,7 @@ api.MapPost("/brief/approve", (HttpContext ctx) => api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) => { var r = BriefStore.Reject(ctx.Zorgverlener().Bsn, Authz.ResolvePrincipal(ctx), req.Comments, Now()); - LogBrief("reject", r); + LogBrief(ctx, "reject", r); return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn."); }) .Produces() @@ -654,7 +768,7 @@ api.MapPost("/brief/send", (HttpContext ctx) => // port); the backend only guards the approved→sent transition (not role-gated // today — see Authz.CanActOn(Send, …), a mechanical dispatch step). var r = BriefStore.Send(ctx.Zorgverlener().Bsn, Now()); - LogBrief("send", r); + LogBrief(ctx, "send", r); return BriefResult(ctx, r, "Versturen kan niet in deze status."); }) .Produces() @@ -671,7 +785,10 @@ api.MapPost("/brief/reveal-bignummer", (HttpContext ctx) => var canReveal = Authz.CanRevealBigNummer(principal); var steppedUp = ctx.Request.Headers["X-Step-Up"] == "true"; var allowed = canReveal && steppedUp; - AuditAuthz(ctx, "brief:reveal-bignummer", "brief/" + ctx.Zorgverlener().Bsn, allowed, principal); + // RB-02/BIO-008: the resource ref is the brief, not the subject — a BSN concatenated + // here lands in a persisted, admin-visible column the "no PII" guarantee covers. One + // brief exists per owner, so the id added nothing the acting principal did not imply. + AuditAuthz(ctx, "brief:reveal-bignummer", "brief", allowed, principal); if (!allowed) return Results.Problem( detail: canReveal @@ -684,31 +801,6 @@ api.MapPost("/brief/reveal-bignummer", (HttpContext ctx) => // OpenAPI doc, same seam as /brief/preview and uploads. .ExcludeFromDescription(); -// Server-rendered HTML preview (WP-25): "what you compose is what is sent" — the -// same LetterHtml.Render a sent brief archived. Hand-written on the FE (fetch → -// blob → new tab), so excluded from the OpenAPI doc, same seam as uploads. Sent -// letters serve their frozen archive; anything else renders live with a watermark. -api.MapGet("/brief/preview", (HttpContext ctx) => -{ - var e = BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn); - if (e.Status.Tag == "sent" && e.ArchivedHtml is { } archived) - return Results.Content(archived, "text/html"); - var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, null); - return Results.Content(LetterHtml.Render(e, template, Now(), watermark: true), "text/html"); -}) -.ExcludeFromDescription(); - -// Proefbrief: the admin's unpublished draft template rendered over a fixture -// brief, so the appearance can be checked before publishing touches real letters. -api.MapGet("/admin/org-template/{subOrgId}/preview", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () => -{ - var view = OrgTemplateStore.AdminView(subOrgId); - if (view is null) return Results.NotFound(); - var fixture = BriefSeed.NewBrief("proefbrief"); - return Results.Content(LetterHtml.Render(fixture, view.Draft, Now(), watermark: true), "text/html"); -})) -.ExcludeFromDescription(); - api.MapPost("/brief/reset", (HttpContext ctx) => { // Demo "start over": recreate a fresh draft. No guards — showcase affordance only. @@ -723,25 +815,44 @@ api.MapPost("/brief/reset", (HttpContext ctx) => // as drafter/approver); the same Authz check gates every endpoint and feeds the // `orgtemplate:edit` capability on /me, so emit and enforce cannot drift. --- +// --- reads --- + api.MapGet("/admin/org-templates", (HttpContext ctx) => OrgAdmin(ctx, () => Results.Ok(OrgTemplateStore.List()))) +.Gate("OrgAdmin") .WithName("orgTemplates") .Produces>() .ProducesProblem(StatusCodes.Status403Forbidden); api.MapGet("/admin/org-template/{subOrgId}", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () => OrgTemplateStore.AdminView(subOrgId) is { } view ? Results.Ok(view) : Results.NotFound())) +.Gate("OrgAdmin") .WithName("orgTemplateGET") .Produces() .ProducesProblem(StatusCodes.Status403Forbidden) .Produces(StatusCodes.Status404NotFound); +// Proefbrief: the admin's unpublished draft template rendered over a fixture +// brief, so the appearance can be checked before publishing touches real letters. +api.MapGet("/admin/org-template/{subOrgId}/preview", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () => +{ + var view = OrgTemplateStore.AdminView(subOrgId); + if (view is null) return Results.NotFound(); + var fixture = BriefSeed.NewBrief("proefbrief"); + return Results.Content(LetterHtml.Render(fixture, view.Draft, Now(), watermark: true), "text/html"); +})) +.Gate("OrgAdmin") +.ExcludeFromDescription(); + +// --- writes --- + api.MapPut("/admin/org-template/{subOrgId}", (string subOrgId, SaveOrgTemplateRequest req, HttpContext ctx) => OrgAdmin(ctx, () => { var reject = OrgTemplateRules.RejectDraft(req.Draft); if (reject is not null) return Results.Problem(detail: reject, statusCode: StatusCodes.Status400BadRequest); return OrgTemplateStore.SaveDraft(subOrgId, req.Draft) is { } view ? Results.Ok(view) : Results.NotFound(); })) +.Gate("OrgAdmin") .WithName("orgTemplatePUT") .Produces() .ProducesProblem(StatusCodes.Status400BadRequest) @@ -756,6 +867,7 @@ api.MapPost("/admin/org-template/{subOrgId}/publish", (string subOrgId, HttpCont subOrgId, r.Version, r.AffectedUnsentBriefs); return r is not null ? Results.Ok(r) : Results.NotFound(); })) +.Gate("OrgAdmin") .WithName("orgTemplatePublish") .Produces() .ProducesProblem(StatusCodes.Status403Forbidden) @@ -763,6 +875,7 @@ api.MapPost("/admin/org-template/{subOrgId}/publish", (string subOrgId, HttpCont api.MapPost("/admin/org-template/{subOrgId}/rollback/{version:int}", (string subOrgId, int version, HttpContext ctx) => OrgAdmin(ctx, () => OrgTemplateStore.Rollback(subOrgId, version) is { } view ? Results.Ok(view) : Results.NotFound())) +.Gate("OrgAdmin") .WithName("orgTemplateRollback") .Produces() .ProducesProblem(StatusCodes.Status403Forbidden) @@ -770,17 +883,20 @@ api.MapPost("/admin/org-template/{subOrgId}/rollback/{version:int}", (string sub app.Run(); -static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true"; - // One gate for every org-template endpoint — the enforce twin of the -// `orgtemplate:edit` capability RoleCapabilities emits (single Authz source). A denial -// is audited (PRD-0002 §8); the allow path is left un-logged (the endpoints log their -// own effect, e.g. publish). +// `orgtemplate:edit` capability RoleCapabilities emits (single Authz source). +// +// RB-07/BIO-007: every gate below audits the real decision, allow *and* deny. Auditing +// only denials left /beheer/audit able to answer "who was turned away" but not "who +// changed this", which for a register whose integrity is the product is the wrong half +// (PRD-0002 §8 lists approvals alongside denials). The allow row is written by the gate, +// not by the endpoint, so a new admin endpoint cannot be added that forgets it. IResult OrgAdmin(HttpContext ctx, Func action) { var principal = Authz.ResolvePrincipal(ctx); - if (Authz.CanManageOrgTemplates(principal)) return action(); - AuditAuthz(ctx, "orgtemplate:edit", "org-templates", false, principal); + var ok = Authz.CanManageOrgTemplates(principal); + AuditAuthz(ctx, "orgtemplate:edit", "org-templates", ok, principal); + if (ok) return action(); return Results.Problem(detail: "Alleen een beheerder mag organisatiesjablonen beheren.", statusCode: StatusCodes.Status403Forbidden); } @@ -790,8 +906,9 @@ IResult OrgAdmin(HttpContext ctx, Func action) IResult StamdataAdmin(HttpContext ctx, Func action) { var principal = Authz.ResolvePrincipal(ctx); - if (Authz.CanEditStamdata(principal)) return action(); - AuditAuthz(ctx, "stamdata:edit", "stamdata", false, principal); + var ok = Authz.CanEditStamdata(principal); + AuditAuthz(ctx, "stamdata:edit", "stamdata", ok, principal); + if (ok) return action(); return Results.Problem(detail: "Alleen een beheerder mag stamdata onderhouden.", statusCode: StatusCodes.Status403Forbidden); } @@ -801,8 +918,9 @@ IResult StamdataAdmin(HttpContext ctx, Func action) IResult CasesAdmin(HttpContext ctx, Func action) { var principal = Authz.ResolvePrincipal(ctx); - if (Authz.CanManageCases(principal)) return action(); - AuditAuthz(ctx, "cases:manage", "cases", false, principal); + var ok = Authz.CanManageCases(principal); + AuditAuthz(ctx, "cases:manage", "cases", ok, principal); + if (ok) return action(); return Results.Problem(detail: "Alleen een beheerder mag aanvragen beheren.", statusCode: StatusCodes.Status403Forbidden); } @@ -813,18 +931,23 @@ IResult CasesAdmin(HttpContext ctx, Func action) // zorgverlener with X-Role=admin still gets denied. `resource` feeds the denial's audit row. IResult Beoordelen(HttpContext ctx, string resource, Func action) { - if (Authz.CanBeoordelen(ctx.Caller())) return action(); - AuditAuthz(ctx, "aanvraag:beoordelen", resource, false, Authz.ResolvePrincipal(ctx)); + var ok = Authz.CanBeoordelen(ctx.Caller()); + AuditAuthz(ctx, "aanvraag:beoordelen", resource, ok, Authz.ResolvePrincipal(ctx)); + if (ok) return action(); return Results.Problem(detail: "Alleen een behandelaar mag aanvragen beoordelen.", statusCode: StatusCodes.Status403Forbidden); } -// One gate for the feature-flag toggle — the enforce twin of `flags:manage` (WP-47). -IResult FlagsAdmin(HttpContext ctx, Func action) +// One gate for the feature-flag toggle — the enforce twin of `flags:manage` (WP-47). Takes a +// per-call `resource` like Beoordelen does, because the toggle endpoint writes no log line of +// its own (BIO-007): a bare "feature-flags" row would say a flag changed without saying which, +// and this is the surface CQ-004/ADR-C-009 hinge on. +IResult FlagsAdmin(HttpContext ctx, string resource, Func action) { var principal = Authz.ResolvePrincipal(ctx); - if (Authz.CanManageFeatureFlags(principal)) return action(); - AuditAuthz(ctx, "flags:manage", "feature-flags", false, principal); + var ok = Authz.CanManageFeatureFlags(principal); + AuditAuthz(ctx, "flags:manage", resource, ok, principal); + if (ok) return action(); return Results.Problem(detail: "Alleen een beheerder mag functievlaggen beheren.", statusCode: StatusCodes.Status403Forbidden); } @@ -856,12 +979,6 @@ void RecordZgwDivergence(HttpContext ctx, string id, string referentie, Exceptio AuthzAuditStore.Record("zgw:divergence", referentie, allowed: false, Authz.ResolvePrincipal(ctx).Role.ToString(), cid); } -// Keep the last `keep` characters, mask the rest — mirrors the FE maskTail -// (src/app/shared/ui/debug-state/mask.ts) so wire redaction and the dev panel agree. -static string MaskTail(string value, int keep) => - value.Length <= keep ? new string('*', value.Length) - : new string('*', value.Length - keep) + value[^keep..]; - static string Now() => DateTimeOffset.UtcNow.ToString("o"); BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new( @@ -875,7 +992,7 @@ BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new( // behandel scherm can show whom/what it concerns without brief/ importing registratie. // The BIG-nummer ships MASKED by default (PRD-0002 §5c, field-level PII); the reveal // endpoint returns the full value, gated + audited. - new CaseContextDto(SeedData.Registration.Naam, MaskTail(SeedData.Registration.BigNummer, 3), e.Beroep, BriefSeed.AanvraagReferentie)); + new CaseContextDto(SeedData.Registration.Naam, Pii.MaskTail(SeedData.Registration.BigNummer, 3), e.Beroep, BriefSeed.AanvraagReferentie)); // Emit (decision flags, via ToView) and enforce (Forbidden/Conflict below) both run // through Authz — see BriefStore.Review and Authz.CanActOn — so they cannot drift. @@ -886,20 +1003,29 @@ IResult BriefResult(HttpContext ctx, (BriefStore.Outcome outcome, BriefEntity? e _ => Results.Problem(detail: "Ongeldige overgang voor de huidige status van de brief.", statusCode: StatusCodes.Status409Conflict), }; -void LogBrief(string action, (BriefStore.Outcome outcome, BriefEntity? entity) r) => - app.Logger.LogInformation("brief {Action} outcome={Outcome} status={Status}", - action, r.outcome, r.entity?.Status.Tag ?? "-"); +// RB-07/BIO-007: every brief transition already funnelled through here for its log line, +// so the audit row goes here too — a fifth transition cannot be added that logs but leaves +// no trail. Resource is the bare "brief" (RB-02: never the owner's BSN); the decision is +// the transition's own outcome, so a 403 or a 409 is as visible as a success. +void LogBrief(HttpContext ctx, string action, (BriefStore.Outcome outcome, BriefEntity? entity) r) +{ + app.Logger.LogInformation("brief {Action} outcome={Outcome} status={Status}", + action, r.outcome, r.entity?.Status.Tag ?? "-"); + AuditAuthz(ctx, "brief:" + action, "brief", r.outcome == BriefStore.Outcome.Ok, Authz.ResolvePrincipal(ctx)); +} // Audit + outcome for a submit, with NO personal data: only kind, outcome, // generated reference and the caller's correlation id (the observability seam — a // real system ships this to structured logging / an audit store). A repeated // Idempotency-Key short-circuits to the first call's result — see IdempotencyStore -// — so a retried submit dedupes instead of minting a second reference. +// — so a retried submit dedupes instead of minting a second reference. The key is +// scoped to the caller (RB-18/BIO-018): two callers who happen to send the same +// client-chosen header value do not share a cached result. IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList? documents = null) { var cid = ctx.Items.TryGetValue("CorrelationId", out var v) ? (string)v! : "none"; var idemKey = ctx.Request.Headers.TryGetValue("Idempotency-Key", out var k) && !string.IsNullOrEmpty(k) - ? k.ToString() + ? $"{ctx.Caller().SubjectId}:{k}" : null; if (idemKey is not null && IdempotencyStore.TryGet(idemKey, out var cached)) @@ -936,5 +1062,25 @@ IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList(this TBuilder builder, string wrapper) + where TBuilder : IEndpointConventionBuilder + { + builder.WithMetadata(new AuthzGateMetadata(wrapper)); + return builder; + } +} + // Exposed so the integration tests can spin up the app with WebApplicationFactory. public partial class Program { } diff --git a/backend/src/BigRegister.Api/Stamdata/Professions.cs b/backend/src/BigRegister.Api/Stamdata/Professions.cs index 285f2c9..aa83209 100644 --- a/backend/src/BigRegister.Api/Stamdata/Professions.cs +++ b/backend/src/BigRegister.Api/Stamdata/Professions.cs @@ -19,14 +19,26 @@ public static class Professions /// Every mapping in the data-file, typed. public static readonly IReadOnlyList Mappings = StamdataFile.Load("professions"); - /// The mappings valid today, as a program→beroep lookup. Consumers that don't - /// yet reason about a peildatum (e.g. DiplomaRules.ProfessionFor) use this — it - /// preserves the pre-valid-time behaviour exactly while the file's rows are all current. - public static readonly IReadOnlyDictionary ByProgram = - Mappings.Where(m => StamdataFile.ActiveOn(m.GeldigVan, m.GeldigTot, DateOnly.FromDateTime(DateTime.Today))) + /// The mappings valid on , as a program→beroep lookup. + /// + /// Takes the peildatum as an argument rather than reading the clock. It used to be a + /// static readonly field filtered on DateTime.Today, which evaluated once at + /// type-load: a long-running process kept yesterday's answer across midnight, and a mapping + /// whose geldigVan fell after startup never appeared at all. It also made both + /// branches of permanently unreachable from here, which + /// is why this table's validity window was never exercised by a test. + public static IReadOnlyDictionary ByProgramOn(DateOnly on) => + Mappings.Where(m => StamdataFile.ActiveOn(m.GeldigVan, m.GeldigTot, on)) .ToDictionary(m => m.Program, m => m.Beroep, StringComparer.OrdinalIgnoreCase); - /// Distinct professions, in declaration order — the list a user may declare - /// for a manual (unlisted) diploma. + /// The mappings valid today. Consumers that don't yet reason about a peildatum + /// (e.g. DiplomaRules.ProfessionFor) use this — same behaviour as before, but + /// evaluated per call so the date is current. + public static IReadOnlyDictionary ByProgram => ByProgramOn(Today()); + + /// Distinct professions valid today, in declaration order — the list a user may + /// declare for a manual (unlisted) diploma. public static IReadOnlyList All() => ByProgram.Values.Distinct().ToList(); + + private static DateOnly Today() => DateOnly.FromDateTime(DateTime.Today); } diff --git a/backend/src/BigRegister.Api/Zgw/ZgwHttpClient.cs b/backend/src/BigRegister.Api/Zgw/ZgwHttpClient.cs index 6890fa6..6882c5d 100644 --- a/backend/src/BigRegister.Api/Zgw/ZgwHttpClient.cs +++ b/backend/src/BigRegister.Api/Zgw/ZgwHttpClient.cs @@ -24,7 +24,7 @@ internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens) { using var res = await SendWithRetryAsync(() => new HttpRequestMessage(HttpMethod.Get, url), caller); return (await res.Content.ReadFromJsonAsync()) - ?? throw new InvalidOperationException($"ZGW GET {url} returned null body."); + ?? throw new InvalidOperationException($"ZGW GET {Redact(url)} returned null body."); } public async Task PostAsync(string url, object body, CallerIdentity? caller = null) @@ -32,7 +32,7 @@ internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens) using var res = await SendWithRetryAsync( () => new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(body) }, caller); return (await res.Content.ReadFromJsonAsync()) - ?? throw new InvalidOperationException($"ZGW POST {url} returned null body."); + ?? throw new InvalidOperationException($"ZGW POST {Redact(url)} returned null body."); } /// @@ -42,7 +42,7 @@ internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens) /// partial commit on the two non-idempotent ZGW POSTs (/statussen, /rollen) and /// retrying risks a duplicate write — the create-zaak/document POSTs are additionally /// protected by OpenZaak's own uniqueness constraint on (bronorganisatie, identificatie). - /// A non-transient (or exhausted) failure throws with the status + a body snippet, which + /// A non-transient (or exhausted) failure throws with the status + the redacted path, which /// Program.cs's submit endpoint catches and records as a flagged divergence rather /// than letting it diverge silently (see openzaak-integration.md's "Write resilience" section). /// @@ -73,15 +73,26 @@ internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens) continue; } - var body = await res.Content.ReadAsStringAsync(); - var snippet = body.Length > 500 ? body[..500] : body; - var message = $"ZGW {req.Method} {req.RequestUri} failed: {(int)res.StatusCode} {snippet}"; + // RB-05/BIO-009: path only — no query string, no response-body snippet. The + // BSN-filtered zaken list puts a BSN in the query, and OpenZaak echoes the request in + // its error bodies, so both used to reach a message Program.cs persists as a flagged + // divergence and writes to the application log. Status + path routes the failure; + // ZGW_DEBUG_HTTP=1 (ZgwDiagnosticHandler) is the deliberate opt-in for the rest. + var message = $"ZGW {req.Method} {Redact(req.RequestUri)} failed: {(int)res.StatusCode} {res.ReasonPhrase}"; var status = res.StatusCode; res.Dispose(); throw new HttpRequestException(message, null, status); } } + /// The path without its query string — ZGW filters travel as query parameters and + /// one of them is a BSN (rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn), so no + /// ZGW url may be interpolated into a message that is logged or persisted (RB-05). + private static string Redact(string url) => + Uri.TryCreate(url, UriKind.Absolute, out var u) ? u.GetLeftPart(UriPartial.Path) : url.Split('?')[0]; + + private static string Redact(Uri? url) => url is null ? "(no uri)" : url.GetLeftPart(UriPartial.Path); + private static bool IsTransient(HttpStatusCode status) => status is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout; diff --git a/backend/swagger.json b/backend/swagger.json index 6ca507f..e0ccac2 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -194,6 +194,16 @@ } } }, + "400": { + "description": "Bad Request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "403": { "description": "Forbidden", "content": { @@ -210,45 +220,6 @@ } } }, - "/api/v1/registrations": { - "post": { - "tags": [ - "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RegistratieRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReferentieResponse" - } - } - } - }, - "422": { - "description": "Unprocessable Content", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, "/api/v1/change-requests": { "post": { "tags": [ @@ -439,7 +410,14 @@ "description": "No Content" }, "403": { - "description": "Forbidden" + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } }, "404": { "description": "Not Found" @@ -708,6 +686,73 @@ } } }, + "/api/v1/admin/audit": { + "get": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AuthzAuditDto" + } + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/api/v1/admin/cases/{id}": { + "delete": { + "tags": [ + "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "404": { + "description": "Not Found" + }, + "403": { + "description": "Forbidden", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, "/api/v1/werkvoorraad": { "get": { "tags": [ @@ -854,73 +899,6 @@ } } }, - "/api/v1/admin/cases/{id}": { - "delete": { - "tags": [ - "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "No Content" - }, - "404": { - "description": "Not Found" - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/api/v1/admin/audit": { - "get": { - "tags": [ - "BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AuthzAuditDto" - } - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, "/api/v1/me": { "get": { "tags": [ @@ -1022,6 +1000,9 @@ } } } + }, + "404": { + "description": "Not Found" } } }, @@ -2468,23 +2449,6 @@ }, "additionalProperties": false }, - "RegistratieRequest": { - "type": "object", - "properties": { - "diplomaHerkomst": { - "type": "string", - "nullable": true - }, - "documents": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DocumentRefDto" - }, - "nullable": true - } - }, - "additionalProperties": false - }, "RegistrationDto": { "type": "object", "properties": { diff --git a/backend/tests/BigRegister.Tests/AdminCasesTests.cs b/backend/tests/BigRegister.Tests/AdminCasesTests.cs index 9bfc9dc..475778a 100644 --- a/backend/tests/BigRegister.Tests/AdminCasesTests.cs +++ b/backend/tests/BigRegister.Tests/AdminCasesTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http.Json; using BigRegister.Api.Contracts; +using BigRegister.Api.Data; using Microsoft.AspNetCore.Mvc.Testing; namespace BigRegister.Tests; @@ -34,7 +35,10 @@ public class AdminCasesTests(TestWebApplicationFactory factory) : IClassFixture< list.EnsureSuccessStatusCode(); var cases = (await list.Content.ReadFromJsonAsync>())!; var mine = cases.Single(x => x.Id == a.Id); - Assert.False(string.IsNullOrEmpty(mine.Owner)); // admin list carries the owner + // RB-03/BIO-003: the owner is carried, but masked — it is a BSN, and this list is + // read by someone who is not the subject. + Assert.Equal("******782", mine.Owner); + Assert.DoesNotContain(DocumentStore.DemoOwner, mine.Owner); } finally { diff --git a/backend/tests/BigRegister.Tests/AuthzAuditTests.cs b/backend/tests/BigRegister.Tests/AuthzAuditTests.cs index a670bd1..1252557 100644 --- a/backend/tests/BigRegister.Tests/AuthzAuditTests.cs +++ b/backend/tests/BigRegister.Tests/AuthzAuditTests.cs @@ -1,8 +1,10 @@ using System.Net; +using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.RegularExpressions; using BigRegister.Api.Contracts; using BigRegister.Api.Data; +using BigRegister.Domain.Features; using Microsoft.AspNetCore.Mvc.Testing; namespace BigRegister.Tests; @@ -26,6 +28,20 @@ public class AuthzAuditTests(TestWebApplicationFactory factory) : IClassFixture< return (await res.Content.ReadFromJsonAsync>())!; } + private async Task UploadAsOwner() + { + var form = new MultipartFormDataContent(); + var file = new ByteArrayContent(new byte[] { 1, 2, 3 }); + file.Headers.ContentType = new MediaTypeHeaderValue("application/pdf"); + form.Add(file, "file", "diploma.pdf"); + form.Add(new StringContent("diploma"), "categoryId"); + form.Add(new StringContent("local-rb08"), "localId"); + form.Add(new StringContent("registratie"), "wizardId"); + var res = await _client.PostAsync("/api/v1/uploads", form); + res.EnsureSuccessStatusCode(); + return (await res.Content.ReadFromJsonAsync())!.DocumentId; + } + [Fact] public async Task A_denied_admin_action_is_recorded() { @@ -43,6 +59,85 @@ public class AuthzAuditTests(TestWebApplicationFactory factory) : IClassFixture< Assert.Contains(await AuditLog(), e => e.Action == "brief:reveal-bignummer"); } + /// RB-07/BIO-007: the trail used to record only denials, so `/beheer/audit` could answer + /// "who was turned away" but not "who changed this" — for a register whose integrity is the + /// product, the wrong half. Every gate now audits the real decision. + [Fact] + public async Task An_allowed_admin_action_is_recorded() + { + (await _client.SendAsync(Admin(HttpMethod.Get, "/api/v1/admin/cases"))).EnsureSuccessStatusCode(); + Assert.Contains(await AuditLog(), e => e.Action == "cases:manage" && e.Decision == "allow" && e.Role == "Admin"); + } + + /// RB-08/BIO-003: the admin upload delete used to be gated by a standalone X-Admin + /// header, outside Authz and writing no AuthzAuditStore row at all. Routing it through + /// CasesAdmin (cases:manage) gives it the same allow-path row every other admin-cases + /// endpoint gets, for free, per RB-07. `CasesAdmin` audits under a fixed "cases" + /// resource shared with the other admin-cases endpoints, so this asserts a **count** + /// increase — reading the store directly (not via `GET /admin/audit`, itself a + /// `CasesAdmin` endpoint that would write its own row and confound the count) — + /// rather than mere presence, which this class's other cases:manage calls would + /// already satisfy even without the fix. + [Fact] + public async Task An_admin_upload_delete_is_recorded() + { + bool IsCasesManageAllow(AuthzAuditEntry e) => + e.Action == "cases:manage" && e.Decision == "allow" && e.Role == "Admin"; + + var documentId = await UploadAsOwner(); + var before = AuthzAuditStore.List().Count(IsCasesManageAllow); + + (await _client.SendAsync(Admin(HttpMethod.Delete, $"/api/v1/admin/uploads/{documentId}"))) + .EnsureSuccessStatusCode(); + + Assert.Equal(before + 1, AuthzAuditStore.List().Count(IsCasesManageAllow)); + } + + /// The flag toggle writes no log line of its own, so the audit row is the only record that + /// it happened — a bare "feature-flags" resource would not say which flag. + [Fact] + public async Task A_feature_flag_toggle_records_which_flag_changed() + { + var toggle = Admin(HttpMethod.Put, $"/api/v1/admin/flags/{FeatureFlags.InschrijvingOpen}"); + toggle.Content = JsonContent.Create(new { enabled = false }); + (await _client.SendAsync(toggle)).EnsureSuccessStatusCode(); + + Assert.Contains(await AuditLog(), e => + e.Action == "flags:manage" && e.Decision == "allow" && + e.Resource == $"feature-flags/{FeatureFlags.InschrijvingOpen}=False"); + } + + /// Every brief transition funnels through LogBrief, so all four are covered by the audit + /// call living there. The allow side is asserted in + /// BriefEndpointTests.Submit_succeeds_when_required_sections_filled, which already has + /// the fill-the-sections scaffolding; this is the refused side — a rejected transition must + /// leave a row rather than being dropped. + [Fact] + public async Task A_refused_brief_transition_is_recorded() + { + // No brief exists for this subject and nothing is filled in → illegal transition. + Assert.Equal(HttpStatusCode.Conflict, (await _client.PostAsync("/api/v1/brief/submit", null)).StatusCode); + Assert.Contains(await AuditLog(), e => e.Action == "brief:submit" && e.Decision == "deny"); + } + + /// RB-02/BIO-008: the schema test below asserts on **column names**, so a BSN inside a + /// column called `Resource` was invisible to it — and one was there, concatenated as + /// `"brief/" + Bsn`. This asserts on the stored **values** instead. Four documents + /// promise this trail holds no PII; this is the test that makes the promise checkable. + [Fact] + public async Task No_audit_row_carries_a_subjects_bsn() + { + const string subject = "999999990"; + var reveal = new HttpRequestMessage(HttpMethod.Post, "/api/v1/brief/reveal-bignummer"); + reveal.Headers.Add("X-Subject", subject); + Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(reveal)).StatusCode); + + var bsns = new[] { subject, DocumentStore.DemoOwner }; + foreach (var e in await AuditLog()) + foreach (var field in new[] { e.Action, e.Resource, e.Decision, e.Role, e.At, e.CorrelationId }) + Assert.DoesNotContain(bsns, bsn => field.Contains(bsn, StringComparison.Ordinal)); + } + [Fact] public void The_audit_schema_carries_no_pii() { diff --git a/backend/tests/BigRegister.Tests/BeoordelingTests.cs b/backend/tests/BigRegister.Tests/BeoordelingTests.cs index 19c0291..f79ffbb 100644 --- a/backend/tests/BigRegister.Tests/BeoordelingTests.cs +++ b/backend/tests/BigRegister.Tests/BeoordelingTests.cs @@ -2,6 +2,7 @@ using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using BigRegister.Api.Contracts; +using BigRegister.Api.Data; using Microsoft.AspNetCore.Mvc.Testing; namespace BigRegister.Tests; @@ -150,6 +151,12 @@ public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture var view = (await detail.Content.ReadFromJsonAsync())!; Assert.Equal("Goedgekeurd", view.Aanvraag.Status.Tag); Assert.False(view.Decisions.CanBesluiten); // terminal — no further decision allowed + + // RB-07/BIO-007: the gate records that a behandelaar was allowed to act; this records + // what they decided, which is the question /beheer/audit exists to answer. + Assert.Contains(AuthzAuditStore.List(), e => + e.Action == "aanvraag:besluit" && e.Decision == "allow" && + e.Resource == $"aanvraag/{a.Id}/Goedkeuren"); } finally { diff --git a/backend/tests/BigRegister.Tests/BriefEndpointTests.cs b/backend/tests/BigRegister.Tests/BriefEndpointTests.cs index d38973c..78cdd05 100644 --- a/backend/tests/BigRegister.Tests/BriefEndpointTests.cs +++ b/backend/tests/BigRegister.Tests/BriefEndpointTests.cs @@ -28,10 +28,14 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu return new SaveBriefRequest(sections); } - private async Task Get() + /// RB-23: `GET /brief` no longer seeds a brief on first call, so every test that + /// needs one present creates it explicitly through `POST /brief/reset` + /// (`BriefStore.ResetAndCreate`) — the same command the "start over" affordance uses. + private async Task SeedBrief() { BriefStore.Reset(); - var view = await _client.GetFromJsonAsync("/api/v1/brief"); + var res = await _client.PostAsync("/api/v1/brief/reset", null); + var view = await res.Content.ReadFromJsonAsync(); Assert.NotNull(view); return view.Brief; } @@ -44,10 +48,26 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu return req; } + // --- RB-23/CQ-007: GET /brief is a pure query — it must not create a row. --- + [Fact] - public async Task Get_creates_a_draft_with_expected_sections_locked_and_empty() + public async Task Get_returns_404_and_writes_no_row_when_no_brief_exists_for_the_owner() { - var brief = await Get(); + BriefStore.Reset(); + + var res = await _client.GetAsync("/api/v1/brief"); + Assert.Equal(HttpStatusCode.NotFound, res.StatusCode); + + // The non-idempotent write CQ-007 flagged: a GET that allocated a row on first call. + // Assert directly against the store, not only the HTTP status, so a regression that + // reintroduces GetOrCreate-style seeding fails here even if the response shape stays 404. + Assert.Null(BriefStore.Get(DocumentStore.DemoOwner)); + } + + [Fact] + public async Task SeedBrief_creates_a_draft_with_expected_sections_locked_and_empty() + { + var brief = await SeedBrief(); Assert.Equal("draft", brief.Status.Tag); Assert.Equal(new[] { "aanhef", "kern", "slot" }, brief.Sections.Select(s => s.SectionKey)); // aanhef + slot are locked, predefined and prefilled; only kern is editable + empty. @@ -63,7 +83,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu [Fact] public async Task Get_offers_only_global_and_arts_scoped_besluit_tagged_passages() { - await Get(); + await SeedBrief(); var view = await _client.GetFromJsonAsync("/api/v1/brief"); Assert.NotNull(view); // global passages + the arts-scoped one; no other-beroep passages leak in. @@ -78,7 +98,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu [Fact] public async Task Get_joins_the_case_context_with_the_BIG_nummer_masked() { - await Get(); + await SeedBrief(); var view = await _client.GetFromJsonAsync("/api/v1/brief"); Assert.NotNull(view); // Case context is joined onto the screen DTO for the behandel scherm header. @@ -128,7 +148,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu [Fact] public async Task Save_is_drafter_only() { - var brief = await Get(); + var brief = await SeedBrief(); var save = FilledFrom(brief); var approver = Post("/api/v1/brief", role: "approver"); @@ -142,7 +162,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu [Fact] public async Task Submit_blocks_on_empty_required_section() { - await Get(); + await SeedBrief(); // Nothing filled yet → required sections empty → 409. Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/submit"))).StatusCode); } @@ -150,7 +170,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu [Fact] public async Task Submit_succeeds_when_required_sections_filled() { - await Get(); + await SeedBrief(); var view = await _client.GetFromJsonAsync("/api/v1/brief"); Assert.NotNull(view); await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(view.Brief)); @@ -160,12 +180,17 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu var submitted = await res.Content.ReadFromJsonAsync(); Assert.NotNull(submitted); Assert.Equal("submitted", submitted.Brief.Status.Tag); + + // RB-07/BIO-007: the allow side of the transition leaves a row, not just a log line. + // Resource is the bare "brief" — never the owner's BSN (RB-02). + Assert.Contains(AuthzAuditStore.List(), + e => e.Action == "brief:submit" && e.Decision == "allow" && e.Resource == "brief"); } [Fact] public async Task Drafter_cannot_approve_own_letter_but_a_different_reviewer_can() { - var brief = await Get(); + var brief = await SeedBrief(); await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); await _client.SendAsync(Post("/api/v1/brief/submit")); @@ -182,7 +207,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu [Fact] public async Task Reject_returns_comments() { - var brief = await Get(); + var brief = await SeedBrief(); await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); await _client.SendAsync(Post("/api/v1/brief/submit")); @@ -197,7 +222,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu [Fact] public async Task Editing_a_rejected_letter_reopens_it_to_draft() { - var brief = await Get(); + var brief = await SeedBrief(); await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); await _client.SendAsync(Post("/api/v1/brief/submit")); await _client.SendAsync( @@ -213,7 +238,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu [Fact] public async Task Send_only_from_approved() { - var brief = await Get(); + var brief = await SeedBrief(); await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); await _client.SendAsync(Post("/api/v1/brief/submit")); @@ -231,7 +256,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu [Fact] public async Task Decisions_on_the_view_mirror_the_acting_principal_and_live_status() { - var brief = await Get(); + var brief = await SeedBrief(); var view = await _client.GetFromJsonAsync("/api/v1/brief"); Assert.NotNull(view); Assert.True(view.Decisions.CanEdit); // default (no X-Role) = drafter, draft status @@ -264,7 +289,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu [Fact] public async Task Reset_recreates_a_fresh_draft_with_locked_prefilled_sections() { - var brief = await Get(); + var brief = await SeedBrief(); // Advance out of draft so the reset back to draft is observable. await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); await _client.SendAsync(Post("/api/v1/brief/submit")); diff --git a/backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs b/backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs new file mode 100644 index 0000000..de01174 --- /dev/null +++ b/backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs @@ -0,0 +1,147 @@ +using BigRegister.Api.Contracts; +using BigRegister.Api.Data; +using BigRegister.Domain.Authorization; +using BigRegister.Domain.Letters; + +namespace BigRegister.Tests.Domain; + +public class BriefRuleTests +{ + private static BriefStatusDto Status(string tag) => new(tag); + + private static readonly Principal Drafter = new(PrincipalRole.Drafter); + private static readonly Principal Approver = new(PrincipalRole.Approver); + + // --- CanSave ----------------------------------------------------------------- + + [Theory] + [InlineData("draft")] + [InlineData("rejected")] + public void A_drafter_may_save_a_draft_or_rejected_letter(string tag) => + Assert.Equal(BriefStore.Outcome.Ok, BriefRules.CanSave(Status(tag), isDrafter: true)); + + [Theory] + [InlineData("submitted")] + [InlineData("approved")] + [InlineData("sent")] + public void A_drafter_may_not_save_a_non_editable_letter(string tag) => + Assert.Equal(BriefStore.Outcome.Conflict, BriefRules.CanSave(Status(tag), isDrafter: true)); + + [Theory] + [InlineData("draft")] + [InlineData("submitted")] + public void A_non_drafter_is_forbidden_to_save_regardless_of_status(string tag) => + // Role is checked before status: Forbidden wins even against an otherwise-open status. + Assert.Equal(BriefStore.Outcome.Forbidden, BriefRules.CanSave(Status(tag), isDrafter: false)); + + // --- StatusAfterSave ----------------------------------------------------------- + + [Fact] + public void Saving_a_rejected_letter_reopens_it_to_draft() => + Assert.Equal("draft", BriefRules.StatusAfterSave(Status("rejected")).Tag); + + [Fact] + public void Saving_a_draft_letter_leaves_its_status_unchanged() => + Assert.Equal("draft", BriefRules.StatusAfterSave(Status("draft")).Tag); + + // --- RequiredFilled -------------------------------------------------------------- + + private static LetterSectionDto Section(string key, bool required, int blockCount) => + new(key, key, required, Enumerable.Range(0, blockCount) + .Select(i => new LetterBlockDto("freeText", $"{key}-{i}", new RichTextBlockDto(Array.Empty()))) + .ToList()); + + [Fact] + public void No_required_sections_means_nothing_to_fill() => + Assert.True(BriefRules.RequiredFilled(Array.Empty())); + + [Fact] + public void An_optional_empty_section_does_not_block_submission() => + Assert.True(BriefRules.RequiredFilled(new[] { Section("slot", required: false, blockCount: 0) })); + + [Fact] + public void A_required_section_with_a_block_is_filled() => + Assert.True(BriefRules.RequiredFilled(new[] { Section("kern", required: true, blockCount: 1) })); + + [Fact] + public void A_required_section_with_no_blocks_is_not_filled() => + Assert.False(BriefRules.RequiredFilled(new[] { Section("kern", required: true, blockCount: 0) })); + + [Fact] + public void One_unfilled_required_section_blocks_submission_even_if_others_are_filled() => + Assert.False(BriefRules.RequiredFilled(new[] + { + Section("kern", required: true, blockCount: 1), + Section("bijlage", required: true, blockCount: 0), + })); + + // --- CanSubmit ----------------------------------------------------------------- + + [Fact] + public void A_drafter_may_submit_a_filled_draft() => + Assert.Equal(BriefStore.Outcome.Ok, BriefRules.CanSubmit(Status("draft"), isDrafter: true, requiredFilled: true)); + + [Fact] + public void A_drafter_may_not_submit_an_unfilled_draft() => + Assert.Equal(BriefStore.Outcome.Conflict, BriefRules.CanSubmit(Status("draft"), isDrafter: true, requiredFilled: false)); + + [Fact] + public void A_drafter_may_not_submit_a_letter_that_is_not_a_draft() => + Assert.Equal(BriefStore.Outcome.Conflict, BriefRules.CanSubmit(Status("submitted"), isDrafter: true, requiredFilled: true)); + + [Fact] + public void A_non_drafter_is_forbidden_to_submit_even_a_filled_draft() => + // Role is checked before status/completeness: Forbidden wins over Conflict. + Assert.Equal(BriefStore.Outcome.Forbidden, BriefRules.CanSubmit(Status("draft"), isDrafter: false, requiredFilled: true)); + + // --- CanSend --------------------------------------------------------------------- + + [Fact] + public void An_approved_letter_may_be_sent() => + Assert.Equal(BriefStore.Outcome.Ok, BriefRules.CanSend(Status("approved"))); + + [Theory] + [InlineData("draft")] + [InlineData("submitted")] + [InlineData("rejected")] + [InlineData("sent")] + public void Only_an_approved_letter_may_be_sent(string tag) => + Assert.Equal(BriefStore.Outcome.Conflict, BriefRules.CanSend(Status(tag))); + + // --- CanDecide (Approve/Reject shared guard) -------------------------------------- + + [Theory] + [InlineData(BriefAction.Approve)] + [InlineData(BriefAction.Reject)] + public void An_approver_may_decide_a_submitted_letter_drafted_by_someone_else(BriefAction action) => + Assert.Equal( + BriefStore.Outcome.Ok, + BriefRules.CanDecide(action, Status("submitted"), Approver, drafterId: BriefStore.DrafterId)); + + [Fact] + public void A_drafter_may_not_approve_or_reject() => + Assert.Equal( + BriefStore.Outcome.Forbidden, + BriefRules.CanDecide(BriefAction.Approve, Status("submitted"), Drafter, drafterId: BriefStore.DrafterId)); + + [Fact] + public void An_approver_may_not_decide_a_letter_they_drafted_themselves() => + // Four-eyes / SoD: the acting approver id happens to equal the letter's drafterId. + Assert.Equal( + BriefStore.Outcome.Forbidden, + BriefRules.CanDecide(BriefAction.Approve, Status("submitted"), Approver, drafterId: BriefStore.ApproverId)); + + [Fact] + public void An_approver_may_not_decide_a_letter_that_is_not_submitted() => + Assert.Equal( + BriefStore.Outcome.Conflict, + BriefRules.CanDecide(BriefAction.Approve, Status("draft"), Approver, drafterId: BriefStore.DrafterId)); + + [Fact] + public void Entitlement_is_checked_before_status_forbidden_wins_over_conflict() => + // Same actor as drafter AND a non-submitted status: still Forbidden, not Conflict — + // matches the store's original check order (Authz.CanActOn before the status guard). + Assert.Equal( + BriefStore.Outcome.Forbidden, + BriefRules.CanDecide(BriefAction.Approve, Status("draft"), Approver, drafterId: BriefStore.ApproverId)); +} diff --git a/backend/tests/BigRegister.Tests/Domain/ProfessionsTests.cs b/backend/tests/BigRegister.Tests/Domain/ProfessionsTests.cs new file mode 100644 index 0000000..e0ddf82 --- /dev/null +++ b/backend/tests/BigRegister.Tests/Domain/ProfessionsTests.cs @@ -0,0 +1,47 @@ +using BigRegister.Stamdata; + +namespace BigRegister.Tests.Domain; + +/// +/// The profession↔program map's validity window (TE-009). `ByProgram` used to be a +/// `static readonly` field filtered on `DateTime.Today` at type-load, so both branches of +/// `StamdataFile.ActiveOn` were unreachable from here and nothing asserted the window at all. +/// Now that the peildatum is a parameter, these are the two branches. +/// +public class ProfessionsTests +{ + [Fact] + public void A_mapping_is_absent_before_its_geldigVan() + { + // Every seeded row starts 2000-01-01; nothing is valid the day before. + Assert.Empty(Professions.ByProgramOn(new DateOnly(1999, 12, 31))); + } + + [Fact] + public void A_mapping_is_present_on_and_after_its_geldigVan() + { + Assert.Equal("Arts", Professions.ByProgramOn(new DateOnly(2000, 1, 1))["geneeskunde"]); + Assert.Equal("Arts", Professions.ByProgramOn(new DateOnly(2026, 8, 26))["geneeskunde"]); + } + + [Fact] + public void A_closed_mapping_is_absent_from_its_geldigTot_onwards() + { + // geldigTot is exclusive (`on < tot`), so the row drops out on the boundary date itself. + foreach (var m in Professions.Mappings.Where(m => m.GeldigTot is DateOnly)) + { + var tot = m.GeldigTot!.Value; + Assert.True(Professions.ByProgramOn(tot.AddDays(-1)).ContainsKey(m.Program)); + Assert.False(Professions.ByProgramOn(tot).ContainsKey(m.Program)); + } + } + + [Fact] + public void ByProgram_is_evaluated_per_call_not_captured_at_type_load() + { + // The regression this guards: a long-running process must not keep serving the answer + // it computed at startup. Same date in, same answer; different date in, different answer. + Assert.Equal(Professions.ByProgram.Count, Professions.ByProgramOn(DateOnly.FromDateTime(DateTime.Today)).Count); + Assert.NotEqual(Professions.ByProgram.Count, Professions.ByProgramOn(new DateOnly(1999, 12, 31)).Count); + } +} diff --git a/backend/tests/BigRegister.Tests/Domain/SubmissionRuleTests.cs b/backend/tests/BigRegister.Tests/Domain/SubmissionRuleTests.cs index 2265864..ff3028e 100644 --- a/backend/tests/BigRegister.Tests/Domain/SubmissionRuleTests.cs +++ b/backend/tests/BigRegister.Tests/Domain/SubmissionRuleTests.cs @@ -4,14 +4,6 @@ namespace BigRegister.Tests.Domain; public class SubmissionRuleTests { - [Fact] - public void Manual_diploma_is_rejected() => - Assert.NotNull(SubmissionRules.RejectRegistratie("handmatig")); - - [Fact] - public void Duo_diploma_is_accepted() => - Assert.Null(SubmissionRules.RejectRegistratie("duo")); - [Fact] public void Zero_hours_is_rejected() => Assert.NotNull(SubmissionRules.RejectZeroUren(0)); diff --git a/backend/tests/BigRegister.Tests/EndpointTests.cs b/backend/tests/BigRegister.Tests/EndpointTests.cs index d7335a1..b8fb2d7 100644 --- a/backend/tests/BigRegister.Tests/EndpointTests.cs +++ b/backend/tests/BigRegister.Tests/EndpointTests.cs @@ -69,26 +69,6 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture(); - Assert.NotNull(body); - Assert.StartsWith("BIG-2026-", body.Referentie); - } - - [Fact] - public async Task Registration_with_manual_diploma_is_rejected_with_problem_details() - { - var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("handmatig")); - Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode); - var contentType = res.Content.Headers.ContentType; - Assert.NotNull(contentType); - Assert.Contains("application/problem+json", contentType.ToString()); - } - [Fact] public async Task Change_request_with_valid_phone_succeeds() { @@ -101,11 +81,16 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture())!; + var submit = await _client.PostAsJsonAsync($"/api/v1/applications/{aanvraag.Id}/submit", + new { diplomaHerkomst = "duo", documents = new[] { new DocumentRefDto("diploma", "digital", doc.DocumentId) } }); submit.EnsureSuccessStatusCode(); Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode); } @@ -224,11 +213,13 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture(); + + var callerBRequest = ChangeRequestWithKey(sharedKey); + callerBRequest.Headers.Add("X-Subject", "999888777"); + var callerB = await _client.SendAsync(callerBRequest); + callerB.EnsureSuccessStatusCode(); + var callerBBody = await callerB.Content.ReadFromJsonAsync(); + + Assert.NotEqual(callerABody!.Referentie, callerBBody!.Referentie); + } + [Fact] public async Task A_rejected_submission_replays_the_same_rejection_not_a_retry() { diff --git a/backend/tests/BigRegister.Tests/LetterHtmlTests.cs b/backend/tests/BigRegister.Tests/LetterHtmlTests.cs index a6415c4..3d59f71 100644 --- a/backend/tests/BigRegister.Tests/LetterHtmlTests.cs +++ b/backend/tests/BigRegister.Tests/LetterHtmlTests.cs @@ -80,6 +80,44 @@ public class LetterHtmlTests private static readonly string GoldenPath = Path.Combine(AppContext.BaseDirectory, "LetterHtml.golden.html"); + // A minimal brief whose body renders the "datum" placeholder — the golden-file + // fixture above never uses it in the body, only in the letterhead, so it cannot + // exercise ResolveAuto's "datum" case (TE-007). + private static BriefEntity FixtureBriefWithDatumInBody() => new() + { + BriefId = "datum-brief-1", + Owner = "golden", + Beroep = "arts", + TemplateId = "besluit-arts", + DrafterId = BriefStore.DrafterId, + Placeholders = new[] + { + new PlaceholderDefDto("datum", "Datum", true), + }, + Sections = new() + { + new("kern", "Kern van het besluit", true, new List + { + new("freeText", "kern-1", new RichTextBlockDto(new[] + { + new ParagraphDto(new[] { new RichTextNodeDto("placeholder", Key: "datum") }), + })), + }), + }, + Status = new BriefStatusDto("draft"), + }; + + private static string ExtractLetterheadDate(string html) => + Regex.Match(html, "
    Datum
    ([^<]+)
    ").Groups[1].Value; + + private static string ExtractBodyDatumParagraph(string html) + { + var bodyStart = html.IndexOf("
    ", StringComparison.Ordinal); + var bodyEnd = html.IndexOf("
    ", StringComparison.Ordinal); + var body = html[bodyStart..bodyEnd]; + return Regex.Match(body, "

    ([^<]+)

    ").Groups[1].Value; + } + [Fact] public void Render_matches_the_golden_file() { @@ -88,6 +126,29 @@ public class LetterHtmlTests Assert.Equal(golden, html); } + [Fact] + public void Render_resolves_the_body_datum_placeholder_from_the_given_at_not_the_wall_clock() + { + const string historicalAt = "2019-03-14T08:00:00.0000000+00:00"; + + var html = LetterHtml.Render(FixtureBriefWithDatumInBody(), Template, historicalAt, watermark: false); + + Assert.Equal("14 maart 2019", ExtractBodyDatumParagraph(html)); + } + + [Fact] + public void Render_keeps_the_letterhead_date_and_the_body_datum_in_agreement_for_a_historical_at() + { + // A historical `at` (an archive re-render, a back-dated letter) is the case + // where the letterhead and the body datum placeholder could disagree within + // one document, if the body still read the wall clock (TE-007). + const string historicalAt = "2019-03-14T08:00:00.0000000+00:00"; + + var html = LetterHtml.Render(FixtureBriefWithDatumInBody(), Template, historicalAt, watermark: false); + + Assert.Equal(ExtractLetterheadDate(html), ExtractBodyDatumParagraph(html)); + } + [Fact] public void Every_letter_prefixed_class_exists_in_letter_css() { diff --git a/backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs b/backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs index 01784b9..dcf26b2 100644 --- a/backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs +++ b/backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs @@ -58,8 +58,8 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas public async Task Publish_increments_the_version() { ResetStores(); - // One unsent brief for this sub-org (GetOrCreate on first read). - await _client.GetAsync("/api/v1/brief"); + // One unsent brief for this sub-org (RB-23: GET no longer seeds — create explicitly). + await _client.PostAsync("/api/v1/brief/reset", null); var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin")); res.EnsureSuccessStatusCode(); @@ -74,7 +74,7 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas public async Task Publish_appends_to_the_version_history() { ResetStores(); - await _client.GetAsync("/api/v1/brief"); + await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: GET no longer seeds — create explicitly var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin")); res.EnsureSuccessStatusCode(); @@ -87,8 +87,8 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas public async Task Publish_counts_the_unsent_briefs_it_affects() { ResetStores(); - // One unsent brief for this sub-org (GetOrCreate on first read). - await _client.GetAsync("/api/v1/brief"); + // One unsent brief for this sub-org (RB-23: GET no longer seeds — create explicitly). + await _client.PostAsync("/api/v1/brief/reset", null); var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin")); res.EnsureSuccessStatusCode(); @@ -154,7 +154,8 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas private async Task WalkBriefToSentThenRepublish() { ResetStores(); - var brief = (await _client.GetFromJsonAsync("/api/v1/brief"))!.Brief; + var resetRes = await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: create explicitly + var brief = (await resetRes.Content.ReadFromJsonAsync())!.Brief; var filled = brief.Sections .Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required, s.Required && s.Blocks.Count == 0 @@ -210,7 +211,8 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas public async Task Admin_cannot_slip_into_the_brief_review_flow() { ResetStores(); - var brief = (await _client.GetFromJsonAsync("/api/v1/brief"))!.Brief; + var resetRes = await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: create explicitly + var brief = (await resetRes.Content.ReadFromJsonAsync())!.Brief; var filled = brief.Sections .Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required, s.Required && s.Blocks.Count == 0 diff --git a/backend/tests/BigRegister.Tests/PreviewEndpointTests.cs b/backend/tests/BigRegister.Tests/PreviewEndpointTests.cs index 62369ec..d86c9df 100644 --- a/backend/tests/BigRegister.Tests/PreviewEndpointTests.cs +++ b/backend/tests/BigRegister.Tests/PreviewEndpointTests.cs @@ -41,7 +41,7 @@ public class PreviewEndpointTests(TestWebApplicationFactory factory) : IClassFix public async Task Preview_of_an_unsent_brief_renders_live_with_a_watermark() { ResetStores(); - await _client.GetAsync("/api/v1/brief"); // GetOrCreate the demo draft + await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: GET no longer seeds — create explicitly var res = await _client.GetAsync("/api/v1/brief/preview"); res.EnsureSuccessStatusCode(); @@ -54,7 +54,8 @@ public class PreviewEndpointTests(TestWebApplicationFactory factory) : IClassFix public async Task Preview_of_a_sent_brief_serves_the_archive_unchanged_after_a_republish() { ResetStores(); - var brief = (await _client.GetFromJsonAsync("/api/v1/brief"))!.Brief; + var resetRes = await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: create explicitly + var brief = (await resetRes.Content.ReadFromJsonAsync())!.Brief; await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/submit")); await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/approve", role: "approver")); diff --git a/backend/tests/BigRegister.Tests/RouteInventoryTests.cs b/backend/tests/BigRegister.Tests/RouteInventoryTests.cs new file mode 100644 index 0000000..a010096 --- /dev/null +++ b/backend/tests/BigRegister.Tests/RouteInventoryTests.cs @@ -0,0 +1,150 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace BigRegister.Tests; + +/// RB-12/BIO-016 (BL-006 — "the backend has zero automated architecture enforcement"): the +/// only thing that used to keep an admin-shaped endpoint behind `Authz` was a human noticing +/// in review. BIO-003 (`X-Admin`, a second gate outside `Authz`) and BIO-004 (two endpoints +/// with no gate at all) are exactly the failure mode this test is a safety net for — and it is +/// the safety net RB-19 (a 900-line `Program.cs` reorder) leans on, so its value is entirely in +/// being hard to fool. +/// +/// Every mapped route must be accounted for exactly one of two ways: +/// - it carries an marker (.Gate("XAdmin"), added at the +/// call site in Program.cs) naming one of the five admin authz wrappers, or +/// - it is named, with a reason, in below. +/// +/// The allow-list is deliberately not "public routes" — most of its entries are NOT public. +/// `GET /applications/{id}` requires a caller identity and is scoped to that caller's own BSN +/// inline (`ctx.Zorgverlener()`), not through one of the five wrappers, which only gate the +/// coarse admin/behandelaar surfaces. Recording that here, with the actual reason, is the point +/// of BIO-016's remediation ("makes 'this endpoint is public' a decision someone wrote down") +/// generalised to every route that isn't wrapper-gated: the reviewer reads a name and a reason, +/// not silence. +public class RouteInventoryTests(TestWebApplicationFactory factory) : IClassFixture +{ + // Not TestWebApplicationFactory's HttpClient — this never issues a request, only reads the + // route table off the host's DI container. Uses the shared per-class isolated db file (see + // TestWebApplicationFactory's own doc comment) rather than a bare `new + // WebApplicationFactory()`, which would share the mutable static Db.ConnectionString + // with whatever other test class last set it and race "table already exists" against it. + private TestWebApplicationFactory Factory { get; } = factory; + + private sealed record AllowListEntry(string Method, string Pattern, string Reason); + + private static readonly AllowListEntry[] AllowList = + [ + // --- Orchestrator probes: no data, no PII, run before any identity concern applies. --- + new("GET", "/health", "Liveness probe for orchestrators."), + new("GET", "/health/ready", "Readiness probe for orchestrators."), + + // --- Static/reference demo data (SeedData & friends): identical for every caller in + // this POC (one seeded citizen), nothing to scope by. --- + new("GET", "/api/v1/dashboard-view", "Static reference data (SeedData) — same for every caller in this POC."), + new("GET", "/api/v1/notes", "Static reference data (SeedData.Notes) — same for every caller in this POC."), + new("GET", "/api/v1/brp/address", "Static BRP reference fixture — same for every caller in this POC."), + new("GET", "/api/v1/duo/diplomas", "Static DUO reference fixture + manual-diploma policy — same for every caller."), + new("GET", "/api/v1/intake/policy", "Config VALUE shipped for instant FE feedback (ADR-0001); the server re-validates as authority."), + new("GET", "/api/v1/uploads/categories", "Static per-wizard category config, no PII, no per-caller distinction."), + new("GET", "/api/v1/flags", "Feature-flag catalog + state, readable by any principal by design (WP-47) — only the PUT toggle is admin-gated."), + new("GET", "/api/v1/me", "Reflects only the ACTING caller's own role-derived capabilities — no other caller's data to leak."), + + // --- Citizen-submitted writes / ownership-scoped inline (ctx.Zorgverlener()/ctx.Caller()), + // not a role-only admin wrapper because the boundary is resource ownership, not a role. --- + new("POST", "/api/v1/change-requests", "Citizen submission; Submit() records outcome + idempotency, attributed to the acting caller."), + new("POST", "/api/v1/uploads", "Upload is attributed to ctx.Zorgverlener() as owner — there is no pre-existing resource to own yet."), + new("GET", "/api/v1/uploads/{documentId}/content", "Ownership-scoped inline (RB-01/BIO-004): owning citizen, or a behandelaar via Authz.CanBeoordelen."), + new("GET", "/api/v1/uploads/status", "Ownership-scoped inline: DocumentStore.ByLocalIds filtered to ctx.Zorgverlener().Bsn."), + new("DELETE", "/api/v1/uploads/{documentId}", "Ownership-scoped inline: DocumentStore.DeleteOwned keyed by ctx.Zorgverlener().Bsn."), + new("GET", "/api/v1/applications", "Ownership-scoped inline: IZaakSource.ListMyCases(ctx.Zorgverlener(), ...)."), + new("GET", "/api/v1/applications/{id}", "Ownership-scoped inline: ApplicationStore.Get(id, ctx.Zorgverlener().Bsn)."), + new("POST", "/api/v1/applications", "Ownership-scoped inline: created under ctx.Zorgverlener().Bsn."), + new("PUT", "/api/v1/applications/{id}", "Ownership-scoped inline: ApplicationStore.SyncDraft keyed by ctx.Zorgverlener().Bsn."), + new("DELETE", "/api/v1/applications/{id}", "Ownership-scoped inline: ApplicationStore.Get/.Delete keyed by ctx.Zorgverlener().Bsn."), + new("POST", "/api/v1/applications/{id}/submit", "Ownership-scoped inline: ApplicationStore.Submit keyed by ctx.Zorgverlener().Bsn."), + + // --- External caller, not a Principal at all. --- + new("POST", "/api/v1/zgw/notificaties", "OpenZaak's NRC, not a user: gated by a fixed-time shared-secret comparison, audited directly."), + + // --- Brief (letter composition): PRD-0002's own status-machine enforcement is the + // enforce/emit twin for this whole surface (Authz.CanActOn via BriefStore, ToView's + // Decisions dto) — a different single-source-of-truth than the five Program.cs wrappers, + // not a missing one. --- + new("GET", "/api/v1/brief", "Ownership-scoped inline: BriefStore.Get(ctx.Zorgverlener().Bsn), 404 when absent (RB-23)."), + new("PUT", "/api/v1/brief", "Brief status-machine enforcement: BriefStore.Save + Authz.CanActOn (drafter-only)."), + new("POST", "/api/v1/brief/submit", "Brief status-machine enforcement: BriefStore.Submit + Authz.CanActOn."), + new("POST", "/api/v1/brief/approve", "Brief status-machine enforcement: BriefStore.Approve + Authz.CanActOn (approver != drafter)."), + new("POST", "/api/v1/brief/reject", "Brief status-machine enforcement: BriefStore.Reject + Authz.CanActOn."), + new("POST", "/api/v1/brief/send", "Brief status-machine enforcement: BriefStore.Send; not role-gated today, per the endpoint's own comment."), + new("POST", "/api/v1/brief/reveal-bignummer", "Own inline capability + step-up check (Authz.CanRevealBigNummer + X-Step-Up), audited directly."), + new("GET", "/api/v1/brief/preview", "Ownership-scoped inline: BriefStore.Get(ctx.Zorgverlener().Bsn), 404 when absent (RB-23); hand-written FE fetch."), + new("POST", "/api/v1/brief/reset", "Deliberately unguarded demo affordance — the endpoint's own comment says so: 'showcase affordance only'."), + ]; + + private static readonly HashSet KnownWrappers = + ["OrgAdmin", "StamdataAdmin", "CasesAdmin", "Beoordelen", "FlagsAdmin"]; + + private static IEnumerable RealRoutes(EndpointDataSource source) => + source.Endpoints.OfType() + // MapGroup's own catch-all/description endpoints carry no HTTP method — not a route + // an HTTP client can actually hit distinctly, so not this test's concern. + .Where(e => e.Metadata.GetMetadata() is not null); + + private static string Key(string method, string pattern) => $"{method} {pattern}"; + + [Fact] + public void Every_mapped_route_is_authz_gated_or_on_the_named_allow_list() + { + var source = Factory.Services.GetRequiredService(); + + var allowed = AllowList.ToDictionary(e => Key(e.Method, e.Pattern)); + var seenAllowListKeys = new HashSet(); + var unaccounted = new List(); + + foreach (var route in RealRoutes(source)) + { + var pattern = route.RoutePattern.RawText!; + foreach (var method in route.Metadata.GetMetadata()!.HttpMethods) + { + var key = Key(method, pattern); + var gated = route.Metadata.GetMetadata() is { } gate && KnownWrappers.Contains(gate.Wrapper); + var listed = allowed.ContainsKey(key); + if (listed) seenAllowListKeys.Add(key); + if (!gated && !listed) unaccounted.Add(key); + } + } + + Assert.True(unaccounted.Count == 0, + "Route(s) with no authz gate and no allow-list entry — either add `.Gate(\"XAdmin\")` " + + "at the mapping site, or add a named, reasoned entry to RouteInventoryTests.AllowList:\n" + + string.Join("\n", unaccounted)); + + // The allow-list is a decision log, not a wishlist — an entry for a route that no longer + // exists (renamed, removed) is exactly the kind of drift this test exists to catch. + var stale = allowed.Keys.Except(seenAllowListKeys).ToList(); + Assert.True(stale.Count == 0, + "Allow-list entry with no matching live route (stale — the route was renamed or " + + "removed):\n" + string.Join("\n", stale)); + } + + /// Every `.Gate(...)` call must name one of the five known wrappers — a typo here would + /// silently fall back to "unaccounted for" above, but pinning it down explicitly gives a + /// clearer failure than the generic route-mismatch message. + [Fact] + public void Every_gate_marker_names_a_known_admin_wrapper() + { + var source = Factory.Services.GetRequiredService(); + + var unknown = RealRoutes(source) + .Select(r => r.Metadata.GetMetadata()) + .Where(g => g is not null) + .Select(g => g!.Wrapper) + .Where(w => !KnownWrappers.Contains(w)) + .Distinct() + .ToList(); + + Assert.True(unknown.Count == 0, "Unknown wrapper name(s) in a .Gate(...) call: " + string.Join(", ", unknown)); + } +} diff --git a/backend/tests/BigRegister.Tests/StamdataEndpointTests.cs b/backend/tests/BigRegister.Tests/StamdataEndpointTests.cs index ac197ae..a676cc1 100644 --- a/backend/tests/BigRegister.Tests/StamdataEndpointTests.cs +++ b/backend/tests/BigRegister.Tests/StamdataEndpointTests.cs @@ -59,6 +59,16 @@ public class StamdataEndpointTests(TestWebApplicationFactory factory) : IClassFi Assert.Empty(table.Rows); } + /// RB-16/BIO-019: DateOnly.Parse used to throw FormatException on unparseable input, + /// surfacing as an unhandled 500 instead of the 400-with-problem-details every other + /// bad-input check in this endpoint file returns. + [Fact] + public async Task Unparseable_peildatum_is_400_not_500() + { + var res = await _client.SendAsync(Req(HttpMethod.Get, "/api/v1/stamdata/professions?peildatum=not-a-date", role: "admin")); + Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode); + } + [Fact] public async Task Unknown_table_is_404() { diff --git a/backend/tests/BigRegister.Tests/StubIdentityProviderTests.cs b/backend/tests/BigRegister.Tests/StubIdentityProviderTests.cs index 529a154..28fef57 100644 --- a/backend/tests/BigRegister.Tests/StubIdentityProviderTests.cs +++ b/backend/tests/BigRegister.Tests/StubIdentityProviderTests.cs @@ -1,6 +1,8 @@ using BigRegister.Api.Data; using BigRegister.Domain.Authorization; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Testing; namespace BigRegister.Tests; @@ -93,4 +95,32 @@ public class StubIdentityProviderTests var caller = Resolve(role: "admin", medewerker: "m.jansen"); Assert.Equal(PrincipalRole.Admin, caller.Role); } + + /// RB-09/BIO-002: IIdentityProvider.Resolve can now return null ("no identity"), but this + /// stub's own contract stays non-nullable — it is a developer convenience that always invents + /// a caller, never a source of "no identity" itself. A request with genuinely no headers at + /// all still resolves to the seeded citizen, unchanged. + [Fact] + public void Never_returns_null_even_with_no_headers_at_all() + { + Assert.NotNull(new StubIdentityProvider().Resolve(new DefaultHttpContext())); + } +} + +/// RB-09/BIO-002: in Production, StubIdentityProvider is not registered at all (it is +/// Development-only) and there is no real DigiD/employee-SSO IIdentityProvider in this POC yet — +/// so a Production build must fail at startup rather than silently resolving every request to +/// the seeded citizen (the failure mode BIO-002 documents). +public class ProductionIdentityProviderTests +{ + [Fact] + public void Production_environment_with_no_real_identity_provider_fails_at_startup() + { + using var factory = new WebApplicationFactory() + .WithWebHostBuilder(builder => builder.UseEnvironment("Production")); + + // The throw happens while the app builds services, before any request can be served — + // triggered here by the test host materialising that host to hand out a client. + Assert.ThrowsAny(() => factory.CreateClient()); + } } diff --git a/backend/tests/BigRegister.Tests/SwaggerGateTests.cs b/backend/tests/BigRegister.Tests/SwaggerGateTests.cs new file mode 100644 index 0000000..90fb36a --- /dev/null +++ b/backend/tests/BigRegister.Tests/SwaggerGateTests.cs @@ -0,0 +1,48 @@ +using System.Net; +using BigRegister.Domain.Authorization; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace BigRegister.Tests; + +/// RB-15/BIO-015: `app.UseSwagger()`/`app.UseSwaggerUI()` used to run unconditionally — the +/// OpenAPI document (every route + request/response shape) and SwaggerUI's "Try it out" were +/// reachable in every environment, including a real deployment. Both are now gated behind +/// `app.Environment.IsDevelopment()`. +public class SwaggerGateTests(TestWebApplicationFactory factory) : IClassFixture +{ + [Fact] + public async Task Swagger_document_is_served_in_development() + { + // The default test environment (WebApplicationFactory defaults to "Development" when + // nothing overrides it — same fact RB-09's implementation note relies on) — this is the + // regression guard that the gate didn't also break the documented `npm run gen:api` / + // local-dev-Swagger-UI experience. + var res = await factory.CreateClient().GetAsync("/swagger/v1/swagger.json"); + Assert.Equal(HttpStatusCode.OK, res.StatusCode); + } + + /// Production cannot boot at all today (RB-09: no real IIdentityProvider exists yet), which + /// is a *stronger* guarantee than "no Swagger in Production" — but it also means a plain + /// `UseEnvironment("Production")` host never reaches this middleware to prove the gate + /// itself works, only that the whole app refuses to start. This uses a third environment + /// name (neither "Development" nor "Production") with a test-supplied `IIdentityProvider` — + /// the one thing Program.cs doesn't register outside those two branches — so the host + /// actually boots and this test exercises the real gate, not RB-09's unrelated startup throw. + [Fact] + public async Task Swagger_document_is_not_served_outside_development() + { + // Built on top of the shared `factory` fixture (via WithWebHostBuilder), not a bare `new + // WebApplicationFactory()` — that keeps this host on the fixture's own per-class + // isolated AppDb temp path (see TestWebApplicationFactory's doc comment; RB-12's + // implementation note records the "table already exists" collision a bare factory hits + // by sharing the mutable static Db.ConnectionString instead). + using var staging = factory.WithWebHostBuilder(builder => builder + .UseEnvironment("Staging") + .ConfigureTestServices(services => services.AddSingleton())); + + var res = await staging.CreateClient().GetAsync("/swagger/v1/swagger.json"); + Assert.Equal(HttpStatusCode.NotFound, res.StatusCode); + } +} diff --git a/backend/tests/BigRegister.Tests/UploadAccessTests.cs b/backend/tests/BigRegister.Tests/UploadAccessTests.cs new file mode 100644 index 0000000..6537b01 --- /dev/null +++ b/backend/tests/BigRegister.Tests/UploadAccessTests.cs @@ -0,0 +1,99 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using BigRegister.Api.Contracts; +using BigRegister.Api.Data; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace BigRegister.Tests; + +/// Who may see what about an upload. RB-01/BIO-004: GET /uploads/{id}/content and +/// /uploads/status used to take no HttpContext at all — a diploma or identity scan was +/// protected by GUID unguessability alone, while DELETE on the same resource was +/// owner-scoped. RB-04/BIO-005: the document audit trail recorded the raw owner BSN as +/// its Actor, on a store whose own doc comment says it holds no PII. +public class UploadAccessTests(TestWebApplicationFactory factory) : IClassFixture +{ + private readonly HttpClient _client = factory.CreateClient(); + + private const string OtherCitizen = "999999990"; + + private async Task UploadAsOwner() + { + var form = new MultipartFormDataContent(); + var file = new ByteArrayContent(new byte[] { 1, 2, 3 }); + file.Headers.ContentType = new MediaTypeHeaderValue("application/pdf"); + form.Add(file, "file", "diploma.pdf"); + form.Add(new StringContent("diploma"), "categoryId"); + form.Add(new StringContent("local-rb01"), "localId"); + form.Add(new StringContent("registratie"), "wizardId"); + var res = await _client.PostAsync("/api/v1/uploads", form); + Assert.Equal(HttpStatusCode.Created, res.StatusCode); + return (await res.Content.ReadFromJsonAsync())!.DocumentId; + } + + private Task Get(string path, params (string Name, string Value)[] headers) + { + var req = new HttpRequestMessage(HttpMethod.Get, path); + foreach (var (name, value) in headers) req.Headers.Add(name, value); + return _client.SendAsync(req); + } + + [Fact] + public async Task The_owner_can_read_the_bytes() + { + var id = await UploadAsOwner(); + Assert.Equal(HttpStatusCode.OK, (await Get($"/api/v1/uploads/{id}/content")).StatusCode); + } + + [Fact] + public async Task Another_citizen_gets_404_not_403() + { + var id = await UploadAsOwner(); + // 404, not 403: a foreign id must not be distinguishable from one that never existed. + Assert.Equal(HttpStatusCode.NotFound, + (await Get($"/api/v1/uploads/{id}/content", ("X-Subject", OtherCitizen))).StatusCode); + } + + [Fact] + public async Task A_behandelaar_can_read_a_linked_document() + { + var id = await UploadAsOwner(); + Assert.Equal(HttpStatusCode.OK, + (await Get($"/api/v1/uploads/{id}/content", ("X-Medewerker", "medewerker-1"))).StatusCode); + } + + [Fact] + public async Task A_medewerker_without_the_behandelaar_rol_does_not() + { + var id = await UploadAsOwner(); + Assert.Equal(HttpStatusCode.NotFound, + (await Get($"/api/v1/uploads/{id}/content", + ("X-Medewerker", "medewerker-1"), ("X-Rollen", "geen"))).StatusCode); + } + + [Fact] + public async Task The_document_audit_trail_records_a_masked_actor() + { + var id = await UploadAsOwner(); + (await _client.DeleteAsync($"/api/v1/uploads/{id}")).EnsureSuccessStatusCode(); + + var rows = DocumentStore.AuditLog.Where(e => e.DocumentId == id).ToList(); + Assert.Equal(new[] { "upload", "delete-user" }, rows.Select(e => e.Action)); + Assert.All(rows, e => Assert.Equal("******782", e.Actor)); + // The unmasked BSN stays where it is load-bearing — the ownership key, not the trail. + Assert.All(rows, e => Assert.DoesNotContain(DocumentStore.DemoOwner, e.Actor)); + } + + [Fact] + public async Task Status_reports_another_citizens_localId_as_unknown() + { + await UploadAsOwner(); + var res = await Get("/api/v1/uploads/status?localIds=local-rb01", ("X-Subject", OtherCitizen)); + res.EnsureSuccessStatusCode(); + var status = (await res.Content.ReadFromJsonAsync())!; + var item = Assert.Single(status.Results); + Assert.Equal("unknown", item.Status); + Assert.Null(item.DocumentId); + } +} diff --git a/backend/tests/BigRegister.Tests/WerkvoorraadTests.cs b/backend/tests/BigRegister.Tests/WerkvoorraadTests.cs index dc76a16..c665f08 100644 --- a/backend/tests/BigRegister.Tests/WerkvoorraadTests.cs +++ b/backend/tests/BigRegister.Tests/WerkvoorraadTests.cs @@ -38,7 +38,8 @@ public class WerkvoorraadTests(TestWebApplicationFactory factory) : IClassFixtur var queue = (await res.Content.ReadFromJsonAsync>())!; var mine = queue.Single(x => x.Id == a.Id); Assert.Equal("InBehandeling", mine.Status.Tag); - Assert.False(string.IsNullOrEmpty(mine.Owner)); // cross-owner, like /admin/cases + // RB-03/BIO-003: masked, like /admin/cases — both inherit ToAdminSummaryDto. + Assert.Equal("******782", mine.Owner); } finally { diff --git a/backend/tests/BigRegister.Tests/ZgwDivergenceTests.cs b/backend/tests/BigRegister.Tests/ZgwDivergenceTests.cs index 881e922..be845bb 100644 --- a/backend/tests/BigRegister.Tests/ZgwDivergenceTests.cs +++ b/backend/tests/BigRegister.Tests/ZgwDivergenceTests.cs @@ -111,6 +111,32 @@ public class ZgwDivergenceTests Assert.Null(stored.ZgwError); } + /// RB-05/BIO-009: `ZgwError` is persisted to SQLite and written to the application log, so + /// the message it carries may not include the response body (OpenZaak echoes the request in + /// its errors) or the request's query string (ZGW filters travel there, and one of them is + /// `rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn`). + [Fact] + public async Task A_recorded_divergence_carries_no_response_body_and_no_query_string() + { + // The zaak POST succeeds; the statustypen GET — the one call here that carries a query + // string — fails, so the recorded message is built from a url that has one. + var stub = new ZgwStubHandler(SuccessBody, + (url, _) => url.StartsWith($"{ZtBase}/statustypen") ? HttpStatusCode.ServiceUnavailable : HttpStatusCode.OK); + using var factory = Factory(stub); + using var client = factory.CreateClient(); + + var id = await CreateConcept(client); + (await client.PostAsJsonAsync($"/api/v1/applications/{id}/submit", new { diplomaHerkomst = "duo" })) + .EnsureSuccessStatusCode(); + + var error = ApplicationStore.ListAll().Single(a => a.Id == id).ZgwError; + Assert.NotNull(error); + Assert.DoesNotContain("stub failure", error); // no response-body snippet + Assert.DoesNotContain("?", error); // no query string + Assert.Contains($"{ZtBase}/statustypen", error); // the path still routes the failure + Assert.Contains("503", error); + } + private static HttpRequestMessage AdminRequest(HttpMethod method, string path) { var req = new HttpRequestMessage(method, path); diff --git a/docs/project/refactor-backlog-setup/README.md b/docs/project/refactor-backlog-setup/README.md new file mode 100644 index 0000000..e2b432a --- /dev/null +++ b/docs/project/refactor-backlog-setup/README.md @@ -0,0 +1,63 @@ +# Refactoring backlog — automated setup + +## What's in this package + +``` +refactor-backlog-setup/ + setup.sh ← run this once, from the root of the target repo + agents/ ← source prompts (edit these if you need to tweak + scope/wording before running setup.sh) + _persistence-protocol.md + 00-baseline.prompt.md + 01-readability.prompt.md + 02-testability.prompt.md + 03-ddd-hexagonal.prompt.md + 04-cqrs-light.prompt.md + 05-bdd.prompt.md + 06-adr-conformance.prompt.md + 07-bio2-compliance.prompt.md + 08-consolidation.prompt.md + 09-implementation.prompt.md (template — one TICKET-ID per Phase 3 dispatch) +``` + +## Usage + +1. Copy this `refactor-backlog-setup/` folder into the root of the target repo + (or reference it via a relative path). +2. Edit anything in `agents/` if scope/exclusions need repo-specific detail + (e.g. exact module paths, ADR folder location) — the prompts currently use + the defaults agreed in the design conversation. +3. Run: + ``` + bash refactor-backlog-setup/setup.sh + ``` + This creates `./refactor-backlog/` with: + - `_status.md` initialized, all agents `not_started` + - `00-baseline.md` through `07-bio2-compliance.md` initialized with headers + - `99-backlog.md` empty, ready for Consolidation + - `implementation/` folder for Phase 3 notes + - `final-prompts/` — every agent prompt with the persistence protocol + already merged in. **These are the exact prompts to dispatch — no manual + copy-paste needed.** + +## Dispatch order + +1. Dispatch `final-prompts/00-baseline.prompt.md` (Opus). Wait for + `_status.md` → baseline: complete. +2. Dispatch the 7 Phase 1 prompts in parallel (Opus): `01` through `07`. + Each checks its own dependency in `_status.md` before starting. +3. Once all 7 show `complete`, dispatch `final-prompts/08-consolidation.prompt.md` + (Opus). It writes `99-backlog.md` and halts for human approval — check the + file for any `ADR-fix` or BIO2-flagged tickets before proceeding. +4. For each approved ticket, copy `final-prompts/09-implementation.prompt.md`, + fill in `TICKET-ID:`, dispatch (Sonnet). Run tickets in parallel within a CD + batch, sequential across batches, per the `Depends on` column in + `99-backlog.md`. + +## Re-running / resuming + +Safe to re-run `setup.sh` only on a fresh workspace — it does not check for an +existing `./refactor-backlog/` and will overwrite `_status.md` and the phase +output files. If a run is already in progress, don't re-run `setup.sh`; just +re-dispatch the relevant `final-prompts/*.prompt.md` — each agent reads +`_status.md` and its own output file first and resumes from where it left off. diff --git a/docs/project/refactor-backlog-setup/agents/00-baseline.prompt.md b/docs/project/refactor-backlog-setup/agents/00-baseline.prompt.md new file mode 100644 index 0000000..73ea9c2 --- /dev/null +++ b/docs/project/refactor-backlog-setup/agents/00-baseline.prompt.md @@ -0,0 +1,22 @@ +MODEL: Opus +OUTPUT FILE: /refactor-backlog/00-baseline.md +DEPENDS ON: none + +--- + +[Insert contents of _persistence-protocol.md here] + +ROLE: Metrics Baseline Agent + +Before any refactoring suggestions, establish a baseline for the scoped codebase: +- Test coverage (line/branch) per module, .NET and Angular separately. +- Cyclomatic complexity per method/function (flag >10). +- Duplication percentage (tool-based, e.g. jscpd/SonarQube if configured). +- Dependency graph / layering violations (existing static analysis if present). +- Count and location of existing CQRS-light and hexagonal architecture patterns + already in use (so later agents compare against actual current state, not + assumed absence). + +Output: a metrics table per module, plus a short list of modules ranked +worst-to-best on each metric. This file is fixed input to every Phase 1 agent — +no agent may propose a change without citing which baseline metric it improves. diff --git a/docs/project/refactor-backlog-setup/agents/01-readability.prompt.md b/docs/project/refactor-backlog-setup/agents/01-readability.prompt.md new file mode 100644 index 0000000..cc9f23d --- /dev/null +++ b/docs/project/refactor-backlog-setup/agents/01-readability.prompt.md @@ -0,0 +1,15 @@ +MODEL: Opus +OUTPUT FILE: /refactor-backlog/01-readability.md +DEPENDS ON: 00-baseline.md (complete) + +--- + +[Insert contents of _persistence-protocol.md here] + +AGENT: Readability Agent + +Junior = fluency in language constructs, not domain knowledge. Assume familiarity +with generics, async/await, LINQ, DI, RxJS operators, TS type system — do NOT flag +idiomatic use of these as "unreadable". Flag only: unclear naming, methods/components +exceeding [N] lines, nesting >3 levels, magic values, misleading types, missing guard +clauses. Cite baseline complexity score per finding. diff --git a/docs/project/refactor-backlog-setup/agents/02-testability.prompt.md b/docs/project/refactor-backlog-setup/agents/02-testability.prompt.md new file mode 100644 index 0000000..8d381cf --- /dev/null +++ b/docs/project/refactor-backlog-setup/agents/02-testability.prompt.md @@ -0,0 +1,14 @@ +MODEL: Opus +OUTPUT FILE: /refactor-backlog/02-testability.md +DEPENDS ON: 00-baseline.md (complete) + +--- + +[Insert contents of _persistence-protocol.md here] + +AGENT: Testability Agent + +Flag constructs that block unit testing without excessive mocking: static/singleton +dependencies, hidden I/O, constructors doing work, mixed pure/impure logic. Cite +baseline coverage gap per finding. Propose the minimal seam needed (interface +extraction, pure function split) — not a rewrite. diff --git a/docs/project/refactor-backlog-setup/agents/03-ddd-hexagonal.prompt.md b/docs/project/refactor-backlog-setup/agents/03-ddd-hexagonal.prompt.md new file mode 100644 index 0000000..7e050f7 --- /dev/null +++ b/docs/project/refactor-backlog-setup/agents/03-ddd-hexagonal.prompt.md @@ -0,0 +1,16 @@ +MODEL: Opus +OUTPUT FILE: /refactor-backlog/03-ddd-hexagonal.md +DEPENDS ON: 00-baseline.md (complete) + +--- + +[Insert contents of _persistence-protocol.md here] + +AGENT: DDD/Hexagonal Agent + +Target architecture: hexagonal (ports/adapters), already partially present in the +codebase per baseline.md — treat that as the pattern to extend, not reinvent. Do NOT +introduce hexagonal structure into modules where it is absent; only propose closing +gaps where the pattern is already started. Flag anemic domain models, domain logic +leaked into controllers/services/components, primitive obsession, missing ubiquitous +language. diff --git a/docs/project/refactor-backlog-setup/agents/04-cqrs-light.prompt.md b/docs/project/refactor-backlog-setup/agents/04-cqrs-light.prompt.md new file mode 100644 index 0000000..850d4ac --- /dev/null +++ b/docs/project/refactor-backlog-setup/agents/04-cqrs-light.prompt.md @@ -0,0 +1,15 @@ +MODEL: Opus +OUTPUT FILE: /refactor-backlog/04-cqrs-light.md +DEPENDS ON: 00-baseline.md (complete) + +--- + +[Insert contents of _persistence-protocol.md here] + +AGENT: CQRS-light Agent + +Target: command/query separation at the application-service level (not event +sourcing or separate read models unless already present per baseline.md). Only +extend existing CQRS-light patterns — do not introduce the pattern into modules +where it's absent. Identify handlers/services mixing reads and writes within +modules that already show the pattern elsewhere. diff --git a/docs/project/refactor-backlog-setup/agents/05-bdd.prompt.md b/docs/project/refactor-backlog-setup/agents/05-bdd.prompt.md new file mode 100644 index 0000000..67859a7 --- /dev/null +++ b/docs/project/refactor-backlog-setup/agents/05-bdd.prompt.md @@ -0,0 +1,14 @@ +MODEL: Opus +OUTPUT FILE: /refactor-backlog/05-bdd.md +DEPENDS ON: 00-baseline.md (complete) + +--- + +[Insert contents of _persistence-protocol.md here] + +AGENT: BDD Agent + +Check whether existing tests express behavior in domain/business language mapped +to acceptance criteria, or only technical steps. Flag test names/structure gaps. +Do not propose new BDD tooling if none is present — flag as a separate structural +item instead, not a per-module ticket. diff --git a/docs/project/refactor-backlog-setup/agents/06-adr-conformance.prompt.md b/docs/project/refactor-backlog-setup/agents/06-adr-conformance.prompt.md new file mode 100644 index 0000000..13e43e8 --- /dev/null +++ b/docs/project/refactor-backlog-setup/agents/06-adr-conformance.prompt.md @@ -0,0 +1,16 @@ +MODEL: Opus +OUTPUT FILE: /refactor-backlog/06-adr-conformance.md +DEPENDS ON: 00-baseline.md (complete) + +--- + +[Insert contents of _persistence-protocol.md here] + +AGENT: ADR-Conformance Agent + +Read all ADRs/docs in the repo. Compare code against each. Two outcomes per +deviation: +(a) code violates a correct ADR → refactoring ticket, cite ADR. +(b) ADR itself appears outdated/wrong given current code or constraints → propose + an ADR amendment as a separate ticket type ("ADR-fix"), with rationale — not + a code ticket. diff --git a/docs/project/refactor-backlog-setup/agents/07-bio2-compliance.prompt.md b/docs/project/refactor-backlog-setup/agents/07-bio2-compliance.prompt.md new file mode 100644 index 0000000..1068022 --- /dev/null +++ b/docs/project/refactor-backlog-setup/agents/07-bio2-compliance.prompt.md @@ -0,0 +1,24 @@ +MODEL: Opus +OUTPUT FILE: /refactor-backlog/07-bio2-compliance.md +DEPENDS ON: 00-baseline.md (complete) + +--- + +[Insert contents of _persistence-protocol.md here] + +AGENT: BIO2/Compliance Agent + +No explicit control list supplied — using the following BIO2/ISO 27002:2022 +controls, selected for privacy and security relevance. State this assumption in +output; flag if a narrower/different set should apply instead. + +- Access control (9.1, 9.2, 9.4): authorization checks, RBAC, least privilege. +- Logging & monitoring (8.15, 8.16): audit trails, esp. BIG-register/DUO data access. +- Data classification & handling (5.12, 5.13): BSN, health data, AVG-sensitive fields. +- Cryptography (8.24): encryption at rest/in transit. +- Secure development (8.25, 8.28, 8.29): secure coding, review, security testing gates. +- Change control (8.32): deployment register / change approval exceptions. +- Input validation (8.26): boundary validation on public-facing forms/APIs. + +Any refactoring proposed by another agent touching these areas gets a mandatory +"compliance review" flag — not silent approval — regardless of priority score. diff --git a/docs/project/refactor-backlog-setup/agents/08-consolidation.prompt.md b/docs/project/refactor-backlog-setup/agents/08-consolidation.prompt.md new file mode 100644 index 0000000..1319d8e --- /dev/null +++ b/docs/project/refactor-backlog-setup/agents/08-consolidation.prompt.md @@ -0,0 +1,35 @@ +MODEL: Opus +OUTPUT FILE: /refactor-backlog/99-backlog.md +DEPENDS ON: 01-readability.md through 07-bio2-compliance.md (all complete) + +--- + +[Insert contents of _persistence-protocol.md here — file-level: re-run only if a +Phase 1 file changed since last run] + +ROLE: Consolidation & CD-Sequencing Agent + +Input: all Phase 1 files (01–07) + 00-baseline.md. + +1. Deduplicate overlapping findings across agents — merge into one ticket, list all + contributing reasons/agents. +2. Score priority: + P1 = violates a correct ADR, blocks testability, or is a BIO2 compliance risk. + P2 = significant maintainability cost, moderate effort. + P3 = low urgency. +3. Sequence for continuous delivery: every ticket must be independently deployable + without a big-bang release. Reject/split any ticket that can't ship alone — + decompose into a dependency chain of smaller tickets. +4. Any ticket touched by the BIO2 agent requires compliance sign-off before merge, + regardless of priority score — mark explicitly. +5. Output final table: + +| ID | Module | Category | Description | Baseline metric improved | Effort (S/M/L) | +Risk | Priority | CD batch # | Depends on | Status | + +6. Separately list "ADR-fix" tickets — require human/architect approval before any + dependent code ticket proceeds. + +HALT CONDITION: after writing 99-backlog.md, stop and report to the human for +approval before any Implementation Agent (Phase 3) starts — even if no tickets +carry a compliance or ADR-fix flag. diff --git a/docs/project/refactor-backlog-setup/agents/09-implementation.prompt.md b/docs/project/refactor-backlog-setup/agents/09-implementation.prompt.md new file mode 100644 index 0000000..ae584ec --- /dev/null +++ b/docs/project/refactor-backlog-setup/agents/09-implementation.prompt.md @@ -0,0 +1,33 @@ +MODEL: Sonnet +OUTPUT: status update in /refactor-backlog/99-backlog.md + + /refactor-backlog/implementation/[ticket-id].md +DEPENDS ON: ticket status = not_started, no unresolved compliance/ADR-fix flag, + all tickets in "Depends on" column = implemented or needs_review + +--- + +[Insert contents of _persistence-protocol.md here — scoped to one ticket, not a +module list] + +AGENT: Implementation Agent + +Input: one ticket from 99-backlog.md (fill in TICKET-ID below), the Phase 1 +file(s) that produced it, and 00-baseline.md. + +TICKET-ID: [fill in before dispatching this agent] + +Scope discipline: +- Implement exactly the change described in the ticket. No scope expansion, no + incidental fixes. +- If the ticket is ambiguous or underspecified for implementation, do not guess — + write a blocker note to the ticket's status and stop. +- Do not modify architecture/pattern decisions (hexagonal boundaries, CQRS-light + structure) — those are Opus-level design calls already made in the ticket. If + implementation reveals the design call was wrong, flag back to Consolidation + rather than deciding unilaterally. +- Tickets touching a BIO2-flagged item are blocked until human compliance + sign-off is recorded in the ticket status — do not implement first and flag + after. + +Update ticket status in 99-backlog.md: not_started → in_progress → implemented +→ needs_review. Append implementation notes to implementation/[ticket-id].md. diff --git a/docs/project/refactor-backlog-setup/agents/_persistence-protocol.md b/docs/project/refactor-backlog-setup/agents/_persistence-protocol.md new file mode 100644 index 0000000..37183a3 --- /dev/null +++ b/docs/project/refactor-backlog-setup/agents/_persistence-protocol.md @@ -0,0 +1,25 @@ +PERSISTENCE & RESUME PROTOCOL + +Before starting work: +1. Read /refactor-backlog/_status.md. If your row says "complete", stop — do not re-run. +2. If "in_progress", read your own output file. Treat modules already listed as done. + Resume from "Last module processed" + 1. +3. If "not_started", confirm your dependencies show "complete" in _status.md. If not, + stop and report a blocking dependency instead of guessing. + +While working: +4. Append findings incrementally, one module at a time. After each module, update + _status.md: "Last module processed" and "Last updated". +5. Each finding gets a stable ID (e.g. RD-014) that never changes across runs. +6. If interrupted, the file + status row is the full recovery state. + +On completion: +7. Mark your _status.md row "complete" only once every module in scope has a + corresponding section in your output file. + +Every output file starts with: +## Scope: [modules covered] +## Status: [not_started | in_progress | complete] +## Last updated: [timestamp] +## Depends on: [file(s)] +## --- diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/00-baseline.md b/docs/project/refactor-backlog-setup/refactor-backlog/00-baseline.md new file mode 100644 index 0000000..c71617c --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/00-baseline.md @@ -0,0 +1,547 @@ +## Scope: apps/ssp (6 contexts), apps/behandelportal (3 contexts), libs/shared (11 layers), libs/beheer (5 layers), backend/src/BigRegister.Api (6 folders), backend/tests + +## Status: complete + +## Last updated: 2026-08-26 + +## Depends on: none + +## --- + +# 00 — Metrics Baseline + +Fixed input to every Phase 1 agent (01–07). **No agent may propose a change without +citing a `BL-###` observation or a metric row from this file.** + +--- + +## 1. Method, and what to trust + +Everything was measured with tooling already in the repo. No dependency was added, no +config file edited. + +| Metric | Command | Trust | +| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | +| FE coverage | `npm run test:coverage` → `coverage/{ssp,behandelportal,shared,beheer}/lcov.info` | exact | +| FE spec reach | derived: source files present in any lcov vs. files on disk | exact | +| BE coverage | `cd backend && dotnet test BigRegister.slnx --filter "Category!=Integration" --collect:"XPlat Code Coverage"` | exact | +| TS complexity + fn length | `npx eslint apps libs --rule '{"complexity":["warn",0],"max-lines-per-function":["warn",{"max":0,"skipBlankLines":true,"skipComments":true}]}' -f json` — threshold 0 makes ESLint report _every_ function with its score, giving a full distribution rather than only violations | exact (ESLint core rule, no plugin) | +| C# complexity | `node docs/project/refactor-backlog-setup/refactor-backlog/tools/baseline-scan.mjs --complexity` | **file CC exact-ish; per-method CC approximate** | +| Duplication | `node …/tools/baseline-scan.mjs --dup` | **approximate** | +| Layering violations | `npm run dep:check` | exact | +| Coupling / instability | `npx depcruise apps//src libs --config .dependency-cruiser..js --metrics --output-type metrics` | exact | + +`tools/baseline-scan.mjs` is new and lives in this workspace so the numbers reproduce. +It is a crude text scan: line-window hashing for duplication (not token-based like +jscpd), regex method detection for C# (does not understand expression-bodied members or +nested lambdas). Deterministic, zero-install, good enough to answer "did this move". +Upgrade path if a ticket ever needs more precision: jscpd, and a Roslyn analyzer. + +**Excluded as generated** (counted once, then excluded everywhere): +`libs/shared/src/infrastructure/api-client.ts` (2372 lines, NSwag) and +`backend/.../Data/Migrations/**` (17 files, 2690 lines, EF Core). + +**LCOV aggregation rule.** The four FE project runs overlap — `libs/shared` files appear +in `ssp`'s lcov as well as `shared`'s. Each file is attributed to its owning module and +the **best** result across the four runs is taken; a shared file exercised by an app's +specs is genuinely covered. + +**Protocol deviation, stated for the record.** The measurement runs are repo-global (one +coverage run, one depcruise run) and cannot be executed module-by-module; only the +write-up is per module. The resume contract still holds — the file is the recovery state. + +--- + +## 2. Size inventory + +| Unit | src .ts/.cs | src lines | spec files | spec lines | stories | +| ------------------------------ | ----------: | ---------: | ---------: | ---------: | ------: | +| apps/ssp | 92 | 10 415 | 39 | 3 564 | 21 | +| apps/behandelportal | 29 | 1 309 | 7 | 396 | 4 | +| libs/shared (excl. generated) | 86 | 5 194 | 21 | 1 226 | 40 | +| libs/beheer | 13 | 1 146 | 4 | 269 | 1 | +| **Frontend total** | **220** | **18 064** | **71** | **5 455** | **66** | +| backend/src (excl. Migrations) | 54 | 4 989 | — | — | — | +| backend/tests | 39 | — | 39 | 4 253 | — | +| e2e | 5 | 332 | — | — | — | + +Backend source by folder: `Program.cs` 940 · `Data` (excl. Migrations) 1 697 · `Domain` +991 · `Zgw` 710 · `Contracts` 329 · `Stamdata` 322. Zero `.html` template files exist +anywhere in `apps/`/`libs/` — every Angular template is inline. + +--- + +## 3. Coverage + +### 3a. Frontend — line/branch coverage of files a spec actually reaches + +| Module | Files | Lines | Line % | Branches | Branch % | +| -------------------------- | -----: | --------: | --------: | --------: | --------: | +| ssp/auth | 4 | 35 | 42.9% | 26 | 46.2% | +| bhp/auth | 4 | 35 | 42.9% | 26 | 46.2% | +| libs/shared/upload | 2 | 125 | 52.0% | 118 | 50.0% | +| libs/beheer/application | 1 | 70 | 65.7% | 37 | 40.5% | +| libs/beheer/infrastructure | 1 | 37 | 67.6% | 43 | 60.5% | +| ssp/herregistratie | 5 | 302 | 70.9% | 286 | 67.8% | +| libs/shared/layout | 2 | 80 | 72.5% | 36 | 55.6% | +| libs/shared/ui | 13 | 279 | 72.8% | 230 | 75.2% | +| ssp/brief | 11 | 486 | 75.3% | 461 | 68.8% | +| ssp/registratie | 21 | 430 | 80.0% | 471 | 77.3% | +| libs/shared/application | 8 | 76 | 80.3% | 50 | 70.0% | +| bhp/behandeling | 5 | 107 | 91.6% | 146 | 81.5% | +| libs/shared/infrastructure | 9 | 75 | 94.7% | 84 | 81.0% | +| libs/shared/kernel | 5 | 28 | 96.4% | 20 | 90.0% | +| libs/beheer/domain | 2 | 54 | 98.1% | 64 | 75.0% | +| ssp/showcase | 1 | 11 | 100.0% | 4 | 100.0% | +| libs/shared/testing | 3 | 9 | 100.0% | 2 | 50.0% | +| **TOTAL** | **98** | **2 240** | **75.1%** | **2 104** | **70.6%** | + +### 3b. Frontend — spec _reach_ (the number that matters) + +75.1% is coverage **of the 98 files a spec imports**. It is not coverage of the codebase. +122 of 220 source files are never loaded by any Vitest run at all. + +| Module | Source files | Reached | Never reached | % reached | +| ---------------------------------------- | -----------: | ------: | ------------: | --------: | +| ssp/root, ssp/shell, bhp/root, bhp/shell | 12 | 0 | 12 | 0% | +| libs/shared/domain | 3 | 0 | 3 | 0% | +| libs/beheer/ui | 4 | 0 | 4 | 0% | +| libs/beheer/contracts | 1 | 0 | 1 | 0% | +| libs/shared/layout | 11 | 2 | 9 | 18% | +| bhp/behandeling | 16 | 5 | 11 | 31% | +| ssp/showcase | 3 | 1 | 2 | 33% | +| libs/shared/ui | 34 | 13 | 21 | 38% | +| ssp/brief | 26 | 11 | 15 | 42% | +| bhp/auth | 8 | 4 | 4 | 50% | +| libs/shared/upload | 4 | 2 | 2 | 50% | +| ssp/registratie | 41 | 21 | 20 | 51% | +| ssp/herregistratie | 9 | 5 | 4 | 56% | +| ssp/auth | 6 | 4 | 2 | 67% | +| libs/beheer/domain | 3 | 2 | 1 | 67% | +| libs/shared/application | 11 | 8 | 3 | 73% | +| libs/shared/infrastructure | 11 | 9 | 2 | 82% | +| libs/shared/kernel | 5 | 5 | 0 | 100% | +| **TOTAL** | **220** | **98** | **122** | **45%** | + +Most of the 122 are `ui/` components, which CLAUDE.md §5 deliberately exercises through +**Storybook + the a11y addon (66 stories)**, not Vitest. That is a house decision, not a +gap — see BL-004 before filing anything against it. + +### 3c. Backend + +| Module | Files | Lines | Line % | Branches | Branch % | +| ------------------ | -----: | --------: | --------: | --------: | --------: | +| backend/Domain | 17 | 730 | 94.2% | 356 | 82.0% | +| backend/Stamdata | 10 | 252 | 96.8% | 120 | 71.7% | +| backend/Program.cs | 1 | 1 160 | 97.4% | 316 | 84.8% | +| backend/Contracts | 2 | 334 | 97.6% | 40 | 65.0% | +| backend/Zgw | 8 | 624 | 98.1% | 124 | 85.5% | +| backend/Data | 13 | 1 852 | 99.0% | 400 | 75.5% | +| **TOTAL** | **51** | **4 952** | **97.6%** | **1 356** | **79.6%** | + +241 tests, all green. Every backend source file is reached. Branch coverage is the weak +axis, not line coverage — `Contracts` 65.0%, `Stamdata` 71.7%, `Data` 75.5%. + +--- + +## 4. Complexity + +### 4a. TypeScript (exact — ESLint core `complexity`) + +Distribution over 2 085 source functions (specs/stories excluded): +**p50 1 · p75 2 · p90 3 · p95 5 · p99 12 · max 27.** 25 functions exceed CC 10 (1.2%). + +Function length over 1 149 functions: **p50 3 · p75 7 · p90 13 · p95 20 · p99 34 · +max 143.** Only 2 functions exceed 75 lines. + +| Module | Fns | max CC | p90 CC | CC>10 | max fn lines | fn>75 | +| -------------------------------------------------------- | --: | -----: | -----: | ----: | -----------: | ----: | +| libs/shared/upload | 90 | 27 | 4 | 1 | 109 | 1 | +| ssp/registratie | 361 | 23 | 4 | 7 | 143 | 1 | +| ssp/herregistratie | 144 | 19 | 3 | 4 | 31 | 0 | +| libs/shared/infrastructure | 32 | 19 | 6 | 1 | 33 | 0 | +| ssp/brief | 608 | 17 | 3 | 6 | 73 | 0 | +| bhp/behandeling | 85 | 16 | 5 | 3 | 28 | 0 | +| libs/beheer/domain | 22 | 11 | 7 | 1 | 24 | 0 | +| libs/beheer/infrastructure | 13 | 11 | 7 | 1 | 25 | 0 | +| libs/shared/ui | 285 | 11 | 3 | 1 | 37 | 0 | +| libs/beheer/ui | 100 | 10 | 1 | 0 | 8 | 0 | +| libs/shared/application | 61 | 7 | 3 | 0 | 30 | 0 | +| bhp/auth · ssp/auth · ssp/shell | 63 | 5 | 3 | 0 | 13 | 0 | +| libs/beheer/application | 42 | 4 | 3 | 0 | 15 | 0 | +| libs/shared/kernel | 18 | 4 | 4 | 0 | 13 | 0 | +| libs/shared/layout | 75 | 4 | 2 | 0 | 29 | 0 | +| ssp/showcase · ssp/root · bhp/root · libs/shared/testing | 86 | 3 | 1 | 0 | 15 | 0 | + +**Read BL-001 before filing any complexity ticket.** 23 of the 25 CC>10 functions are +reducers (9), `parse*` trust boundaries (10), or `validate*` (4) — all three are +mandated house idioms. + +The 25, in full: + +| CC | Function | Location | Kind | +| --: | ------------------------ | -------------------------------------------------------------------------------- | --------- | +| 27 | `reduceUpload` | libs/shared/src/upload/upload.machine.ts:131 | reducer | +| 23 | `validateStep` | apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts:117 | validate | +| 20 | `reduce` | apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts:321 | reducer | +| 19 | `validateStep` | apps/ssp/src/app/herregistratie/domain/intake.machine.ts:97 | validate | +| 19 | `parseAanvraagStatus` | apps/ssp/src/app/registratie/infrastructure/applications.adapter.ts:72 | parse | +| 19 | `fetch` | libs/shared/src/infrastructure/api-client.provider.ts:49 | **other** | +| 17 | `parseOrgTemplate` | apps/ssp/src/app/brief/infrastructure/brief.adapter.ts:331 | parse | +| 17 | `parseDuoLookup` | apps/ssp/src/app/registratie/infrastructure/duo.adapter.ts:46 | parse | +| 16 | `parseBeoordelingStatus` | apps/behandelportal/src/app/behandeling/infrastructure/beoordeling.adapter.ts:31 | parse | +| 16 | `parseStatus` | apps/ssp/src/app/brief/infrastructure/brief.adapter.ts:202 | parse | +| 16 | `validateAll` | apps/ssp/src/app/registratie/domain/registratie-wizard.machine.ts:166 | validate | +| 15 | `parseBeoordelingView` | apps/behandelportal/src/app/behandeling/infrastructure/beoordeling.adapter.ts:68 | parse | +| 15 | `validateAll` | apps/ssp/src/app/herregistratie/domain/intake.machine.ts:135 | validate | +| 14 | `reduce` | apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts:70 | reducer | +| 14 | `reduce` | apps/ssp/src/app/brief/domain/org-template.machine.ts:57 | reducer | +| 14 | `reduce` | apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts:161 | reducer | +| 14 | `reduce` | apps/ssp/src/app/herregistratie/domain/intake.machine.ts:222 | reducer | +| 14 | `reduce` | apps/ssp/src/app/registratie/domain/change-request.machine.ts:57 | reducer | +| 13 | `reduce` | apps/ssp/src/app/brief/domain/brief.machine.ts:188 | reducer | +| 12 | `parsePassage` | apps/ssp/src/app/brief/infrastructure/brief.adapter.ts:235 | parse | +| 12 | `parseBrief` | apps/ssp/src/app/brief/infrastructure/brief.adapter.ts:277 | parse | +| 12 | `parseDashboardView` | apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.ts:50 | parse | +| 11 | `reduce` | libs/beheer/src/domain/stamdata-editor.machine.ts:31 | reducer | +| 11 | `parseAuditEntries` | libs/beheer/src/infrastructure/audit.adapter.ts:21 | parse | +| 11 | `collect` | libs/shared/src/ui/rich-text-editor/rich-text-dom.ts:130 | **other** | + +Functions over 75 lines — the entire population: +`createDraftSync` 143 lines (apps/ssp/src/app/registratie/application/draft-sync.ts:50), +`reduceUpload` 109 lines (libs/shared/src/upload/upload.machine.ts:131). + +### 4b. C# (approximate) + +| Module | Files | Σ file CC | max file CC | Methods | max method CC | p90 | CC>10 | +| ------------------ | ----: | --------: | ----------: | ------: | ------------: | --: | ----: | +| backend/Program.cs | 1 | **78** | **78** | 13 | 12 | 7 | 1 | +| backend/Data | 23 | 101 | 27 | 77 | 16 | 4 | 1 | +| backend/tests | 39 | 121 | 26 | 230 | 20 | 2 | 1 | +| backend/Domain | 18 | 71 | 21 | 36 | 8 | 6 | 0 | +| backend/Stamdata | 10 | 34 | 21 | 11 | 10 | 6 | 0 | +| backend/Zgw | 8 | 39 | 11 | 20 | 8 | 4 | 0 | +| backend/Contracts | 2 | 4 | 3 | 6 | 3 | 3 | 0 | + +Method-length distribution (n=393): p50 9 · p90 22 · p99 90 · max 293. + +Highest-CC files: `Program.cs` 78 · `Data/ApplicationStore.cs` 27 · +`tests/OpenZaakZaakSourceTests.cs` 26 · `Domain/Letters/LetterHtml.cs` 21 · +`Stamdata/StamdataTable.cs` 21 · `Data/BriefStore.cs` 17. + +Methods over CC 10: `ToDto` (Data/BriefStore.cs:34, CC 16), +`LogBrief` (Program.cs:889, CC 12), and one 293-line test method +(`CreateZaak_posts_zaak_status_and_rol_and_maps_the_result_back`, CC 20). + +--- + +## 5. Duplication (approximate — 6-line normalized window) + +Repo-wide: **1 755 of 24 701 significant lines duplicated = 7.1%.** + +| Module | Sig. lines | Duplicated | % | +| --------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------: | ---------: | ---------: | +| ssp/auth | 211 | 211 | **100.0%** | +| bhp/auth | 243 | 211 | **86.8%** | +| bhp/root | 90 | 68 | 75.6% | +| ssp/root | 122 | 68 | 55.7% | +| bhp/shell | 21 | 8 | 38.1% | +| ssp/herregistratie | 1 536 | 121 | 7.9% | +| ssp/brief | 5 015 | 393 | 7.8% | +| backend/tests | 2 538 | 197 | 7.8% | +| backend/Data | 1 068 | 82 | 7.7% | +| libs/shared/infrastructure | 510 | 36 | 7.1% | +| libs/beheer/domain | 250 | 17 | 6.8% | +| libs/beheer/ui | 509 | 34 | 6.7% | +| libs/beheer/application | 185 | 12 | 6.5% | +| bhp/behandeling | 954 | 55 | 5.8% | +| ssp/shell | 194 | 8 | 4.1% | +| ssp/registratie | 3 798 | 144 | 3.8% | +| libs/shared/ui | 2 604 | 58 | 2.2% | +| backend/Zgw | 327 | 6 | 1.8% | +| ssp/showcase | 487 | 8 | 1.6% | +| backend/Program.cs | 510 | 6 | 1.2% | +| libs/shared/application | 566 | 6 | 1.1% | +| libs/shared/layout | 869 | 6 | 0.7% | +| backend/Contracts, backend/Domain, backend/Stamdata, libs/shared/{domain,kernel,upload,testing,environments}, libs/beheer/{contracts,infrastructure}, e2e | 1 894 | 0 | 0.0% | + +Top clone pairs: + +| Windows | Pair | +| ------: | ------------------------------------------------------------------------------------------------ | +| 39 | `bhp/auth/application/session.store.ts` ↔ `ssp/auth/application/session.store.ts` | +| 36 | `bhp/auth/auth.guard.spec.ts` ↔ `ssp/auth/auth.guard.spec.ts` | +| 35 | `bhp/auth/ui/login-form/…` ↔ `ssp/auth/ui/login-form/…` | +| 25 | `bhp/app.config.ts` ↔ `ssp/app.config.ts` | +| 23 | `bhp/auth/ui/login.page.ts` ↔ `ssp/auth/ui/login.page.ts` | +| 21 | `bhp/auth/auth.guard.ts` ↔ `ssp/auth/auth.guard.ts` | +| 13 | `libs/shared/…/role.interceptor.spec.ts` ↔ `…/subject.interceptor.spec.ts` | +| 11 | `brief/ui/letter-canvas.stories.ts` ↔ `brief/ui/letter-composer.stories.ts` | +| 9 | `ssp/brief/ui/org-template.page.ts` ↔ `libs/beheer/src/ui/stamdata.page.ts` | +| 8 | `bhp/behandeling/domain/besluit.machine.ts` ↔ `ssp/registratie/domain/change-request.machine.ts` | +| 8 | `tests/OpenZaakZaakSourceTests.cs` ↔ `tests/ZgwDivergenceTests.cs` | +| 7 | `bhp/…/besluit-form.component.ts` ↔ `ssp/…/change-request-form.component.ts` | +| 7 | `ssp/…/intake-wizard.component.ts` ↔ `ssp/…/registratie-wizard.component.ts` | + +**The auth duplication is a deliberate decision, not an accident** — see BL-002 before +proposing to merge it. + +--- + +## 6. Layering and coupling + +`npm run dep:check`: **0 violations** (223 modules, 584 dependencies cruised) across 11 +`severity: error` rules — `shared-no-features`, `beheer-no-features`, `shared-no-beheer`, +`-no-other-app`, per-context `--scope`, `domain-is-pure`, +`contracts-import-nothing`, `ui-not-infrastructure`, `apiclient-infrastructure-only`, +`no-testing-in-production`, `no-circular`. + +Instability I = Ce/(Ca+Ce). Low I = stable base, high I = volatile leaf. Measured in the +ssp cruise (behandelportal's figures for `libs/*` differ only in Ca, same shape): + +| Folder | N | Ca | Ce | I | +| --------------------------------------- | --: | --: | --: | -----: | +| apps/ssp/src/app/brief | 46 | 2 | 143 | 99% | +| apps/ssp/src/app/herregistratie | 18 | 4 | 80 | 95% | +| apps/ssp/src/app/showcase | 4 | 1 | 17 | 94% | +| apps/ssp/src/app/registratie | 67 | 19 | 181 | 91% | +| apps/behandelportal/src/app/behandeling | 24 | 2 | 62 | 97% | +| apps/behandelportal/src/app/auth | 11 | 4 | 25 | 86% | +| apps/ssp/src/app/auth | 9 | 5 | 24 | 83% | +| libs/beheer/src/ui | 5 | 3 | 27 | 90% | +| libs/beheer/src/application | 3 | 2 | 16 | 89% | +| libs/beheer/src/infrastructure | 3 | 3 | 11 | 79% | +| libs/shared/src/layout | 19 | 22 | 52 | 70% | +| libs/shared/src/ui | 68 | 128 | 111 | 46% | +| libs/shared/src/infrastructure | 19 | 43 | 25 | 37% | +| libs/shared/src/application | 18 | 55 | 30 | 35% | +| libs/shared/src/upload | 5 | 30 | 13 | 30% | +| libs/beheer/src/domain | 5 | 8 | 3 | 27% | +| libs/shared/src/testing | 4 | 17 | 2 | 11% | +| libs/shared/src/kernel | 9 | 71 | 4 | **5%** | +| libs/shared/src/domain | 3 | 11 | 0 | 0% | +| libs/beheer/src/contracts | 1 | 0 | 0 | 0% | + +This is textbook: `kernel`/`domain` are the stable base (I ≤ 5%), feature contexts are +volatile leaves (I ≥ 83%), nothing depends on them. **The frontend dependency structure +is not a problem area** — do not spend tickets here. + +**The backend has no equivalent enforcement at all.** `Domain/` purity (verified: zero +`Microsoft.EntityFrameworkCore` / `Microsoft.AspNetCore` imports) holds by convention and +code review only. See BL-006. + +--- + +## 7. Pattern inventory — what already exists + +Agents 03 (DDD/hexagonal) and 04 (CQRS-light) may only **extend** what is listed here. +They may not introduce either pattern into a module where it is absent. + +### Backend + +| Pattern | State | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Ports with 2+ implementations | `IZaakSource` (`Data/LocalZaakSource.cs` ↔ `Zgw/OpenZaakZaakSource.cs`), `IDocumentSource` (`Data/LocalDocumentSource.cs` ↔ `Zgw/OpenZaakDocumentSource.cs`) — config-switched on `Zgw:Enabled` at `Program.cs:57-84`, ADR-0005 | +| Single-impl interface | `IIdentityProvider` → `StubIdentityProvider` | +| **Not behind any port** | 7 static, non-DI stores: `ApplicationStore`, `DocumentStore`, `BriefStore`, `OrgTemplateStore`, `FeatureFlagStore`, `AuthzAuditStore`, `IdempotencyStore`. No `AddDbContext`; each opens a short-lived context via `Db.Create()` under its own lock. Deliberate, documented in `Data/Db.cs` and `Program.cs:40-45` | +| Domain purity | `Domain/` is EF-free and ASP-free (verified). Rules are static classes of pure functions with co-located tests in `tests/Domain/`: `SubmissionRules`, `DocumentRules`, `IntakePolicy`, `BeoordelingRules`, `DiplomaRules`, `HerregistratieRule`, `LetterHtml`, `OrgTemplateRules`, `Authz`, `FeatureFlags`. `Domain/Applications/Aanvraag.cs` is a C# tagged union (`Concept`/`Submitted`/`Decided`) | +| CQRS-light | **Partial.** `Contracts/Dtos.cs` holds 65 records split by direction (`*Request` in, `*Dto`/`*Response` out). Read/write split exists as _comment banners_ inside a single **940-line `Program.cs`** carrying all 48 endpoint mappings. No handler types, no mediator, no `Features/` folders. Cross-cutting behaviour is factored into local helpers (`Submit`, `StamdataAdmin`, `CasesAdmin`, `Beoordelen`, `OrgAdmin`, `FlagsAdmin`) — authorization/idempotency wrappers, not handlers | +| Mapping | `Contracts/Mappers.cs` (`.ToDto()`, `.ToDetailDto()`), `Data/AanvraagMapper.cs` | +| ZGW anti-corruption layer | Fully built: `Zgw/{OpenZaakZaakSource,OpenZaakDocumentSource,ZgwHttpClient,ZgwTokenProvider,ZgwZaakMapper,ZgwOptions,ZgwDiagnosticHandler}.cs`, 5 test files. ADR-0005 | + +### Frontend + +| Pattern | Count | Notes | +| ------------------------------------- | ----: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Command factories (write side) | 3 | `registratie/application/submit-change-request.ts`, `behandeling/application/submit-besluit.ts`, `registratie/application/draft-sync.ts` | +| Mutations living _inline in adapters_ | ~13 | `brief.adapter.ts` (save/submit/approve/reject/send/reset), `org-template.adapter.ts` (save/publish/rollback), `stamdata.adapter.ts` — they call `runSubmit` directly instead of going through a command factory | +| Infrastructure adapters (read side) | 20 | +1 outside an `infrastructure/` folder: `libs/shared/src/upload/upload.adapter.ts` | +| `parse*` trust boundaries | 30 | 24 in adapters, 6 in value objects / kernel | +| Application stores | 15 | all `providedIn: 'root'` | +| Elm-style machines | 9 | 8 under a `domain/` folder; outlier `libs/shared/src/upload/upload.machine.ts` | +| Explicit port | 1 | `SessionPort` + `SESSION_PORT` token (`libs/shared/src/application/session.port.ts`) | +| Config-seam tokens | 3 | `DEBUG_PANEL`, `HEADER_NAV_ITEMS`, `HEADER_ADMIN_LINKS` | +| `contracts/` DTO files | 4 | most adapters consume NSwag-generated types directly instead | + +Shared application kit (`libs/shared/src/application/`): `remote-data.ts`, `store.ts`, +`submit.ts`, `action-state.ts`, `debounced-save.ts`, `history.ts`, +`machine-remote-data.ts`, `pending-saves.ts`. + +### ADRs on record + +`0001` BFF-lite + decision DTOs (Accepted) · `0002` user groups as actors, not bounded +contexts (was **Proposed** when measured; promoted to **Accepted** 2026-08-26 by ADR-C-005, +with §3's `Principal` omission recorded in the ADR as known debt) · `0003` CIBG Huisstijl +(Accepted) · `0004` +stamdata as code (Accepted) · `0005` OpenZaak behind the BFF — the explicit +ports-and-adapters ADR (Accepted) · `0006` test data through the production door +(Accepted). + +--- + +## 8. Rankings, worst to best + +**Spec reach (FE):** ssp/root · ssp/shell · bhp/root · bhp/shell · libs/shared/domain · +libs/beheer/ui · libs/beheer/contracts (all 0%) → libs/shared/layout 18% → +bhp/behandeling 31% → libs/shared/ui 38% → ssp/brief 42% → … → libs/shared/kernel 100%. + +**Line coverage (FE, of reached files):** ssp/auth · bhp/auth 42.9% → +libs/shared/upload 52.0% → libs/beheer/application 65.7% → … → ssp/showcase 100%. + +**Branch coverage (BE):** Contracts 65.0% → Stamdata 71.7% → Data 75.5% → Domain 82.0% → +Program.cs 84.8% → Zgw 85.5%. + +**Duplication:** ssp/auth 100% → bhp/auth 86.8% → bhp/root 75.6% → ssp/root 55.7% → +bhp/shell 38.1% → ssp/herregistratie 7.9% → … → 12 modules at 0%. + +**Complexity (TS, CC>10 count):** ssp/registratie 7 → ssp/brief 6 → ssp/herregistratie 4 +→ bhp/behandeling 3 → 5 modules with 1 → the rest 0. + +**Complexity (C#, file CC):** Program.cs 78 → Data 101 across 23 files (max 27) → +Domain 71 (max 21) → Zgw 39 → Stamdata 34 → Contracts 4. + +**Coupling:** nothing to rank — 0 violations, healthy instability gradient (§6). + +--- + +## 9. Thresholds Phase 1 must use + +Derived from the measured distributions above, not invented. + +| Threshold | Value | Basis | +| ---------------------------------------- | ----------------------------------------- | -------------------------------------------------------------------- | +| Cyclomatic complexity | **> 10** | fixed by the agent prompt; TS p99 is 12, so this catches the top ~1% | +| **Agent 01's `[N] lines` — TS function** | **> 40** | TS fn-length p99 is 34; only 4 functions exceed 50 | +| Agent 01's `[N] lines` — C# method | **> 60** | C# method p99 is 90, p90 is 22 | +| Agent 01's `[N] lines` — component/file | **> 400** | 6 TS files and 1 C# file exceed it | +| Nesting depth | **> 3** | fixed by the agent prompt | +| Duplication worth a ticket | **> 10% of a module's significant lines** | repo mean is 7.1% | + +Largest files (the >400 population): `backend/Program.cs` 940 · +`registratie/ui/registratie-wizard.component.ts` 645 · `showcase/concepts.page.ts` 496 · +`brief/ui/letter-canvas.component.ts` 463 · `brief/infrastructure/brief.adapter.ts` 437 · +`herregistratie/ui/intake-wizard.component.ts` 406. + +--- + +## 10. Baseline observations (cite these) + +Stable IDs. A Phase 1 finding must reference one of these or a metric row above. + +**BL-001 — 23 of 25 TS functions over CC 10 are mandated house idioms.** +Reducers (9), `parse*` trust boundaries (10) and `validate*` (4) are switch/guard-dense +by construction: CLAUDE.md §3 requires a tagged-union reducer over booleans, and ADR-0001 +requires a hand-written `parse*` at every wire boundary. High CC there is the design +working, not decaying. Only two CC>10 functions are outside the idiom: +`api-client.provider.ts:49 fetch` (CC 19) and `rich-text-dom.ts:130 collect` (CC 11). +_Any complexity ticket against a `reduce*`/`parse*`/`validate*` must argue why this case +differs — a bare CC number is not sufficient grounds._ + +**BL-002 — `ssp/auth` is 100% duplicated against `bhp/auth`, deliberately.** +211 of 211 significant lines; `session.store.ts`, `login-form.component.ts`, `login.page.ts` +and `auth.guard.ts` are near-identical. CLAUDE.md §1 and ADR-0002 state auth is _not_ +shared because Zorgverlener and Medewerker are different `Principal` variants expected to +diverge. **The divergence has not happened yet.** This is legitimately in scope for +agent 06 (ADR conformance) as either an `ADR-fix` — the prediction has not held over two +phases of work — or a "still waiting" note. It is _not_ a straightforward +extract-to-shared refactor; that would contradict an accepted ADR. + +> **Sharpened 2026-08-26 by agent 06 (ADR-C-004/005) — verified.** "The prediction has +> not held" is the wrong diagnosis. ADR-0002 §3's concrete deliverable, `Session → +Principal`, was **never built**: `grep -rn "Principal" apps libs` returns exactly one +> hit, a comment in `libs/shared/src/infrastructure/role.ts:8`, and no type. `diff -rq` +> over the two auth folders shows **zero** content differences — 9 of 11 files identical, +> the only delta being two extra files in behandelportal. `behandelportal`'s Behandelaar +> still carries a `bsn` and `login.page.ts:31` logs a backoffice user in through DigiD. +> The rule was not falsified, it was untested; the divergence that did occur went through +> an orthogonal side door (`medewerker.interceptor.ts`) that never touches `Session`. +> Amending ADR-0002 would ratify the omission. Use agent 06's sequencing instead. + +**BL-003 — `Program.cs` is the single largest complexity concentration in the repo.** +940 lines, 48 endpoint mappings, file CC 78 (next-highest file: 27), read/write separated +only by comment banner. 97.4% line / 84.8% branch covered, so it is well-tested, not +fragile — this is a structure finding, not a correctness one. It is the one place where +agent 04's CQRS-light and agent 03's vertical-slice thinking both have real purchase, +_and_ the one place where "extend the existing pattern, don't introduce one" is hardest to +honour: there is no `Features/` folder to extend. + +**BL-004 — 122 of 220 FE source files (55%) are never loaded by any Vitest run.** +Overwhelmingly `ui/` components, which CLAUDE.md §5 covers via 66 Storybook stories with +the a11y addon instead. Agent 02 must distinguish _"untested"_ from _"tested through +Storybook"_ before filing; the genuine gaps are non-`ui/` files with no spec — +`libs/shared/domain` (3 files, 0%), `libs/beheer/contracts`, and the app root/shell files. + +> **Corrected 2026-08-26 by agent 02 — verified.** The two named "genuine gaps" are false +> positives. `libs/shared/src/domain` is 30 lines across 3 files — interfaces and type +> aliases plus a single string const — and `libs/beheer/src/contracts/stamdata.dto.ts` is +> 30 lines of DTO shapes. Neither contains an executable statement, so 0% is correct and +> unimprovable. Close both rows rather than ticketing them. Only the app root/shell files +> remain as candidates from this observation. + +**BL-005 — backend branch coverage lags line coverage by 18 points** (97.6% vs 79.6%). +Weakest: `Contracts` 65.0%, `Stamdata` 71.7%, `Data` 75.5%. Line coverage is near-total, +so the missing tests are edge-case branches, not whole units. + +**BL-006 — the backend has zero automated architecture enforcement.** +The frontend has 11 dependency-cruiser rules at `severity: error`, 0 violations, running +in CI. The backend has none: one assembly, no `Domain.csproj` boundary, no +NetArchTest/ArchUnitNET. `Domain/` purity currently holds by convention. Any agent-03 +proposal that depends on the backend's layering staying clean should note that nothing +enforces it. + +**BL-007 — the FE write side is inconsistently placed.** +3 command factories vs ~13 call sites invoking `runSubmit` directly inside adapters +(`brief.adapter.ts`, `org-template.adapter.ts`, `stamdata.adapter.ts`). CLAUDE.md §3 makes +the command factory the idiom. This is agent 04's clearest extend-an-existing-pattern +target — the pattern exists, it is just not applied uniformly. + +> **Corrected 2026-08-26 by agent 04 (CQ-003/CQ-005) — verified.** The "~13 mutations" +> count was derived from the `runSubmit` helper name and is wrong: 5 of those call sites +> are **reads**, not writes. `libs/beheer/src/infrastructure/stamdata.adapter.ts` exposes +> only `list()` and `load()` and its own docstring says "Both endpoints are reads … There +> is no write method", yet both call `runSubmit`. Same at `brief.adapter.ts:56` and +> `org-template.adapter.ts:39,51`. `runSubmit` is the write fold — it mints the +> Idempotency-Key — so the name, not the code, produced the miscount. Conversely agent 04 +> found **3 mutations this baseline missed entirely**: `ApplicationsStore.cancel`, +> `AdminCasesStore.delete`, `FeatureFlagStore.set` reach the raw `ApiClient` and never +> return a `Result`. Use agent 04's inventory, not this count. + +**BL-008 — `coverageExclude` does not exclude the generated API client.** +`libs/shared/src/infrastructure/api-client.ts` is listed in `coverageExclude` in all four +`angular.json` test targets, yet appears in all four `lcov.info` files (987 lines at ~7%), +dragging the reported `libs/shared/infrastructure` figure from 94.7% down to 6.9%. This +file excludes it manually. Small, real, and cheap to fix. + +**BL-009 — no coverage threshold is enforced anywhere.** +Neither `angular.json` nor CI sets a minimum; `npm run test:coverage` runs in CI and the +output is discarded. There is no ratchet, so no ticket can be verified as "improved +coverage" by CI alone — verify against the numbers in this file. + +**BL-010 — `libs/shared/src/upload/` sits outside the layer convention.** +`upload.machine.ts` (CC 27, 109 lines — the highest in the repo) lives in its own +top-level folder rather than under `domain/`, and `upload.adapter.ts` outside +`infrastructure/`. It is carved out by name in the `apiclient-infrastructure-only` +dependency-cruiser rule, i.e. the exception is already encoded rather than resolved. + +**BL-011 — the FE test suite is flaky under parallel load.** +`npm run test:coverage` failed on the first run with two `[vitest-pool] Timeout waiting +for worker to respond` errors in `libs/shared`, then passed 21/21 when re-run serially. +Not a code defect; relevant to any ticket whose acceptance is "CI green". + +--- + +## 11. Reproducing this file + +```bash +npm run test:coverage # if flaky, re-run the failing project alone +cd backend && dotnet test BigRegister.slnx --filter "Category!=Integration" \ + --collect:"XPlat Code Coverage" +npx eslint apps libs --rule '{"complexity":["warn",0],"max-lines-per-function":["warn",{"max":0,"skipBlankLines":true,"skipComments":true}]}' -f json +node docs/project/refactor-backlog-setup/refactor-backlog/tools/baseline-scan.mjs +npm run dep:check +npx depcruise apps/ssp/src libs --config .dependency-cruiser.ssp.js --metrics --output-type metrics +npx depcruise apps/behandelportal/src libs --config .dependency-cruiser.behandelportal.js --metrics --output-type metrics +``` + +The `baseline-scan.mjs` figures are deterministic and must reproduce exactly. Coverage +figures move with the tests. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/01-readability.md b/docs/project/refactor-backlog-setup/refactor-backlog/01-readability.md new file mode 100644 index 0000000..37451f0 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/01-readability.md @@ -0,0 +1,9 @@ +## Scope: [to be filled by agent] + +## Status: not_started + +## Last updated: - + +## Depends on: [see agent prompt] + +## --- diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/02-testability.md b/docs/project/refactor-backlog-setup/refactor-backlog/02-testability.md new file mode 100644 index 0000000..b4a6b18 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/02-testability.md @@ -0,0 +1,641 @@ +## Scope: apps/ssp (auth, registratie, herregistratie, brief, showcase+shell+root), apps/behandelportal (auth, behandeling, shell+root), libs/shared (domain, application, infrastructure, ui, layout, kernel, upload, testing), libs/beheer, backend (Program.cs, Domain, Data, Zgw, Contracts, Stamdata, tests) + +## Status: complete + +## Last updated: 2026-08-26 + +## Depends on: 00-baseline.md + +## --- + +# 02 — Testability + +What blocks a **unit** test — static/singleton dependencies, hidden I/O, work in +constructors/field initializers, pure logic entangled with impure. Every finding cites a +`BL-###` or a metric row from `00-baseline.md`. Seams proposed are extractions, never +rewrites. + +## How this file reads the baseline + +Three filters were applied before anything was written down, and they killed more +candidates than they kept: + +1. **BL-004's carve-out is honoured.** No finding is filed against a `ui/` (or + `layout/` component) file for lacking a Vitest spec. 66 Storybook stories + the a11y + addon are the house strategy (CLAUDE.md §5), not a gap. +2. **"Untested" ≠ "untestable".** Several of the worst-covered non-`ui/` files are + perfectly injectable and simply have no spec (`submit-besluit.ts`, + `Contracts/Mappers.cs`, `breadcrumb-trail.ts`). Those are noted in their module + section but not filed as testability findings — there is no seam to add. Whoever + owns coverage should pick them up. +3. **Already-covered code needs a positive argument.** Backend line coverage is 97.6% + (BL-005); a "this is untestable" claim there has to point at a branch the current + test shape genuinely cannot reach. Two do (TE-007, TE-008); one points at the cost + of how it is reached (TE-009). + +**Two baseline items are closed as false gaps** — see `libs/shared/domain` and +`libs/beheer/contracts` below. BL-004 names both as "genuine gaps"; on inspection +neither contains an executable statement. + +**Deliberate decisions engaged with, not overridden:** the 7 static backend stores +(documented in `Data/Db.cs`) are left alone — TE-009 extracts rules _out_ of one of +them without touching its shape. BL-002's auth duplication is respected — TE-004 lands +the same seam twice rather than proposing a shared extraction. + +--- + +## apps/ssp — auth + +**TE-001 — `SessionStore.restore()` reads `localStorage` inline, so its shape guard cannot be unit-tested** + +- Module / file:line — `apps/ssp/src/app/auth/application/session.store.ts:12-21` +- **What blocks unit testing.** `restore()` is module-private and calls + `localStorage.getItem(STORAGE_KEY)` itself, then does the parse + shape validation in + the same function. It is invoked from a field initializer + (`private _session = signal(restore())`, L37), so the storage read + happens the instant the singleton is constructed. A spec cannot feed it a raw string; + it must stub the `localStorage` global before the injector builds the store. The + logic being guarded is not incidental — the comments mark it G1 (never persist the + BSN) and G2 (validate the shape before trusting it), i.e. a trust boundary, and + CLAUDE.md §5 mandates a spec for boundary `parse*` adapters. +- **Baseline citation.** §3a: `ssp/auth` 42.9% line / 46.2% branch — **jointly the worst + line coverage in the frontend table** (§8 ranking). Per-file lcov for this file: + **LH 2 / LF 20 (10.0% line), BRH 3 / BRF 13 (23.1% branch)** — 4 of the module's 6 + files are spec-reached (§3b, 67%), yet this one barely executes. +- **Minimal seam.** Split the pure half out and move it next to the type it produces: + `export function parseStoredSession(raw: string | null): Session | null` in + `auth/domain/session.ts` — which **already has a spec file** + (`auth/domain/session.spec.ts`) and is pure TS, so no new test scaffolding is needed. + `restore()` collapses to `parseStoredSession(localStorage.getItem(STORAGE_KEY))`. Three + test cases (absent, non-JSON, wrong shape) cover the guard. +- **Effort S.** Independently shippable in one deploy — pure move, no call-site change + outside the file. +- **Note on BL-002.** `bhp/auth` carries the identical function; the seam lands **twice**, + once per app. That is correct, not duplication to fix — ADR-0002 / CLAUDE.md §1 make + `auth` deliberately unshared, and BL-002 flags any extract-to-shared here as + contradicting an accepted ADR. Agent 06 owns whether that prediction still holds. + +## apps/ssp — registratie + +**No findings.** + +The module's shape is the reason. Every `parse*` in its six adapters is exported and +directly spec'd (`applications`, `big-register`, `brp`, `dashboard-view`, `duo` all have +`.spec.ts` files); the machines are pure `domain/` units with specs; the five value +objects each have one. + +`createDraftSync` deserves an explicit acquittal: at 143 lines it is the longest function +in the repo (§4a, "Functions over 75 lines — the entire population") and it owns a +`setTimeout` debounce, a `Router` navigation and an in-flight-create race guard. It is +nevertheless **the best-seamed effectful unit in the frontend** — deps arrive through an +explicit `DraftSyncDeps` object (`draft-sync.ts:24-33`), `Router`/`ActivatedRoute` are +`inject(..., { optional: true })` so it is inert without them, and `enabled()` exists +specifically so stories and tests can neutralize it (L31-32). It has a spec. Its length +is agent 01's call, not a testability defect. + +`BigProfileStore` creates two `resource()`s in field initializers (constructor-time I/O), +which is normally a blocker — but the store is pure glue over `parseDashboardView` +(exported, spec'd) and `map`/`fromResource` (spec'd), so there is no untested decision +hiding behind the construction. §3a: 80.0% line / 77.3% branch, §3b 51% reach with the +20 unreached files being 11 `ui/` components (BL-004) and 3 pure-type `contracts/` files. + +## apps/ssp — herregistratie + +**No findings.** §3a 70.9% / 67.8%, §3b 56% reach. The four unreached files are the +`ui/` pages and wizard organisms (BL-004) plus `intake-policy.store.ts`, a thin +`resource()` wrapper over the exported-and-spec'd `parseIntakePolicy`. Both machines are +pure, Angular-free and carry four spec files between them, including an acceptance spec. +`intake.testing.ts` gives the wizard specs a fixture builder — the seam already exists. + +## apps/ssp — brief + +**TE-002 — `RevealBigNummerAdapter` hides a trust boundary inside a global-`fetch` method** + +- Module / file:line — `apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.ts:32-41` +- **What blocks unit testing.** The shape validation of the response body — the code's + own comment calls it a "Trust boundary" — is written inline inside `async reveal()`, + after an `await fetch(...)` on the **global** `fetch` (L24). There is no injected transport. To assert that a + `{ bigNummer: 42 }` response is rejected, a spec must stub `globalThis.fetch`; the + boundary itself is not callable. Every other `parse*` in the repo is exported (30 of + them, §7) — this one is the outlier, and it guards a PII reveal (PRD-0002 §5c). The + same shape recurs in the sibling hand-written-`fetch` adapters: + `letter-preview.adapter.ts:56-62` (`errorMessage`) and + `org-template.adapter.ts:82-89` (the proefbrief error mapping) — both un-exported, + both unreachable without a `fetch` stub. +- **Baseline citation.** §3b: `ssp/brief` **42% spec reach** (11 of 26 files) — the + lowest of any non-zero context outside `bhp/behandeling`; §3a 75.3% line / **68.8% + branch**. All three `fetch` adapters are among the 15 unreached files, and none of + them is a `ui/` component, so BL-004's Storybook carve-out does not cover them. +- **Minimal seam.** Export the pure half as a named boundary, matching the file's 30 + siblings: `export function parseRevealed(body: unknown): Result` — + five lines moved verbatim out of the method, which becomes + `return res.ok ? parseRevealed(await res.json().catch(() => null)) : err(...)`. Same + move for `errorMessage` in the other two adapters (already separate functions; they + only need `export` + a spec). No transport abstraction, no `HttpClient` migration — + the hand-written `fetch` stays, and the documented reasons for it + (`.ExcludeFromDescription()`, per-request headers) are untouched. +- **Effort S.** Independently shippable. + +## apps/ssp — showcase, shell, root + +**No findings.** + +§3b lists `ssp/root` + `ssp/shell` (with the behandelportal equivalents) at **0% reach, +12 files**. That is the correct number for what these files are: `main.ts`, +`app.config.ts`, `app.routes.ts`, `app.ts` and `shell/nav.config.ts` are composition +roots and static data — a spec asserting a provider array restates it. `debug-state` +is a dev-only devtool with a Storybook story. + +One honest note, no ticket: `shell/debug-state/mask.ts::redactProfile` is a pure, +Angular-free PII-redaction function (it maps a `BigProfile` to a redacted shape) with no +spec and **no blocker** — it is directly callable today. Its dependencies +(`maskTail`, `REDACTED`) are in `libs/shared/kernel/pii.ts`, which is spec'd at 96.4%. +Missing test, not blocked test. + +`showcase` is 100% line coverage on its one reached file (§3a); `snippets.generated.ts` +is generated and `concepts.page.ts` is a teaching page. + +## apps/behandelportal — auth + +**TE-001 applies here identically** — `apps/behandelportal/src/app/auth/application/session.store.ts:12-21`, +same `restore()`, same `localStorage` read in a field initializer, same §3a row +(`bhp/auth` 42.9% / 46.2%). Fix it in `apps/behandelportal/src/app/auth/domain/session.ts`, +which also already has a spec. Counted once as TE-001; it is two commits, or one commit +touching two apps. + +No additional findings. `medewerker.ts:17-22` reads `window.location.search` + +`sessionStorage` directly, but it is a five-line dev-only role stand-in with the same +shape as `libs/shared/infrastructure/role.ts` — whose twin `subject.ts` is spec'd, so +the pattern is demonstrably testable as written. + +## apps/behandelportal — behandeling + +**No findings.** + +§3b 31% reach (5 of 16) looks alarming and is not: the 11 unreached are 5 `ui/` files +(BL-004), the three `resource()`-wrapper stores, and the two command factories. §3a +records **91.6% line / 81.5% branch** on what is reached — the highest line coverage of +any frontend module in the table. + +Explicitly not a testability finding: `application/submit-besluit.ts` is +**structurally identical** to `apps/ssp/src/app/registratie/application/submit-change-request.ts`, +which has a spec (`submit-change-request.spec.ts`). Same `inject()` + `runSubmit` +factory, same signature shape. It is not blocked by anything; it is a missing spec whose +template already exists in the repo. Both adapters' `parse*` functions are exported and +spec'd (`beoordeling.adapter.spec.ts`, `werkvoorraad.adapter.spec.ts`). + +## apps/behandelportal — shell, root + +**No findings.** Same composition-root reasoning as `ssp/shell + root` above. + +## libs/shared — domain + +**No findings — BL-004's "genuine gap" is a false positive here, and can be closed.** + +§3b lists `libs/shared/domain` at **0% reached, 3 files**, and BL-004 names it first +among "the genuine gaps are non-`ui/` files with no spec". Reading all three files +(30 lines total): `capability.ts` is a 9-member string-literal union, `role.ts` is a +3-member union, `feature-flag.ts` is one `interface` plus one exported string constant. +**There is no executable statement in the folder.** 0% is the correct and unimprovable +number; the types are checked by `tsc` and their runtime counterparts are validated in +`parseMe` (spec'd, 94.7% infrastructure coverage). No ticket should be written against +this row. + +## libs/shared — application + +**No findings.** + +§3a 80.3% / 70.0%, §3b 73% reach (8 of 11). The three unreached are `session.port.ts` +(an `InjectionToken` + interface — a declaration, nothing to run), `feature-flags.store.ts` +and `access.store.ts`. The seam kit itself (`remote-data`, `store`, `submit`, +`history`, `pending-saves`, `machine-remote-data`, `debounced-save`) is fully spec'd — +this is the folder that makes the rest of the frontend testable. + +Noted without a ticket: `AccessStore.can()` (`access.store.ts:34-37`) is a +deny-by-default security gate whose decision reduces to +`rd.tag === 'Success' && rd.value.includes(capability)` over a `resource()` created in a +field initializer. The decision is two lines; the substance it guards +(`parseMe`, where a real silent-deny bug shipped — see the WP-66 regression test in +`me.adapter.spec.ts:26-31`) is already exported and thoroughly spec'd. Extracting a pure +`canFrom(rd, cap)` would be honest but buys close to nothing. Filing it would be volume, +not quality. + +## libs/shared — infrastructure + +**No findings.** + +§3a 94.7% line / 81.0% branch, §3b 82% reach — the second-best module in the repo. +BL-001 singles out `api-client.provider.ts:49 fetch` (CC 19) as one of only two CC>10 +functions outside the mandated idioms, so it is worth stating why it is _not_ a +testability finding: `httpClientFetch(http: HttpClient)` takes its dependency as an +ordinary function parameter (L47) rather than injecting it, and it has a spec +(`api-client.provider.spec.ts`). Its complexity is agent 01's call. The module-level +mutable `pendingIdempotencyKey` (L21) is self-clearing in a `finally` (L25), so it does +not leak between tests. + +## libs/shared — ui + +**No findings — BL-004 governs.** 34 files, 13 reached; the unreached 21 are components +covered by the Storybook + a11y strategy CLAUDE.md §5 mandates. The one non-component +module in the folder, `rich-text-editor/rich-text-dom.ts` (home of `collect`, CC 11 — +the other non-idiom CC>10 function per BL-001), **is** spec'd +(`rich-text-dom.spec.ts`). The layer is doing what the house rules ask. + +## libs/shared — layout + +**No findings.** + +§3b 18% reach (2 of 11) is the lowest non-zero row, but 8 of the 9 unreached are +components (`shell`, `page-shell`, `site-header`, `site-footer`, `breadcrumb`, +`language-switcher`, `wizard-shell`) — BL-004 applies to `layout/` exactly as to `ui/`, +since CLAUDE.md §5 titles both under `Design System/`. + +Two non-component files, neither ticketed: + +- `breadcrumb/breadcrumb-trail.ts::trailFor` is a pure exported function with a subtle + parent-walk and a `delete trail[last].link` mutation, and has no spec. **No blocker** — + it is directly callable, and its sibling `language-switcher/locale-links.ts` is the + spec'd proof. Missing test, not blocked test. +- `route-focus.ts` is a 20-line `ENVIRONMENT_INITIALIZER` wrapping a `Router` subscription + and `afterNextRender`. Genuinely awkward to unit-test, but it is a11y wiring with no + branch worth asserting; a seam here would cost more than it returns. + +## libs/shared — kernel + +**No findings.** §3a 96.4% line / 90.0% branch, §3b **100% reach** — the best module in +the repo, and (§6) the most-depended-on at I = 5% with Ca 71. Pure functions, all spec'd. +This is the reference standard the other findings point back at. + +## libs/shared — upload + +Three findings. This module carries the frontend's weakest testability profile, and +BL-010 already flags it as sitting outside the layer convention. + +**TE-003 — `UploadShellService` declares a port, then injects the concrete class instead** + +- Module / file:line — `libs/shared/src/upload/upload-shell.service.ts:12-24` and `:35` +- **What blocks unit testing.** The file defines `export interface UploadTransport` and + documents it as _the_ swap seam ("swapping it in touches only this interface", L10-11). + It then binds it as + `private transport: UploadTransport = inject(KeepaliveTransport)` (L35) — the + **concrete class**, which is `@Injectable` but **not exported** (L18). A spec that + wants a fake transport cannot reference the class to override its provider, and cannot + provide against the interface (interfaces are not DI tokens). Result: every one of + `upload()`, `delete()`, `cancel()` and `pollReturning()` — the code that translates + transport outcomes into `UploadMsg`s — is reachable only through a real + `XMLHttpRequest`. The port exists on paper and does nothing. +- **Baseline citation.** §3a: `libs/shared/upload` 52.0% line / 50.0% branch — the + worst line coverage of any module except the two `auth` rows. §3b: 50% reach, and + per the lcov file list the **two unreached files are `upload-shell.service.ts` and + `upload-controller.ts`** — neither is a `ui/` component, so this is exactly the + non-`ui/` gap BL-004 says is genuine. +- **Minimal seam.** Add the token the repo already uses elsewhere: + `export const UPLOAD_TRANSPORT = new InjectionToken('UPLOAD_TRANSPORT', +{ providedIn: 'root', factory: () => inject(KeepaliveTransport) })`, then + `inject(UPLOAD_TRANSPORT)` on L35. This **extends an existing pattern** — §7 records + exactly one explicit port in the frontend, `SessionPort` + `SESSION_PORT` + (`libs/shared/src/application/session.port.ts`), with the same interface-plus-token + shape. Runtime behaviour is byte-identical; the default factory returns the same + instance. +- **Effort S.** Independently shippable in one deploy. + +**TE-004 — `createUploadController` performs injection, DOM subscription and an `effect()` at call time** + +- Module / file:line — `libs/shared/src/upload/upload-controller.ts:23-46`, policy at `:62-74` +- **What blocks unit testing.** Calling the factory does four irreversible things before + returning: three `inject()` calls (L24-25, L46), an `effect()` registration (L31), and + `window.addEventListener('focus', onFocus)` (L45). It must therefore run inside a + `TestBed` injection context with `UploadAdapter`, `UploadShellService` and `DestroyRef` + all satisfied — and `UploadShellService` is itself un-fakeable per TE-003, so the + mocking cost compounds. What is trapped behind that cost is real policy: + `onFileSelected` (L62-74) decides per file whether to emit `FileRejected` with reason + `'multiple'`, `FileRejected` with a `rejectReason` result, or to start an upload — a + decision over `(categories, categoryId, files)` with no I/O in it. +- **Baseline citation.** §3a `libs/shared/upload` 52.0% / 50.0%; §3b 50% reach with this + file among the two unreached. §4a additionally records the module's `max CC 27` and the + repo's only two >75-line functions include `reduceUpload` (109 lines) — the reducer this + controller feeds. The reducer is spec'd (`upload.machine.spec.ts`); the code choosing + _which_ messages reach it is not. +- **Minimal seam.** Pure-function split into the file that is already the tested unit: + add `export function planFileSelection(state: UploadState, categoryId: string, files: +{ name: string; type: string; size: number }[]): UploadMsg[]` to `upload.machine.ts`, + moving L62-73 verbatim. `rejectReason` — the predicate it calls — is already exported + from that file and already spec'd, so the move is downhill. The controller keeps the + `crypto.randomUUID()` + `files.set()` + `shell.upload()` side effects and just executes + the plan. No change to the controller's public surface or to the organism that calls it. +- **Effort S.** Independently shippable. + +**TE-005 — `UploadAdapter.xhrUpload` buries response interpretation inside an `XMLHttpRequest` closure** + +- Module / file:line — `libs/shared/src/upload/upload.adapter.ts:113-157`, helpers at `:169-199` +- **What blocks unit testing.** The method constructs `new XMLHttpRequest()` directly + (L118) — no transport parameter, no injected factory — and attaches four listeners + whose bodies contain the actual decisions: 2xx-vs-not (L131), `JSON.parse` of the body + with a fallback (L132-136), ProblemDetails mapping via the un-exported `parseError` + (L193-199), and abort-vs-error disambiguation (L142-144). None of it can be reached + without stubbing the XHR global. Compounding it, the method also branches on + `currentScenario()` at L115 and returns a `setTimeout`-driven dev simulator + (`simulateUpload`, L169-192), so a dev-only fake and the production transport share + one entry point. +- **Baseline citation.** Per-file lcov: **LH 5 / LF 64 (7.8% line), BRH 3 / BRF 57 + (5.3% branch)**. The file is counted as "reached" in §3b only because another spec + imports it — essentially nothing in it executes. It is the single largest contributor + to the module's 52.0% / 50.0% row in §3a. +- **Minimal seam.** Extract the interpretation, not the transport: + `export function uploadOutcome(status: number, responseText: string): Result` containing L131-139's logic plus `parseError`. The listener + becomes a two-line dispatch into it. Optionally (same ticket, still small) move the + `currentScenario()` branch from L115 up into `KeepaliveTransport.send()` — the seam + TE-003 makes usable — so `xhrUpload` is transport only. Do **not** abstract + `XMLHttpRequest`: the file documents why XHR is required (progress events + + cancellation, which `fetch` cannot give) and that reason still holds. +- **Effort S** for `uploadOutcome` alone, **M** if the scenario branch moves too. + Independently shippable; sequence it after TE-003 if both are taken. + +## libs/shared — testing + +**No findings.** §3a 100% line coverage. `given()` (`machine.ts`) and the `RemoteData` +constructors (`remote-data.ts`) are the DSL the domain specs are built on, and the +`no-testing-in-production` dependency-cruiser rule (§6) keeps them out of shipped code. +The gap this folder does _not_ yet cover is a `resource()`-shaped fake — which is why +`AccessStore`/`BigProfileStore` stay unreached — but adding one is a test-infrastructure +task, not a source-code seam, and no metric row demands it. + +## libs/beheer + +**TE-006 — blob-to-browser handoff is inlined in three application-layer commands** + +- Module / file:line — `libs/beheer/src/application/stamdata.store.ts:137-147`; + also `apps/ssp/src/app/brief/application/brief.store.ts:230` and + `apps/ssp/src/app/brief/application/org-template.store.ts:217` +- **What blocks unit testing.** Each of the three commands ends in raw DOM/browser API + calls that jsdom cannot meaningfully execute: `StamdataStore.download()` does + `URL.createObjectURL` → `document.createElement('a')` → `a.click()` → + `URL.revokeObjectURL`; `BriefStore.previewLetter()` and + `OrgTemplateStore.proefbrief()` both do `window.open(URL.createObjectURL(blob), +'_blank')`. Because the call is the **last statement**, the entire success path of each + command is unassertable — a spec can only exercise the early-return/failure branches. + `brief.store.spec.ts` demonstrates this exactly: it tests `previewLetter`'s failure + case (which returns at the `!r.ok` guard) and cannot test the success case. In + `download()` the untestable tail sits directly behind a two-clause guard + (`if (!s || !this.canDownload()) return;`, L139), so the guard's true-branch is + permanently dark. +- **Baseline citation.** §3a: `libs/beheer/application` **40.5% branch — the worst + branch coverage of any frontend module in the table**, and its 65.7% line figure is + third-worst. Per-file lcov confirms `stamdata.store.ts` _is_ that row: LH 46 / LF 70, + **BRH 15 / BRF 37**. On the brief side, §3a `ssp/brief` is 68.8% branch and + `brief.store.ts` measures **BRH 32 / BRF 64 — exactly 50%**. +- **Minimal seam.** One small injectable in `libs/shared/src/application`, mirroring the + `SESSION_PORT` token shape already in that folder: + `export const BLOB_PRESENTER = new InjectionToken<{ open(b: Blob): void; download(b: +Blob, filename: string): void }>('BLOB_PRESENTER', { providedIn: 'root', factory: () => +realBlobPresenter })`. The three commands each lose 1-4 lines of DOM code and gain one + method call; specs provide a recording fake and finally assert the success paths + (including `toJson(...)`'s output actually reaching the file, which today is only + tested one level down in `beheer/domain`). The content-producing logic stays exactly + where it is. +- **Effort S** (one token + three one-line edits) — **M** including the specs the seam + unlocks. Independently shippable; the three call sites can also land separately. + +**Other beheer layers — no findings.** + +- `libs/beheer/contracts` — §3b lists it at **0% reached, 1 file**, and BL-004 names it + as a genuine gap. It is not: `stamdata.dto.ts` is 30 lines of `interface` and `type` + declarations with **zero executable statements** and, by design, zero imports (it is + the wire seam). Like `libs/shared/domain`, this row should be closed rather than + ticketed. +- `libs/beheer/ui` — 0% reach, 4 files, all components → BL-004 / Storybook. +- `libs/beheer/domain` — 98.1% line, spec'd machine and rules. Nothing blocked. +- `libs/beheer/infrastructure` — `parseStamdataTable`/`parseColumn`/`parseRows` all + exported and spec'd; the 60.5% branch figure is unexercised defensive arms in an + otherwise open unit. + +--- + +## backend/Program.cs + +**No findings.** + +§3c: 97.4% line / 84.8% branch, the second-best branch figure on the backend. Ten +endpoint bodies read the wall clock inline (`DateTimeOffset.UtcNow` at L282, L286, L301, +L390, L394, L426, L435, L449, L482, L931; `DateOnly.FromDateTime(DateTime.Today)` at +L138), which would normally be a finding — but **every rule and mapper they hand it to +already takes the instant as a parameter**: `HerregistratieRule.Evaluate(reg, today)`, +`ToDetailDto(a, now)`, `ToStatusDto(a, now)`, `ListCases(now)`, +`ApplicationStore.RecordBesluit(..., now)`. The clock-dependent _decisions_ are all +testable at any date; only the endpoint wiring is pinned to now, and that wiring is what +`EndpointTests`/`AdminCasesTests` legitimately cover through the host. Injecting +`TimeProvider` into 48 minimal-API lambdas would be a rewrite, not a seam, and no metric +row asks for it. + +BL-003 (940 lines, file CC 78, read/write split by comment banner) is a **structure** +finding, explicitly, and belongs to agents 03/04. + +## backend/Domain + +**TE-007 — `LetterHtml.ResolveAuto` reads the wall clock although `Render` is already given the instant** + +- Module / file:line — `backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs:138` + (resolver) vs. `:27` (signature) and `:49` (the correct usage) +- **What blocks unit testing.** `Render(BriefEntity brief, OrgTemplateDto template, +string at, bool watermark)` already accepts the letter's instant, and uses it properly + for the letterhead: `sb.Append(Enc(FormatDatumNl(at)))` at L49. But the body's + `datum` placeholder resolves through `ResolveAuto`, which ignores `at` and calls + `FormatDatumNl(DateTimeOffset.UtcNow.ToString("o"))` (L138). `ResolveAuto` is + `private static`, reached only via `RenderNode` ← `RenderParagraphs` ← `Render`, so a + test has no way to pin the value: it can only assert "whatever today is". The gap is + visible in the existing test file — `LetterHtmlTests.cs:26` declares a + `new PlaceholderDefDto("datum", "Datum", true)` in the fixture and **no assertion + anywhere in the file checks what it renders to**. This is a pure `Domain/` rule class + reaching for ambient state, which is precisely the purity §7 credits the folder with. +- **Baseline citation.** §3c: `backend/Domain` **82.0% branch** — the third-weakest + branch axis (BL-005). §4b: `Domain/Letters/LetterHtml.cs` file **CC 21**, the + third-highest-CC file in the backend after `Program.cs` (78) and + `Data/ApplicationStore.cs` (27). +- **Minimal seam.** Thread the parameter that already exists: `ResolveAuto(string key, +string label, string at)` → `"datum" => FormatDatumNl(at)`, passing `at` down through + `RenderParagraphs`/`RenderNode` (both private, both already in `Render`'s call chain + with `at` in scope). Two signature changes, one expression change, zero public API + change, zero call-site change. Then assert the rendered `datum` against a fixed + expected string in `LetterHtmlTests`. +- **Secondary benefit, stated conservatively.** This is not a shipped bug today — every + caller (`Program.cs:697`, `:708`, `BriefStore.cs:120`) passes `Now()` at render time, + so the two dates coincide. It becomes one the moment `Render` is called with a + historical `at` (re-rendering an archive, back-dating a letter), at which point the + letterhead and the body would disagree within a single document. +- **Effort S.** Independently shippable. + +No other Domain findings. §7's claim holds under inspection: `SubmissionRules`, +`DocumentRules`, `IntakePolicy`, `BeoordelingRules`, `DiplomaRules`, +`HerregistratieRule`, `OrgTemplateRules`, `Authz` and `FeatureFlags` are static classes of +pure functions with a matching file in `tests/Domain/`, and the clock-dependent ones take +their instant as an argument. That is the correct shape. + +## backend/Data + +**TE-008 — brief state-transition and authorization rules live inside DB-opening, lock-held store methods** + +- Module / file:line — `backend/src/BigRegister.Api/Data/BriefStore.cs`, five guard + clusters: `:72-76` (Save), `:88-90` (Submit), `:111-112` (Send), `:162-164` + (Approve/Reject shared path), plus the `RequiredFilled(e)` predicate +- **What blocks unit testing.** Each guard is a pure decision over + `(status tag, actor role, entity completeness)` — e.g. `Save` returns `Forbidden` if + `!isDrafter`, `Conflict` unless the status is `draft` or `rejected`, and reopens a + `rejected` letter to `draft`; `Submit` additionally requires `RequiredFilled`. But each + sits **inside** a method that has already done `lock (_gate) { using var db = +Db.Create(); ... }`, so exercising any of them requires a booted host and a real SQLite + file. There is no `BriefRules` class: `Domain/Letters/` contains only `LetterHtml.cs` + and `OrgTemplateRules.cs`. The pattern is visibly **half-applied** — `Authz.CanActOn` + at L163 _is_ a pure `Domain/` call, sitting one line away from three guards that are not. + The `Save` guard's own comment says it "mirrors the FE reducer", i.e. it is business + logic with a known pure counterpart on the other side of the wire. +- **Baseline citation.** §3c: `backend/Data` **75.5% branch** — named in BL-005 as one + of the three weak branch axes, against 99.0% line coverage (the exact signature of + "every unit is entered, edge branches are not"). §4b: `Data/BriefStore.cs` file + **CC 17**, and its `ToDto` at **CC 16** is the highest-CC non-`Program.cs` method in + the backend. §5: `backend/Data` 7.7% duplication. +- **Minimal seam.** Add `Domain/Letters/BriefRules.cs` with pure statics — + `CanSave(BriefStatusDto status, bool isDrafter) → Outcome`, + `StatusAfterSave(BriefStatusDto) → BriefStatusDto`, + `CanSubmit(status, isDrafter, bool requiredFilled) → Outcome`, `CanSend(status)`, + `CanDecide(status, Principal, drafterId)` — and have each store method call one. + The store keeps its lock, its `Db.Create()`, its static shape and its signature; only + the `if` cascade moves. This **extends the pattern §7 already records** for + `SubmissionRules` / `BeoordelingRules` / `OrgTemplateRules` / `DocumentRules`, and adds + a `tests/Domain/BriefRuleTests.cs` alongside the seven that exist. +- **Explicitly NOT proposed: changing the static-store shape.** `Data/Db.cs:6-12` + documents the static, non-DI store decision, and + `tests/TestWebApplicationFactory.cs:1-12` states the position outright — "Serializing + test classes is the fix, not a redesign of the stores for a test-only concern." + TE-008 respects that completely: it is orthogonal, and works _because_ the rules never + needed the DbContext in the first place. +- **The cost this seam actually pays down.** Because `Db.ConnectionString` is one static + field, that same file carries + `[assembly: CollectionBehavior(DisableTestParallelization = true)]` — **all 241 backend + tests run serially, process-wide**, and every brief-rule assertion currently pays a + host boot + SQLite file for a decision that is a pure function of two enums. Each rule + moved out of `BriefStore` moves a test out of the serialized integration lane into the + free-running unit lane. That is the argument for the seam; it is not an argument for + touching the stores. +- **Effort M** (five extractions + one new test file). Independently shippable, and + splittable one method at a time if preferred. + +Two smaller Data notes, neither ticketed: `IdempotencyStore` is the only store that is +purely in-memory with no `Reset()` and no TTL, so its dictionary survives +`TestWebApplicationFactory` disposal and is shared by every test class in the process — +harmless today only because `IdempotencyTests.cs:24` keys on `Guid.NewGuid()`. And +`DocumentStore.cs:54` / `AuthzAuditStore.cs:35` stamp `DateTimeOffset.UtcNow` inline +while `ApplicationStore.RecordBesluit` correctly takes `now` — an inconsistency, but +neither audit timestamp is asserted on, so no metric supports a ticket. + +## backend/Zgw + +**No findings.** §3c 98.1% line / **85.5% branch — the strongest branch figure on the +backend**, and §5 records 1.8% duplication. This is the module that was built as +ports-and-adapters from the start (ADR-0005): `IZaakSource`/`IDocumentSource` each have +two implementations (§7), `ZgwHttpClient` takes an injected `HttpClient`, and +`tests/ZgwStubHandler.cs` provides the transport fake — five test files ride on it. It +is the backend's worked example of the seam TE-003 asks the upload module for. + +## backend/Contracts + +**No testability findings — but state the gap accurately.** + +§3c records `backend/Contracts` at **65.0% branch, the worst branch figure in the repo** +(BL-005 names it first). It is nonetheless not a testability finding: `Mappers.cs` is 79 +lines of pure `static` extension methods over records, with the clock already injected +where it matters (`ToStatusDto(this Aanvraag a, DateTimeOffset now)` at `:52`, +`ToSummaryDto(..., now)` at `:68`, `ToDetailDto(..., now)` at `:76`), and `Dtos.cs` is +250 lines of `record` declarations. §4b confirms the shape: file CC 4, max method CC 3, +the lowest complexity of any backend folder. **Nothing blocks a unit test here.** + +What is missing is a test _file_: `backend/tests/` has `Domain/`, `Acceptance/` and +`Builders/` folders but no `Contracts/`, so all 65% is incidental coverage picked up +through endpoint tests. That is a coverage ticket for whoever owns coverage, requiring +zero source change — and per BL-009 there is no ratchet, so it would have to be verified +against §3c's numbers by hand. + +## backend/Stamdata + +**TE-009 — `Professions.ByProgram` freezes its valid-time filter at type-load from `DateTime.Today`** + +- Module / file:line — `backend/src/BigRegister.Api/Stamdata/Professions.cs:25-27` +- **What blocks unit testing.** `ByProgram` is a `static readonly IReadOnlyDictionary` + whose initializer runs `Mappings.Where(m => StamdataFile.ActiveOn(m.GeldigVan, +m.GeldigTot, DateOnly.FromDateTime(DateTime.Today)))`. Two compounding problems: the + peildatum is the ambient wall clock, **and** the result is computed once per process at + type-load and then immutable. A test cannot ask "which mappings are active on + 2030-01-01" — not by arranging state, not by ordering, not at all. The temporal + behaviour of the one business-tunable table that has a validity window is therefore + unreachable. The file's own comment concedes the consequence: it "preserves the + pre-valid-time behaviour exactly **while the file's rows are all current**" — i.e. the + `ActiveOn` call is presently a constant-true filter, so both of its interesting + branches (not-yet-valid, expired) are dead in every run. +- **Baseline citation.** §3c: `backend/Stamdata` 96.8% line but **71.7% branch** — + named in BL-005 as the second-weakest branch axis, an exact 25-point line/branch split. + §4b: `Stamdata/StamdataTable.cs` file CC 21, joint-third-highest in the backend. + This is the rare backend case where "untestable" is defensible against 97.6% line + coverage: the lines run, the branches provably cannot. +- **Minimal seam.** Add the parameterized overload and define the existing field in terms + of it: + `public static IReadOnlyDictionary ByProgramOn(DateOnly on) => Mappings.Where(m => StamdataFile.ActiveOn(m.GeldigVan, m.GeldigTot, on)).ToDictionary(...);` + then `public static readonly IReadOnlyDictionary ByProgram = ByProgramOn(DateOnly.FromDateTime(DateTime.Today));`. + **Zero call-site changes** — `DiplomaRules.ProfessionFor` and `All()` keep using + `ByProgram`. `StamdataValidationTests` gains the ability to assert both validity-window + branches against authored future/expired rows. +- **This extends an existing pattern in the same folder.** `StamdataTable.cs:63` already + does exactly this — `Temporal ? Rows().Where(r => ActiveOn(r, on)).ToArray() : Rows()`, + with `on` as a parameter — and `StamdataFile.ActiveOn(van, tot, on)` (`:36`) is already + clock-free. `Professions.cs` is the one caller that swallows the parameter. +- **Effort S.** Independently shippable; additive only. + +## backend/tests + +**No findings.** 39 files, 4 253 lines, 241 green tests, every backend source file +reached (§3c). The suite already carries the fixtures a unit lane needs +(`Builders/AanvraagBuilder.cs`, `ZgwStubHandler.cs`, `TestWebApplicationFactory` with +per-class throwaway SQLite files). + +The one structural observation is not a defect to fix here: +`[assembly: CollectionBehavior(DisableTestParallelization = true)]` +(`TestWebApplicationFactory.cs:12`) serializes the entire suite because +`Db.ConnectionString` is a process-global. The repo reached that decision deliberately +and documented the race it prevents. Rather than reopen it, TE-008 and TE-009 reduce how +much _needs_ to run in that serialized lane. Note also §4b's outlier in this folder: a +**293-line test method at CC 20** +(`CreateZaak_posts_zaak_status_and_rol_and_maps_the_result_back`, +`OpenZaakZaakSourceTests.cs`) — a test-readability item for agent 01, not a testability +seam. + +--- + +## Summary + +| ID | Title | Module | Blocker | Baseline | Effort | 1 deploy | +| ------ | -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------ | ------ | -------- | +| TE-001 | `SessionStore.restore()` reads `localStorage` inline | ssp/auth + bhp/auth | hidden I/O in a field initializer; guard is module-private | §3a 42.9%/46.2% (worst line); file LH 2/20, BRH 3/13 | S ×2 | yes | +| TE-002 | Trust boundary hidden inside a global-`fetch` method | ssp/brief | un-exported shape validation behind `await fetch` | §3b 42% reach; §3a 68.8% branch | S | yes | +| TE-003 | `UploadTransport` port declared, concrete class injected | libs/shared/upload | `inject(KeepaliveTransport)`; class not exported | §3a 52.0%/50.0%; file unreached (§3b, non-`ui/`) | S | yes | +| TE-004 | `createUploadController` injects + binds `window` at call time | libs/shared/upload | 3× `inject()`, `effect()`, `addEventListener` before returning | §3a 52.0%/50.0%; file unreached (§3b, non-`ui/`) | S | yes | +| TE-005 | `xhrUpload` interprets responses inside an XHR closure | libs/shared/upload | `new XMLHttpRequest()` hard-coded; dev simulator shares the method | file LH 5/64 (7.8%), BRH 3/57 (5.3%) | S–M | yes | +| TE-006 | Blob-to-browser handoff inlined in 3 commands | libs/beheer + ssp/brief | `window.open` / `a.click()` as the last statement of each command | §3a beheer/application 40.5% branch (worst); brief.store 50% | S–M | yes | +| TE-007 | `LetterHtml` resolves `datum` from `UtcNow`, not from `at` | backend/Domain | ambient clock in a private resolver inside a pure rule class | §3c Domain 82.0% branch; §4b file CC 21 | S | yes | +| TE-008 | Brief transition rules live inside DB-opening store methods | backend/Data | 5 pure guards behind `lock` + `Db.Create()` | §3c Data 75.5% branch (BL-005); §4b CC 17, `ToDto` CC 16 | M | yes | +| TE-009 | `Professions.ByProgram` freezes valid-time at type-load | backend/Stamdata | `static readonly` + `DateTime.Today`; both branches unreachable | §3c Stamdata 71.7% branch (BL-005) | S | yes | + +**Modules with no findings:** ssp/registratie · ssp/herregistratie · ssp/showcase+shell+root · +bhp/behandeling · bhp/shell+root · libs/shared/{domain, application, infrastructure, ui, +layout, kernel, testing} · libs/beheer/{domain, infrastructure, ui, contracts} · +backend/Program.cs · backend/Zgw · backend/Contracts · backend/tests. + +**Baseline rows recommended for closure as false gaps:** `libs/shared/domain` (0% reach, +3 files) and `libs/beheer/contracts` (0% reach, 1 file) — both named in BL-004 as genuine +gaps; both contain only type declarations and no executable statement. + +**Cross-references, not owned here:** BL-001 complexity (agent 01) · BL-002 auth +duplication (agent 06) · BL-003 `Program.cs` structure (agents 03/04) · BL-006 backend +architecture enforcement (agent 03) · BL-007 write-side placement (agent 04) · BL-008 +`coverageExclude` · BL-009 no coverage ratchet — which means none of the findings above +can be verified as "improved" by CI alone; verify against `00-baseline.md`'s numbers · +BL-010 `libs/shared/upload` layer placement (TE-003/004/005 all land inside that +carve-out and do not resolve it) · BL-011 suite flakiness under parallel load. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/03-ddd-hexagonal.md b/docs/project/refactor-backlog-setup/refactor-backlog/03-ddd-hexagonal.md new file mode 100644 index 0000000..37451f0 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/03-ddd-hexagonal.md @@ -0,0 +1,9 @@ +## Scope: [to be filled by agent] + +## Status: not_started + +## Last updated: - + +## Depends on: [see agent prompt] + +## --- diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/04-cqrs-light.md b/docs/project/refactor-backlog-setup/refactor-backlog/04-cqrs-light.md new file mode 100644 index 0000000..b775777 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/04-cqrs-light.md @@ -0,0 +1,543 @@ +## Scope: apps/ssp (auth, registratie, herregistratie, brief, showcase+shell+root), apps/behandelportal (auth, behandeling, shell+root), libs/shared (per layer), libs/beheer, backend (Program.cs, Domain, Data, Zgw, Contracts, Stamdata) + +## Status: complete + +## Last updated: 2026-08-26 + +## Depends on: 00-baseline.md + +## --- + +# 04 — CQRS-light: command/query separation at the application-service level + +**Mandate reminder, applied literally.** This agent may only _extend_ CQRS-light where +baseline §7 records it already exists. It may not introduce it. Every finding below names +the concrete existing artifact it extends. Three things the baseline flagged as tempting +are therefore **not** filed as tickets — they are in "Out of mandate (pattern absent)" at +the end. + +**What "the pattern" concretely is in this repo** (from §7 + CLAUDE.md §3, so later +sections can just point at it): + +| Side | The existing artifact | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| FE query | `resource({ loader })` in an `infrastructure/*.adapter.ts` + a `parse*` boundary + a `providedIn:'root'` read store exposing `RemoteData` | +| FE command | `application/submit-*.ts` command factory → `runSubmit(fn, fallback)` → `Result` → the caller dispatches a Msg | +| FE fold | `libs/shared/src/application/submit.ts` — the single try/catch + ProblemDetails → error-string fold, **and** the Idempotency-Key mint point | +| BE | `Contracts/Dtos.cs` direction split (`*Request` in / `*Dto`+`*Response` out); read/write comment banners in `Program.cs` | +| BE read-mdl | `ToDetailDto(now)` / `ToDto(now)` — the read side _projects_ status from timestamps rather than the write side storing it | + +**The cleanest module in the repo is `bhp/behandeling`**, and it is worth naming up front +because three findings below propose making another module look like it: it splits its +query adapter (`beoordeling.adapter.ts` `get`, `werkvoorraad.adapter.ts` `list`) from its +command adapter (`besluit.adapter.ts` `besluit`) into **separate files**, wraps only the +command in a command factory (`application/submit-besluit.ts`), and keeps the read store +(`beoordeling.store.ts`, `werkvoorraad.store.ts`) write-free. Its backend counterpart does +the same: `Program.cs:441` "read side only" banner, `Program.cs:464` the write banner. That +is the target shape, and it is already in the tree. + +--- + +## apps/ssp — auth + +**No findings.** + +`SessionStore.login` (`apps/ssp/src/app/auth/application/session.store.ts:58`) is a write +and `session`/`isAuthenticated` are reads, but they share one three-field aggregate with no +network read path at all — `DigidAdapter.authenticate` is the only I/O and it is a command. +There is nothing to separate. §7 lists no command factory or read adapter in this context to +extend. BL-002 is agent 06's, not this agent's. + +--- + +## apps/ssp — registratie + +### CQ-001 — `createDraftSync` is registered as a command factory but owns three query paths + +- **Module / file:line** — `apps/ssp/src/app/registratie/application/draft-sync.ts:50-236` + (queries at `:141 load`, `:160 findConcept`, `:179 resume`; commands at `:63 ensureId`, + `:100 flush`, `:213 submit`, `:221 reset`) +- **Extends** — the command-factory idiom itself. §7 counts `draft-sync.ts` as one of the + repo's **3 command factories**, alongside `submit-change-request.ts` (18 lines, one + command, zero reads) and `submit-besluit.ts` (16 lines, one command, zero reads). This + finding asks the third member of that set to look like the other two. +- **Baseline citation** — §7 Frontend, "Command factories (write side) | 3"; §4a metric + row **`createDraftSync` 143 lines** (the largest function in the codebase, tied to + `reduceUpload`'s 109 only in the two-member `fn>75` population); §9 threshold "TS + function > 40 lines". +- **The mixing, concretely** — the factory returns four members. `resume()` is pure query + orchestration: read `?aanvraag`, `adapter.detail(linked)`, else `findConcept()` → + `adapter.list()` → `parseApplications`. `submit()`/`reset()`/the debounce `effect` are + writes. They are entangled through three pieces of shared mutable closure state — `id`, + `ensuring`, and `resumeGate` (`:56-61`) — where `resumeGate` exists _only_ so the write + path (`ensureId`) can wait for the read path (`resume`) to finish. That coupling is + genuine and load-bearing, which is exactly why it is worth naming rather than leaving as + an unexplained 143-line function. +- **Proposed change, minimal** — extract the read half into + `application/find-concept.ts`: `findConcept(adapter, type)` and `loadConcept(adapter, id)` + as free functions taking the adapter (no `inject`, so they get a direct spec — + `draft-sync.spec.ts` already exists and would shrink). `createDraftSync` keeps `resumeGate` + and the write path and calls them. This is a move, not a redesign; the closure state stays + where it is. +- **Effort** — M. Independently shippable in one deploy (no wire change, no DTO change). + +### CQ-002 — two read stores perform writes that bypass the `runSubmit` fold + +- **Module / file:line** — `apps/ssp/src/app/registratie/application/applications.store.ts:54-64` + (`cancel`) and `apps/ssp/src/app/registratie/application/admin-cases.store.ts:46-56` + (`delete`); the adapter methods are + `infrastructure/applications.adapter.ts:60 cancel` and `:41 deleteAny`. +- **Extends** — `runSubmit` + `SUBMIT_FAILED` (`libs/shared/src/application/submit.ts:15,28`), + the fold that all 16 other mutations in the repo pass through, and the + `createSubmitChangeRequest` command factory that lives _in this same folder_ + (`registratie/application/submit-change-request.ts`) and does exactly this for the other + registratie write. +- **Baseline citation** — **BL-007** ("the FE write side is inconsistently placed"); + §7 Frontend, "Command factories | 3" vs "Mutations living inline in adapters | ~13". + Note these two are a _fourteenth and fifteenth_ case BL-007 did not enumerate: they are + worse than the ~13, because those at least reach `runSubmit` inside the adapter — these + reach the raw `ApiClient` and never produce a `Result` at all. +- **The mixing, concretely** — both stores own a `RemoteData` read signal _and_ a write, and + the write's failure path is `catch { this.state.set(before); }` — a bare rollback with no + error channel. A failed cancel makes the row silently reappear with no message, no + `ActionState`, no ProblemDetails `detail`. `BriefStore`/`OrgTemplateStore` in the sibling + context both hold an `ActionState` + `lastError` for exactly this. The bare + `adapter.cancel()` also means the `Idempotency-Key` on the wire is a fresh UUID minted per + HTTP attempt by `api-client.provider.ts:58`, not the per-logical-submit key `runSubmit` + promises at `submit.ts:11-13` — that invariant's docstring is currently false for these + two calls (harmless today: `Program.cs` only honours the header inside the `Submit` helper, + see CQ-005's note). +- **Proposed change, minimal** — route both through `runSubmit` and surface the error. + Two options, pick one and apply to both stores identically: + (a) smallest — `const r = await runSubmit(() => this.adapter.cancel(id), SUBMIT_FAILED); +if (!r.ok) { this.state.set(before); this.error.set(r.error); }` plus one `error` signal; + (b) fuller — a `application/cancel-application.ts` command factory mirroring + `submit-change-request.ts`, which the store injects. (a) is enough to close the finding. +- **Effort** — S. Independently shippable; (a) touches 2 files plus a UI line each to render + the error. + +### Not filed — `applications.adapter.ts` mixes 3 reads and 5 writes in one file + +`infrastructure/applications.adapter.ts:31-66` holds `list`/`listAll`/`detail` next to +`create`/`syncDraft`/`cancel`/`deleteAny`/`submit`, where `bhp/behandeling` splits the +equivalent into `beoordeling.adapter.ts` + `besluit.adapter.ts`. The split would be the +structural enabler for CQ-002(b). On its own, though, it moves 8 thin one-line +`this.client.x()` wrappers between files and changes nothing observable — file placement is +agent 03's axis, not a read/write-mixing defect. Noted here so it is a deliberate omission +rather than a miss; fold it into CQ-002 if that ticket takes option (b). + +### Not filed — `BigProfileStore` + +`application/big-profile.store.ts` is the reference implementation of the split and needs no +change: reads are two resources projected through `parseDashboardView`, and the only +write-adjacent members are the `beginHerregistratie`/`confirmHerregistratie`/ +`rollbackHerregistratie` invalidation hooks (`:65-74`) — the store never performs the write +itself, `createDraftSync.submit` does. ADR-0001's "Out of scope" section already records the +optimistic-flag race; that is a correctness note, not a CQRS-light one. + +--- + +## apps/ssp — herregistratie + +**No findings.** + +`IntakePolicyStore` (`application/intake-policy.store.ts`) is a pure query facade over one +`resource()`. The context has no write of its own — its submit is `createDraftSync.submit`, +owned by `registratie` and covered by CQ-001. `intake.machine.ts` / `herregistratie.machine.ts` +are reducers; §3's "side effects stay out of the reducer" is honoured (verified: no `inject`, +no adapter import in either). + +--- + +## apps/ssp — brief + +### CQ-003 — `runSubmit` (the write-side fold, incl. the Idempotency-Key mint) is used for reads + +- **Module / file:line** — `apps/ssp/src/app/brief/infrastructure/brief.adapter.ts:56` + (`load` → `briefGET`), `apps/ssp/src/app/brief/infrastructure/org-template.adapter.ts:39` + (`list` → `orgTemplates`) and `:51` (`load` → `orgTemplateGET`). Same defect in + libs/beheer — see that section; one ticket should fix all five call sites. +- **Extends** — the two halves already present in `libs/shared/src/application/submit.ts`: + the ProblemDetails→`Result` **fold** (which reads legitimately want) and the + **`withIdempotencyKey` wrapper** (which is write-only by construction). The read idiom it + should join is the one the other 6 read adapters use — `resource({ loader })` + + `parse*` — or, for these imperative reads, the fold alone. +- **Baseline citation** — **BL-007**; §7 Frontend "Infrastructure adapters (read side) | 20" + and "Mutations living inline in adapters | ~13" — these three GETs are counted in the + wrong column of that inventory, because the code cannot tell them apart from the writes + beside them. +- **The mixing, concretely** — `submit.ts:11-13` states `runSubmit` is "the one place a + logical submit's Idempotency-Key is minted — once per `runSubmit` call". Each of these + reads therefore mints a UUID and assigns the module-level `pendingIdempotencyKey` + (`api-client.provider.ts:21-26`) for the duration of a GET. It is inert today: the header + is attached only when `method !== 'GET'` (`api-client.provider.ts:58`). But + `api-client.provider.ts:15-19` explicitly documents that the module-level variable "holds + up because every submit command calls its adapter synchronously (no await before reaching + this file)". Reads have no such discipline — `BriefStore.load()` and + `OrgTemplateStore.load()` are awaited across `await`s — so routing reads through the write + helper quietly widens the assumption that comment relies on. Reading `brief.adapter.ts:55-92` + it is also simply impossible to see which of the seven methods are commands: all seven are + `await runSubmit(...) → parseBriefView`. +- **Proposed change, minimal** — split `submit.ts` in place, no new concept: + `runResult(fn, fallback)` = the existing try/catch + `problemDetail` fold; + `runSubmit(fn, fallback)` = `runResult(() => withIdempotencyKey(crypto.randomUUID(), fn), fallback)`. + Point the 5 reads at `runResult`. Zero behaviour change, and afterwards the keyword at each + call site states the side. `submit.spec.ts` already exists and covers the fold. +- **Effort** — S. Independently shippable in one deploy; 1 shared file + 3 adapters (+2 in + libs/beheer). + +### Not filed — `BriefStore` and `OrgTemplateStore` own both the read path and the write commands + +`application/brief.store.ts` holds `load()` alongside `save`/`submit`/`approve`/`reject`/ +`send`/`resetDemo`/`revealBigNummer`; `application/org-template.store.ts` holds +`load`/`selectSubOrg` alongside `flushSave`/`confirmPublish`/`rollback`. BL-007 points at +these as "mutations living inline in adapters", and it is tempting to file them. + +They are deliberately not filed, and the reason matters for whoever reads this next. +CLAUDE.md §3 defines a command as "does the HTTP, then dispatches a message describing the +outcome" — and that is precisely what `BriefStore.transition()` (`:248-259`) and +`OrgTemplateStore.confirmPublish()` (`:175-189`) do: `ActionState → Busy`, cancel the +debounce, call the adapter, then `store.dispatch(...)` or `actionState.set(Failed)`. The +reducer stays pure. These stores _are_ the command layer; they are not a store that +accidentally grew writes. Both also implement the write→invalidate-read handoff correctly +(`confirmPublish` reloads via `selectSubOrg`, mirroring `BigProfileStore.confirmHerregistratie`'s +`viewRes.reload()`). Extracting six `createSubmitX()` factories out of `BriefStore` would move +code without changing which layer performs which effect. **The real defect in these two files +is CQ-003, and that is filed.** + +--- + +## apps/ssp — showcase, shell, root + +**No findings.** `showcase/concepts.page.ts`, `app.ts`, `app.config.ts`, `app.routes.ts` +contain no application services, no adapter calls and no state writes — routing, providers +and a teaching page. §7 lists no pattern here to extend. (Their 0% spec reach in §3b is +agent 02's; their `bhp/root` duplication in §5 is agent 01's.) + +--- + +## apps/behandelportal — auth + +**No findings.** Identical to ssp/auth by BL-002; the same reasoning applies. + +--- + +## apps/behandelportal — behandeling + +**No findings — this module is the reference implementation.** + +Stated positively so later phases do not "clean it up" into something worse: +`werkvoorraad.adapter.ts` (`list`) and `beoordeling.adapter.ts` (`get`) are query-only files; +`besluit.adapter.ts` (`besluit`) is a command-only file; `submit-besluit.ts` is the command +factory; `werkvoorraad.store.ts` and `beoordeling.store.ts` contain no writes at all — not +even a rollback. `besluit-form.component.ts:88` holds the command +(`private submit = createSubmitBesluit()`), never the adapter. §3a records the highest FE +line coverage of any feature context here (91.6%), which is consistent with the split: the +read stores are trivially testable because nothing writes through them. + +--- + +## apps/behandelportal — shell, root + +**No findings.** Same as ssp/shell+root. + +--- + +## libs/shared — application + +### CQ-004 — `FeatureFlagStore.set` writes without the fold and drops the error entirely + +- **Module / file:line** — `libs/shared/src/application/feature-flags.store.ts:53-59`; + adapter at `libs/shared/src/infrastructure/feature-flags.adapter.ts:18`; caller at + `libs/beheer/src/ui/feature-flags.page.ts:93`. +- **Extends** — `runSubmit`/`SUBMIT_FAILED` (`libs/shared/src/application/submit.ts`), which + lives in this very folder — the store sits three files away from the fold it skips. +- **Baseline citation** — **BL-007**; §7 Frontend "Mutations living inline in adapters | ~13" + (this is another case not in BL-007's enumeration, which named only `brief.adapter.ts`, + `org-template.adapter.ts` and `stamdata.adapter.ts`). +- **The mixing, concretely** — the store owns the read (`load`, `flags`, `all`, `enabled`) + and the admin write. The write is `try { await this.adapter.set(...) } finally { await this.load() }` + — **no `catch`**. The rejection propagates out of `set()`; the caller is + `void this.store.set(key, enabled)` (`feature-flags.page.ts:93`), so a failed toggle + becomes an unhandled promise rejection. The admin sees the switch flick back after the + `finally`'s reload with no explanation and no ProblemDetails `detail`, on a write gated by + `flags:manage` that is exactly the kind an operator needs confirmation of. Every other + write in the repo that goes through `runSubmit` gets `problemDetail(e, fallback)`. +- **Proposed change, minimal** — `const r = await runSubmit(() => this.adapter.set(key, enabled), SUBMIT_FAILED); +await this.load(); if (!r.ok) this.error.set(r.error);` with one `error` signal rendered by + `feature-flags.page.ts`. Keep the reload unconditional (it is the read-side invalidation and + is correct). +- **Effort** — S. Independently shippable; 2 files. + +### Not filed — the rest of the layer + +`access.store.ts` (query-only over `/me`), `remote-data.ts`, `store.ts`, `action-state.ts`, +`debounced-save.ts`, `history.ts`, `machine-remote-data.ts`, `pending-saves.ts`, +`session.port.ts` are the read/state kit itself, not services. `submit.ts` is the subject of +CQ-003 rather than a finding of its own. + +--- + +## libs/shared — infrastructure + +**No findings.** `me.adapter.ts` and `feature-flags.adapter.ts` are correctly +direction-labelled (`list` vs `set`); `api-client.provider.ts` is the single HTTP seam and +already gates the Idempotency-Key on `method !== 'GET'`, i.e. the _transport_ layer honours +the command/query split that CQ-003 shows the _application_ layer blurring. The CC-19 `fetch` +there is BL-001's "outside the idiom" case and belongs to agent 01. + +--- + +## libs/shared — upload + +**No findings.** + +`upload.adapter.ts` mixes reads (`categoriesResource`, `status`) and writes (`xhrUpload`, +`deleteDocument`), and `upload-shell.service.ts` mixes `upload`/`delete`/`cancel` (commands) +with `pollReturning` (a query). It is tempting to file, and it is deliberately not: **BL-010** +records that this whole folder sits outside the layer convention by design and is carved out +by name in the `apiclient-infrastructure-only` dependency-cruiser rule. §7 lists no adapter +or command factory here to extend — `upload.adapter.ts` is explicitly footnoted as the +adapter that is _not_ in an `infrastructure/` folder. Resolving BL-010 (agent 03's call) has +to come first; a read/write split layered on top of an already-exceptional layout would +entrench the exception. Worth noting for whoever takes BL-010: `UploadShellService` is +otherwise the FE's most complete command implementation — every method takes a `Dispatch` and +reports its outcome as a Msg, which is the CLAUDE.md §3 shape done exactly right. + +--- + +## libs/shared — domain, contracts, kernel, ui, layout, testing, environments + +**No findings.** No application services, no adapters, no writes. `kernel/fp.ts`'s `Result` +is the return type the command side is built on, not a service. + +--- + +## libs/beheer + +### CQ-005 — both stamdata reads run through `runSubmit`, in a file that documents itself as write-free + +- **Module / file:line** — `libs/beheer/src/infrastructure/stamdata.adapter.ts:27` (`list`) + and `:42` (`load`). +- **Extends** — the same `submit.ts` split proposed in CQ-003. **Fix them in one ticket**; + they are listed separately only because the module scope requires it. +- **Baseline citation** — **BL-007** (which names `stamdata.adapter.ts` explicitly among the + ~13 "mutations living inline in adapters"); §7 Frontend "Infrastructure adapters (read + side) | 20". +- **The mixing, concretely** — sharper here than anywhere else in the repo, because the + file's own docstring (`:16-20`) says: _"Both endpoints are reads … There is no write method + — the edit is downloaded and lands as a PR."_ Both nevertheless call `runSubmit`. BL-007 + counts this adapter on the write side of the inventory on the strength of that call, when + the module is in fact the repo's only genuinely CQRS-clean context: `StamdataStore` has no + write command at all (`download()` at `:137` is a local `Blob` + anchor click, zero + network), and `AuditStore` is read-only. The tooling and the baseline both mis-classify + this module purely because of the helper name. +- **Proposed change, minimal** — point both at `runResult` per CQ-003. Nothing else in this + library changes. +- **Effort** — S. Ships with CQ-003 in the same deploy. + +### Not filed — `libs/beheer` application/domain/ui/contracts otherwise + +`stamdata.store.ts` and `audit.store.ts` are query-only (see above). `stamdata-editor.machine.ts` +is a pure reducer. The `ui/` layer holds no adapter calls. + +--- + +## backend — Program.cs + +### CQ-006 — the read/write banner split is established, honoured once, then abandoned for 5 of 7 feature sections + +- **Module / file:line** — `backend/src/BigRegister.Api/Program.cs`. Banners at `:133` + ("GET: screen-shaped reads"), `:185` ("POST: submits"), `:441` ("read side only") and + `:464` ("record a behandelaar's decision"). Mixed sections: `:197` Document upload, + `:275` Applications, `:598` Brief, `:721` Organization templates, and admin-cases split + non-contiguously across `:424`, `:554`, `:566`. +- **Extends** — the banner convention _inside this file_, specifically the WP-65 pair at + `:441`/`:464`, which already splits one feature's query endpoints from its command endpoint + under two banners. This proposal applies the `:441`/`:464` treatment to the five sections + that predate it. **No handler types, no mediator, no `Features/` folders** — see the + out-of-mandate section for why that larger move is not proposed here. +- **Baseline citation** — **BL-003** (940 lines, 48 endpoints, file CC 78 vs next-highest 27, + "read/write separated only by comment banner"); §7 Backend CQRS-light row ("Read/write + split exists as _comment banners_"); §3c `Program.cs` 97.4% line / 84.8% branch. +- **The mixing, concretely** — the file opens by declaring direction as its organising + principle (`:133` reads, `:185` writes), then from `:197` switches to feature grouping + without saying so, and every subsequent section interleaves: + | Section | Line | Reads | Writes | + | -------------------------------------------------------------------------------------------- | ------------: | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | + | Document upload | 197 | `GET /uploads/categories`, `GET /uploads/{id}/content`, `GET /uploads/status` | `POST /uploads`, `DELETE /uploads/{id}`, `DELETE /admin/uploads/{id}` | + | Applications | 275 | `GET /applications`, `GET /applications/{id}` | `POST`, `PUT /{id}`, `DELETE /{id}`, `POST /{id}/submit` | + | Admin cases | 424, 554, 566 | `GET /admin/cases`, `GET /admin/audit` | `DELETE /admin/cases/{id}` — **129 lines away** from its list, with werkvoorraad, beoordeling, besluit and the ZGW notification hook in between | + | Brief | 598 | `GET /brief`, `GET /brief/preview` | `PUT /brief`, `POST submit/approve/reject/send/reveal-bignummer/reset` | + | Org templates | 721 | `GET /admin/org-templates`, `GET /admin/org-template/{id}` | `PUT /{id}`, `POST /{id}/publish`, `POST /{id}/rollback` | + | `GET /admin/org-template/{subOrgId}/preview` (`:703`) is additionally filed under the Brief | + | banner rather than the Org-templates one. The consequence is not a bug — §3c confirms the | + | file is well tested, and this is a **structure finding, not a correctness one** — it is that | + | a reader cannot answer "what can mutate state here?" without reading all 940 lines, and that | + | the two cross-cutting write wrappers (`Submit`'s idempotency replay, `RecordZgwDivergence`) | + | have no visible scope. | +- **Proposed change, minimal** — within each existing feature section, order reads first then + writes and insert the `:441`/`:464`-style sub-banners; move `DELETE /admin/cases/{id}` + (`:554`) and `GET /admin/audit` (`:566`) up beside `GET /admin/cases` (`:424`); move the + org-template preview from the Brief section to the org-template one. **Pure reordering and + comments** — no signature, route, DTO or behaviour change, so §3c's 97.4%/84.8% coverage is + the regression net and the diff is reviewable line-for-line. +- **Effort** — S. Independently shippable in one deploy. One caveat for whoever schedules it: + it is a large-diff/zero-semantic-change commit, so land it alone, never mixed with a + behaviour change. + +### CQ-007 — `GET /brief` creates a brief, though the explicit create command already exists + +- **Module / file:line** — `backend/src/BigRegister.Api/Program.cs:603` + (`api.MapGet("/brief", …)`) → `backend/src/BigRegister.Api/Data/BriefStore.cs:50` + (`GetOrCreate` — `db.Briefs.Add(created); db.SaveChanges();`). +- **Extends** — the command/query direction split that `Contracts/Dtos.cs` encodes + (`*Request` in / `*Dto` out) and that the banners at `:133`/`:185` state as the file's + premise; and concretely, **`POST /brief/reset` (`:712` → `BriefStore.ResetAndCreate`)** — + the create-a-fresh-brief command already exists as a POST. The write half of `GetOrCreate` + has a command counterpart; the query does not need to duplicate it. +- **Baseline citation** — **BL-003**; §7 Backend CQRS-light row. +- **The mixing, concretely** — this is the only endpoint in the backend where a GET performs + a persisted write. Everything else respects the direction, and notably the read side goes + out of its way to _avoid_ writing: `ToDetailDto(DateTimeOffset.UtcNow)` / + `ToDto(now)` project Concept → InBehandeling → Goedgekeurd from stored timestamps on every + read rather than mutating a status column (`Program.cs:284`, `Data/AanvraagMapper.cs`), which + is a textbook CQRS read-model projection and the strongest evidence the convention is + intended. `GET /brief` breaks it: a plain read is non-idempotent on first call, allocates a + row, and — since the FE retries GETs automatically (`api-client.provider.ts:66`, + `retry({ count: 2, delay: 500 })`, GET-only, precisely because GETs are assumed safe) — + a transient failure can enter the create path more than once. `BriefStore.GetOrCreate` is + `lock`-guarded so no duplicate row results today; the objection is that the safety depends + on the lock rather than on the endpoint being a query. +- **Proposed change, minimal** — `GET /brief` returns 404 when no brief exists for the owner; + `BriefStore.GetOrCreate` splits into `Get` (query) and the existing `ResetAndCreate` (already + there). `BriefStore.load()` on the FE (`brief.adapter.ts:55`) treats 404 by calling the + existing `reset()` command once. **This is the least certain finding in this file** and the + only one with a behaviour change: it costs one extra round-trip on a first visit and touches + the brief tests. If the demo-seeding convenience is judged to outweigh the principle, the + acceptable alternative is to leave the code alone and add one line at `:603` saying the GET + seeds on first call — the defect is as much that it is undocumented as that it exists. +- **Effort** — M. Independently shippable, but FE and BE must land together (the 404 contract), + so it is the one finding here that is not a single-side deploy. + +--- + +## backend — Domain + +**No findings.** `Domain/` is static classes of pure functions (`SubmissionRules`, +`IntakePolicy`, `BeoordelingRules`, `HerregistratieRule`, `OrgTemplateRules`, `Authz`, +`FeatureFlags`, `LetterHtml`, `DiplomaRules`, `DocumentRules`) with no persistence and no I/O — +verified EF-free and ASP-free per §7. A pure decision function has no read/write axis to +separate. `Domain/Applications/Aanvraag.cs`'s `Concept`/`Submitted`/`Decided` tagged union is +the write model; §3c records 94.2% line coverage. + +--- + +## backend — Data + +**No findings within mandate.** The seven static stores (`ApplicationStore`, `DocumentStore`, +`BriefStore`, `OrgTemplateStore`, `FeatureFlagStore`, `AuthzAuditStore`, `IdempotencyStore`) +each expose reads and writes on one type — `ApplicationStore` alone has 6 reads +(`Get`/`List`/`GetAny`/`GetByReferentie`/`ListAll` + `ToDetailDto`) and 7 writes. That is +ordinary repository design, and §7 records these as "Not behind any port … Deliberate, +documented in `Data/Db.cs`". Splitting them into read/write repositories would be +_introducing_ the pattern into a module where §7 records it absent — out of mandate. See the +out-of-mandate section. One observation to hand on rather than file: `AanvraagMapper` / +`ToDetailDto(now)` is a real read-model projection and is cited approvingly in CQ-007; do not +let a future ticket "simplify" it into a stored status column. + +--- + +## backend — Zgw + +**No findings.** `OpenZaakZaakSource` / `OpenZaakDocumentSource` implement ports whose +interfaces (`IZaakSource`, `IDocumentSource`) already separate by operation +(`ListMyCases`/`ListCases` vs `CreateZaak`), and §7 records the ACL as "Fully built" under +ADR-0005. §3c gives it the second-highest branch coverage in the backend (85.5%). Nothing to +extend. + +--- + +## backend — Contracts + +**No findings.** `Contracts/Dtos.cs`'s 65 records split by direction is the backend's +strongest CQRS-light artifact and is cited as the pattern several findings above extend. +§3c's 65.0% branch coverage (the backend's weakest, per **BL-005**) is agent 02's axis, not +this one — a DTO record has no read/write mixing to fix. + +--- + +## backend — Stamdata + +**No findings.** Config-as-code tables (ADR-0004), validated at build by +`StamdataValidationTests`, never runtime-editable — read-only by definition. Its only +endpoints (`Program.cs:164`, `:173`) are both GETs behind the `StamdataAdmin` gate, correctly +placed under the reads banner at `:158`. Together with `libs/beheer` (CQ-005) this is the +cleanest end-to-end query slice in the repo. + +--- + +## Out of mandate (pattern absent) + +Filed here rather than as tickets, per the "name the pattern you extend or don't file it" +rule. Agent 08 or a human decides whether any of these becomes a ticket. + +**OOM-A — extracting `Program.cs` into `Features/` folders with handler types.** +This is the change BL-003 most obviously invites: 940 lines, 48 endpoints, file CC 78 against +a next-highest of 27. It is out of mandate because §7 is explicit that the backend has +**"No handler types, no mediator, no `Features/` folders"**, and the local helpers (`Submit`, +`StamdataAdmin`, `CasesAdmin`, `Beoordelen`, `OrgAdmin`, `FlagsAdmin`) are authorization and +idempotency wrappers, not handlers — verified by reading them at `Program.cs:772-940`. There +is no CQRS-light structure here to extend, only one to introduce. CQ-006 is the largest step +available _within_ the mandate, and it deliberately stops at ordering and comments. Note also +that CQ-006 is a strict prerequisite for OOM-A should it ever be taken: you cannot cut a +940-line file into vertical slices while five of its seven sections interleave directions. + +**OOM-B — read/write repository split in `backend/Data`.** +`ApplicationStore` (file CC 27, the highest in `Data`) and the six sibling stores each mix +reads and writes. Splitting them into query and command repositories would introduce the +pattern where §7 records it absent. It would also collide with the documented static/no-DI/ +`Db.Create()`-per-call design (§7: "Deliberate, documented in `Data/Db.cs` and +`Program.cs:40-45`"), which agent 06 may have views on. + +**OOM-C — no read model, no event sourcing, and none proposed.** +Stated explicitly so a later phase does not read this file as a step toward one. Baseline §7 +records no separate read model or event store, so per the role definition neither is in scope. +The `ToDetailDto(now)` status projection is a read-side _derivation_, not a materialised read +model, and CQ-007 argues it should stay that way. + +**OOM-D — BL-011 affects every acceptance criterion here.** +Not a finding, a scheduling note: the FE suite is flaky under parallel load, so "CI green" +alone does not verify CQ-001..005. Verify against §3a/§4a numbers, per **BL-009** (no +coverage threshold is enforced anywhere, so nothing ratchets). + +--- + +## Summary + +| ID | Module | Title | Extends | Baseline | Effort | One deploy? | +| ------ | ----------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------- | -------------- | ------ | ----------------------- | +| CQ-001 | ssp/registratie | `createDraftSync` is a command factory owning 3 query paths | the command-factory idiom (`submit-change-request.ts`) | §7, §4a, §9 | M | yes | +| CQ-002 | ssp/registratie | 2 read stores write without the `runSubmit` fold; errors dropped | `runSubmit` + `createSubmitChangeRequest` | BL-007, §7 | S | yes | +| CQ-003 | ssp/brief | `runSubmit` (write fold + idempotency mint) used for 3 reads | `submit.ts` fold vs `withIdempotencyKey` | BL-007, §7 | S | yes | +| CQ-004 | libs/shared/application | `FeatureFlagStore.set` skips the fold, drops the error entirely | `runSubmit`/`SUBMIT_FAILED` (same folder) | BL-007, §7 | S | yes | +| CQ-005 | libs/beheer | 2 stamdata reads run through `runSubmit`; ship with CQ-003 | `submit.ts` fold vs `withIdempotencyKey` | BL-007, §7 | S | yes (with CQ-003) | +| CQ-006 | backend/Program.cs | read/write banner split abandoned in 5 of 7 feature sections | the `:441`/`:464` WP-65 banner pair, in-file | BL-003, §7,§3c | S | yes — land it alone | +| CQ-007 | backend/Program.cs | `GET /brief` creates; `POST /brief/reset` already exists | direction split in `Contracts/Dtos.cs`; `POST /brief/reset` | BL-003, §7 | M | **no** — FE+BE together | + +**Modules with no findings:** ssp/auth · ssp/herregistratie · ssp/showcase+shell+root · +bhp/auth · bhp/behandeling (the reference implementation) · bhp/shell+root · +libs/shared/{infrastructure, upload, domain, contracts, kernel, ui, layout, testing, +environments} · backend/{Domain, Data, Zgw, Contracts, Stamdata}. + +**Suggested sequencing.** CQ-003 + CQ-005 are one ticket (one shared-file split, five call +sites) and should go first — they make the direction legible at every call site, which is +what CQ-002 and CQ-004 then apply consistently. CQ-006 is independent and can run in +parallel on the backend. CQ-001 is the only FE ticket with real design content. CQ-007 is +the only one needing a coordinated deploy and the only one whose premise is arguable — +schedule it last, or take its documentation-only alternative. + +**Honest scale.** Six of seven findings are S/M and none is a correctness defect except +CQ-004's dropped error and CQ-002's silent rollback. Baseline §6 (0 dependency violations, +textbook instability gradient) and §3c (97.6% backend line coverage) are accurate: this is a +well-structured codebase, and the CQRS-light work available is consistency work, not repair. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/05-bdd.md b/docs/project/refactor-backlog-setup/refactor-backlog/05-bdd.md new file mode 100644 index 0000000..37451f0 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/05-bdd.md @@ -0,0 +1,9 @@ +## Scope: [to be filled by agent] + +## Status: not_started + +## Last updated: - + +## Depends on: [see agent prompt] + +## --- diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/06-adr-conformance.md b/docs/project/refactor-backlog-setup/refactor-backlog/06-adr-conformance.md new file mode 100644 index 0000000..cdf9cbd --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/06-adr-conformance.md @@ -0,0 +1,582 @@ +## Scope: ADR-0001…0006 (all six), checked against apps/ssp, apps/behandelportal, libs/shared, libs/beheer, backend/src/BigRegister.Api, backend/tests, e2e + +## Status: complete + +## Last updated: 2026-08-26 + +## Depends on: 00-baseline.md + +## --- + +# 06 — ADR conformance + +Every claim below was verified by reading the code, not by trusting the ADR text or +CLAUDE.md. Where the two disagree with each other **and** with reality, that is flagged +as an ADR-fix, per CLAUDE.md's own "the docs win — update this file" rule. + +**Headline.** The architecture is in good shape. All six ADRs are substantially +conformed to in their **decisions**; there is no case where the codebase has quietly +adopted a different architecture. What has decayed is the ADRs' **descriptive prose** — +four of the six describe a repo layout, a set of files, or an out-of-scope list that +WP-49…WP-75 has since overtaken. Five of the eleven findings are therefore ADR-fixes. + +**On BL-002 (the auth duplication), my verdict up front, because the brief asked for an +argument rather than an assertion:** ADR-0002's non-sharing rule **needs no amendment and +should not be relaxed**. The prediction has not _failed_; it is _unexecuted_. See +ADR-C-004/ADR-C-006 for the reasoning and for what should actually be filed instead. + +--- + +## ADR-0001 — BFF-lite endpoints + decision DTOs (Accepted) + +**Core decision: conformed.** The two policy shapes the ADR prescribes are both live and +correctly applied. + +- _Decision flag_ — verified server-computed and rendered, never recomputed: + `apps/ssp/src/app/brief/application/brief.store.ts:103-108` derives `canEdit`/ + `canApprove`/`canReject`/`canSend`/`canRevealBigNummer` purely from + `BriefState.loaded.decisions`, with an explicit comment at line 31 ("this store never + computes them itself"). Same shape in + `apps/behandelportal/src/app/behandeling/infrastructure/beoordeling.adapter.ts:87-97` + (`decisions.canBesluiten`, rejected at the parse boundary if absent). +- _Config value_ — `apps/ssp/src/app/herregistratie/domain/intake.machine.ts:52` takes + `scholingThreshold` as a parameter; `SCHOLING_THRESHOLD_DEFAULT` (line 43) survives only + as the offline fallback the ADR sanctions, wired through + `application/intake-policy.store.ts:18-19`. No hardcoded `1000` is used as authority. +- _Parse boundary_ — 30 `parse*` functions (baseline §7). ADR-0001's own "out of scope" + item _"Runtime DTO validation on every endpoint (only the dashboard view has it)"_ is + substantially discharged. +- `libs/shared/src/application/remote-data.ts` + ``, and `dep:check`'s + `ui-not-infrastructure` rule at 0 violations (baseline §6), keep the "infrastructure is + the only network layer" clause enforced — with one carve-out, ADR-C-002. + +### ADR-C-001 — ADR-0001's worked example describes a POC that no longer exists + +- **Type: ADR-fix** +- **ADR cited:** ADR-0001, §"Worked example in this POC" — opening sentence _"This POC has + no real backend (static mock JSON + fake submit timers), so the 'BFF output' is a static + file"_; §"Out of scope here", bullets 1 and 4. +- **Evidence (verified):** + - There is a real backend: `backend/src/BigRegister.Api/Program.cs` (940 lines, 48 + endpoint mappings — baseline §2/BL-003). + - Every artifact the worked example names by path is gone. `find` returns nothing for + `public/mock/dashboard-view.json`, `public/mock/intake-policy.json`, or + `src/app/herregistratie/contracts/intake-policy.dto.ts`. `apps/ssp/public/mock/` does + not exist. The surviving contract file moved to + `apps/ssp/src/app/registratie/contracts/dashboard-view.dto.ts` (WP-67's `src/` → + `apps/ssp/src/` move), so §A's three cited paths are all wrong. + - Out-of-scope bullet 1 ("runtime DTO validation on **every** endpoint — only the + dashboard view has it") is stale: 30 `parse*` boundaries exist. Out-of-scope bullet 4 + ("Real OpenAPI/TypeSpec codegen toolchain") is stale: `npm run gen:api` generates + `libs/shared/src/infrastructure/api-client.ts` (2372 lines, NSwag) and CI drift-checks + it. +- **Baseline citation:** baseline §2 (`Program.cs` 940 lines), §7 pattern inventory + (`parse*` trust boundaries = 30; excluded-as-generated api-client 2372 lines), BL-003. +- **Proposed resolution:** rewrite §"Worked example" against the shipped system and prune + the discharged out-of-scope bullets. No code changes. +- **What the amended ADR should say:** the worked example should read as _"implemented + against `backend/src/BigRegister.Api`"_, cite + `apps/ssp/src/app/registratie/{contracts/dashboard-view.dto.ts,infrastructure/dashboard-view.adapter.ts}` + and `GET /api/v1/dashboard-view` / `GET /api/v1/intake/policy` as the endpoints, and + reduce §"Out of scope" to the two items still genuinely open (the `BigProfileStore` + optimistic-update race, and session persistence / multi-tab sync). +- **Blocked code tickets:** none. This is purely descriptive drift; the decision is intact. +- **Effort: S** + +### ADR-C-002 — `libs/shared/src/upload/` does network outside `infrastructure/` + +- **Type: code-violates-ADR** +- **ADR cited:** ADR-0001, §Decision — the DTO/adapter seam; operationalised in CLAUDE.md + §4 as _"`infrastructure/` is the **only** layer that touches the network — the + anti-corruption boundary"_. +- **Evidence (verified):** `libs/shared/src/upload/upload.adapter.ts` injects `ApiClient` + (line 3 import, line 56 `inject(ApiClient)`) and opens a raw `XMLHttpRequest` at line + 118 — i.e. it is a genuine network adapter — yet sits in a top-level `upload/` folder, + not under `libs/shared/src/infrastructure/`. Its sibling `upload.machine.ts` is an + Elm-style reducer sitting outside any `domain/` folder (the only one of 9 machines to do + so). The exception is **hard-coded into the enforcement itself**: + `.dependency-cruiser.base.js:105` reads + `from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' }` — the rule is written + around the violation rather than the violation being fixed, which is why baseline §6 + reports 0 violations despite this. +- **Baseline citation:** BL-010 (verbatim: the exception "is already encoded rather than + resolved"); baseline §7 FE pattern inventory ("Infrastructure adapters (read side): 20 + **+1 outside an `infrastructure/` folder**"; "Elm-style machines: 9 — 8 under a `domain/` + folder; outlier `libs/shared/src/upload/upload.machine.ts`"). +- **Proposed resolution:** move `upload.adapter.ts` → `libs/shared/src/infrastructure/`, + `upload.machine.ts` (+ its spec) → `libs/shared/src/domain/`, and + `upload-controller.ts`/`upload-shell.service.ts` → `libs/shared/src/application/`. Then + **delete the `^libs/shared/src/upload/` carve-out** from + `.dependency-cruiser.base.js:105` — that deletion is the acceptance criterion, since it + is what proves the exception is resolved rather than relocated. Note the side benefit: + `libs/shared/src/domain` currently has 0% spec reach across 3 files (baseline §3b), and + this moves a well-specced machine into it. +- **Effort: M** (mechanical move + import updates across 30 dependents — `libs/shared/upload` + has Ca 30, baseline §6 — plus the depcruise rule edit) + +### ADR-C-003 — `contracts/` vs the generated client: ADR-0001 and CLAUDE.md §4 no longer agree with the code + +- **Type: ADR-fix** +- **ADR cited:** ADR-0001, §"Why DTOs _decouple_ rather than couple" — _"Manage it with + **one source of truth** (OpenAPI or TypeSpec) that **generates types for both sides**"_ — + read against CLAUDE.md §4's flat rule _"DTO lives in `contracts/`"_. +- **Evidence (verified):** only 4 hand-written `contracts/` DTO files exist + (`apps/ssp/src/app/registratie/contracts/{brp-address,dashboard-view,duo-diplomas}.dto.ts`, + `libs/beheer/src/contracts/stamdata.dto.ts`), against 20 infrastructure adapters, 19 of + which import types from `@shared/infrastructure/api-client` directly. Crucially, the + surviving hand-written contract **documents its own obsolescence**: + `dashboard-view.dto.ts:10-11` says _"In production these types are GENERATED from the + OpenAPI/TypeSpec spec (one source of truth for both sides)"_. So the code has reached + ADR-0001's stated target state, and the hand-written `contracts/` files are the + pre-codegen scaffolding — but CLAUDE.md §4 still states the pre-codegen rule as the + standing law, which will mislead the next feature author. +- **Baseline citation:** baseline §7 FE pattern inventory — _"`contracts/` DTO files: 4 — + most adapters consume NSwag-generated types directly instead"_; agent-brief note "Only 4 + `contracts/` DTO files exist". +- **Proposed resolution:** amend ADR-0001, then correct CLAUDE.md §4 to match (CLAUDE.md's + own precedence rule requires this direction). +- **What the amended ADR should say:** add a short §"Where the contract lives, after + codegen": the generated client (`libs/shared/src/infrastructure/api-client.ts`, + regenerated by `npm run gen:api`, drift-checked in CI) **is** the wire contract and the + single source of truth; a hand-written `contracts/*.dto.ts` is warranted only where + codegen does not reach — a hand-rolled `fetch`/XHR endpoint, or a shape the generator + types too loosely — and in either case the hand-written file must still import nothing. + The `parse*` trust boundary in `infrastructure/` is **unchanged and still mandatory** + regardless of where the type came from: a generated type is a compile-time claim about + the wire, not a runtime guarantee. Then decide explicitly whether the 4 survivors stay + (they are more precise than the generated shapes) or are retired — and record which. +- **Blocked code tickets:** any ticket that would either (a) delete the 4 remaining + `contracts/` files or (b) add new hand-written DTOs for already-generated endpoints must + wait for this amendment; today CLAUDE.md §4 can be cited to justify both directions. +- **Effort: S** (ADR + CLAUDE.md edit; the follow-on code decision is separately sized) + +--- + +## ADR-0002 — User groups as actors, not bounded contexts (**Proposed**, amended WP-67) + +This is the ADR with the most divergence, and the one the baseline pointed me at. Three +findings. The core modelling decision — contexts drawn by capability, not by who logs in — +**is** conformed to: there is no `zorgverlener/` or `behandelaar/` folder anywhere, the +contexts are `registratie`/`herregistratie`/`brief`/`behandeling`/`beheer` (capability +names), and both apps integrate through one backend aggregate via decision DTOs. + +### ADR-C-004 — the `Principal` union never landed, although actor #2 did + +- **Type: code-violates-ADR** +- **ADR cited:** ADR-0002 §3 ("Separate identity from authorization") — _"Model the + principal as a **discriminated union** … This replaces the flat `Session` the day a second + actor arrives"_; §Consequences — _"The one concrete FE change when actor #2 lands is + `Session → Principal` in the `auth` context"_; §"Out of scope" — deferred _"until a second + actor is actually introduced"_. Actor #2 was introduced in WP-61 and consolidated in + WP-67. **The deferral condition has been met and the change was not made.** +- **Evidence (verified):** + - `grep -rn "Principal" apps libs --include=*.ts` returns exactly **one** hit, and it is + a comment: `libs/shared/src/infrastructure/role.ts:8`. The type does not exist in the + frontend. + - `apps/behandelportal/src/app/auth/domain/session.ts` is byte-identical to the SSP's and + still reads `interface Session { readonly bsn: string; readonly naam: string }` — a + Behandelaar carrying a BSN, which §3 names as the precise thing the union exists to make + unrepresentable. + - The backoffice login is literally the citizen login. `apps/behandelportal/src/app/auth/ui/login.page.ts:31` + is `async login(bsn: string)`, renders `intro="Log in op uw persoonlijke BIG-register +omgeving."`, and calls `SessionStore.login(bsn)` → + `auth/infrastructure/digid.adapter.ts:14`, which returns + `ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' })`. A behandelaar logs into the + backoffice as a zorgverlener, by DigiD, with a citizen's name in the header. + - The medewerker identity that _does_ exist bypasses the auth model entirely: + `apps/behandelportal/src/app/auth/infrastructure/medewerker.interceptor.ts:14-19` stamps + `X-Medewerker`/`X-Rollen` onto every `/api/v1/` request. It never touches `Session`. +- **Baseline citation:** BL-002 (`ssp/auth` 211/211 significant lines duplicated; + `session.store.ts` 39 windows, `login-form.component.ts` 35, `login.page.ts` 23) — the + 100% figure is the _measurement_ of this finding: the files are identical **because** the + modelling change that would differentiate them was skipped. +- **Proposed resolution:** land `Session → Principal` as ADR-0002 §3 specifies. In + `apps/behandelportal`: replace `Session` with the `medewerker` variant, replace + `DigidAdapter` with a `MedewerkerAdapter` that resolves `MEDEWERKER_ID` + `currentRollen()` + (the values `medewerker.ts` already holds) into a `Principal`, and make `login.page.ts` + an SSO-stand-in entry rather than a BSN form. In `apps/ssp`: the `zorgverlener` variant. + This makes the two `auth` contexts genuinely differ — which is what ADR-0002 §3 asserted + would happen and is the honest resolution of BL-002. +- **Effort: M** + +### ADR-C-005 — ADR-0002 is still `Proposed` after two apps shipped against it + +- **Type: ADR-fix** +- **ADR cited:** ADR-0002 header, `Status: Proposed · Date: 2026-07-01`. +- **Evidence (verified):** `apps/behandelportal` exists with 29 source files and 3 contexts + (baseline §2); the ADR has been amended once in-document (§"Amendment (WP-67, + 2026-08-01)"); its structural rulings are enforced in CI today — + `.dependency-cruiser..js`'s `-no-other-app` and `shared-no-beheer` rules run at + `severity: error` with 0 violations. An architectural decision that CI enforces is not + "Proposed". The other five ADRs are all `Accepted`, so this is an inconsistency in the + ADR set itself, not a deliberate signal. +- **Baseline citation:** baseline §7 "ADRs on record" — _"`0002` … (**Proposed**, amended + WP-67)"_; baseline §6 (11 `severity: error` rules, 0 violations, 223 modules cruised); + baseline §2 (apps/behandelportal: 29 src files, 1 309 lines). +- **Proposed resolution:** promote to `Accepted`, dated to WP-67. +- **What the amended ADR should say:** `Status: Accepted · Date: 2026-07-01 · Amended +2026-08-01 (WP-67)`. Also update §"Out of scope here", which still lists _"Building the + Behandeling backoffice application"_ and _"The backend aanvraag status lifecycle + + authorization endpoints/DTOs"_ as unbuilt — both shipped (WP-61…67; `AanvraagStatusTag`, + `GET /me` capabilities, `Domain/Authz.cs`). The one bullet that stays is real + authentication. Leave the `Session → Principal` bullet in scope but re-word it from + "deferred until a second actor is introduced" to a stated debt — it is ADR-C-004. +- **Blocked code tickets:** none strictly, but agent 08 should surface this **before** + ADR-C-004, because ADR-C-004's justification is "the ADR says to do this" and a `Proposed` + ADR is weak grounds for a refactor ticket. +- **Effort: S** + +### ADR-C-006 — extract the actor-agnostic route guards to `libs/shared` (the part of BL-002 that will never diverge) + +- **Type: code-violates-ADR** — but note carefully: it violates CLAUDE.md §2's + "composition over duplication" and the DRY intent, **not** ADR-0002 §3. ADR-0002 §3 is + about _identity and login flow_. A route guard is neither. +- **ADR cited:** ADR-0002 §3 — the scope of the non-sharing decision is `Principal` and the + login flow ("the two groups **authenticate differently**"); §Consequences names + `auth.guard.ts` and `session.store.ts` only as the _seams that localise_ the change, not + as things that must be duplicated. Read with CLAUDE.md §1's rule that a genuinely + cross-app concern belongs in `libs/shared`. +- **Evidence (verified):** `diff -ru apps/ssp/src/app/auth apps/behandelportal/src/app/auth` + reports **no content differences at all** — 9 of 11 files are byte-identical; the only + delta is two _additional_ files in behandelportal (`medewerker.ts`, + `medewerker.interceptor.ts`). Within those 9, `auth.guard.ts` is entirely actor-agnostic: + `authGuard` (lines 8-12) reads only `SessionStore.isAuthenticated()` and `Router`; + `capabilityGuard` (lines 25-33) adds only `AccessStore.can(capability)` and + `whenReady()` — and `AccessStore` already lives in `libs/shared/src/application`. + Both apps' routes redirect to the same `/login` and `/dashboard` paths + (`apps/behandelportal/src/app/app.routes.ts:10,17`). Both app configs already register + `{ provide: SESSION_PORT, useExisting: SessionStore }` + (`apps/behandelportal/src/app/app.config.ts:65`), so the seam for a shared guard exists + today. +- **Baseline citation:** BL-002 top clone pairs — `auth.guard.spec.ts` **36 windows** and + `auth.guard.ts` **21 windows**, i.e. 57 of the 211 duplicated lines, the single largest + block after `session.store.ts`. +- **Proposed resolution:** move `authGuard`/`capabilityGuard` + `auth.guard.spec.ts` to + `libs/shared/src/application/` (or a `libs/shared/src/routing/`), injecting `SESSION_PORT` + instead of the app-local `SessionStore`. One small widening is needed: + `libs/shared/src/application/session.port.ts:9-12` currently exposes only + `session: Signal<{naam: string} | null>` and `logout()` — add + `readonly isAuthenticated: Signal` (or have the guard derive it from + `session() !== null`, which both `SessionStore`s already do at + `session.store.ts:40`). Each app keeps a two-line re-export at `@auth/auth.guard` so its + `app.routes.ts` is untouched. +- **Explicitly NOT proposed, and why.** No ticket to merge `session.store.ts`, + `session.ts`, `digid.adapter.ts`, `login-form.component.ts` or `login.page.ts`, and **no + ADR-fix relaxing ADR-0002 §3.** The brief invited me to treat BL-002 as a prediction the + code failed to bear out. It is not. Those five files are identical because ADR-C-004 was + never executed — the divergence the ADR predicted has in fact already arrived, it just + arrived through an orthogonal side door (`medewerkerInterceptor`, a dev-only HTTP header + stamp) instead of through the `Principal` seam the ADR designated. Merging them now would + cement a citizen DigiD/BSN login as the backoffice's shared login, which is the one + outcome ADR-0002 §3 was written to prevent. The correct sequencing is ADR-C-005 (accept + the ADR) → ADR-C-004 (land `Principal`) → **re-measure**. My expectation is that + post-ADR-C-004 the residual `ssp/auth` ↔ `bhp/auth` duplication drops from 211 lines to + under 40 on its own. If ADR-C-004 is still unstarted at the **next backlog cycle**, that + is the point at which the ADR-fix conversation becomes legitimate — not now. +- **Effort: S** + +### Observation for agent 07 (BIO2), not a ticket here + +`medewerkerInterceptor` is registered **only** inside `isDevMode()` +(`apps/behandelportal/src/app/app.config.ts:57-63`). In a production build the backoffice +therefore sends no `X-Medewerker`/`X-Rollen` at all, and `StubIdentityProvider` falls +through to its zorgverlener default. ADR-0002 §3's "authorization enforced at the backend +boundary" holds structurally, but the behandelportal's _identity_ has no non-dev path. The +ADR lists real employee SSO as out of scope, so this is not an ADR conformance defect — +flagging it because it is the kind of thing a compliance pass should see stated, and +because ADR-C-004 is the natural place to close it. + +--- + +## ADR-0003 — CIBG Huisstijl (Bootstrap 5.2) as the design system (Accepted) + +**Core decision: conformed.** All five decision points hold. The package is vendored at +`public/cibg-huisstijl/` (`css`, `fonts`, `icons`, `images` present, licensed RO/Rijks text +fonts absent per point 5); the token bridge is intact at `libs/shared/styles.scss`; atoms +emit Bootstrap classes with their `input()` APIs preserved; `check:tokens` +(`scripts/check-tokens.sh`) runs in `npm run ci`; the gap register exists at +`libs/shared/docs/cibg-gaps.mdx` with 9 `// CIBG-GAP EXTENSION:` markers in code. + +### ADR-C-007 — ADR-0003's file paths and its `app-alert` example are both stale + +- **Type: ADR-fix** +- **ADR cited:** ADR-0003 §Decision point 1 (`src/index.html`), point 2 (`src/styles.scss`), + point 4 (_"CIBG omits Bootstrap's `.alert` and `.navbar`, so `app-alert` is a small + token-styled surface"_), §Consequences (`.storybook/`, `src/docs/cibg-gaps.mdx`). +- **Evidence (verified):** + - Every path moved in WP-67: `src/styles.scss` → `libs/shared/styles.scss`; + `src/index.html` → `apps/ssp/src/index.html` **and** `apps/behandelportal/src/index.html` + (two now, not one); `.storybook/` → `.storybook-ssp/` and `.storybook-behandelportal/`; + `src/docs/cibg-gaps.mdx` → `libs/shared/docs/cibg-gaps.mdx`. + - Point 4's `app-alert` claim is **factually wrong about the current code**. + `libs/shared/src/ui/alert/alert.component.ts:13-16` documents itself as a _"Thin wrapper + over the vendored `.feedback feedback-*` classes: the design system owns surface + icon"_, + and the template (lines 31-37) binds `.feedback-info`/`.feedback-success`/ + `.feedback-warning`/`.feedback-error`. Its only local CSS is a 3-line flex fix. It is not + hand-rolled and carries no `CIBG-GAP` marker — correctly, since it is not a gap. + - **CLAUDE.md §2 repeats the same stale claim** verbatim: _"(Where CIBG lacks a class — + e.g. `alert` — the atom is a small hand-rolled surface built from the token bridge; see + ADR-0003.)"_ Both documents must be corrected, ADR first. +- **Baseline citation:** baseline §2 (the monorepo layout the paths must be rewritten + against: apps/ssp, apps/behandelportal, libs/shared, libs/beheer); baseline §7 config-seam + tokens (`HEADER_NAV_ITEMS`/`HEADER_ADMIN_LINKS`/`DEBUG_PANEL`) — the WP-67 two-app split + these paths belong to. +- **Proposed resolution:** repoint all five paths; replace the `.alert` example in point 4. +- **What the amended ADR should say:** point 4 should keep the principle ("hand-roll what + CIBG's build drops, and mark it") but pick a live example — `skeleton` and `spinner` are + the cleanest (both in the register, both genuinely absent from the vendored build) — and + note that `.alert` was subsequently resolved onto the vendored `.feedback` classes, so + it is no longer a gap. CLAUDE.md §2's parenthetical must be corrected in the same diff. +- **Blocked code tickets:** none. +- **Effort: S** + +### ADR-C-008 — the CIBG gap register is one row behind the markers in code + +- **Type: code-violates-ADR** (the artifact the ADR mandates is incomplete; the fix is a + one-row doc edit, not a code change) +- **ADR cited:** ADR-0003 §Consequences, final bullet — _"Hand-rolled components (point 4) + are tracked in the **CIBG gap register** … every deviation from the design system carries + a `// CIBG-GAP EXTENSION:` marker so it's auditable rather than silently drifting."_ +- **Evidence (verified):** 9 files carry a `CIBG-GAP` marker + (`debug-state`, `language-switcher`, `wizard-shell`, `application-link`, + `placeholder-chip`, `rich-text-editor`, `skeleton`, `spinner`, `status-badge`); the + register table at `libs/shared/docs/cibg-gaps.mdx:26-33` has **8** rows. + `libs/shared/src/layout/language-switcher/language-switcher.component.ts:7-9` carries a + full, well-formed marker (`"Taal instellen" … no vendored Huisstijl class ships for it … +See cibg-gaps.mdx`) and has no corresponding row. The register's own §"Keeping this + register honest" concedes there is no automated diff and instructs _"If markers and this + table drift, trust the code and fix the table"_ — so this is exactly the drift it + predicted, caught by review as designed. +- **Baseline citation:** baseline §2 (libs/shared 86 src files / 5 194 lines — the surface + the register must cover); baseline §6 (`libs/shared/src/layout` is a cruised module with + Ca 22). +- **Proposed resolution:** add the `language-switcher` row (CIBG concept: "Taal instellen"; + reason: no vendored class in this build). Optionally add the CI script the register + declines — a ~10-line `grep -l CIBG-GAP | diff` in `scripts/` folded into `check:tokens` + would make the drift impossible to reintroduce. I would file the row as the ticket and + the script as an explicitly optional second step, matching the register's own + proportionality argument. +- **Effort: S** + +--- + +## ADR-0004 — Stamdata as code (Accepted) + +**Core decision: conformed.** `backend/src/BigRegister.Api/Stamdata/` holds 15 files — +typed C# (`Beroep.cs`, `PolicyQuestions.cs`, `Professions.cs`, `StamdataCatalog.cs`, +`StamdataTable.cs`, `StamdataFile.cs`) plus the checked-in JSON data-files +(`beroepen.json`, `documentconfidentialiteit.json`, `opleidingen.json`, +`professions.json`, `specialismen.json`) the WP-29 follow-on introduced. Coverage is 96.8% +line (baseline §3c). The `beheer/stamdata` editor is read-only-plus-download, not a write +path, exactly as the ADR's own WP-29 note states. There is **no** runtime write endpoint +for any stamdata table. + +### ADR-C-009 — feature flags are a second runtime-editable exception the ADR does not acknowledge + +- **Type: ADR-fix** +- **ADR cited:** ADR-0004 §Decision — _"Never a production database, never runtime-editable"_ + — and §"The deliberate exception: org-templates", which names **one** exception in the + singular and justifies it narrowly ("specific to one sub-organization's identity"). +- **Evidence (verified):** `backend/src/BigRegister.Api/Data/FeatureFlagStore.cs` is a + second admin-writable SQLite surface, added by WP-47 — after ADR-0004 (2026-07-20) — and + **its own doc-comment states the equivalence the ADR does not**: _"Runtime feature-flag + state (WP-47). SQLite-backed like `OrgTemplateStore`, same single-gate idiom."_ It exposes + `Set(key, enabled)` writing `db.FeatureFlags`, surfaced through the admin page + `/beheer/functies` (`apps/behandelportal/src/app/app.routes.ts:44-50`, gated by + `capabilityGuard('flags:manage')`). +- **Assessment — and why this is an ADR-fix, not a violation.** The design is genuinely + ADR-0004-shaped, not a breach of it: the _catalog_ (which flags exist, their descriptions + and defaults) is compiled-in code (`Domain/Features/FeatureFlags.Catalog`), only the + boolean override persists, and `IsEnabled` fails closed for an unknown key + (`FeatureFlagStore.cs:45-47`), so a bad DB row cannot invent a flag. That is the ADR's + actual principle — schema and values gated at compile time — applied correctly. What is + wrong is the ADR's _text_: it states the rule as a closed list of one exception, which + means the next operational-config surface has no principle to test itself against and + will either be waved through or blocked on a technicality. +- **Baseline citation:** baseline §7 backend pattern inventory — the 7 static stores listed + as "Not behind any port", which includes both `OrgTemplateStore` **and** + `FeatureFlagStore`; baseline §3c (`backend/Stamdata` 96.8% line / 71.7% branch; + `backend/Data` 99.0% / 75.5%). +- **Proposed resolution:** amend ADR-0004. No code change; the code is right. +- **What the amended ADR should say:** replace §"The deliberate exception: org-templates" + with §"The deliberate exception: operational configuration", stating the **test** rather + than a list — runtime-editable persistence is permitted only when (1) the catalog/schema + of what may be set lives in code, (2) an unknown or unlisted key is rejected/fails closed, + (3) the value is operational (per-organisation identity, an on/off rollout switch) and not + a shared business rule whose wrong value breaks the register for everyone, and (4) writes + are admin-capability-gated and audited. Then list the two surfaces that pass it today — + `OrgTemplateStore` (WP-23/26) and `FeatureFlagStore` (WP-47) — and note that both are + admin-gated. Add a matching sentence to CLAUDE.md §4, which currently repeats the + singular framing ("Org-templates are the deliberate exception"). +- **Blocked code tickets:** any ticket proposing a third runtime-editable config surface + should wait for this test to be written down, rather than arguing by analogy to + org-templates. +- **Effort: S** + +--- + +## ADR-0005 — OpenZaak (ZGW APIs) behind the BFF (Accepted) + +**Conformed, with no findings.** This is the cleanest ADR in the set and I am recording +that plainly rather than manufacturing a ticket. + +Verified point by point: + +- The anti-corruption layer is in the .NET BFF and nowhere else. Every ZGW type lives under + `backend/src/BigRegister.Api/Zgw/` (8 files: `OpenZaakZaakSource`, + `OpenZaakDocumentSource`, `ZgwHttpClient`, `ZgwTokenProvider`, `ZgwZaakMapper`, + `ZgwOptions`, `ZgwDiagnosticHandler`, `NotificatieDto`). `grep` for + `OpenZaakZaakSource|OpenZaakDocumentSource` outside `Zgw/` returns only the two DI + registrations in `Program.cs:67,69` and eight explanatory comments — no ZGW shape reaches + a consumer. +- The config-switched port pair is exactly as decided: `Program.cs:59-84` reads + `Zgw:Enabled` and binds either `OpenZaakZaakSource`/`OpenZaakDocumentSource` (via + `AddHttpClient`, with the WP-60 15s timeouts) or `LocalZaakSource`/`LocalDocumentSource`. + Default is local, so the POC still runs fully offline as the ADR's second constraint + requires. +- The FE is untouched by the switch: consumers inject the interface only + (`Program.cs:206,281,340,425,434,446,473`), and the same `ApplicationSummaryDto` is + returned either way — zero DTO/api-client drift, matching the ADR's first `+`. +- The ADR's stated **minus** is still accurate and honestly scoped: `Program.cs:465` + confirms _"The local write runs against ApplicationStore directly (not the IZaakSource + seam)"_, which is precisely the "only some endpoints have a source interface; each future + slice introduces its own seam" consequence the ADR wrote down. An ADR that predicted its + own remaining gap and the gap stayed where predicted is conformance, not drift. +- `backend/Zgw` is the best-covered backend module at 98.1% line / 85.5% branch (baseline + §3c) and the second-lowest duplication (1.8%, baseline §5) — the seam the ADR claimed + would be "unit-testable without a live server" measurably is. + +Note for agent 03: BL-006 (no NetArchTest/ArchUnitNET, one assembly) means nothing +_enforces_ the "ZGW shapes never leave `Zgw/`" property that this ADR depends on. It holds +today by convention. That is agent 03's ticket to size, not mine — I record only that +ADR-0005's conformance is currently review-maintained, not CI-maintained. + +--- + +## ADR-0006 — Test data through the production door (Accepted) + +**Core decision: partially conformed.** Three of the five mechanisms are properly in place; +the frontend replay idiom — the ADR's own flagship — is adopted in one spec out of nine. + +Conformed: + +- §1 backend type-state builder — `backend/tests/BigRegister.Tests/Builders/AanvraagBuilder.cs` + exists and is used in 5 test files (`Acceptance/BesluitLifecycleTests.cs`, + `Acceptance/IntakeSubmissionTests.cs`, `ApplicationTests.cs`, + `Domain/BeoordelingRuleTests.cs`, `OpenZaakZaakSourceTests.cs`) — the exact two files the + ADR's Context named as the problem, plus three more. +- §4 `RemoteData` named constructors — `libs/shared/src/testing/remote-data.ts` exists. +- §5 e2e actors/seed-refs — `e2e/support/actors.ts` exists; no page-object layer was added. +- No `'x' as BrandedType` cast appears in any spec (`grep` for + `as Postcode|as Uren|as BigNummer|as Bsn` in `*.spec.ts`: zero hits) — the §3 illegal + route is genuinely closed. + +### ADR-C-010 — four machine specs hand-roll the exact state literal ADR-0006 §2 forbids + +- **Type: code-violates-ADR** +- **ADR cited:** ADR-0006 §2 (_"No object is built directly. A fixture is the result of + running real `Msg`s through the real `reduce`"_) and the Decision table's "Pure reducer / + state machine (frontend)" row: **Do not** — _"A literal returning `{ tag: 'Editing', ... }` + by hand"_; plus §Consequences (_"a hardcoded `errors: {}` fixture literal can't drift from + what validation actually produces"_). +- **Evidence (verified) — each of these returns a state literal, and three of the four + hardcode the `errors: {}` the ADR calls out by name:** + - `apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts:19-25` — + `const answering = (...): IntakeState => ({ tag: 'Answering', answers, cursor, errors: {}, scholingThreshold })`. + This is the worst instance: `apps/ssp/src/app/herregistratie/domain/intake.testing.ts` + exists **in the same folder**, exports `givenIntake`, and is the ADR's own quoted example + (§2's code block is `export const givenIntake = given(reduce, initial)`) — yet + `givenIntake` is imported only by `intake.acceptance.spec.ts`, never by the machine spec + it was written for. + - `apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts:25-31` — + `const invullen = (...): RegistratieState => ({ tag: 'Invullen', draft: {...}, cursor, errors: {}, upload: initialUpload })`. + - `apps/behandelportal/src/app/behandeling/domain/besluit.machine.spec.ts:5-9` — + `const editingWith = (...): BesluitState => ({ tag: 'Editing', draft: {...}, errors: {} })`. + - `apps/ssp/src/app/brief/domain/brief.machine.spec.ts:67-74` — + `const loaded = (...): BriefState => ({ tag: 'loaded', brief, availablePassages, decisions })`. + - For contrast, two specs **do** conform and should be the pattern to copy: + `libs/beheer/src/domain/stamdata-editor.machine.spec.ts:17-23` (`seedLoaded()` = + `reduce(initial, {tag:'Loaded', …})`) and + `apps/ssp/src/app/brief/domain/org-template.machine.spec.ts:29-30` + (`reduce({tag:'loading'}, {tag:'DraftLoaded', …})`). Only 1 of 9 machines has a + `*.testing.ts`; `change-request.machine.spec.ts:6` honours the idiom but declares + `given(reduce, initial)` inline in the spec rather than in a `*.testing.ts`. +- **Baseline citation:** baseline §7 FE pattern inventory (Elm-style machines: 9); + baseline §3a — `ssp/herregistratie` 70.9% line / 67.8% branch and `ssp/brief` 75.3% / + 68.8%, i.e. the two modules whose specs hand-roll states are also two of the three + weakest-covered non-auth FE modules, which is consistent with fixtures asserting shapes + the reducer may not actually produce. +- **Proposed resolution:** add `*.testing.ts` next to each of the four machines + (`registratie-wizard`, `brief`, `besluit`, plus adopt the existing `intake.testing.ts`), + each a one-liner `export const givenX = given(reduce, initial)` per §2, and rewrite the + four literal helpers as message replays. Where a state genuinely is not reachable by + replay, that is a finding in its own right and should be recorded in the ticket rather + than worked around — it means the reducer cannot produce a state the spec asserts. +- **Effort: M** (four specs; mechanical but each needs the right message sequence worked out, + and `brief.machine.spec.ts` is the largest) + +### ADR-C-011 — `unwrapOk` has zero adopters; its one call site reimplements it inline + +- **Type: code-violates-ADR** +- **ADR cited:** ADR-0006 §3 (_"Value objects → `unwrapOk`, never a cast"_) and the Decision + table's "Value object / parser" row. +- **Evidence (verified):** `unwrapOk` is defined in + `libs/shared/src/testing/value-object.ts` and referenced nowhere else in `apps/` or + `libs/` except `libs/shared/docs/testing.mdx` — zero spec consumers. The one place that + needs it hand-rolls the same three lines: + `apps/ssp/src/app/registratie/application/submit-change-request.spec.ts:8-9` reads + `const telefoon = parseTelefoonnummer('0612345678'); if (!telefoon.ok) throw new Error('fixture phone should parse');` + — semantically identical to `unwrapOk(parseTelefoonnummer('0612345678'))`, which is + exactly the duplication the ADR shipped the helper to remove. +- **Baseline citation:** BL-004 (122 of 220 FE source files never loaded by any Vitest run + — `libs/shared/src/testing` is one of the few modules at 100% reach, so a helper here that + nothing imports is measurably dead weight, not merely unexercised); baseline §3a + `libs/shared/testing` 3 files, 100% line. +- **Proposed resolution:** one-line change in `submit-change-request.spec.ts` to call + `unwrapOk`. Then judge honestly whether one call site justifies keeping the helper — if a + future ticket finds it still has one consumer, deleting it and keeping the inline guard is + the equally valid answer, and ADR-0006 §3's real requirement (never a cast) is satisfied + either way. File it as "adopt or delete", not "adopt". +- **Effort: S** + +--- + +## ADR-fix tickets (require architect approval) + +Agent 08 must surface these five for human sign-off. None of them are code changes; all +five change what the repo's architecture documents _claim_, and two of them gate code +tickets. + +| ID | ADR | What the amendment does | Gates | Effort | +| ------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ------ | +| **ADR-C-001** | 0001 | Rewrite the worked example against the real backend; drop the two discharged out-of-scope bullets | nothing | S | +| **ADR-C-003** | 0001 | State that the generated client **is** the wire contract post-codegen; scope `contracts/` to codegen gaps; correct CLAUDE.md §4 | any ticket deleting the 4 surviving `contracts/*.dto.ts`, or adding a hand-written DTO for a generated endpoint | S | +| **ADR-C-005** | 0002 | `Proposed` → `Accepted`; refresh §"Out of scope" (backoffice + status lifecycle + authz DTOs all shipped) | **ADR-C-004** — a `Proposed` ADR is weak grounds for a refactor ticket, so this must land first | S | +| **ADR-C-007** | 0003 | Repoint five WP-67-stale paths; replace the false `app-alert` hand-rolled example; correct CLAUDE.md §2 | nothing | S | +| **ADR-C-009** | 0004 | Generalise "the org-templates exception" into a stated four-part test; list both passing surfaces; correct CLAUDE.md §4 | any future third runtime-editable config surface | S | + +**Ordering dependency, called out explicitly as the brief requires:** ADR-C-005 → ADR-C-004 +→ (re-measure BL-002) → possibly-nothing. ADR-C-006 is deliberately **not** in this chain: +extracting the actor-agnostic route guards is compatible with ADR-0002 §3 as written and +needs no approval. **No ADR-fix is proposed against ADR-0002 §3's non-sharing rule.** I +considered it, as instructed, and rejected it: the rule's prediction has not been falsified, +it has not been tested, because the change that would test it (ADR-C-004) was never made. +Amending an ADR to match code that never executed its decision would ratify the omission +rather than the evidence. + +--- + +## Summary + +| ADR | Verdict | Findings | +| -------- | --------------------------------------------------- | --------------------------------------- | +| **0001** | Decision conformed; prose stale | ADR-C-001 (fix), 002 (code), 003 (fix) | +| **0002** | Decision conformed; §3 unexecuted; still `Proposed` | ADR-C-004 (code), 005 (fix), 006 (code) | +| **0003** | Conformed; two documentation defects | ADR-C-007 (fix), 008 (code/doc) | +| **0004** | Conformed; exception clause under-general | ADR-C-009 (fix) | +| **0005** | **Fully conformed — no findings** | — | +| **0006** | Partially conformed; §2 adopted 1 of 9 | ADR-C-010 (code), 011 (code) | + +Six code tickets (1×M, 1×M, 1×M, 3×S) and five ADR-fixes (all S). No finding proposes +introducing a pattern that does not already exist in the repo, and every one cites a +baseline observation or metric row. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/07-bio2-compliance.md b/docs/project/refactor-backlog-setup/refactor-backlog/07-bio2-compliance.md new file mode 100644 index 0000000..708af24 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/07-bio2-compliance.md @@ -0,0 +1,1128 @@ +## Scope: apps/ssp (auth, registratie, herregistratie, brief, showcase+shell+root), apps/behandelportal (auth, behandeling, shell+root), libs/shared (per layer), libs/beheer, backend (Program.cs, Domain, Data, Zgw, Contracts, Stamdata) — controls 5.12, 5.13, 8.15, 8.16, 8.24, 8.25, 8.26, 8.28, 8.29, 8.32, 9.1, 9.2, 9.4 + +## Status: complete + +## Last updated: 2026-08-27 + +## Depends on: 00-baseline.md, 02-testability.md, 04-cqrs-light.md, 06-adr-conformance.md + +## --- + +# 07 — BIO2 / ISO 27002:2022 compliance + +**Scope, in full.** Frontend: `ssp/auth`, `ssp/registratie`, `ssp/herregistratie`, `ssp/brief`, +`ssp/showcase+shell+root`, `bhp/auth`, `bhp/behandeling`, `bhp/shell+root`, `libs/shared` +(domain, application, infrastructure, ui, layout, kernel, upload, testing, environments), +`libs/beheer`. Backend: `Program.cs`, `Domain`, `Data`, `Zgw`, `Contracts`, `Stamdata`. +Controls: 5.12, 5.13, 8.15, 8.16, 8.24, 8.25, 8.26, 8.28, 8.29, 8.32, 9.1, 9.2, 9.4. + +--- + +## 0. The control-set assumption, stated for the record + +**No explicit control list was supplied to this agent.** The seven areas below were selected +for privacy and security relevance to a BIG-register portal handling BSN, diploma and +health-professional registration data: + +| # | Area | ISO 27002:2022 / BIO2 | +| --- | ------------------------------ | --------------------- | +| 1 | Access control | 9.1, 9.2, 9.4 | +| 2 | Logging & monitoring | 8.15, 8.16 | +| 3 | Data classification & handling | 5.12, 5.13 | +| 4 | Cryptography | 8.24 | +| 5 | Secure development | 8.25, 8.28, 8.29 | +| 6 | Change control | 8.32 | +| 7 | Input validation | 8.26 | + +**Flag if a different set should apply.** Three plausible narrowings/widenings a reviewer +should decide on before this file is treated as authoritative: + +- **BIO 2.0 thema-uitwerkingen** rather than raw ISO 27002 — a Dutch government system would + normally be assessed against the BIO's own thematic elaborations (toegangsbeveiliging, + logging & monitoring), which are stricter on logging retention and on the "verwerking van + bijzondere persoonsgegevens" than the bare ISO controls used here. Nothing below would be + withdrawn under that set; several items would rise in severity. +- **NEN 7510** (Dutch healthcare information security) is arguably the governing standard for + a register of healthcare professionals. Not applied here. +- **AVG/GDPR obligations proper** (art. 5 minimisation, art. 9 special-category, art. 30 + register of processing, art. 32 measures) are referenced only where the code itself invokes + them. A DPIA is out of scope for this pass and is listed in the pre-production checklist. + +**Framing, per the brief.** This POC has deliberately faked authentication; CLAUDE.md's +"Out of scope" excludes real auth/DigiD and PRD-0002 §3 excludes real AD/OIDC/SAML. **No +finding below asks for real DigiD or employee SSO.** Findings are split: + +- **Defect now** — wrong even for a POC. Typically: PII reaching a store or a log that the + code's own contract says holds none, or a missing authorization check that has nothing to + do with the identity stub. +- **Production gate** — correct for a POC, must be true before production. These are the + pre-production checklist at the end. + +Nine of the twenty findings are **defect now**. Where a dev-only affordance is genuinely +stripped from a production build, that is said plainly and no finding is filed +(see §1.4 and the module notes). + +--- + +## 1. Control area: access control (9.1, 9.2, 9.4) + +### BIO-001 — the backend trusts client-asserted identity headers in every environment + +- **Control:** 9.2 (user access provisioning), 9.4 (least privilege / secure log-on) +- **Class:** **production gate** +- **Severity: high** — a single request header grants the full admin capability set. It is + high not because it is unknown (it is documented in three places) but because it is the + item on which every other authorization control in the system rests: `Authz`, the + capability model, the four-eyes rule and the audit trail are all correct _given_ a + trustworthy `Principal`, and all worthless without one. +- **Evidence (read):** + - `backend/src/BigRegister.Api/Domain/Authorization/StubIdentityProvider.cs:23-27` — + `ctx.Request.Headers["X-Role"]` maps directly to `PrincipalRole.Admin`. No verification, + no signature, no environment guard. + - `:29-31` — `X-Medewerker` present ⇒ `MedewerkerCaller`, id taken verbatim from the header; + `:38-46` `X-Rollen` likewise. + - `:33-35` — `X-Subject` sets the caller's BSN, i.e. the ownership key every owner-scoped + store reads. + - `backend/src/BigRegister.Api/Program.cs:53` registers this as the only `IIdentityProvider` + unconditionally; `:114-119` runs it as middleware for every request in every environment. + - `Domain/Authorization/Authz.cs:12-16` and `StubIdentityProvider.cs:7-9` both label it + "dev stub — NOT a security boundary". The labelling is accurate and complete; **nothing + in the build enforces it.** +- **Baseline citation:** §7 Backend pattern inventory — "Single-impl interface | + `IIdentityProvider` → `StubIdentityProvider`"; **BL-006** (the backend has zero automated + architecture enforcement, so nothing would fail a build that shipped this stub). +- **Remediation, minimal:** do not replace the stub in this backlog. Two cheap, in-scope + steps: (a) fail fast — throw at startup when + `builder.Environment.IsProduction() && provider is StubIdentityProvider`, so the stub can + never boot outside Development; (b) give `IIdentityProvider.Resolve` a way to say "no + identity" (see BIO-002), so the production swap is a drop-in rather than a redesign. +- **Effort:** S (a), S (b). The real provider is out of scope and is a checklist item, not a + ticket. + +### BIO-002 — in a production build the backoffice has no identity, and the default is a citizen + +_Agent 06 handed this over explicitly (`06-adr-conformance.md`, "Observation for agent 07"). +Here is what it actually means for 9.4._ + +- **Control:** 9.4 (least privilege), 9.2 (provisioning); PRD-0002 §4 goal 4 (deny-by-default) +- **Class:** **production gate** +- **Severity: high** — the failure mode is an _identity substitution_, not merely a missing + identity, and it fails open in the direction nobody checked. +- **Evidence (read), and the answer to "what identity does a production backoffice user get":** + - `apps/behandelportal/src/app/app.config.ts:57-63` — `medewerkerInterceptor` is inside the + `isDevMode()` array. A production bundle sends **no** `X-Medewerker` / `X-Rollen`. + - `StubIdentityProvider.cs:29-37` — with no `X-Medewerker` and no `X-Subject`, the provider + falls through to `new ZorgverlenerCaller(DocumentStore.DemoOwner, SeedData.Registration.Naam, +PrincipalRole.Drafter)`. `DocumentStore.cs:48` — `DemoOwner = "123456782"`, the single + seeded citizen's BSN. + - **So a production backoffice user authenticates to the backend as the seeded citizen, + role `drafter`.** Concretely: + - **Fails closed, correctly, on the backoffice capability.** + `Authz.CanBeoordelen(caller)` is `caller is MedewerkerCaller m && …`, so a zorgverlener + is `false` regardless of `X-Role`. `GET /werkvoorraad`, `GET /beoordeling/{id}` and + `POST /beoordeling/{id}/besluit` all 403 through the `Beoordelen` gate + (`Program.cs:814-820`) and write a deny audit row. `GET /me` returns an empty capability + list, so `capabilityGuard('aanvraag:beoordelen')` (`app.routes.ts:25`) denies too. This + part of the design is right and should be recorded as such. + - **Fails open on the citizen's own rights.** The same user _is_ the seeded citizen for + every citizen-scoped endpoint: `GET /applications`, `GET /applications/{id}`, + `PUT/DELETE /applications/{id}`, `POST /applications/{id}/submit`, `DELETE /uploads/{id}`, + `GET|PUT /brief`, `POST /brief/submit|send|reset` (`Program.cs:281-372`, `:253`, + `:603-656`) all resolve `ctx.Zorgverlener().Bsn` to `123456782`. An employee with no + employee identity is granted a **citizen's** read and write rights over that citizen's + aanvragen, uploads and letters. + - **Holds the PII-reveal capability.** `Authz.CanRevealBigNummer(principal)` is + `principal.Role == PrincipalRole.Drafter` — and `drafter` is exactly the role the + no-header default produces. See BIO-006. + - **Root cause, and why it is worth a ticket now:** `IIdentityProvider.Resolve` returns a + non-nullable `CallerIdentity` (`IIdentityProvider.cs:12`). The interface **cannot express + "no identity"**, so any implementation — stub or real — is forced to invent one for an + unauthenticated request. `CallerIdentityHttpContextExtensions.Caller()` + (`CallerIdentity.cs:44-50`) already throws rather than defaulting when the middleware did + not run, i.e. the codebase reaches for fail-loud one layer up and then defaults one layer + down. +- **Baseline citation:** §7 Backend — "Single-impl interface | `IIdentityProvider` → + `StubIdentityProvider`"; §2 size inventory (`apps/behandelportal` 29 src files, 1 309 lines + — a whole app with no non-dev identity path). +- **Remediation, minimal:** change `Resolve` to `CallerIdentity?` and have the middleware + either reject (401) or set an explicit `AnonymousCaller` when it returns null; keep + `StubIdentityProvider` returning the current default **only** under + `IHostEnvironment.IsDevelopment()`. That is the smallest change that makes "unauthenticated" + representable, and it is the natural home for agent 06's **ADR-C-004** (`Session → Principal`). +- **Effort:** S for the interface + middleware; the behandelportal's real login is out of scope. + +### BIO-003 — `X-Admin` is a second authorization gate, outside `Authz`, unaudited, with no caller + +- **Control:** 9.4; 8.15 (audit trail); PRD-0002 §7 ("a single shared authorization helper … + used on every endpoint … the helper makes emit and enforce the same code path") +- **Class:** **defect now** +- **Severity: medium** — a destructive cross-owner delete behind the weakest gate in the + system, leaving no audit record. Not high only because the stronger gate it should use is + itself header-asserted today (BIO-001). +- **Evidence (read):** + - `Program.cs:773` — `static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";` + - `Program.cs:268-270` — `DELETE /admin/uploads/{documentId}` is gated by `IsAdmin` alone, + not by `Authz.CanManageCases` / the `CasesAdmin` wrapper the four sibling admin surfaces + use (`:801-808`). + - `Data/DocumentStore.cs:165-179` — `AdminDelete` "bypasses ownership", deletes the row and + its bytes, and writes only a `DocumentStore.Audit("delete-admin", …)` metadata row — **no** + `AuthzAuditStore` entry, so the action never appears on `/beheer/audit`. + - `grep -rn "X-Admin"` over `apps`, `libs`, `backend`, `e2e`: the only sender is + `backend/tests/BigRegister.Tests/EndpointTests.cs:231`. **No frontend uses this endpoint.** + It is an orphaned gate, not a live seam. +- **Baseline citation:** **BL-003** (940 lines, 48 endpoint mappings in one file, read/write + separated only by a comment banner — the structural condition under which one endpoint keeps + a superseded gate); §7 Backend CQRS-light row (the local helpers `Submit`, `StamdataAdmin`, + `CasesAdmin`, `Beoordelen`, `OrgAdmin`, `FlagsAdmin` are "authorization/idempotency wrappers" + — `IsAdmin` is the one that never became a wrapper). +- **Remediation, minimal:** route the endpoint through `CasesAdmin` (or a new + `Authz.CanDeleteAnyDocument`) and delete `IsAdmin`; add the `AuditAuthz` call the sibling + gates make. One test (`EndpointTests.cs:231`) changes its header. +- **Effort:** S + +### BIO-004 — two upload endpoints have no authorization check at all + +- **Control:** 9.4 (broken object-level authorization); 5.12 (the objects are diploma and + identity scans) +- **Class:** **defect now** +- **Severity: high** — it is the only place in the backend where AVG-relevant _content_ is + served with no owner and no capability test, and the codebase demonstrably knows the + pattern: the sibling `DELETE` on the same resource is owner-scoped, and submit validates + foreign ids. Mitigating factor, stated honestly: document ids are `Guid.NewGuid()` + (`DocumentStore.cs:54`) and local ids are `crypto.randomUUID()` on the client, so this is a + capability-URL exposure rather than an enumerable one. +- **Evidence (read):** + - `Program.cs:231-237` — `GET /uploads/{documentId}/content` calls + `DocumentStore.Get(documentId)` and streams `doc.Content`. The lambda does not take + `HttpContext`; it cannot check anything. + - `Data/DocumentStore.cs:65-72` — `Get` has no owner parameter. + - Contrast, one screen away: `Program.cs:253-254` `DELETE /uploads/{documentId}` → + `DocumentStore.DeleteOwned(documentId, ctx.Zorgverlener().Bsn)` (`:146-153`, explicit + `d.Owner != owner` check), and `Program.cs:367` → + `DocumentStore.ForeignIds(documentIds, ctx.Zorgverlener().Bsn)` (`:103-113`), whose own + docstring says it "guards submit/draft-sync against a citizen attaching another citizen's + upload to their own aanvraag". + - `Program.cs:242-250` — `GET /uploads/status?localIds=` maps client local ids to document + ids for **any** caller, with no owner filter (`DocumentStore.ByLocalIds`, `:76-84`). + - **Why it is unscoped is legible:** `Program.cs:451-452` (the beoordeling detail) hands a + behandelaar the `DocumentId`s of another citizen's uploads, so a cross-owner read is a + genuine requirement. The defect is that the requirement was met by removing the check + rather than by widening it. +- **Baseline citation:** §7 Backend — `DocumentStore` listed among the 7 stores "Not behind + any port"; §3c `backend/Data` **75.5% branch** against 99.0% line (**BL-005**), the exact + signature of "the unit is entered, the guard branches are not there to enter". +- **Remediation, minimal:** take `HttpContext` in both lambdas and allow when + `doc.Owner == ctx.Caller().SubjectId` **or** `Authz.CanBeoordelen(ctx.Caller())` **or** + `Authz.CanManageCases(Authz.ResolvePrincipal(ctx))`; 404 (not 403) otherwise, per PRD-0002 + §8's "avoid resource-existence enumeration". Same predicate for `/uploads/status`. +- **Effort:** S + +### BIO-005 — `POST /registrations` links arbitrary document ids with no ownership check + +- **Control:** 9.4; 8.26 (unvalidated request field driving a state change) +- **Class:** **defect now** +- **Severity: medium** — integrity/availability, not confidentiality: the caller cannot read + another citizen's document, only permanently mark it `Linked`, which blocks that citizen + from ever deleting it (`DocumentStore.DeleteOwned` returns `Linked`, `Program.cs:257-259`). +- **Evidence (read):** + - `Program.cs:187-190` — `POST /registrations` passes `req.Documents` straight to `Submit`. + - `Program.cs:919-925` (`Submit`) — `DocumentStore.Link(documents.Where(…).Select(d => d.DocumentId!))` + and `DocumentStore.Audit("post-delivery", …)`. Neither is owner-scoped; + `DocumentStore.Link` (`Data/DocumentStore.cs:129-141`) takes no owner at all. + - Contrast `Program.cs:367`, the newer submit path, which rejects foreign ids before linking. + - **The endpoint has no frontend caller.** `grep` over `apps`/`libs` for the generated + client's `registrations` method returns nothing outside `api-client.ts` itself; only + `/change-requests` is still called (`registratie/infrastructure/change-request.adapter.ts:17`). +- **Baseline citation:** **BL-003** (48 endpoint mappings in one 940-line file — the condition + under which a guard added on one submit path is not added to the other); §7 Backend + CQRS-light row, which names `Submit` as one of the cross-cutting wrappers. +- **Remediation, minimal:** either delete the endpoint (it is dead, and WP-72 already removed + its siblings), or add the one `ForeignIds` guard the other submit path uses. Deleting is + smaller and removes the surface entirely. +- **Effort:** S + +### BIO-006 — the PII-reveal capability belongs to the default role, and its step-up is a client-asserted constant + +- **Control:** 9.4 (least privilege); PRD-0002 §5d ("the server re-checks the environment + attribute before permitting") +- **Class:** **production gate** (the role mapping is a documented POC choice) with one + **defect-now** sub-item (the step-up check is a no-op as wired) +- **Severity: medium** +- **Evidence (read):** + - `Domain/Authorization/Authz.cs` — `CanRevealBigNummer(principal) => principal.Role == PrincipalRole.Drafter`. + - `StubIdentityProvider.cs:23-27` — the `_ =>` arm of the role switch is `Drafter`. So the + **absence** of any role header yields the role that holds the PII reveal. `roles-and-access.md` + states this as "`drafter` … the only role that may reveal a BSN" without noting that it is + also the default. + - `Program.cs:668-681` — the reveal requires `canReveal && ctx.Request.Headers["X-Step-Up"] == "true"`. + - `apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.ts:26` — the client sends + `'X-Step-Up': 'true'` **unconditionally, as a literal**. The precondition is therefore + satisfied by every call that reaches the endpoint; it constrains nothing. The endpoint's + own comment at `Program.cs:665` calls it "stubbed here as the X-Step-Up header", which is + accurate, but the FE side turns the stub into a constant rather than into a gesture-gated + value. +- **Baseline citation:** §7 Backend — `Domain/.../Authz.cs` listed among the pure rule classes + with co-located tests; §3b `ssp/brief` **42% spec reach** (11 of 26 files), with + `reveal-bignummer.adapter.ts` among the 15 unreached and **not** a `ui/` file, so BL-004's + Storybook carve-out does not cover it. +- **Remediation, minimal:** (a) send `X-Step-Up` only from the confirm handler, not from the + adapter's literal — one line, and it makes the stub behave like the control it stands in + for; (b) record in `roles-and-access.md` that `drafter` is the default role, so the + least-privilege consequence is visible; (c) production gate: bind the reveal to an app-overlay + attribute rather than the coarse role, as PRD-0002 §5c already says ("Role-based in the POC; + a real system resolves it from the app overlay independent of role"). +- **Effort:** S for (a)+(b); the overlay is a checklist item. + +### BIO-013 — the seeded-citizen endpoints ignore the caller entirely + +- **Control:** 9.4 (row-level scoping); 5.12 +- **Class:** **production gate** — explicitly acknowledged as unbuilt in PRD-0002 §9 P2 + ("Row-level scoping (§5b) still unbuilt") +- **Severity: medium** — with one seeded citizen it is invisible; the moment a second identity + exists (which `?subject=` already creates in e2e) every citizen reads the seeded citizen's + BRP address, birthdate and registration. +- **Evidence (read):** `Program.cs:135-155` — `GET /dashboard-view`, `GET /notes`, + `GET /brp/address`, `GET /duo/diplomas` take no `HttpContext` and return `SeedData.Registration` + / `SeedData.Person` / `SeedData.BrpAddress` regardless of the resolved caller. The + identity middleware runs (`:114-119`) and its result is discarded. +- **Baseline citation:** §7 Backend — the read side is "screen-shaped reads. Decisions are + computed here"; **BL-003** (all 48 mappings in one file). §3c `Program.cs` 97.4% line — + these endpoints are covered, so the gap is by design, not by omission. +- **Remediation, minimal:** none proposed for the POC. The checklist item is: every read that + returns person data must take the caller and scope on it, and the acceptance test is a + second seeded citizen who cannot see the first's data. +- **Effort:** M (out of this backlog) + +### BIO-018 — `IdempotencyStore` is keyed on a client-supplied string with no caller scoping, TTL or bound + +- **Control:** 9.4; 8.26 +- **Class:** **defect now** +- **Severity: low** — the cached values are only a `ReferentieResponse` or a ProblemDetails, so + a cross-caller replay leaks a reference number, not personal data. Filed because it is an + unscoped shared cache in an access-control path and the fix is trivial. +- **Evidence (read):** `Data/IdempotencyStore.cs:11-27` — a process-global + `Dictionary` keyed on the header alone; the file's own `ponytail:` comment + concedes "no TTL/eviction … an unbounded dictionary keyed on client-supplied strings is a + memory leak at scale". `Program.cs:901-909` reads and writes it with the raw header value, + never composed with the caller's `SubjectId`. +- **Baseline citation:** §7 Backend — `IdempotencyStore` listed among the 7 stores "Not behind + any port"; agent 02's `backend/Data` note ("the only store that is purely in-memory with no + `Reset()` and no TTL … shared by every test class in the process"). +- **Remediation, minimal:** key on `$"{ctx.Caller().SubjectId}:{idemKey}"`. One line, and it + also removes the cross-test-class bleed agent 02 flagged. +- **Effort:** S + +--- + +## 2. Control area: logging & monitoring (8.15, 8.16) + +### BIO-007 — only _denied_ authorization decisions are audited; successful admin and approval actions are not + +- **Control:** 8.15 (logging), 8.16 (monitoring activities); PRD-0002 §8 ("Audit log of + authorization-relevant events — **denials, PII reveals, approvals/rejections**, step-up, + break-glass") +- **Class:** **defect now** +- **Severity: medium** — the queryable trail the product ships as its audit surface + (`/beheer/audit`) cannot answer "who changed this", only "who was turned away". For a + register whose integrity is the product, that is the wrong half. +- **Evidence (read) — every `AuditAuthz` call site, checked:** + - `Program.cs:783` `OrgAdmin` → `allowed: false`. `:794` `StamdataAdmin` → `false`. + `:805` `CasesAdmin` → `false`. `:817` `Beoordelen` → `false`. `:827` `FlagsAdmin` → `false`. + All five gates audit **only** the denial branch; the allow branch calls `action()` and + returns. + - The one exception is `Program.cs:674` (the reveal), which passes the real `allowed`. + `:539` audits the NRC notification. `:856` audits a ZGW divergence. + - **Therefore the following leave no row in `AuthzAuditStore`:** `PUT /admin/flags/{key}` + (`:592`, and `Data/FeatureFlagStore.cs:54-66` writes nothing either — the endpoint does + not even emit a log line), `PUT /admin/org-template/{subOrgId}` (`:739`), + `POST /admin/org-template/{subOrgId}/rollback/{version}` (`:764`), + `DELETE /admin/cases/{id}` (`:554`, log line only at `:557`), + `DELETE /admin/uploads/{documentId}` (`:268`, see BIO-003), + `POST /brief/approve` / `/reject` / `/send` (`:631-658`, `LogBrief` writes a log line with + no actor), and `POST /beoordeling/{id}/besluit` (`:473`). + - The comment at `Program.cs:777-778` states the intent — "the allow path is left un-logged + (the endpoints log their own effect, e.g. publish)" — and the intent is only half met: + publish (`:755`) and admin case delete (`:557`) log; the other six do not log at all. +- **Baseline citation:** §7 Backend — `AuthzAuditStore` listed among the 7 static stores; + §3c `backend/Program.cs` 97.4% line / **84.8% branch** — the allow branches run constantly + and simply have nothing in them. +- **Remediation, minimal:** move the `AuditAuthz` call from each gate's deny branch to the + gate itself, passing the real boolean and wrapping `action()`: + `var ok = Authz.CanX(p); AuditAuthz(ctx, "x", resource, ok, p); return ok ? action() : Forbidden(...)`. + Five identical edits in `Program.cs:779-830`, no signature change, no new concept. Add + `AuditAuthz` to the three brief transitions and the besluit. +- **Effort:** S for the five gates; M including the brief/besluit sites and their tests. + +### BIO-008 — the BSN is written into the authz audit trail's `Resource` column + +- **Control:** 8.15; 5.12 (classification of special-category data) +- **Class:** **defect now** +- **Severity: high** — this is the single clearest "wrong even for a POC" item in the file: + a national identifier is persisted to a store whose own type documentation, class + documentation and endpoint documentation all state it holds none, and it is then served over + an API and rendered in a UI that repeat the same claim. +- **Evidence (read), the whole chain:** + 1. `Program.cs:674` — + `AuditAuthz(ctx, "brief:reveal-bignummer", "brief/" + ctx.Zorgverlener().Bsn, allowed, principal);` + The BSN is concatenated into the `resource` argument. + 2. `Program.cs:840-844` — that argument is logged + (`"authz action={Action} resource={Resource} …"`) **and** passed to + `AuthzAuditStore.Record(action, resource, …)`. + 3. `Data/AuthzAuditStore.cs:30-38` — `Record` inserts it as `AuthzAuditEntry.Resource` into + SQLite. The entity's doc comment (`:5-8`) says "**never** a name, BSN, or the value that + was (or wasn't) revealed"; the class comment (`:22-23`) says "Holds NO PII by construction + (see the entity); the schema test asserts it". + 4. `Program.cs:566-569` — `GET /admin/audit` returns `a.Resource` verbatim in `AuthzAuditDto`. + 5. `libs/beheer/src/ui/audit.page.ts:10-12` — the page's own header comment reads + "data-minimised, no PII", and its table renders the resource column. + 6. `Program.cs:666-667` — the endpoint comment: "every attempt — allow or deny — is audited + with **NO PII** (AuditAuthz)". +- **The false assurance, named:** `backend/tests/BigRegister.Tests/AuthzAuditTests.cs:51-53` + asserts over **column names** — + `Assert.DoesNotContain(names, n => Regex.IsMatch(n, "naam|name|bsn|value|waarde", …))` — + not over values. The BSN travels in a column called `Resource`, which the regex cannot see. + Four documents claim the control; the test that is cited as enforcing it does not enforce it. +- **Baseline citation:** §7 Backend — the 7 static stores, `AuthzAuditStore` among them; + §3c `backend/Data` **75.5% branch** (**BL-005**). +- **Remediation, minimal:** the resource ref for a per-owner brief does not need the BSN — use + the brief's own id, or `"brief/" + MaskTail(bsn, 3)` (the masker already exists at + `Program.cs:861-863` and is already used for the BIG-nummer at `:878`). Then extend + `AuthzAuditTests` to assert on **values**: seed a reveal attempt and assert no stored + `Resource` matches `\d{9}`. +- **Effort:** S + +### BIO-009 — the BSN is the `Actor` on every document audit row + +- **Control:** 8.15; 5.12 +- **Class:** **defect now** +- **Severity: medium** — same class as BIO-008 but a narrower blast radius: this table is not + exposed by any endpoint (`grep`: `DocumentStore.AuditLog` has no caller in `Program.cs`), so + it is a storage-side leak only. +- **Evidence (read):** + - `Data/DocumentStore.cs:52-63` — `Add(..., string owner)` ends with + `Audit("upload", doc.DocumentId, categoryId, owner)`, and `owner` is the caller's BSN + (`Program.cs:224` passes `ctx.Zorgverlener()`, whose `Bsn` becomes `StoredDocument.Owner`). + - `:159` — `DeleteOwned` likewise: `Audit("delete-user", documentId, categoryId, owner)`. + - `:181-189` — `Audit` persists it as `AuditEntry.Actor`. + - The class doc comment (`:30-36`) states "The audit log holds metadata only (never file + content **or other PII**)." + - `StoredDocument.Owner` itself is the BSN by design (it is the ownership key) — that is + correct and is **not** the finding; the finding is the _audit_ row, which needs only a + pseudonymous actor. +- **Baseline citation:** §7 Backend — `DocumentStore` among the 7 static stores; §3c + `backend/Data` 99.0% line / 75.5% branch. +- **Remediation, minimal:** pass `MaskTail(owner, 3)` (or a per-session pseudonym) as the + `actor` argument at `:61` and `:159`; the ownership column is untouched. +- **Effort:** S + +### BIO-010 — the BSN reaches logs and a persisted aanvraag field through the ZGW error path + +- **Control:** 8.15; 5.12 +- **Class:** **defect now**, conditional on `Zgw:Enabled=true` (off by default, + `appsettings.json`) — which is why the severity is medium and not high today, and why it + becomes high the moment OpenZaak is switched on. +- **Severity: medium** +- **Evidence (read):** + - `Zgw/OpenZaakZaakSource.cs:52` — the citizen-scoped list builds + `url += $"?rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn={Uri.EscapeDataString(bsn)}"`. + The BSN is in the request URI. + - `Zgw/ZgwHttpClient.cs:76-81` — on a non-transient failure the client throws + `new HttpRequestException($"ZGW {req.Method} {req.RequestUri} failed: {(int)res.StatusCode} {snippet}")`, + i.e. the exception message carries **both** the BSN-bearing URI **and** up to 500 characters + of the OpenZaak response body. `Zgw/OpenZaakZaakSource.cs:246` shows the write side posts + `inpBsn` in the body, and `Data/DocumentStore.cs:44-47` records that OpenZaak's own + validation errors for that field are a real, observed failure mode — so the body snippet + is a live BSN-echo path, not a hypothetical one. + - `Program.cs:851-856` (`RecordZgwDivergence`) — `app.Logger.LogError(ex, …)` logs that + message, **and** `ApplicationStore.SetZgwError(id, ex.Message)` persists it to the aanvraag + row (`Data/ApplicationStore.cs:286-294`). `Data/AanvraagMapper.cs:25,50,63,78,96` carry it + onward through the mapper. + - Mitigating, verified: `ZgwError` is **not** on any DTO in `Contracts/` and does not appear + anywhere in the frontend — the value is stored and logged, not served. + - Also verified and **not** a finding: `Zgw/ZgwDiagnosticHandler.cs:17-24` logs only method, + URI and byte counts, and only when `request.Content is not null` — so the BSN-bearing GET + URI is never reached by it, and no body is logged. It is opt-in behind `ZGW_DEBUG_HTTP=1` + (`Program.cs:73-78`). That hatch is clean. +- **Baseline citation:** §7 Backend — "ZGW anti-corruption layer | Fully built"; §3c + `backend/Zgw` 98.1% line / **85.5% branch — the strongest branch figure on the backend**, + so this is a design gap, not a test gap. +- **Remediation, minimal:** in `ZgwHttpClient.SendWithRetryAsync`, build the message from + `req.RequestUri.GetLeftPart(UriPartial.Path)` (drop the query) and omit the body snippet + from the _message_, logging it separately at Debug if the diagnostic value is wanted. +- **Effort:** S + +--- + +## 3. Control area: data classification & handling (5.12, 5.13) + +### BIO-011 — cross-owner list endpoints ship unmasked BSNs while the detail endpoint masks + +- **Control:** 5.12 (classification), 5.13 (labelling/handling); PRD-0002 §5c ("Default DTO + carries a **masked** BSN … or omits it entirely") +- **Class:** **defect now** +- **Severity: medium** — the correct behaviour is implemented one file away, so this is an + inconsistency rather than a missing capability, and the list is the _wider_ exposure (every + open case, not one). +- **Evidence (read):** + - `Contracts/Mappers.cs:72-74` — `ToAdminSummaryDto(now) => a.ToSummaryDto(now) with { Owner = a.Owner }`. + `a.Owner` is the raw BSN. `Contracts/Dtos.cs:107` documents the field as "populated for the + admin cross-owner list (WP-36)". + - `Data/LocalZaakSource.cs:15-16` — `ListCases` maps every row through `ToAdminSummaryDto`. + - `Program.cs:425-427` — `GET /admin/cases` returns `zaken.ListCases(...)` unmodified. + `Program.cs:434-438` — `GET /werkvoorraad` returns the same list, filtered by status only. + - **Contrast, in the same file:** `Program.cs:453` — the beoordeling detail does + `var masked = c with { Owner = MaskTail(c.Owner!, 3) };` before returning. The detail + masks; the list that leads to it does not. + - Rendered unmasked in both UIs: + `apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item-view.ts:28` + (`subtitle: $localize\`:@@werkvoorraad.row.bsn:BSN ${item.owner}\``) and +`apps/ssp/src/app/registratie/ui/admin-cases.page.ts:91`("Eigenaar (BSN)").`behandeling/domain/beoordeling.ts:26`correctly documents its own field as "masked by the +server";`werkvoorraad-item.ts:20` documents its as "always populated" with no masking note. +- **Baseline citation:** §7 Backend — "Mapping | `Contracts/Mappers.cs` (`.ToDto()`, + `.ToDetailDto()`), `Data/AanvraagMapper.cs`" (the exact seam); §3a `bhp/behandeling` + 91.6% line / 81.5% branch — well tested, so nothing here is accidental. +- **Remediation, minimal:** apply `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` + (`Mappers.cs:74`) — one expression, and both list endpoints inherit it. If an admin genuinely + needs the full value, that is a reveal action with the existing gated+audited shape, not a + default list field. +- **Effort:** S + +### BIO-012 — `?role=` and `?subject=` are **not** stripped from production builds on three hand-written `fetch` paths + +- **Control:** 5.13 (handling — a BSN written to web storage and onto the wire), 9.4 +- **Class:** **defect now** — the defect is that a documented control does not hold, not that + the residual exposure is large. Stated plainly: an attacker who can send a header does not + need this path (see BIO-001), so the incremental attack value is low. The value of the + finding is that the docs and CLAUDE.md assert a production property the code does not have, + and anyone reasoning about production risk from those docs will get it wrong. +- **Severity: medium** +- **Evidence (read):** + - The interceptor chain **is** correctly gated: `apps/ssp/src/app/app.config.ts:58-62` and + `apps/behandelportal/src/app/app.config.ts:57-63` both register + `[scenarioInterceptor, roleInterceptor, subjectInterceptor(, medewerkerInterceptor)]` + only when `isDevMode()`. That much of the claim is true. + - But three adapters bypass `HttpClient` entirely and set the headers themselves, with **no** + `isDevMode()` guard: + - `apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.ts:26` — + `headers: { 'X-Role': currentRole(), 'X-Step-Up': 'true' }` + - `apps/ssp/src/app/brief/infrastructure/letter-preview.adapter.ts:43-46` — + `headers: { 'X-Role': currentRole(), ...(subject ? { 'X-Subject': subject } : {}) }` + - `apps/ssp/src/app/brief/infrastructure/org-template.adapter.ts:79` — + `headers: { 'X-Role': currentRole() }` + - The readers are ungated too: `libs/shared/src/infrastructure/role.ts:23-31` and + `libs/shared/src/infrastructure/subject.ts:23-29` both read the query param and **write it + into `sessionStorage`** on any navigation, in any build. For `?subject=` that value is a + **BSN** — persisted to web storage under `dev-subject`, then sent as `X-Subject` on + `/brief/preview`. `subject.interceptor.ts:14-29` argues at length that the BSN must not + leave `SessionStore` ("`SessionStore`'s G1 comment is explicit that the BSN … is never + persisted or otherwise handed outward, by design") and then routes it through + `sessionStorage` instead. + - The claim being contradicted: `docs/reference/roles-and-access.md:23` — "Both are wired + only under `isDevMode()` — **they do not exist in a production build**"; CLAUDE.md's + "Scenario toggle (**dev-only**, not wired in prod builds)" and "Dev role stand-in + (**dev-only**)". `?scenario=` genuinely is stripped (its only consumer is the gated + interceptor plus `upload.adapter.ts`'s simulator); `?role=` and `?subject=` are not. + - BSN-in-URL has its own consequences independent of the header: browser history, `Referer`, + and any reverse-proxy access log. +- **Baseline citation:** §3b `ssp/brief` **42% spec reach** (11 of 26 files) — all three + hand-written `fetch` adapters are among the 15 unreached, and none is a `ui/` file, so + **BL-004**'s Storybook carve-out does not cover them; §3a `ssp/brief` 68.8% branch. +- **Remediation, minimal:** guard the three adapters — + `...(isDevMode() ? { 'X-Role': currentRole() } : {})` — or, cleaner, have `currentRole()` + and `currentSubject()` return `undefined` outside `isDevMode()` so every caller inherits the + guard and the sessionStorage write disappears with it. Then correct + `docs/reference/roles-and-access.md:23`. +- **Effort:** S + +### BIO-017 — two PII guards have no executable test + +- **Control:** 8.29 (security testing in development); 5.13 +- **Class:** **defect now** +- **Severity: low** — both guards were verified correct by reading them; the finding is that + nothing would catch a regression. +- **Evidence (read):** + - `apps/ssp/src/app/auth/application/session.store.ts` — **G1 holds on every path, + verified:** `restore()` (`:12-21`) returns `{ bsn: '', naam }` and never reads a stored + BSN; the constructor `effect()` (`:43-48`) writes only `{ naam: s.naam }`; `login()` + (`:52-56`) sets the signal in memory only; `logout()` removes the key. There is no path + that writes the BSN to `localStorage`. Identical in + `apps/behandelportal/src/app/auth/application/session.store.ts`. + - But agent 02's **TE-001** is right that the guard is untestable as written: `restore()` + is module-private and reads `localStorage` in a field initializer. Per-file lcov (agent 02, + from §3a): **LH 2 / LF 20 (10.0% line), BRH 3 / BRF 13**. + - `apps/ssp/src/app/shell/debug-state/mask.ts:13-25` — `redactProfile` is a pure, exported, + directly callable PII-redaction function with **no spec** (agent 02's "missing test, not + blocked test"). It redacts name, birthdate and address and masks the BIG-nummer; verified + correct by reading. +- **Baseline citation:** §3a `ssp/auth` · `bhp/auth` **42.9% line / 46.2% branch — jointly the + worst line coverage in the frontend** (§8 ranking); **BL-009** (no coverage threshold is + enforced anywhere, so nothing ratchets this). +- **Remediation, minimal:** take agent 02's TE-001 seam (`parseStoredSession` in + `auth/domain/session.ts`, which already has a spec file) and add the three cases — + absent, non-JSON, wrong shape — plus one that asserts a stored `{"bsn":"…","naam":"…"}` + yields `bsn: ''`. Add a five-line spec for `redactProfile`. +- **Effort:** S (this is TE-001 plus one assertion; it does not need its own ticket if TE-001 + is scheduled — but the BSN assertion must be in TE-001's acceptance criteria, which today + it is not). + +--- + +## 4. Control area: cryptography (8.24) + +### BIO-014 — nothing is encrypted at rest: document bytes, BSNs and the audit trail sit in a plaintext SQLite file + +- **Control:** 8.24 (use of cryptography) +- **Class:** **production gate** +- **Severity: high** — the file contains the highest-classification data in the system in one + place, unprotected. +- **Evidence (read) — what is at rest, and what class of data:** + + | Table | Contains | Class | + | ----------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------- | + | `Documents` (`DocumentStore.cs:9-20`) | `byte[] Content` — the uploaded diploma / identity / language-proficiency scan | AVG art. 9-adjacent; identity documents | + | `Documents.Owner` | BSN | AVG art. 87 national identifier | + | `Applications.Owner` (`ApplicationStore`) | BSN | same | + | `Applications.ZgwError` | may contain a BSN (BIO-010) | same | + | `AuthzAudit.Resource` | contains a BSN today (BIO-008) | same | + | `AuditEntries.Actor` | BSN (BIO-009) | same | + | `Briefs` / `Briefs.ArchivedHtml` | the rendered letter, incl. name, BIG-nummer, address | personal data | + - `Data/Db.cs:16-18` — `UseSqlite(ConnectionString)`, default + `"Data Source=bigregister.db"`. No key, no `SQLCipher`, no page encryption. + - `DocumentStore.cs:3-8` records the intent — "a real backend persists them to blob storage + keyed by DocumentId" — so the shape is a known stand-in. + - The file lands in the app's working directory and, under `docker-compose.yml`, on the host + through the `./backend:/src:z` bind mount. **Verified: it is gitignored** + (`backend/.gitignore:5`, `git check-ignore` confirms both copies), and `git ls-files` shows + zero tracked `.db` files — so there is no committed-database finding. + - No transparent-data-encryption, no filesystem-level requirement, and no key management + exists anywhere in the repo. + - Also verified and **not** a finding: no secrets are committed. `appsettings.json`'s `Zgw` + block ships every credential field empty, `backend/openzaak/setup_configuration/data.prod.yaml` + and `seeded.env` are both gitignored (`.gitignore:57`, `:60`), and + `ZgwOptions.Secret`'s doc comment correctly states it is "Held only by the BFF, never the + browser". `ZgwTokenProvider.cs` mints a short-lived HS256 JWT per call with no refresh + token to store — sound for what it is. + +- **Baseline citation:** §2 size inventory — `backend/Data` (excl. Migrations) 1 697 lines, + the largest backend folder after `Program.cs`; §7 Backend — the 7 static stores, "each opens + a short-lived context via `Db.Create()` under its own lock". +- **Remediation, minimal:** none for the POC. Checklist items: move document bytes out of the + relational store to encrypted object storage keyed by `DocumentId` (the code already says + this is the target); require encryption at rest for the database (managed-service TDE or + SQLCipher); define key custody and rotation. **Prerequisite:** BIO-008/009/010 first, so the + BSN is not in three places that do not need it before deciding what must be encrypted. +- **Effort:** L (infrastructure), but S to write the requirement down and to stop widening it. + +### BIO-015 — no transport security, no security response headers, Swagger and `AllowedHosts: *` unconditional + +- **Control:** 8.24 (cryptography in transit), 8.28 (secure coding — attack surface) +- **Class:** **production gate** +- **Severity: medium** +- **Evidence (read):** `Program.cs:86-131` is the whole pipeline. It contains no + `UseHttpsRedirection`, no `UseHsts`, no `UseAuthentication`/`UseAuthorization`, and no + response-header middleware — so no `Strict-Transport-Security`, no + `X-Content-Type-Options: nosniff`, no `Content-Security-Policy`, no `Referrer-Policy`. + `app.UseSwagger(); app.UseSwaggerUI();` (`:121-122`) run in every environment, with no + `app.Environment.IsDevelopment()` guard. `appsettings.json` sets `"AllowedHosts": "*"`. + CORS (`:37-39`) allows only `http://localhost:4200` — which is tight, and worth noting is + effectively unused since both the dev servers and `docker-compose.yml` proxy `/api` + same-origin (`API_PROXY_TARGET`); the behandelportal's `:4201` is not in the list and does + not need to be. + - **`nosniff` in context:** the upload allow-list is `application/pdf`, `image/jpeg`, + `image/png` only (`Domain/Documents/DocumentCategory.cs:20-22`, enforced at + `Program.cs:216-217`), and `GET /uploads/{id}/content` serves `inline` only for pdf and + `image/*` (`:235`). No script-capable type (SVG, HTML) can be uploaded, so the usual + stored-XSS-via-inline-attachment path is **closed by the allow-list**. The content type is + nevertheless client-declared rather than sniffed from magic bytes, which is why `nosniff` + belongs on the checklist rather than being a finding today. +- **Baseline citation:** **BL-003** (`Program.cs` 940 lines, file CC 78 — the one place every + pipeline decision lives); §3c `Program.cs` 97.4% line / 84.8% branch. +- **Remediation, minimal:** wrap Swagger in `if (app.Environment.IsDevelopment())` — one line, + and it is a genuine attack-surface reduction with no POC cost. The rest are checklist items, + most of which belong to the reverse proxy rather than the app. +- **Effort:** S for Swagger; the rest is deployment configuration. + +--- + +## 5. Control area: secure development (8.25, 8.28, 8.29) + +### BIO-016 — what the security gates cover, and what they do not + +- **Control:** 8.25 (secure development lifecycle), 8.28 (secure coding), 8.29 (security + testing in development and acceptance) +- **Class:** **production gate** +- **Severity: medium** +- **Evidence (read) — `.github/workflows/ci.yml`, in full:** + + **Present, and genuinely blocking:** + - `semgrep scan --config p/default --config p/csharp --metrics=off --error` (`:254-279`) — + SAST on both sides, `--error` makes it a gate, telemetry off, prior findings triaged + rather than suppressed wholesale. + - `npm audit --omit=dev` (`:128`) — the shipped bundle must audit clean. + - All actions pinned to full SHAs (`:32-33`, `:64`, `:70`, …) — supply-chain hygiene. + - `permissions: contents: read` at workflow level (`:9-11`), plus per-ref concurrency cancel. + - `npm run dep:check` (`:109`) — 11 `severity: error` architecture rules, **frontend only**. + - `dotnet format --verify-no-changes` + `dotnet test --filter "Category!=Integration"` + (`:199-204`) — 241 backend tests. + - `api-client-drift` (`:281-317`) — the wire contract cannot drift unnoticed. + + **Absent:** + - **No dependency vulnerability scan on the backend.** There is no + `dotnet list package --vulnerable --include-transitive` step; `npm audit` covers only the + frontend. The .NET dependency tree is unscanned. + - **No secret scanning** (gitleaks/trufflehog). Today nothing is committed (verified in + BIO-014), so this is prevention, not repair. + - **No authorization regression suite as a gate.** `AuthzTests.cs`, `AuthzAuditTests.cs`, + `WerkvoorraadTests.cs` and `StubIdentityProviderTests.cs` exist and run inside + `dotnet test`, but nothing asserts the _set_ of gated endpoints — so an endpoint added + without a gate (BIO-004's shape) fails no test. + - **No backend architecture enforcement at all — BL-006.** `Domain/` purity holds by + convention. The property ADR-0005 depends on ("ZGW shapes never leave `Zgw/`") and the + property this file depends on ("authorization lives in `Authz`") are both review-maintained. + - **No coverage ratchet — BL-009.** Nothing can regress-test a security fix by CI number. + - No DAST, no container image scan, no SBOM. Reasonable omissions for a POC; listed so the + production decision is explicit. + +- **Baseline citation:** **BL-006** (verbatim: "the backend has zero automated architecture + enforcement … `Domain/` purity currently holds by convention") and **BL-009** (no coverage + threshold is enforced anywhere). +- **Remediation, minimal:** two cheap additions with real value here — + (a) `dotnet list package --vulnerable --include-transitive` as a failing step in the + `backend` job; (b) one endpoint-inventory test that enumerates the app's route table and + asserts every route outside a small allow-list passes through one of the six authorization + wrappers. (b) is the test that would have caught BIO-003, BIO-004 and BIO-005. +- **Effort:** S for (a), M for (b). + +--- + +## 6. Control area: change control (8.32) + +### BIO-020 — the only deployment artifact in the repo builds development bundles + +- **Control:** 8.32 (change management); 8.25 +- **Class:** **production gate** +- **Severity: medium** — there is no release path, so there is no gate at which any of the + production-gate items in this file would be checked. +- **Evidence (read):** `docker-compose.yml` — the `api` service sets + `ASPNETCORE_ENVIRONMENT=Development` and runs `dotnet run`; both `web` and + `web-behandelportal` run `npx ng build … --configuration development --localize`, and the + file's own header comment states why: "development config keeps `isDevMode()=true` so the + dev tools render". The image is `mcr.microsoft.com/dotnet/sdk:10.0` / `node:24-slim`, and + the header opens "dev-server images (not multi-stage prod builds) — this is a demo". + **Consequence for every dev hatch in this file:** in the one containerised deployment the + repo ships, `isDevMode()` is `true`, so `roleInterceptor`, `subjectInterceptor`, + `medewerkerInterceptor`, `scenarioInterceptor` and the `⚙ state` debug panel are all live. + That is correct for a demo and must not be mistaken for a production deployment. +- **Baseline citation:** §2 size inventory (two apps + one backend, all deployed by this one + file); **BL-006** (no automated enforcement that would distinguish the two). +- **Remediation, minimal:** none for the POC. Checklist: a separate production compose/Helm + artifact with `--configuration production`, `ASPNETCORE_ENVIRONMENT=Production`, and a + release checklist that names this file's production gates. +- **Effort:** M (out of this backlog) + +**Positive finding, recorded rather than ticketed.** Change control over the _business rules_ +is genuinely strong and is the model the rest should follow: stamdata is config-as-code +(ADR-0004), validated at build by `StamdataValidationTests`, with **no runtime write +endpoint at all** — verified: `Program.cs:164` and `:173` are both GETs behind +`StamdataAdmin`, and `libs/beheer`'s editor downloads a JSON file for a reviewed PR +(`libs/beheer/src/infrastructure/stamdata.adapter.ts:16-20`, "There is no write method"). A +bad reference-data edit fails CI, never production. The two sanctioned runtime-editable +surfaces (`OrgTemplateStore`, `FeatureFlagStore`) both keep their catalog in code and fail +closed on an unknown key (`Data/FeatureFlagStore.cs:56`) — but see BIO-007: their **writes are +not audited**, which fails clause (4) of the four-part test agent 06's ADR-C-009 proposes. + +--- + +## 7. Control area: input validation (8.26) + +**The server is the authority, and it is.** Verified against ADR-0001's rule ("the FE renders +decisions, it does not recompute business rules"): `SubmissionRules.RejectPhoneChange` +(`Domain/Submissions/SubmissionRules.cs:36-42`) re-validates the phone number server-side +with the same normalisation the FE's `parseTelefoonnummer` applies, and says so; +`DocumentRules.RejectUpload` (`Domain/Documents/DocumentCategory.cs:82-90`) authoritatively +enforces the content-type allow-list and the size cap before any byte is stored; +`Program.cs:208-217` validates multipart shape and required fields before reading the file; +`Program.cs:367` validates document ownership on submit. `IntakePolicy.ScholingThreshold` is +shipped to the FE as a _value_ for instant feedback and re-validated server-side — the +config-value shape ADR-0001 prescribes. The 30 FE `parse*` boundaries (baseline §7) are +defence in depth on the response direction, not a substitute; CLAUDE.md explicitly puts +runtime DTO validation on every endpoint out of scope, and this pass does not reopen that. + +Two gaps, both narrow: + +### BIO-019 — `GET /stamdata/{table}?peildatum=` 500s on unparseable input + +- **Control:** 8.26 +- **Class:** **defect now** +- **Severity: low** — admin-gated, and the failure is a 500 rather than a leak. Filed because + an unhandled exception on a user-supplied string is exactly what 8.26 exists to prevent, and + in a Development environment the exception detail is returned to the caller. +- **Evidence (read):** `Program.cs:178` — + `var rows = peildatum is { Length: > 0 } p ? t.RowsOn(DateOnly.Parse(p)) : t.Rows();`. + `DateOnly.Parse` throws `FormatException` on anything unparseable; there is no + `TryParse`, no 400 path, and `.Produces` on the endpoint declares only 200/403/404. +- **Baseline citation:** §3c `backend/Stamdata` 96.8% line but **71.7% branch** — named in + **BL-005** as the second-weakest branch axis; this is one of the unentered branches. +- **Remediation, minimal:** `DateOnly.TryParse(p, out var d) ? … : Results.Problem(statusCode: 400)`. +- **Effort:** S + +### Noted, not filed + +`GET /uploads/status?localIds=` splits an unbounded comma-separated list into a single +`WHERE IN` query (`Program.cs:242-248`, `DocumentStore.ByLocalIds`). No cap. Real but +negligible at POC scale; it belongs with BIO-018's "bound the client-supplied inputs" theme +rather than as a ticket of its own. + +--- + +# Module sections + +Every module in scope gets a section. "No findings" is a result, not an omission. + +## apps/ssp — auth + +**BIO-017** (the G1 guard has no executable test). **The guard itself holds on every path** — +`restore()`, the persistence `effect()`, `login()` and `logout()` were each read and none +writes the BSN to storage. No other findings: `DigidAdapter` is the faked login CLAUDE.md +places out of scope, and `Session.bsn` never leaves the in-memory signal (verified: the one +place that needed it, `subject.interceptor.ts`, deliberately does **not** reach into +`SessionStore` — see BIO-012 for what it does instead). + +## apps/ssp — registratie + +**No findings of its own.** It is the consumer side of **BIO-011**: +`ui/admin-cases.page.ts:91` renders the unmasked BSN column the backend ships; the fix is +server-side. `application/draft-sync.ts` and the five value objects +(`domain/value-objects/`) do format validation only and never act as authority — correct per +ADR-0001. `parseBsn` (`libs/shared/src/kernel/bsn.ts`) is checksum-only and says so. + +## apps/ssp — herregistratie + +**No findings.** The scholing threshold arrives from the server as a config value +(`domain/intake.machine.ts:52`) with the offline fallback ADR-0001 sanctions; nothing in this +context touches PII, authorization or the network directly. + +## apps/ssp — brief + +**BIO-012** (the three hand-written `fetch` adapters carry `?role=`/`?subject=` into +production builds) and **BIO-006** (the client sends `X-Step-Up: 'true'` as a literal). The +context is otherwise the best-behaved consumer of the decision-DTO pattern in the repo: +`application/brief.store.ts:31,103-108` derives every gate from `BriefState.loaded.decisions` +and states "this store never computes them itself" — verified true. + +## apps/ssp — showcase, shell, root + +**BIO-017** (`shell/debug-state/mask.ts::redactProfile` has no spec; verified correct by +reading). **The dev panel is genuinely gated and is not a finding** — +`libs/shared/src/layout/shell/shell.component.ts:73,79` renders it only under +`@if (isDev && debugPanel)` with `isDev = isDevMode()`, and +`shell/debug-state/debug-state.component.ts:176` masks the BSN even there. The `showcase` +context is a teaching page and reads every context by sanction; it introduces no data path. + +## apps/behandelportal — auth + +**BIO-002** — this is where it lands. `app.config.ts:57-63` is the gate; +`auth/infrastructure/medewerker.ts:14` is the fixed stand-in id. Also relevant and already +owned by agent 06: `auth/ui/login.page.ts:31` logs a backoffice user in through DigiD with a +BSN (ADR-C-004). + +## apps/behandelportal — behandeling + +**No findings of its own.** Consumer side of **BIO-011**: +`domain/werkvoorraad-item-view.ts:28` renders the unmasked BSN for every queue row while +`domain/beoordeling.ts:26` correctly documents its own field as server-masked. Positively: +`infrastructure/beoordeling.adapter.ts:87-97` rejects the payload at the parse boundary if +`decisions.canBesluiten` is absent — deny-by-default at the wire, which is the right reflex. + +## apps/behandelportal — shell, root + +**BIO-002** (the `app.config.ts` interceptor gate). No other findings — routing, providers and +nav config only. + +## libs/shared — domain + +**No findings.** `capability.ts`, `role.ts`, `feature-flag.ts` — 30 lines of type declarations +with no executable statement (agent 02's verified correction to BL-004). The capability names +are enforced at runtime by `parseMe`, which is exported and spec'd. + +## libs/shared — application + +**No findings.** Both access-control primitives were read and are correct: +`access.store.ts:34-37` is deny-by-default (`rd.tag === 'Success' && rd.value.includes(cap)`), +and `whenReady()` (`:50-53`) exists specifically so the guard cannot read `can()` mid-load and +deny an entitled user. `auth.guard.ts:22-47` (already moved here per agent 06's ADR-C-006) +reads only `SESSION_PORT` and `AccessStore` and documents itself accurately as "the UX +pre-gate. The backend re-enforces regardless (403)". **That claim was verified endpoint by +endpoint for the admin surfaces and holds:** `/beheer/stamdata` → `StamdataAdmin` +(`Program.cs:164,173`), `/beheer/zaken` → `CasesAdmin` (`:425,554`), `/beheer/audit` → +`CasesAdmin` (`:566`), `/beheer/functies` → `FlagsAdmin` (`:592`), `/brief/huisstijl` → +`OrgAdmin` (`:726,732,739,751,764,703`), `/aanvraag/:id` → `Beoordelen` (`:446,473`). Every +capability the guard checks has a server-side twin. The exceptions are in `Program.cs`, not +here: BIO-003 (a gate outside `Authz`), BIO-004 and BIO-005 (endpoints with no gate at all) — +and none of those has a `capabilityGuard` claiming to front it. + +## libs/shared — infrastructure + +**BIO-012** — `role.ts:23-31` and `subject.ts:23-29` are the ungated readers, and +`subject.ts` is where a BSN reaches `sessionStorage`. Otherwise clean: +`api-client.provider.ts:58` attaches the Idempotency-Key only when `method !== 'GET'`, and +`:66`'s `retry({count: 2})` is GET-only. + +## libs/shared — ui + +**No findings.** No network, no storage, no PII decision. The `masked-value` atom renders what +it is given; the masking itself lives in `kernel/pii.ts`. + +## libs/shared — layout + +**No findings.** `shell.component.ts:73,79` is the dev-panel gate and it is correct +(see ssp/shell above). + +## libs/shared — kernel + +**No findings, and this is the reference standard.** `bsn.ts` is a "parse, don't validate" +value object whose doc comment correctly classifies a BSN as "art. 9 GDPR/AVG +special-category data" and correctly scopes itself to format+elfproef ("identity is still +faked in this POC"). `pii.ts`'s `maskTail`/`maskBsn` are pure, and the backend keeps a +verified twin (`Program.cs:861-863`) so wire redaction and UI redaction agree. §3a: 96.4% +line / 90.0% branch, §3b **100% reach** — the best-covered module in the repo. + +## libs/shared — upload + +**No findings in this remit**, but two dependencies to record: the document bytes this module +moves are the objects **BIO-004** serves without a check and **BIO-014** stores unencrypted. +`upload.adapter.ts:216-217`'s server-side counterpart enforces the content-type allow-list +authoritatively, so the FE's accept filter is UX only — the correct division. Agent 02's +TE-005 (`uploadOutcome`) is an 8.26-relevant boundary; see the compliance-review section. + +## libs/shared — testing + +**No findings.** Kept out of production by the `no-testing-in-production` dependency-cruiser +rule (§6, 0 violations). + +## libs/shared — environments + +**No findings.** `apiBaseUrl` only; no credentials, no keys. + +## libs/beheer + +**No findings of its own.** It is the display surface for **BIO-008**: `ui/audit.page.ts:10-12` +describes the trail as "data-minimised, no PII" and renders the `resource` column that today +contains a BSN. `ui/feature-flags.page.ts:93` is the caller of the unaudited admin write in +**BIO-007**. `infrastructure/stamdata.adapter.ts:16-20` is genuinely read-only, which is the +positive ADR-0004 note in §6. + +## backend/Program.cs + +**BIO-003, BIO-004, BIO-005, BIO-006, BIO-007, BIO-013, BIO-015, BIO-019.** Eight of the +twenty findings land in one 940-line file, which is itself the observation: **BL-003** records +it as "the single largest complexity concentration in the repo", and the six authorization +wrappers (`OrgAdmin`, `StamdataAdmin`, `CasesAdmin`, `Beoordelen`, `FlagsAdmin`, plus the +orphaned `IsAdmin`) sit 500 lines below the endpoints they gate, with nothing that enumerates +which endpoint uses which. That is the structural condition under which BIO-003/004/005 exist, +and it is why BIO-016's proposed route-table test is worth more than three individual fixes. + +## backend/Domain + +**BIO-001, BIO-002, BIO-006** — all three live in `Domain/Authorization/`. The rest of the +folder is clean and its design is right: `Authz` is a single source of truth where it is used, +the same function both emits the decision flag and gates the mutation +(`Authz.Decisions` ↔ `Authz.CanActOn` ↔ `BriefStore.Review`), and the four-eyes rule +(`CanActOn(Approve, …) => principal.Role == Approver && ActingId(principal) != drafterId`) is +a real segregation-of-duty control, correctly ordered Forbidden-before-Conflict. +`CanBeoordelen(CallerIdentity)` is deliberately caller-kind-derived rather than role-derived +and is the one capability that a forged `X-Role` cannot reach — the right instinct, and the +reason BIO-002 fails closed in that direction. `Domain/` is EF-free and ASP-free (§7), though +nothing enforces it (**BL-006**). + +## backend/Data + +**BIO-008, BIO-009, BIO-014, BIO-018.** All four are storage-side data-handling items rather +than logic defects; the store shape itself (static, `Db.Create()`-per-call, documented in +`Data/Db.cs:6-12`) is out of scope here and is deliberately not challenged — agent 02 reaches +the same conclusion from the testability side. + +## backend/Zgw + +**BIO-010** only. Otherwise the strongest module in the backend for this pass: the ACL is +complete (ADR-0005, fully conformed per agent 06), the client secret is BFF-only and never +reaches the browser, tokens are short-lived and minted per call with no stored refresh +credential, the inbound notification webhook fails closed on an unconfigured secret +(`ZgwOptions.NotificatieAuthorization`: "Empty (the default) means every notification is +rejected — an unconfigured secret must never mean 'accept anything'"), and the diagnostic +handler logs no bodies and is opt-in. §3c: 98.1% line / **85.5% branch, the best on the +backend**. + +## backend/Contracts + +**BIO-011** — `Mappers.cs:74` is the single line that decides whether a cross-owner list ships +a raw BSN, so the fix is one expression here rather than at each endpoint. No other findings: +`Dtos.cs` contains no `Bsn` field by name (verified by grep) — the BSN travels only as +`Owner`, which is exactly the field BIO-011 addresses. + +## backend/Stamdata + +**BIO-019** only, and it is a low-severity parse gap. The module is otherwise the best +change-control story in the repo — see the positive finding in §6. + +--- + +# Compliance review required + +**This is the mandatory flag. Agent 08 must carry every row into `99-backlog.md`, attached to +the finding, not filed as a separate ticket.** These are findings from agents 02, 04 and 06 +that touch a control area in §0. A flag is not a rejection: each of these should proceed, with +a compliance acceptance criterion added to its definition of done. + +| Their ID | Their title (abbrev.) | Control touched | Why it is flagged, and what the added acceptance criterion must be | +| ------------------- | ---------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **TE-001** | `SessionStore.restore()` reads `localStorage` inline | 5.13, 8.29 | The extracted `parseStoredSession` **is** the G1 PII guard. Ship it with an explicit assertion that a stored `{"bsn":…}` yields `bsn: ''` — see **BIO-017**. Without that assertion the ticket moves the guard without testing it. Lands twice (both apps). | +| **TE-002** | Trust boundary hidden in a global-`fetch` method | 5.12, 8.26, 8.15 | The boundary guards a **PII reveal**. `reveal-bignummer.adapter.ts` is also the site of **BIO-012** (`X-Role` ungated) and **BIO-006** (`X-Step-Up: 'true'` literal). Fix all three in one touch of the file, or the next reviewer will re-open it. | +| **TE-003** | `UploadTransport` port declared, concrete class injected | 5.12, 8.26 | The transport carries identity-document bytes. Introducing `UPLOAD_TRANSPORT` must not make it possible to substitute a transport in a production build — token factory only, no route/query override. | +| **TE-004** | `createUploadController` injects + binds at call time | 8.26 | `planFileSelection` is the client-side accept/reject decision. It is **UX only**; the ticket must not let it read as authority — the server allow-list (`DocumentRules.RejectUpload`) stays the authority. State that in the moved function's docstring. | +| **TE-005** | `xhrUpload` interprets responses inside an XHR closure | 8.26 | `uploadOutcome` is a trust boundary on an untrusted response. Also: the extraction must not move `parseError`'s ProblemDetails text into a place where it can be logged with a filename. | +| **TE-006** | Blob-to-browser handoff inlined in 3 commands | 5.13 | The blobs are **rendered letters containing name, BIG-nummer and address**, and the stamdata download is an admin export. `BLOB_PRESENTER` must not gain any persistence or caching; recording fakes belong in specs only. | +| **TE-008** | Brief transition rules live inside store methods | **9.4** | The five guards being extracted include the authorization ones, and `Authz.CanActOn` (the **four-eyes / SoD** rule) sits one line away at `BriefStore.cs:163`. Acceptance: `BriefRules` must **call** `Authz`, never re-implement it, and `tests/Domain/BriefRuleTests.cs` must cover approver == drafter. | +| **CQ-002** | 2 read stores write without the fold; errors dropped | 8.15, 8.16 | One of the two is `AdminCasesStore.delete` — a **destructive admin action** whose failure is currently silent. The error surfacing this ticket adds is the FE half of **BIO-007**'s server-side audit gap. Ship them aware of each other. | +| **CQ-003 / CQ-005** | `runSubmit` (write fold + idempotency mint) used for reads | 9.4, 8.26 | Splitting `runResult`/`runSubmit` narrows who mints an `Idempotency-Key`, which is the key **BIO-018** shows is an unscoped cache key. Sequence CQ-003 before BIO-018 so the scoping change lands on a smaller call set. | +| **CQ-004** | `FeatureFlagStore.set` skips the fold, drops the error | 8.15, 8.32 | A `flags:manage` admin write that fails silently **and** writes no audit row (**BIO-007**). This is the concrete case that fails clause (4) of agent 06's **ADR-C-009** four-part test. Fix the FE error and the BE audit row together. | +| **CQ-006** | Read/write banner split abandoned in 5 of 7 sections | **9.4** | A large-diff, zero-semantic-change reordering across **all 48 endpoint mappings**, including every authorization wrapper call site. Acceptance: `AuthzTests`, `AuthzAuditTests`, `WerkvoorraadTests`, `OrgTemplateEndpointTests`, `StamdataEndpointTests` and `AdminCasesTests` all green, **and** a reviewer confirms each moved endpoint kept its gate. Land it alone, as CQ-006 already says. | +| **CQ-007** | `GET /brief` creates a row | 8.26, 9.4 | A GET with a persisted side effect, auto-retried by the FE. Either fix is acceptable to compliance; the documentation-only alternative is **not** — a non-idempotent GET must be visible in the code, not only in a ticket. | +| **ADR-C-002** | `libs/shared/src/upload/` does network outside `infrastructure/` | 5.12 | Moving the adapter that carries identity documents. Mechanical, but the acceptance criterion (deleting the depcruise carve-out) must not be met by widening a different rule. | +| **ADR-C-004** | The `Principal` union never landed | **9.2, 9.4** | This is the natural home for **BIO-002**. Acceptance must include: the behandelportal's identity works in a **production** build, and `IIdentityProvider` can express "no identity". Landing `Principal` on the FE alone would close the ADR and leave BIO-002 open. | +| **ADR-C-006** | Extract the actor-agnostic route guards to `libs/shared` | **9.4** | Already present in the tree at `libs/shared/src/application/auth.guard.ts` — verify the state before ticketing. Any future change to `authGuard`/`capabilityGuard` is an access-control change and needs the guard spec re-run for both apps. | +| **ADR-C-009** | Generalise the runtime-editable-config exception | **8.32, 9.4** | Agent 06's proposed clause (4) is "writes are admin-capability-gated **and audited**". Today they are gated but **not audited** (**BIO-007**). Either the ADR amendment lands with BIO-007, or the amendment ratifies a control the code does not implement. | + +**Not flagged** (read and judged to touch no control in §0): TE-007, TE-009, CQ-001, +ADR-C-001, ADR-C-003, ADR-C-005, ADR-C-007, ADR-C-008, ADR-C-010, ADR-C-011. + +--- + +# Pre-production compliance checklist + +The **production gate** items, as a checklist. None of these is a defect in the POC; every one +must be true before this system holds real BSNs. Ordered by dependency, not by severity. + +**Identity and access (9.1, 9.2, 9.4)** + +- [ ] **Replace `StubIdentityProvider`** with a provider built from verified DigiD claims + (zorgverlener) and verified employee-SSO/eHerkenning claims (medewerker). `X-Role`, + `X-Subject`, `X-Medewerker`, `X-Rollen` and `X-Admin` are removed as inputs, not merely + ignored. — **BIO-001** +- [ ] **`IIdentityProvider` can express "no identity"**, and an unauthenticated request is + rejected rather than defaulted. No code path may resolve a caller from a constant. — + **BIO-002** +- [ ] **The behandelportal has a non-dev identity path.** Verify by building both apps with + `--configuration production` and confirming the backoffice cannot act as a citizen. — + **BIO-002** (see also ADR-C-004) +- [ ] **A startup assertion** fails the app in Production if the resolved `IIdentityProvider` + is the stub. — **BIO-001** +- [ ] **Row-level scoping** (PRD-0002 §5b) on every read that returns person data. Acceptance: + a second seeded citizen cannot see the first's dashboard, notes, BRP address or diplomas. + — **BIO-013** +- [ ] **The PII-reveal capability comes from the app overlay, not the coarse role**, and is not + held by the default role. — **BIO-006** +- [ ] **Real step-up.** `X-Step-Up` is replaced by a server-verified assurance/recency + attribute; no client may satisfy it with a constant. — **BIO-006** + +**Cryptography (8.24)** + +- [ ] **Encryption at rest** for the database, with documented key custody and rotation. — + **BIO-014** +- [ ] **Document bytes move to encrypted object storage** keyed by `DocumentId` (the code + already names this as the target). — **BIO-014** +- [ ] **TLS everywhere**: `UseHttpsRedirection` + HSTS at the edge, and no plaintext listener. + — **BIO-015** +- [ ] **Security response headers**: `X-Content-Type-Options: nosniff`, CSP, + `Referrer-Policy`, and a real `AllowedHosts`. — **BIO-015** +- [ ] **Swagger and the OpenAPI document are Development-only.** — **BIO-015** + +**Logging, monitoring and retention (8.15, 8.16)** + +- [ ] **Every authorization-relevant event is audited on the allow path too** — admin + mutations, brief approvals/rejections, besluiten, PII reveals. — **BIO-007** +- [ ] **No BSN in any audit row, log line or persisted error field**, enforced by a test that + asserts on **values**, not column names. — **BIO-008, BIO-009, BIO-010** +- [ ] **Audit retention, integrity and access** are defined: how long, append-only, who may + read `/beheer/audit` (today it reuses `cases:manage`, which `Program.cs:565` already + flags as a placeholder for a dedicated `audit:read`). +- [ ] **Log shipping and alerting** — the audit trail is a SQLite table with no export path + today. + +**Data protection (5.12, 5.13)** + +- [ ] **A DPIA** covering BSN, uploaded identity documents and the BIG register, with a + documented lawful basis and retention schedule. Nothing in the repo covers this. +- [ ] **Data minimisation on every list endpoint** — no unmasked BSN as a default field. — + **BIO-011** +- [ ] **Deletion / retention** for uploaded documents and the audit trail. `AdminDelete` + exists; no retention policy does. + +**Secure development (8.25, 8.28, 8.29)** + +- [ ] **Backend dependency vulnerability scanning** in CI. — **BIO-016** +- [ ] **Secret scanning** in CI. — **BIO-016** +- [ ] **An authorization regression gate**: a test that enumerates the route table and asserts + every route passes an authorization wrapper or is on an explicit allow-list. — **BIO-016** +- [ ] **Backend architecture enforcement** (NetArchTest/ArchUnitNET) so `Domain/` purity, the + "ZGW shapes stay in `Zgw/`" property (ADR-0005) and "authorization lives in `Authz`" are + CI-maintained rather than review-maintained. — **BL-006** +- [ ] **A coverage ratchet**, so a security fix can be verified as not regressed by CI. — + **BL-009** +- [ ] **Penetration test / DAST** before go-live, with BIO-004's object-level authorization and + BIO-005's document linking as named test cases. + +**Change control (8.32)** + +- [ ] **A production build and deployment artifact exists** (`--configuration production`, + `ASPNETCORE_ENVIRONMENT=Production`), separate from the demo compose file, and its + release checklist references this list. — **BIO-020** +- [ ] **Verify by build, not by reading**: in a production bundle, `?role=`, `?subject=`, + `?scenario=`, `?rollen=` and the `⚙ state` panel are all inert — including on the three + hand-written `fetch` paths. — **BIO-012** + +--- + +## Summary + +| ID | Title | Control | Class | Sev. | Module | Effort | +| ----------- | -------------------------------------------------------------------- | ---------- | --------------- | ------ | ----------------------- | ------ | +| **BIO-001** | Backend trusts client-asserted identity headers in every environment | 9.2, 9.4 | production gate | high | BE/Domain+Program | S | +| **BIO-002** | Production backoffice has no identity; default is the seeded citizen | 9.4 | production gate | high | bhp/auth+root, BE | S | +| **BIO-003** | `X-Admin` is a second, unaudited gate outside `Authz` | 9.4, 8.15 | **defect now** | medium | BE/Program | S | +| **BIO-004** | `GET /uploads/{id}/content` + `/uploads/status` have no authz check | 9.4, 5.12 | **defect now** | high | BE/Program+Data | S | +| **BIO-005** | `POST /registrations` links arbitrary documents, unscoped | 9.4, 8.26 | **defect now** | medium | BE/Program | S | +| **BIO-006** | Reveal capability on the default role; step-up is a client constant | 9.4 | production gate | medium | BE/Domain, ssp/brief | S | +| **BIO-007** | Only denied decisions are audited; admin writes leave no trail | 8.15, 8.16 | **defect now** | medium | BE/Program | S–M | +| **BIO-008** | BSN written into the authz audit trail's `Resource` column | 8.15, 5.12 | **defect now** | high | BE/Program+Data | S | +| **BIO-009** | BSN is the `Actor` on every document audit row | 8.15, 5.12 | **defect now** | medium | BE/Data | S | +| **BIO-010** | BSN reaches logs + a persisted field via the ZGW error path | 8.15, 5.12 | **defect now** | medium | BE/Zgw+Program | S | +| **BIO-011** | Cross-owner lists ship unmasked BSNs; the detail masks | 5.12, 5.13 | **defect now** | medium | BE/Contracts | S | +| **BIO-012** | `?role=` / `?subject=` reach production on 3 hand-written fetches | 5.13, 9.4 | **defect now** | medium | ssp/brief, shared/infra | S | +| **BIO-013** | Seeded-citizen endpoints ignore the caller (no row scoping) | 9.4, 5.12 | production gate | medium | BE/Program | M | +| **BIO-014** | No encryption at rest for bytes, BSNs and the audit trail | 8.24 | production gate | high | BE/Data | L | +| **BIO-015** | No TLS/HSTS/nosniff/CSP; Swagger + `AllowedHosts:*` unconditional | 8.24, 8.28 | production gate | medium | BE/Program | S | +| **BIO-016** | CI security gates: semgrep+audit present; 5 gaps | 8.25/28/29 | production gate | medium | repo (CI) | S–M | +| **BIO-017** | Two PII guards have no executable test | 8.29, 5.13 | **defect now** | low | ssp/auth, ssp/shell | S | +| **BIO-018** | `IdempotencyStore` unscoped by caller, no TTL, unbounded | 9.4, 8.26 | **defect now** | low | BE/Data | S | +| **BIO-019** | `?peildatum=` 500s on unparseable input | 8.26 | **defect now** | low | BE/Stamdata | S | +| **BIO-020** | The only deployment artifact builds development bundles | 8.32 | production gate | medium | repo (compose) | M | + +**Twelve defect-now findings** (BIO-003, 004, 005, 007, 008, 009, 010, 011, 012, 017, 018, 019 +— BIO-006's step-up sub-item is counted inside its production gate), **eight production gates**. +Fifteen of the twenty are effort **S**. + +**Modules with no findings of their own:** ssp/registratie · ssp/herregistratie · +bhp/behandeling · libs/shared/{domain, application, ui, layout, kernel, upload, testing, +environments} · libs/beheer. + +**Recorded as correct, so a later pass does not "fix" them:** the deny-by-default +`AccessStore.can()` + `whenReady()` pair; `capabilityGuard`'s "UX pre-gate, backend +re-enforces" claim, verified endpoint by endpoint for all six admin surfaces; +`Authz.CanBeoordelen`'s caller-kind derivation (the one capability a forged `X-Role` cannot +reach); the four-eyes rule in `Authz.CanActOn` with Forbidden-before-Conflict ordering; the +`isDevMode()` gate on the debug panel and the interceptor chain; the ZGW secret never reaching +the browser and the notification webhook failing closed on an unset secret; the upload +content-type allow-list enforced server-side; stamdata having no runtime write endpoint at +all; and `libs/shared/src/kernel/{bsn,pii}.ts`, which are the standard the rest should be +measured against. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md new file mode 100644 index 0000000..5bd4177 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md @@ -0,0 +1,437 @@ +## Scope: all findings from 00-baseline, 02-testability, 04-cqrs-light, 06-adr-conformance, 07-bio2-compliance — deduplicated, scored, CD-sequenced + +## Status: complete + +## Last updated: 2026-08-27 + +## Depends on: 00-baseline.md, 02-testability.md, 04-cqrs-light.md, 06-adr-conformance.md, 07-bio2-compliance.md + +## --- + +# 99 — Consolidated refactoring backlog + +**47 findings in, 33 open tickets + 5 ADR-fixes + 1 shipped set out.** Everything below +traces to at least one `TE-`/`CQ-`/`ADR-C-`/`BIO-` finding and cites a baseline metric. + +**HALT lifted 2026-08-27** — the operator approved the backlog and Phase 3 started. +**CD batch 1 (RB-01..RB-06) is implemented**, one commit per ticket on branch +`refactor/adr-c-006-shared-route-guards`, each with a note in `implementation/rb-0N.md`. +Batches 2–6 are untouched. The `Status` column below is the source of truth. + +Two batch-1 findings had knock-on effects a later ticket must not re-derive: + +- **RB-01's residual is RB-09's problem.** Both callers of the document-content endpoint + reach it as a plain browser navigation (`` / `previewUrl`), carrying no identity + header and passing through no interceptor, so `StubIdentityProvider` answers with the + seeded citizen. The links keep working only because one citizen owns every document in + the POC. That is BIO-002; RB-09 needs this endpoint to receive a real credential. +- **RB-06 also deleted `SubmissionRules.RejectRegistratie`**, which the row did not ask for. + It was reachable only from the deleted endpoint and contradicted by the live submit path. + Recorded as the ticket's one judgement call in `implementation/rb-06.md`. + +`Pii.MaskTail` now lives in `Domain/People/Pii.cs` (moved out of `Program.cs` by RB-03) — +**RB-11 and any later redaction work should use it rather than hand-rolling a second copy.** + +--- + +## Coverage of this backlog — read this before treating it as complete + +Three of the seven Phase 1 agents were **deliberately skipped** by the operator +(reasons recorded in `_status.md`). This backlog therefore contains **no findings of the +following kinds**, and their absence is not evidence that none exist: + +| Agent not run | Category of finding that is absent | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **01 — readability** | Function/file length, naming, nesting depth, comment quality, dead code, test readability. No ticket below is a "this is too long/unclear" ticket. | +| **03 — DDD/hexagonal** | Backend layering, vertical-slice structure, port extraction, module boundaries. The backend's structure is untouched except where CQRS-light reached it. | +| **05 — BDD** | Nothing material — the agent self-reduced to a structural note; `gen:behaviour-spec` already covers the intent. | + +Concrete consequences, so nobody assumes these were considered and dismissed: + +- **`createDraftSync` (143 lines, the longest function in the repo, §4a) is only partly + addressed.** RB-21 splits its read half out on CQRS grounds. Whether the remainder is + still too long was never assessed. +- **The other named length/complexity candidates have no owner:** + `api-client.provider.ts:49 fetch` (CC 19) and `rich-text-dom.ts:130 collect` (CC 11) — + the only two CC>10 functions outside the mandated idioms per **BL-001**; the 293-line + CC-20 test method in `OpenZaakZaakSourceTests.cs`; and the six files over 400 lines + (§9). RB-19 reorders `Program.cs` but does not shorten it. +- **Backend structure was assessed only through the CQRS-light lens.** **BL-003**'s + invitation (940 lines → `Features/`) is filed as out-of-mandate **OOM-A**, not a ticket. + **BL-010** (`libs/shared/upload/` outside the layer convention) is resolved only + incidentally, by RB-24, which came from the ADR agent rather than the structure agent. +- **Two baseline observations remain unowned by any agent:** **BL-005** (backend branch + coverage 18 points behind line coverage; `Contracts` 65.0%, `Stamdata` 71.7%, `Data` + 75.5% — `backend/tests/` has no `Contracts/` folder at all) and **BL-009** (no coverage + ratchet anywhere). Neither is a testability _blocker_, so agent 02 correctly declined + both; they are coverage work with no seam to add, and no ticket below covers them. + +--- + +## Already done — implemented and committed, do not re-file + +Branch `refactor/adr-c-006-shared-route-guards`, five commits. + +| Finding | Commit subject | Status | Residual | +| ------------- | ----------------------------------------------------------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **ADR-C-005** | `docs(adr-0002): accept, and record the unbuilt Principal union as debt` | **implemented** | ADR-0002 is now `Accepted`, so **RB-13 (ADR-C-004) now stands on a correct ADR** — that was the whole point of the gate. | +| **ADR-C-006** | `refactor(auth): share the actor-agnostic route guards (ADR-C-006)` | **implemented** | Auth duplication **211 → 151 lines**. §5's `ssp/auth 100% / bhp/auth 86.8%` rows and the `auth.guard*` clone pairs in the baseline are now **stale** — re-measure before citing them. Standing compliance criterion from agent 07: any future change to `authGuard`/`capabilityGuard` is an access-control change and must re-run the guard spec for both apps. | +| **CQ-004** | `fix(flags): surface a failed admin toggle instead of swallowing it` | **implemented** | **Half of its compliance criterion is unmet.** Agent 07 required "fix the FE error **and** the BE audit row together". The FE error shipped; `PUT /admin/flags/{key}` still writes **no** audit row. That half is carried by **RB-07**, and it is why **ADR-C-009** must not be signed off before RB-07 lands. | +| **TE-009** | `fix(stamdata): evaluate the profession validity window per call, not at type-load` | **implemented** | Also closed the latent dead-`ActiveOn`-branch bug. Not compliance-flagged. | +| **BL-008** | `build: make coverageExclude actually exclude the generated API client` | **implemented** | The reported `libs/shared/infrastructure` figure should now read ≈94.7%, not 6.9%. §3a is stale on that row. | + +**Correction to the hand-off.** The brief listed "CQ-002/004 (`FeatureFlagStore.set`)" as +fixed. Only **CQ-004** was — `FeatureFlagStore.set` is the CQ-004 subject. **CQ-002** +(`ApplicationsStore.cancel`, `AdminCasesStore.delete`) is **verified still open**: both +still do `try { await this.adapter.x(id) } catch { this.state.set(before) }` with no +`runSubmit`, no `Result`, and no error channel. It is filed below as **RB-20**. + +--- + +# The backlog + +**How to read the CD batch column.** A batch is a _suggested ordering wave_, not a release +train. Every ticket in the table ships **alone**, on its own merge, without any other +ticket in its batch. Where a ticket genuinely cannot ship alone it was split into a chain +(RB-22/RB-23) — see "Tickets that were rejected and split". `Depends on` means _must be +deployed first_, not _must ship together_. + +**Compliance column.** `SIGN-OFF` = requires compliance sign-off before merge, per rule 4. +Every ticket tracing to a `BIO-` finding, plus every row on agent 07's authoritative +16-row "Compliance review required" list, carries it — regardless of priority. + +| ID | Module | Category | Description | Baseline metric improved | Effort | Risk | Priority | CD batch # | Depends on | Compliance | Status | +| --------- | -------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | ---------- | ---------- | ------------ | -------- | +| **RB-01** | backend/Program.cs + Data | security | Add an owner/capability check to `GET /uploads/{id}/content` and `/uploads/status`; 404 not 403 | §3c Data 75.5% branch vs 99.0% line (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-02** | backend/Program.cs + Data | privacy | Stop concatenating the BSN into `AuthzAudit.Resource`; assert on **values** in the test | §3c Data 75.5% branch (BL-005) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-03** | backend/Contracts | privacy | `MaskTail(a.Owner, 3)` in `ToAdminSummaryDto` — both cross-owner lists inherit it | §3a bhp/behandeling 91.6%/81.5%; §7 Mapping row | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-04** | backend/Data | privacy | Mask the BSN used as `AuditEntry.Actor` on document audit rows (ownership column untouched) | §3c Data 99.0% line / 75.5% branch | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-05** | backend/Zgw | privacy | Drop the BSN-bearing query + body snippet from the `ZgwHttpClient` exception message | §3c Zgw 98.1%/85.5% (best backend branch) — a design gap, not a test gap | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-06** | backend/Program.cs | security | Delete the dead `POST /registrations` (no FE caller) — or add the `ForeignIds` guard | BL-003 (48 mappings in 940 lines, file CC 78) | S | Low | **P1** | 1 | — | **SIGN-OFF** | **done** | +| **RB-07** | backend/Program.cs | audit | Audit the **allow** path in all five authz gates + the 3 brief transitions and the besluit | §3c Program.cs 84.8% branch; BL-003 | S–M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-08** | backend/Program.cs | security | Route `DELETE /admin/uploads/{id}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate | BL-003; §7 CQRS-light wrappers row | S | Low | **P1** | 2 | RB-07 | **SIGN-OFF** | **done** | +| **RB-09** | backend/Domain + Program.cs | security | `IIdentityProvider` can express "no identity"; stub Development-only; fail fast in Production | §7 "Single-impl interface `IIdentityProvider`"; BL-006 | S | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-10** | ssp/auth + bhp/auth + ssp/shell | testability | Extract `parseStoredSession` (×2 apps) + spec `redactProfile`; assert a stored BSN yields `''` | §3a auth 42.9%/46.2% (worst FE line, §8); file LH 2/LF 20, BRH 3/BRF 13 | S | Low | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-11** | ssp/brief + libs/shared/infra | security | Dev hatches out of prod on the 3 hand-written `fetch` paths; export their parse boundaries; fix the doc | §3b ssp/brief 42% reach (11/26, none `ui/`); §3a 68.8% branch | M | Med | **P1** | 2 | — | **SIGN-OFF** | **done** | +| **RB-12** | backend/tests (CI) | security gate | One test enumerating the route table; every route hits an authz wrapper or an explicit allow-list | BL-006 (zero backend architecture enforcement) | M | Low | **P1** | 3 | — | **SIGN-OFF** | **done** | +| **RB-13** | ssp/auth + bhp/auth | ADR execution | Land `Session → Principal`; `MedewerkerAdapter`; backoffice login stops being a DigiD/BSN form | BL-002 (211→151 dup after ADR-C-006; expected <40 after this) | M | Med | **P1** | 3 | RB-09 | **SIGN-OFF** | **done** | +| **RB-14** | repo (CI) | security gate | `dotnet list package --vulnerable --include-transitive` as a failing step | BL-006; §7 (the .NET tree is entirely unscanned today) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-15** | backend/Program.cs | security | Wrap Swagger + the OpenAPI document in `if (app.Environment.IsDevelopment())` | BL-003; §3c Program.cs 97.4%/84.8% | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-16** | backend/Stamdata | input valid. | `DateOnly.TryParse` on `?peildatum=` → 400 instead of an unhandled 500 | §3c Stamdata 96.8% line / **71.7% branch** (BL-005) | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-17** | libs/shared/app + brief + beheer | CQRS-light | Split `runResult` (fold) from `runSubmit` (fold + idempotency mint); point the 5 reads at it | BL-007; §7 "read adapters 20 / mutations inline ~13" | S | Low | P2 | 3 | — | **SIGN-OFF** | **done** | +| **RB-18** | backend/Data | security | Key `IdempotencyStore` on `{SubjectId}:{idemKey}` | §7 stores "Not behind any port"; agent 02's Data note (no TTL, no reset) | S | Low | P2 | 3 | RB-17 | **SIGN-OFF** | **done** | +| **RB-19** | backend/Program.cs | structure | Reorder all 48 endpoints under read/write sub-banners; regroup admin-cases + org-template preview | BL-003 (940 lines, file CC 78 vs next-highest 27) | S | **High** | P2 | 4 | RB-12 | **SIGN-OFF** | **done** | +| **RB-20** | ssp/registratie | CQRS-light | `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`; surface the error | BL-007; §7 "Command factories 3" | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-21** | ssp/registratie | CQRS-light | Extract the read half of `createDraftSync` into `application/find-concept.ts` | §4a `createDraftSync` 143 lines — longest fn in the repo; §9 (>40) | M | Med | P2 | 4 | — | — | **done** | +| **RB-22** | ssp/brief | CQRS-light | _(expand)_ `BriefStore.load()` tolerates a 404 by calling the existing `reset()` once | BL-003; §7 Backend CQRS-light row | S | Low | P2 | 4 | — | **SIGN-OFF** | **done** | +| **RB-23** | backend/Program.cs + Data | CQRS-light | _(contract)_ `GET /brief` 404s when absent; `GetOrCreate` → `Get` | BL-003; §7 Backend CQRS-light row | S | Med | P2 | 4 | RB-22 | **SIGN-OFF** | **done** | +| **RB-24** | libs/shared/upload | ADR conform. | Move `upload/` into `infrastructure`/`domain`/`application`; **delete** the depcruise carve-out | BL-010; §7 "+1 adapter outside `infrastructure/`", "8 of 9 machines in `domain/`"; §3b shared/domain 0% reach | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | +| **RB-25** | libs/shared/upload | testability | `UPLOAD_TRANSPORT` injection token (the `SESSION_PORT` shape) instead of `inject(KeepaliveTransport)` | §3a upload 52.0%/50.0%; §3b file unreached, non-`ui/` | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | +| **RB-26** | libs/shared/upload | testability | Move the accept/reject decision to `planFileSelection` in `upload.machine.ts` | §3a upload 52.0%/50.0%; §4a module max CC 27 | S | Low | P2 | 5 | RB-24 | **SIGN-OFF** | **done** | +| **RB-27** | libs/shared/upload | testability | Extract `uploadOutcome(status, responseText)` out of the XHR closure | file LH 5/64 (**7.8% line**), BRH 3/57 (**5.3% branch**) | S–M | Low | P2 | 5 | RB-25 | **SIGN-OFF** | **done** | +| **RB-28** | libs/beheer + ssp/brief | testability | `BLOB_PRESENTER` token; the 3 commands' success paths become assertable | §3a beheer/application **40.5% branch — worst FE**; brief.store BRH 32/64 | S–M | Low | P2 | 5 | — | **SIGN-OFF** | **done** | +| **RB-29** | backend/Domain | testability | Thread the existing `at` through `LetterHtml.ResolveAuto` instead of reading `UtcNow` | §3c Domain 82.0% branch; §4b `LetterHtml.cs` CC 21 | S | Low | P2 | 5 | — | — | **done** | +| **RB-30** | backend/Data + Domain | testability | Extract 5 brief guards into `Domain/Letters/BriefRules.cs`; add `tests/Domain/BriefRuleTests.cs` | §3c Data **75.5% branch** (BL-005); §4b `BriefStore.cs` CC 17, `ToDto` CC 16 | M | Med | P2 | 5 | — | **SIGN-OFF** | **done** | +| **RB-31** | 4 app contexts (specs only) | ADR conform. | Replace hand-rolled state literals with `given(reduce, initial)` replays in 4 machine specs | §7 Elm machines 9 (1 has a `*.testing.ts`); §3a herreg 67.8% / brief 68.8% branch | M | Low | P2 | 6 | — | — | **done** | +| **RB-32** | libs/shared/docs | ADR conform. | Add the missing `language-switcher` row to the CIBG gap register (9 markers vs 8 rows) | §2 libs/shared 86 files / 5 194 lines; §6 layout Ca 22 | S | Low | P3 | 6 | — | — | **done** | +| **RB-33** | libs/shared/testing | ADR conform. | Adopt `unwrapOk` at its one call site — **or delete it**; both satisfy ADR-0006 §3 | BL-004; §3a libs/shared/testing 3 files, 100% line | S | Low | P3 | 6 | — | — | **done** | + +--- + +## Notes on the table + +**Why P1 is not simply "everything".** Rule 2's P1 definition ("violates a correct ADR, +blocks testability, or is a BIO2 compliance risk") would catch nearly every finding, which +would make the score useless. It is applied as: **P1 = a control is broken, an accepted +ADR's decision is unexecuted, or a security-relevant guard has no test today.** A ticket +that is merely _flagged because it touches a control_ (TE-003/4/5/6/8, CQ-006, ADR-C-002) +is **P2 with mandatory sign-off** — the compliance risk is one the ticket could introduce, +not one that exists. That distinction is the whole reason rule 4 is orthogonal to rule 2. + +**RB-01 and RB-02 sort above every structural ticket** regardless of effort. Both are live +production-shaped defects, independently verified: a BSN concatenated into the persisted +authz audit `Resource` (`Program.cs:674`) and an unauthorized document-content endpoint +(`GET /uploads/{documentId}/content`). Four documents claim the audit trail holds no PII +and the test cited as enforcing it (`AuthzAuditTests.cs:51-53`) asserts on **column +names**, so the BSN travels in a column called `Resource` that the regex cannot see — the +value-asserting test is part of RB-02's definition of done, not a follow-up. + +**RB-11 ships the doc correction in the same diff as the code.** `?role=` and `?subject=` +are _not_ stripped from production builds on three hand-written `fetch` adapters, while +`docs/reference/roles-and-access.md:23` says "they do not exist in a production build". +Correcting the doc without the code, or the code without the doc, both leave the repo +lying about itself. `?subject=` additionally writes a **BSN into `sessionStorage`** in any +build, which is the specific thing `SessionStore`'s G1 comment promises never happens. + +**RB-12 before RB-19, deliberately.** Agent 07 flags CQ-006 as needing the authz suites as +its safety net; agent 04 flags it as the prerequisite for OOM-A. RB-12's route-table test +is the check that "each moved endpoint kept its gate" is verified by CI rather than by a +reviewer's eye across a 900-line diff. RB-19 carries the only **High** risk in the table +for exactly that reason and must land alone, never mixed with a behaviour change. + +**RB-07 gates ADR-C-009, not the other way round.** Agent 06's proposed four-part test for +runtime-editable config includes "writes are admin-capability-gated **and audited**". +Today they are gated and not audited. Signing the ADR amendment first would ratify a +control the code does not implement. + +**RB-13's dependency on RB-09 is real, not stylistic.** Landing `Principal` on the +frontend alone closes ADR-C-004 and leaves BIO-002 wide open: a production behandelportal +build still resolves to the seeded **zorgverlener** — failing closed on backoffice +capabilities (correctly) but **open on every citizen-scoped endpoint** and holding +`CanRevealBigNummer`, because `drafter` is the no-header default. RB-09 makes "no +identity" representable at the interface; RB-13 is the FE half. + +--- + +## Merges — what was deduplicated, and how confident each merge is + +| Merged ticket | Findings folded in | Confidence | Reasoning | +| --------------- | ------------------------------------------ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **RB-10** | TE-001 + BIO-017 | **Certain** | Agent 07 says outright: "this is TE-001 plus one assertion; it does not need its own ticket if TE-001 is scheduled". BIO-017's second half (`redactProfile` spec) is a five-line spec in the same PII-guard category, so it rides along. | +| **RB-11** | BIO-012 + TE-002 + BIO-006(a) + BIO-006(b) | **Certain** | Agent 07 instructs: "Fix all three in one touch of the file, or the next reviewer will re-open it." All four land in the same three `fetch` adapters plus `role.ts`/`subject.ts` plus one doc line. BIO-006(b) is the same doc edit as BIO-012's. | +| **RB-09** | BIO-001(a) + BIO-001(b) + BIO-002 | **Certain** | BIO-001's own remediation _is_ (a) fail-fast + (b) "give `Resolve` a way to say no identity (see BIO-002)". BIO-002's root cause is the same non-nullable `Resolve`. One change, one file pair. | +| **RB-17** | CQ-003 + CQ-005 | **Certain** | Agent 04: "Fix them in one ticket; they are listed separately only because the module scope requires it." One shared-file split, five call sites. | +| **RB-14/12** | BIO-016 split into (a) and (b) | **Certain** | Two unrelated CI changes of different size and different value; the rest of BIO-016's "Absent" list is genuinely a production gate and stays on the checklist. | +| **RB-08** | BIO-003, sequenced behind RB-07 | High | Routing through `CasesAdmin` gives BIO-003's missing audit row for free **once** RB-07 has moved auditing to the allow path. Shipping BIO-003 first would mean writing the audit call twice. It can ship standalone if RB-07 slips. | +| **RB-18** | BIO-018, sequenced behind RB-17 | High | Agent 07: "Sequence CQ-003 before BIO-018 so the scoping change lands on a smaller call set." Not a merge, an ordering constraint. | +| **RB-25/26/27** | TE-003/004/005, sequenced behind RB-24 | **Judgement call** | Agent 04 argued BL-010 must be resolved before anything is layered onto the upload folder, and RB-24 (ADR-C-002) is the ticket that resolves it. But the three seams are each independently shippable **today**, against the current paths. If RB-24 is deferred or rejected, unblock all three — the dependency is hygiene, not correctness. | + +**Merges considered and rejected:** + +- **BIO-008 / BIO-009 / BIO-010 kept as three tickets (RB-02/04/05).** They share a theme + ("no BSN in any audit row, log line or persisted error field") and a shared acceptance + criterion (assert on **values**, e.g. no stored string matching `\d{9}`). They were not + merged because they sit in three modules with three different test suites, and BIO-010 + is conditional on `Zgw:Enabled` (off by default) which gives it a different risk profile. + Three one-line fixes that each ship alone beat one cross-module sweep. **If a reviewer + prefers one ticket, merging them is defensible** — this is the least settled call here. +- **CQ-002 not merged into BIO-007 (RB-07).** They are the two halves of the same + admin-mutation-observability gap, but one is FE error surfacing and the other is BE + auditing. Agent 07 asked only that they "ship aware of each other". Cross-referenced, + not merged. +- **`SessionStore` not merged across the TE-001 / residual-auth-duplication overlap.** + Both touch `session.store.ts`, but agent 06 is explicit that merging the two apps' + session stores now would cement a citizen DigiD/BSN login as the backoffice's login — + the exact outcome ADR-0002 §3 exists to prevent. RB-10 lands the same seam **twice**, on + purpose. The duplication question reopens only after RB-13, on re-measurement. +- **ADR-C-004 not merged into BIO-002.** Split into RB-09 (BE, S) → RB-13 (FE, M) instead, + because a single ticket spanning both would not be independently deployable. + +--- + +## Tickets that were rejected and split (rule 3) + +**CQ-007 → RB-22 then RB-23.** As filed, CQ-007 is the one finding agent 04 marked +"**no** — FE+BE together": the FE must handle a 404 that the BE does not yet return. +Shipping it as one ticket is a coordinated release. Split into the standard +expand/contract pair: + +1. **RB-22 (expand, FE).** `BriefStore.load()` tolerates a 404 by calling the existing + `reset()` command once. Deploys against today's backend as a **no-op** — the BE never + 404s, so the branch is dead on arrival and provably safe. +2. **RB-23 (contract, BE).** `GET /brief` returns 404 when no brief exists; + `BriefStore.GetOrCreate` splits into `Get` + the already-existing `ResetAndCreate`. + Deploys only once RB-22 is live. + +Agent 07 rejected CQ-007's documentation-only alternative outright: "a non-idempotent GET +must be visible in the code, not only in a ticket". That alternative is therefore **not** +on the table. + +**No other ticket failed the single-deploy test.** TE-001 lands in two apps but in one +merge; RB-24 touches 30 dependents but is one atomic move; RB-19 is a 900-line diff but +zero-semantic-change. + +--- + +# ADR-fix tickets — architect approval required before any dependent code ticket + +None of these five is a code change. All five change what the repo's architecture +documents _claim_. **Three of them require a matching CLAUDE.md correction in the same +diff** (CLAUDE.md's own precedence rule: "the docs win — update this file"). + +| ID | ADR | What the amendment does | Gates / blocks | CLAUDE.md edit? | Effort | Compliance | Status | +| ------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | ------ | ------------ | -------- | +| **ADR-C-001** | 0001 | Rewrite the worked example against the real backend; drop the 2 discharged out-of-scope bullets (every path it names no longer exists) | nothing | no | S | — | **done** | +| **ADR-C-003** | 0001 | State that the generated client **is** the wire contract post-codegen; scope `contracts/` to codegen gaps | any ticket deleting the 4 surviving `contracts/*.dto.ts`, or adding a hand-written DTO for a generated endpoint. **No open ticket below is blocked today** — recorded so a future one is. | **yes (§4)** | S | — | **done** | +| **ADR-C-007** | 0003 | Repoint 5 WP-67-stale paths; replace the **factually false** `app-alert` hand-rolled example (it wraps vendored `.feedback` classes) | nothing | **yes (§2)** | S | — | **done** | +| **ADR-C-009** | 0004 | Generalise "the org-templates exception" into a stated four-part test; list both passing surfaces | **RB-07.** Clause (4) is "writes are admin-capability-gated **and** audited". Today they are gated and _not_ audited — sign this before RB-07 and the ADR ratifies a control the code does not implement. | **yes (§4)** | S | **SIGN-OFF** | **done** | +| **ADR-C-005** | 0002 | _(already landed — see "Already done")_ | was the gate on RB-13; now cleared | — | — | — | **done** | + +**No ADR-fix is proposed against ADR-0002 §3's non-sharing rule.** Agent 06 considered it +as instructed and rejected it with evidence: `grep -rn "Principal" apps libs` returns one +comment and no type, so the rule was never _tested_, only _unexecuted_. Amending it now +would ratify the omission rather than the evidence. The correct sequence is +ADR-C-005 (done) → **RB-13** → **re-measure BL-002**; agent 06's expectation is that the +residual duplication drops from 151 lines to under 40 on its own. If RB-13 is still +unstarted at the next backlog cycle, _that_ is when the ADR-fix conversation becomes +legitimate. + +--- + +# Production gates — a release checklist, not tickets + +These are **correct for a POC** and must be true before the system holds real BSNs. They +are deliberately kept out of the ticket table: they are acceptance criteria for a release +that does not exist yet (there is no production build artifact at all — **BIO-020**), not +work that can be merged and deployed this week. Where a _part_ of a production-gate +finding was shippable now, that part was pulled out as a ticket and is named below. + +**Identity and access (9.1, 9.2, 9.4)** + +- [ ] Replace `StubIdentityProvider` with verified DigiD / employee-SSO claims. `X-Role`, + `X-Subject`, `X-Medewerker`, `X-Rollen`, `X-Admin` removed as **inputs**, not ignored. — BIO-001 +- [ ] Verify by building both apps `--configuration production` that the backoffice cannot + act as a citizen. — BIO-002 _(the interface half is **RB-09**; the FE half is **RB-13**)_ +- [ ] Row-level scoping on every read returning person data; acceptance = a second seeded + citizen cannot see the first's dashboard, notes, BRP address or diplomas. — BIO-013 +- [ ] The PII-reveal capability comes from the app overlay, not the coarse role, and is + **not held by the default role**. — BIO-006 _(the `X-Step-Up` literal is in **RB-11**)_ +- [ ] Real step-up: a server-verified assurance/recency attribute no client can satisfy + with a constant. — BIO-006 + +**Cryptography (8.24)** + +- [ ] Encryption at rest with documented key custody and rotation. — BIO-014 + **Prerequisite: RB-02/04/05 first**, so the BSN is not in three places that do not + need it before deciding what must be encrypted. +- [ ] Document bytes move to encrypted object storage keyed by `DocumentId`. — BIO-014 +- [ ] TLS everywhere: `UseHttpsRedirection` + HSTS at the edge. — BIO-015 +- [ ] Security response headers (`nosniff`, CSP, `Referrer-Policy`) and a real + `AllowedHosts`. — BIO-015 _(the Swagger gate is **RB-15**)_ + +**Logging, monitoring and retention (8.15, 8.16)** + +- [ ] Audit retention, integrity and access defined — how long, append-only, and who may + read `/beheer/audit` (it reuses `cases:manage`, which `Program.cs:565` already flags + as a placeholder for a dedicated `audit:read`). +- [ ] Log shipping and alerting — the audit trail is a SQLite table with no export path. +- [ ] _(Covered by tickets: allow-path auditing = **RB-07**; no BSN in any audit row, log + line or persisted error field = **RB-02/04/05**.)_ + +**Data protection (5.12, 5.13)** + +- [ ] A DPIA covering BSN, uploaded identity documents and the register, with lawful basis + and retention schedule. Nothing in the repo covers this. +- [ ] Deletion / retention policy for uploaded documents and the audit trail. +- [ ] _(Covered: data minimisation on list endpoints = **RB-03**.)_ + +**Secure development (8.25, 8.28, 8.29)** + +- [ ] Secret scanning in CI (prevention — nothing is committed today, verified). — BIO-016 +- [ ] Backend architecture enforcement (NetArchTest/ArchUnitNET) so `Domain/` purity, ZGW + containment (ADR-0005) and "authorization lives in `Authz`" are CI- rather than + review-maintained. — BL-006 +- [ ] A coverage ratchet, so a security fix can be verified as not regressed by CI. — BL-009 +- [ ] Penetration test / DAST, with BIO-004's object-level authorization and BIO-005's + document linking as named cases. +- [ ] _(Covered: backend dependency scanning = **RB-14**; the authorization regression gate + = **RB-12**.)_ + +**Change control (8.32)** + +- [ ] A production build and deployment artifact exists, separate from the demo compose + file, and its release checklist references this list. — BIO-020 +- [ ] Verify **by build, not by reading**: in a production bundle `?role=`, `?subject=`, + `?scenario=`, `?rollen=` and the `⚙ state` panel are all inert — including on the + three hand-written `fetch` paths. — BIO-012 _(the code fix is **RB-11**; this box is + the build-time proof)_ + +--- + +# Verified clean — do not "fix" + +Each of these was read and judged correct by the agent named. Re-checking them is wasted +effort; "simplifying" them is a regression. + +**Security and access control** (agent 07, verified endpoint by endpoint) + +- `AccessStore.can()` deny-by-default + `whenReady()` — the pair exists so the guard cannot + read `can()` mid-load and deny an entitled user. +- `capabilityGuard`'s "UX pre-gate, the backend re-enforces" claim — verified true for all + six admin surfaces; every capability the guard checks has a server-side twin. +- `Authz.CanBeoordelen`'s caller-kind derivation — the one capability a forged `X-Role` + cannot reach, and the reason BIO-002 fails _closed_ in that direction. +- The four-eyes rule in `Authz.CanActOn`, Forbidden-before-Conflict ordering. +- The `isDevMode()` gate on the debug panel and on the interceptor chain (the _interceptor_ + chain is correctly gated — RB-11 is about the three adapters that bypass it). +- The ZGW client secret never reaching the browser; the notification webhook failing closed + on an unset secret; `ZgwDiagnosticHandler` logging no bodies and being opt-in. +- The upload content-type allow-list enforced **server-side** — which is also why + `nosniff` is a checklist item and not a finding. +- Stamdata having no runtime write endpoint at all. +- `libs/shared/src/kernel/{bsn,pii}.ts` — the standard the rest should be measured against. +- No secrets committed; no `.db` file tracked (both verified by `git check-ignore`/`ls-files`). + +**Architecture and structure** + +- **ADR-0005 is fully conformed — zero findings** (agent 06). The ZGW anti-corruption layer + is the repo's worked example; the ADR even predicted its own remaining gap and the gap + stayed where predicted. +- **`bhp/behandeling` is the CQRS-light reference implementation** (agent 04). Query + adapters, command adapter and command factory in separate files, write-free read stores. + Do not "clean it up". +- **The FE dependency structure is not a problem area** (baseline §6): 0 violations across + 11 `severity: error` rules, textbook instability gradient (`kernel` I=5%, contexts I≥83%). + Do not spend tickets here. +- `BigProfileStore` — the reference implementation of the read/write split (agent 04). +- The `ToDetailDto(now)` / `ToDto(now)` status projection — a real read-model derivation; + do not let a future ticket "simplify" it into a stored status column (agent 04). +- The 7 static backend stores and `[assembly: DisableTestParallelization]` — deliberate, + documented in `Data/Db.cs`, and explicitly _not_ challenged by agents 02, 04 or 07. + RB-30 works **because** the rules never needed the DbContext, not by redesigning stores. + +**Baseline rows closed as false gaps** (agent 02, verified — do not ticket them) + +- `libs/shared/domain` 0% reach / 3 files, and `libs/beheer/contracts` 0% reach / 1 file. + Both are pure type declarations with **zero executable statements**; 0% is correct and + unimprovable. BL-004 named both as "genuine gaps"; that part of BL-004 is superseded. +- 23 of the 25 CC>10 TS functions are reducers / `parse*` / `validate*` — mandated house + idioms (**BL-001**). A bare CC number is not grounds for a ticket against any of them. +- `createDraftSync` is **acquitted on testability** (explicit deps object, optional + injection, `enabled()` escape hatch, has a spec). RB-21 is a CQRS split, not a fix. +- `httpClientFetch`, `Contracts/Mappers.cs`, `submit-besluit.ts`, `breadcrumb-trail.ts`, + `route-focus.ts`, `AccessStore.can()` — all "missing test, not blocked test", or a seam + that costs more than it returns. Filing them would be volume, not quality. + +--- + +# Out of mandate — recorded so a later phase does not read this file as a step toward them + +- **OOM-A — `Program.cs` → `Features/` folders with handler types.** BL-003's most obvious + invitation, and out of mandate because §7 is explicit that the backend has "no handler + types, no mediator, no `Features/` folders" — there is no structure to extend, only one + to introduce. **RB-19 is a strict prerequisite** if it is ever taken: you cannot cut a + 940-line file into vertical slices while five of its seven sections interleave + directions. Agent 03, which would have owned this, did not run. +- **OOM-B — read/write repository split in `backend/Data`.** Would introduce the pattern + where §7 records it absent, and collides with the documented static/no-DI design. +- **OOM-C — no read model, no event sourcing, and none proposed.** +- **OOM-D — BL-011: the FE suite is flaky under parallel load, and BL-009 means nothing + ratchets.** "CI green" alone does not verify any ticket in this backlog. Verify against + `00-baseline.md`'s numbers — **and note that §3a, §3b and §5 are already partly stale** + after the five shipped commits (auth duplication 211→151; `libs/shared/infrastructure` + coverage no longer dragged down by the generated client). **Re-run the baseline before + using it as the before-picture for any ticket below.** + +--- + +## Provenance + +| Source finding | Where it went | +| ------------------------------------------------------- | -------------------------------------------------------------------- | +| TE-001…008 | RB-10, RB-11, RB-25, RB-26, RB-27, RB-28, RB-29, RB-30 | +| TE-009 | **shipped** | +| CQ-001, 002, 003+005, 006, 007 | RB-21, RB-20, RB-17, RB-19, RB-22+RB-23 | +| CQ-004 | **shipped** (BE audit half outstanding → RB-07) | +| ADR-C-001, 003, 007, 009 | ADR-fix table | +| ADR-C-002, 004, 008, 010, 011 | RB-24, RB-13, RB-32, RB-31, RB-33 | +| ADR-C-005, 006 | **shipped** | +| BIO-001, 002 | RB-09 + checklist | +| BIO-003, 004, 005, 007, 008, 009, 010, 011, 018, 019 | RB-08, RB-01, RB-06, RB-07, RB-02, RB-04, RB-05, RB-03, RB-18, RB-16 | +| BIO-006 | RB-11 (a+b) + checklist (c) | +| BIO-012, 017 | RB-11, RB-10 | +| BIO-015, 016 | RB-15 + checklist; RB-14 + RB-12 + checklist | +| BIO-013, 014, 020 | checklist only | +| BL-008 | **shipped** | +| BL-005, BL-009, BL-011 | **unowned** — see "Coverage of this backlog" and OOM-D | +| BL-001, BL-002, BL-004 (partly), BL-006, BL-007, BL-010 | absorbed into the tickets/checklist above | diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/_status.md b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md new file mode 100644 index 0000000..27cbb80 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/_status.md @@ -0,0 +1,112 @@ +# Agent run status + +| Agent | Status | Last module processed | Last updated | Notes | +| --------------- | ------------------- | ----------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| baseline | complete | all (18 FE + 7 BE modules) | 2026-08-26 | 0 layering violations. 11 observations BL-001..BL-011. Thresholds for Phase 1 in §9 — agent 01's `[N] lines` = 40 (TS fn) / 60 (C# method) / 400 (file). Read BL-001, BL-002 and BL-004 before filing complexity, duplication or coverage findings. | +| readability | skipped | n/a | 2026-08-27 | **skipped** — deliberate. BL-001: 23 of the 25 TS functions over CC 10 are reducers / `parse*` boundaries / `validate*`, all mandated house idioms; TS fn-length p99 is 34 with only 2 functions over 75 lines. Little left for this agent to find that is not a false positive. Revisit if the CC>10 population grows outside those three shapes. | +| testability | complete | all 24 modules | 2026-08-26 | 9 findings TE-001..TE-009; 15 modules explicit "no findings". Corrected BL-004 — `libs/shared/domain` and `libs/beheer/contracts` are pure type declarations, 0% is unimprovable (amendment note in 00-baseline.md §10). TE-009 is also a latent correctness bug (dead `ActiveOn` branches). Acquitted `createDraftSync`, `httpClientFetch`, `Contracts/Mappers.cs` in writing. | +| ddd-hexagonal | skipped | n/a | 2026-08-27 | **skipped** — deliberate. FE layering is clean (baseline §6: 0 violations, healthy instability gradient, `kernel` I=5% vs contexts I>=83%); backend `Domain/` is verified EF/ASP-free. The agent may only _extend_ existing hexagonal structure, and the one real target (`Program.cs`) has no `Features/` folder to extend — agent 04 already filed that as out-of-mandate OOM-A. | +| cqrs-light | complete | all (16 FE + 6 BE modules) | 2026-08-26 | 7 findings CQ-001..CQ-007; 12 modules clean. Corrected BL-007 (see the amendment note in 00-baseline.md §10) and found 3 mutations the baseline missed. `Program.cs` → `Features/`+handlers filed as out-of-mandate OOM-A, not a ticket; CQ-006 is its prerequisite. `bhp/behandeling` named the reference implementation — do not "clean it up". | +| bdd | skipped | n/a | 2026-08-27 | **skipped** — deliberate. No BDD tooling present, and the prompt forbids proposing any; it self-reduces to a single structural note. `gen:behaviour-spec` already extracts behaviours from spec names into `libs/shared/docs/behaviour-spec.mdx`, which covers the intent. | +| adr-conformance | complete | all 6 ADRs | 2026-08-26 | 11 findings: 6 code, **5 ADR-fix (architect approval required)**. Sharpened BL-002 — `Principal` was never built, so ADR-0002 was untested not falsified (amendment note in 00-baseline.md §10). ADR-0005 fully conformed. Gates: ADR-C-005→ADR-C-004; ADR-C-003 gates contracts/ cleanup. 3 ADR-fixes need a matching CLAUDE.md correction in the same diff. | +| bio2-compliance | complete | all modules + 7 control areas | 2026-08-27 | 20 findings BIO-001..BIO-020 (12 **defect now**, 8 **production gate**). High: BIO-008 BSN concatenated into the authz audit `Resource` (`Program.cs:674`, verified); BIO-004 `GET /uploads/{documentId}/content` has no authz at all (verified). Answered agent 06's handoff as BIO-002 — a production behandelportal build resolves to the seeded **zorgverlener**, failing closed on backoffice caps but open on citizen-scoped ones incl. `CanRevealBigNummer`. Carries the mandatory **"compliance review required"** list: 16 rows over agents 02/04/06. Also a pre-production checklist (~25 boxes). | +| consolidation | complete (approved) | all inputs | 2026-08-27 | **HALTED for human approval** (per spec) — `99-backlog.md` written, nothing implemented. 33 open tickets RB-01..RB-33 + 5 ADR-fixes + 5 already-shipped, from 47 findings. RB-01 (no authz on upload content) and RB-02 (BSN in the audit `Resource`) sort above all structural work. Gate relaxed to the 4 agents that ran; a "Coverage of this backlog" note records what the 3 skips leave unowned. Caught two orchestrator errors: **CQ-002 is NOT fixed** (verified — `ApplicationsStore.cancel`/`AdminCasesStore.delete` still swallow errors → RB-20), and **CQ-004 shipped with half its compliance criterion unmet** (no audit row on `PUT /admin/flags/{key}`, verified → RB-07, which blocks signing ADR-C-009). OOM-D: re-run the baseline before using it to verify any ticket — ADR-C-006 and BL-008 moved it. **Approved 2026-08-27; HALT lifted.** | + +## Phase 3 — implementation + +**All six batches complete, 2026-08-28. All 33 code tickets (RB-01..RB-33) and the four gated ADR-fixes (ADR-C-001, ADR-C-003, ADR-C-007, ADR-C-009) merged to `refactor/adr-c-006-shared-route-guards`, one commit per ticket. `npm run ci` green after every merge, verified independently before trusting any agent's own report.** Every batch carrying a **SIGN-OFF** ticket shipped only after the architect approval recorded 2026-08-27 (HALT lifted). Three of the four ADR-fixes required a matching CLAUDE.md correction (§2 once, §4 twice); all three landed in the same diff as their ADR amendment, per CLAUDE.md's own precedence rule. + +Across the six batches, several tickets turned out to be factually wrong, incomplete, or overstated relative to the actual code, and every one was reported rather than silently patched over — among them: two ADR-fixes (ADR-C-001's stale out-of-scope bullet, ADR-C-007 catching only half of its own finding), RB-18 (real scope one endpoint, not the several the stale line numbers implied), RB-23 (an unmentioned second `GetOrCreate` call site forced onto the same contract change), RB-25 (overstated which methods the missing token actually blocked), RB-27 (correctly declined to extract abort-vs-error into a signature that cannot express it, and declined an optional move outside its stated scope), RB-28 (TE-006's "cannot test the success case" claim was already false when written), and RB-31 (found a genuinely unreachable state — cursor 2 with no diploma chosen — that the old hand-rolled fixture had been asserting). None of these weakened a ticket; each was implemented correctly once the discrepancy was named. + +| CD batch | Tickets | Status | Notes | +| -------- | ------------------------------------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 1 | RB-01, RB-02, RB-03, RB-04, RB-05, RB-06 | **complete** | Six commits on `refactor/adr-c-006-shared-route-guards`, one per ticket, each with `implementation/rb-0N.md`. `npm run ci` green. Every ticket left a test that was verified red without its fix. Carryover: RB-01's residual belongs to **RB-09** (the content endpoint is reached by a plain browser navigation with no identity header — BIO-002); `Pii.MaskTail` now lives in `Domain/People/Pii.cs`, **use it in RB-11** rather than hand-rolling a second masker; RB-06 additionally deleted `SubmissionRules.RejectRegistratie` (judgement call, recorded). | +| 2 | RB-07, RB-08, RB-09, RB-10, RB-11 | **complete** | All five merged; `npm run ci` green **on the fixed gate** (see below). **RB-07** unblocks signing **ADR-C-009** and closes CQ-004's outstanding half. **RB-09** makes Production throw at startup when no real `IIdentityProvider` exists — note an environment that is neither Development nor Production (e.g. `Staging`) still fails fast, but at `GetRequiredService`, with a worse message. **RB-10** landed `parseStoredSession` twice, once per app, deliberately — TE-001/BL-002 say extract-to-shared contradicts ADR-0002; **RB-13** differentiates them. **RB-11** corrected a factual error in BIO-012 (the proefbrief error mapping was inlined, not already a separate function) and left the step-up as a literal moved one layer up to its only caller — BIO-006(c) stays a production gate. **RB-01's residual is still open** and is not solved by RB-09: the upload-content link is a plain browser navigation with no credential. | +| 3 | RB-12, RB-13, RB-14, RB-15, RB-16, RB-17 | **complete** | All six merged; `npm run ci` green (14 steps — RB-14 added one — backend 260/260). **RB-12 rejected the ticket's binary framing:** of 47 routes only 16 use one of the five admin wrappers; of the remaining 31 only 10 are genuinely public, the other 21 are ownership-scoped inline (`ctx.Zorgverlener()`/`ctx.Caller()`) or use another mechanism. The allow-list therefore carries **a reason per route**, not a blanket "public" label. Known limitation: detection is `.Gate("XAdmin")` metadata declared at mapping time — **a declaration, not a derivation**, so it cannot catch a route that declares a gate it does not have. **This is RB-19's safety net; read `rb-12.md` before starting RB-19.** **RB-13** measured `ssp/auth` ↔ `bhp/auth` duplication at **32 lines each side, down from 168** (backlog expected <40); each app holds only its own `Principal` variant, which is ADR-C-004's own proposed resolution, and ADR-0002's "Known debt" section became an amendment. **RB-14** could not be built as written — `dotnet list package --vulnerable` exits 0 on a High advisory (verified), so a bare `- run:` would have been a gate that enforces nothing; `scripts/dotnet-audit.sh` matches the output instead and is shared by `ci.yml` and `ci-local.sh`. **RB-15** used a third environment name (`Staging`) in its test, since RB-09 makes Production fail to boot at all. | | +| 4 | RB-18..RB-23 | **complete** | All six merged, one commit per ticket, each on its own merge. `npm run ci` green on the combined tree after every merge (14 steps, exit 0). Ran as three waves, because three of the six touch `Program.cs`: **A** = RB-18/20/21/22 in parallel (no file overlap), **B** = RB-23 after RB-22 (expand/contract), **C** = RB-19 alone and last, so it reordered final content. **Two tickets were incomplete, both reported rather than worked around.** RB-23 found `BriefStore.GetOrCreate` had a **second, unmentioned call site** — `GET /brief/preview` — so the split forced that endpoint to change too or the file would not compile; it got the same `Get` + 404 treatment. RB-18's real scope is **one** endpoint, not the nine BIO-018's stale line numbers implied: `Submit` has exactly one call site (`POST /change-requests`). **RB-22 deliberately left the `runResult` idiom** for `BriefAdapter.load()`: it hand-rolls try/catch to read the HTTP status, because `runResult` folds the error to a string and structurally cannot carry a 404. It still reuses the shared `problemDetail` mapper and models the outcome as the `BriefLoadFailure` union, not a sentinel string. Accepted — reviewed the diff before merging. Its once-only bound is stronger than the ticket asked: `recoverFromMissingBrief` never re-enters `load()`, so CQ-007's retry loop is absent, not merely capped. **RB-22 mispredicted one thing harmlessly:** it expected the regenerated client to parse a `ProblemDetails` 404, but `Results.NotFound()` declares no body so it throws a plain `SwaggerException` (matching the 17 other bare-404 endpoints). `isHttpNotFound` reads only `.status`, so it tolerated both — the pair held because the FE half was written defensively. **RB-19 verification, recorded because RB-12's test cannot do it:** RB-12 proves a `.Gate(...)` marker is present, not that it matches the wrapper the handler calls (its own stated declaration-vs-derivation limit). Checked centrally instead — the sorted list of all 47 route strings is identical before and after, **and so is every (route, `.Gate` marker, wrapper actually called in the handler) triple**, with zero gate/handler mismatches. `gen:api` produced an ordering-only diff in `swagger.json` + `api-client.ts` (only the two moved _and documented_ endpoints changed position; the other three moves are `.ExcludeFromDescription()`), committed rather than left to fail the drift job. | +| 5 | RB-24..RB-30 | **complete** | All seven merged, one commit per ticket. `npm run ci` green on the combined tree after every merge. Ran as three waves, not the two the backlog implied: RB-24 rewrites imports in `brief.store.ts` and `org-template.store.ts`, which are two of RB-28's three targets — a dependency the backlog's "25/26/27 depend on 24" note never mentioned. **A** = RB-24 alone (the move), then **A2** = RB-29 + RB-30 in parallel (backend, no file overlap with the move or each other), **B** = RB-25 + RB-26 + RB-28 in parallel once RB-24 landed, **C** = RB-27 alone last, since it depends on RB-25's transport token. **RB-24 expanded its own scope, correctly.** Deleting the dependency-cruiser carve-out — the ticket's own acceptance criterion — exposed a second, real `ui-not-infrastructure` violation the old path had hidden: three UI components injected `UploadAdapter` for nothing but a one-line wrapper over its own exported pure function. The dispatch prompt said to report a second violation, not fix it; the agent judged this one was on the critical path (`dep:check` cannot pass with the carve-out gone otherwise) and fixed it minimally, reusing the existing pure function. Reviewed before merging — sound. **Two more findings were shown to be stale or overstated, on top of the two ADR-fixes found wrong and RB-18/RB-23's incompleteness from batch 4 — nine total now.** RB-25 found TE-003 overstated its own blocker: of the four methods named, only `upload()` and `cancel()` were actually unfakeable through the missing token — `delete()`/`pollReturning()` already went through the exported `UploadAdapter`. RB-28 found TE-006 already false at the time it was written: `brief.store.spec.ts` already had a `previewLetter` success test via jsdom's spyable `URL`/`window` stubs, contradicting the finding's "cannot test the success case" claim — the overall three-site diagnosis still held and was shipped as instructed. **RB-26 made one real design call**, reviewed before merging: `planFileSelection` must return `UploadMsg[]` per its literal signature, but an accepted file's real `localId` needs `crypto.randomUUID()`, which the ticket itself keeps in the controller. It ships a placeholder `localId: ''` discriminated by `.type` alone and never dispatched — verified the index alignment holds for both the multiple-rejection short-circuit and the per-file path. **RB-27 left one thing unextracted, correctly**: TE-005 lumped abort-vs-error disambiguation into the same extraction as `uploadOutcome`, but abort fires on a different event with no `status`/`responseText` at all — it structurally cannot fit the proposed signature. Left in place as a one-line ternary. The optional `currentScenario()` move into `KeepaliveTransport` was also correctly declined — it would have crossed into `upload-shell.service.ts`, outside this ticket's stated single-file scope. **End state of `libs/shared/upload`** (now split across proper layers): every layer that can hold pure logic has one and is spec'd — `upload.machine.ts` (domain, `planFileSelection`), `upload-shell.service.ts` (application, the `UPLOAD_TRANSPORT` seam), `upload-controller.ts` (application), `upload.adapter.ts` (infrastructure, `uploadOutcome`). Only the XHR/DOM boundary itself stays untested by design — TE-005 was explicit that abstracting `XMLHttpRequest` away is not wanted, since the file documents why XHR (not `fetch`) is required. | +| 6 | RB-31, RB-32, RB-33 | **complete** | All three merged, one commit per ticket. `npm run ci` green on the combined tree after every merge. Dispatched as one wave — no file overlap at all (four machine specs in three apps, one docs file, one testing helper plus its one call site). **RB-31 found a real ADR-0006 violation, not a false alarm.** Two `registratie-wizard` tests asserted a state — cursor 2, no diploma chosen — that the real reducer cannot produce, since advancing past `beroep` (cursor 1→2) requires `KiesDiploma`/`KiesHandmatig` to have already run. This is exactly what forcing fixtures through message replay is for: a hand-rolled literal let an impossible state sit in the suite undetected. Fixed by replaying to cursor 1 and applying the diploma choice there instead; `submit()` validates the whole draft regardless of cursor, so the assertions are byte-identical to before — reviewed the diff before merging to confirm the old and new test bodies check the same thing. `intake.machine.spec.ts` also had an existing, correct `intake.testing.ts` sitting unused in its own folder, imported only by the acceptance spec — now wired to both. **RB-32 added the missing `language-switcher` row and took the ticket's explicitly-optional second step**: a ~14-line drift guard in `check-tokens.sh` that diffs every `CIBG-GAP EXTENSION` marker's component directory against the register's rows and fails naming what's missing. Verified the regex before trusting it — two existing rows carry parenthetical suffixes (`wizard-shell (error summary only)`) and the extraction correctly captures only the backtick-quoted name. This closes ADR-0003's own predicted failure mode ("if markers and this table drift, trust the code and fix the table") permanently rather than fixing it once more. **RB-33 made the real adopt-or-delete call the finding asked for, and chose delete.** `unwrapOk` had zero consumers anywhere in the repo since it shipped; manufacturing a first caller purely to satisfy the ticket would have removed no actual duplication, since there was only one occurrence to begin with. Deleted the helper and its doc mention; left the one candidate call site's inline guard alone, since it already satisfies ADR-0006 §3's real requirement (never a cast). | +| ADR-fix | ADR-C-001, ADR-C-003, ADR-C-007, ADR-C-009 | **complete** | All four signed and landed by the architect on 2026-08-27, in one commit; doc-only, no code touched. Three carried the mandatory matching `CLAUDE.md` edit in the same diff (§4 twice, §2 once). **ADR-C-009's RB-07 gate was satisfied first** — all four clauses of its new test were verified against both `OrgTemplateStore` and `FeatureFlagStore` before signing, so the ADR does not ratify a control the code lacks. **Two findings were wrong and are corrected in the notes:** ADR-C-001 told us to keep an out-of-scope bullet reading "`SessionStore` is in-memory", which RB-10/RB-13 made false (the session now persists to `localStorage`; only multi-tab sync is still open), and ADR-C-007 flagged only the `.alert` half of ADR-0003's point 4 — its "header/side-nav use `.nav` + a local blue bar" clause is equally false (`site-header` composes the vendored `.titlebar`/`.logo__*`). ADR-C-007 also over-listed one path: `public/cibg-huisstijl/` never moved. ADR-C-003's open question was decided explicitly — **the 4 hand-written `contracts/*.dto.ts` stay**, because NSwag emits every property optional and flattens `RegistrationStatusDto` into five optional strings, which would make an illegal state representable (CLAUDE.md §3). Gates released: ADR-C-003 (contracts cleanup) and ADR-C-009 (a third runtime-editable surface). Still pending, untouched: **ADR-C-008 → RB-32** — 9 `CIBG-GAP` markers vs 8 register rows, missing row is `language-switcher`. | + +**Standing caveat for every batch:** `dotnet test` reports one failure, +`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`, +which needs a live OpenZaak container. It fails identically on a stashed tree — it is not +caused by any of these tickets. `npm run ci` does not run it. + +## Gate integrity — read before trusting any "ci green" in this file + +`scripts/ci-local.sh` chained six of its steps as `cmd1 && cmd2` under `set -e`. Bash exempts +every command of an AND-OR list **except the last** from `errexit`, so a crash in `cmd1` was +swallowed: the paired check never ran and the script still printed "local CI passed". Verified +directly — `bash -c 'set -e; false && true; echo hi'` prints `hi` and exits 0. + +This hid a **real** `gen:api` crash introduced by RB-09 (`dotnet swagger`'s design-time host +defaults to Production, which RB-09 made throw). `.github/workflows/ci.yml` runs each step as +its own `- run:` and would have caught it, so the local gate was strictly **weaker** than the +remote one — the opposite of its purpose. The worst instance was +`ng build ssp --localize && ng build behandelportal --localize`: a missing English translation +in ssp could not fail the run. + +Fixed in `build: stop ci-local.sh swallowing the first half of every paired step`. **Every +"ci green" recorded for batch 1 and for RB-07/RB-10/RB-11 predates that fix** and is therefore +weaker than it reads; the batch-2 completion run above is the first one made on the honest gate +(13/13 steps, exit 0). Nothing has since been found wrong with batch 1, but it has not been +re-verified under the fixed gate either. + +## Dispatching implementation agents — what actually goes wrong + +Batches 2 and 3 ran tickets as parallel agents in git worktrees. Six of seven agent-runs hit at +least one of these. Put all of it in the prompt. + +1. **The worktree base is not reliable.** **Four of the six** agents were handed a worktree + branched from a stale ancestor — batch 3 was **three for three**, all landing on `ae7781e`, + an unrelated lineage missing every RB ticket _and_ this backlog directory. Make step zero: + `git log --oneline -8`, confirm a **named expected commit**, `git merge` the target branch if + absent, and report which it was. The one agent that was not told to do this found out by luck. +2. **Agents park on background tasks.** Two agents in batch 2 ran `npm run ci` in the background, + then ended their turn waiting for a notification that never usefully arrived; one finished its + work twice and never committed it. Ban `run_in_background` and Monitor, **and say explicitly to + pass the Bash tool's own `timeout: 600000`** — a batch-3 agent still auto-backgrounded because + "run it in the foreground" alone does not defeat the 120s default. +3. **`git checkout ` destroys the work.** Agents verify a test is red by undoing the fix; + restoring it with `git checkout` reverts the whole file. Tell them to undo and redo with edits. +4. **`behaviour-spec.mdx` conflicts on nearly every merge.** It is generated and every agent + regenerates it. Resolve centrally with `npm run gen:behaviour-spec && git add`; tell agents to + expect it and never hand-edit. +5. **Concurrent `dotnet test` runs flake** — a burst of `SQLite Error 1: 'no such table: Documents'`. + Partly host pressure, but RB-12 found a real cause: a bare `new WebApplicationFactory()` + races on the static `Db.ConnectionString`. Use the house `TestWebApplicationFactory` + + `IClassFixture` idiom. Do not trust a backend green while another agent is testing. +6. **Agent worktrees live inside the repo**, so `prettier --check .` walks into them — fixed by + ignoring `.claude/worktrees/` in both `.prettierignore` and `.gitignore`. + +7. **The stale base is now the rule, not the exception.** Batch 4 ran six agents; **five were + handed a bad base**, three of them the same unrelated `ae7781e` lineage. Across batches 2-4 that + is **11 of 13 agent-runs**. Every one self-corrected at step zero. Keep the named-expected-commit + check as the first instruction in every prompt — it is the highest-value line in there. Give the + _current_ HEAD, not the batch's starting commit, when tickets merge sequentially. +8. **A spend limit can kill every agent mid-flight; resume, do not restart.** All four wave-A agents + died on an org monthly-spend 429, three of them at the final CI re-run. Their worktrees kept the + uncommitted work intact. Sending each agent a message resumed it from its own transcript and it + finished from exactly where it stopped — nothing was redone. Check `git -C status` + before assuming work is lost. +9. **The `99-backlog.md` conflict is mechanical and has a fixed recipe.** It fired on three of five + merges. Cause: the central prettier pass reflows the table's column widths, so the whole table + conflicts even though the two sides differ in only one or two status cells. Recipe: take HEAD's + table, flip the incoming ticket's cell, re-run prettier. Verify by parsing both sides cell-by-cell + and printing only the differing cells before discarding either side — do not eyeball a 33-row + table. A ticket's cell reads `**done**` once merged (matching RB-01..RB-17), not `implemented`. +10. **For a zero-semantic-change commit, ask for evidence CI cannot give.** RB-19's diff is 181 lines + in `Program.cs` and no test can prove it changed nothing. The sorted-route-list diff, plus the + route/gate/handler-triple comparison, is what actually made it reviewable. Ask for it in the + prompt and re-run it centrally before merging. +11. **`gen:api` is not always a no-op on a pure reorder.** OpenAPI operation order follows mapping + order, so a reorder legitimately changes `swagger.json` and `api-client.ts`. Tell the agent to + prove the diff is ordering-only (sort every line of both versions, diff, expect empty) and to + commit the regenerated pair, or CI's drift job fails on a correct change. +12. **The backlog's own "depends on" column is not exhaustive — check actual imports before parallelizing a wave.** Batch 5's table said only "25/26/27 depend on 24"; it never mentioned that RB-24 rewrites imports in two of RB-28's three target files. `grep -rln` for the moved module's import path against every other open ticket's target files, before deciding what runs in parallel — not after a conflict. + +**Telling agents to report a ticket as wrong pays off.** Three did: BIO-012 was factually wrong +about the proefbrief error mapping (RB-11), RB-12's wrapper/public binary did not fit the code, and +RB-14 as worded would have shipped a non-gate. None of the three would have been caught by a review +of the diff alone. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/00-baseline.prompt.md b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/00-baseline.prompt.md new file mode 100644 index 0000000..f9e6bac --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/00-baseline.prompt.md @@ -0,0 +1,46 @@ +MODEL: Opus +OUTPUT FILE: /refactor-backlog/00-baseline.md +DEPENDS ON: none + +--- + +PERSISTENCE & RESUME PROTOCOL + +Before starting work: +1. Read /refactor-backlog/_status.md. If your row says "complete", stop — do not re-run. +2. If "in_progress", read your own output file. Treat modules already listed as done. + Resume from "Last module processed" + 1. +3. If "not_started", confirm your dependencies show "complete" in _status.md. If not, + stop and report a blocking dependency instead of guessing. + +While working: +4. Append findings incrementally, one module at a time. After each module, update + _status.md: "Last module processed" and "Last updated". +5. Each finding gets a stable ID (e.g. RD-014) that never changes across runs. +6. If interrupted, the file + status row is the full recovery state. + +On completion: +7. Mark your _status.md row "complete" only once every module in scope has a + corresponding section in your output file. + +Every output file starts with: +## Scope: [modules covered] +## Status: [not_started | in_progress | complete] +## Last updated: [timestamp] +## Depends on: [file(s)] +## --- + +ROLE: Metrics Baseline Agent + +Before any refactoring suggestions, establish a baseline for the scoped codebase: +- Test coverage (line/branch) per module, .NET and Angular separately. +- Cyclomatic complexity per method/function (flag >10). +- Duplication percentage (tool-based, e.g. jscpd/SonarQube if configured). +- Dependency graph / layering violations (existing static analysis if present). +- Count and location of existing CQRS-light and hexagonal architecture patterns + already in use (so later agents compare against actual current state, not + assumed absence). + +Output: a metrics table per module, plus a short list of modules ranked +worst-to-best on each metric. This file is fixed input to every Phase 1 agent — +no agent may propose a change without citing which baseline metric it improves. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/01-readability.prompt.md b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/01-readability.prompt.md new file mode 100644 index 0000000..b5e3c6b --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/01-readability.prompt.md @@ -0,0 +1,39 @@ +MODEL: Opus +OUTPUT FILE: /refactor-backlog/01-readability.md +DEPENDS ON: 00-baseline.md (complete) + +--- + +PERSISTENCE & RESUME PROTOCOL + +Before starting work: +1. Read /refactor-backlog/_status.md. If your row says "complete", stop — do not re-run. +2. If "in_progress", read your own output file. Treat modules already listed as done. + Resume from "Last module processed" + 1. +3. If "not_started", confirm your dependencies show "complete" in _status.md. If not, + stop and report a blocking dependency instead of guessing. + +While working: +4. Append findings incrementally, one module at a time. After each module, update + _status.md: "Last module processed" and "Last updated". +5. Each finding gets a stable ID (e.g. RD-014) that never changes across runs. +6. If interrupted, the file + status row is the full recovery state. + +On completion: +7. Mark your _status.md row "complete" only once every module in scope has a + corresponding section in your output file. + +Every output file starts with: +## Scope: [modules covered] +## Status: [not_started | in_progress | complete] +## Last updated: [timestamp] +## Depends on: [file(s)] +## --- + +AGENT: Readability Agent + +Junior = fluency in language constructs, not domain knowledge. Assume familiarity +with generics, async/await, LINQ, DI, RxJS operators, TS type system — do NOT flag +idiomatic use of these as "unreadable". Flag only: unclear naming, methods/components +exceeding [N] lines, nesting >3 levels, magic values, misleading types, missing guard +clauses. Cite baseline complexity score per finding. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/02-testability.prompt.md b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/02-testability.prompt.md new file mode 100644 index 0000000..a304df5 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/02-testability.prompt.md @@ -0,0 +1,38 @@ +MODEL: Opus +OUTPUT FILE: /refactor-backlog/02-testability.md +DEPENDS ON: 00-baseline.md (complete) + +--- + +PERSISTENCE & RESUME PROTOCOL + +Before starting work: +1. Read /refactor-backlog/_status.md. If your row says "complete", stop — do not re-run. +2. If "in_progress", read your own output file. Treat modules already listed as done. + Resume from "Last module processed" + 1. +3. If "not_started", confirm your dependencies show "complete" in _status.md. If not, + stop and report a blocking dependency instead of guessing. + +While working: +4. Append findings incrementally, one module at a time. After each module, update + _status.md: "Last module processed" and "Last updated". +5. Each finding gets a stable ID (e.g. RD-014) that never changes across runs. +6. If interrupted, the file + status row is the full recovery state. + +On completion: +7. Mark your _status.md row "complete" only once every module in scope has a + corresponding section in your output file. + +Every output file starts with: +## Scope: [modules covered] +## Status: [not_started | in_progress | complete] +## Last updated: [timestamp] +## Depends on: [file(s)] +## --- + +AGENT: Testability Agent + +Flag constructs that block unit testing without excessive mocking: static/singleton +dependencies, hidden I/O, constructors doing work, mixed pure/impure logic. Cite +baseline coverage gap per finding. Propose the minimal seam needed (interface +extraction, pure function split) — not a rewrite. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/03-ddd-hexagonal.prompt.md b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/03-ddd-hexagonal.prompt.md new file mode 100644 index 0000000..ba28367 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/03-ddd-hexagonal.prompt.md @@ -0,0 +1,40 @@ +MODEL: Opus +OUTPUT FILE: /refactor-backlog/03-ddd-hexagonal.md +DEPENDS ON: 00-baseline.md (complete) + +--- + +PERSISTENCE & RESUME PROTOCOL + +Before starting work: +1. Read /refactor-backlog/_status.md. If your row says "complete", stop — do not re-run. +2. If "in_progress", read your own output file. Treat modules already listed as done. + Resume from "Last module processed" + 1. +3. If "not_started", confirm your dependencies show "complete" in _status.md. If not, + stop and report a blocking dependency instead of guessing. + +While working: +4. Append findings incrementally, one module at a time. After each module, update + _status.md: "Last module processed" and "Last updated". +5. Each finding gets a stable ID (e.g. RD-014) that never changes across runs. +6. If interrupted, the file + status row is the full recovery state. + +On completion: +7. Mark your _status.md row "complete" only once every module in scope has a + corresponding section in your output file. + +Every output file starts with: +## Scope: [modules covered] +## Status: [not_started | in_progress | complete] +## Last updated: [timestamp] +## Depends on: [file(s)] +## --- + +AGENT: DDD/Hexagonal Agent + +Target architecture: hexagonal (ports/adapters), already partially present in the +codebase per baseline.md — treat that as the pattern to extend, not reinvent. Do NOT +introduce hexagonal structure into modules where it is absent; only propose closing +gaps where the pattern is already started. Flag anemic domain models, domain logic +leaked into controllers/services/components, primitive obsession, missing ubiquitous +language. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/04-cqrs-light.prompt.md b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/04-cqrs-light.prompt.md new file mode 100644 index 0000000..51057b1 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/04-cqrs-light.prompt.md @@ -0,0 +1,39 @@ +MODEL: Opus +OUTPUT FILE: /refactor-backlog/04-cqrs-light.md +DEPENDS ON: 00-baseline.md (complete) + +--- + +PERSISTENCE & RESUME PROTOCOL + +Before starting work: +1. Read /refactor-backlog/_status.md. If your row says "complete", stop — do not re-run. +2. If "in_progress", read your own output file. Treat modules already listed as done. + Resume from "Last module processed" + 1. +3. If "not_started", confirm your dependencies show "complete" in _status.md. If not, + stop and report a blocking dependency instead of guessing. + +While working: +4. Append findings incrementally, one module at a time. After each module, update + _status.md: "Last module processed" and "Last updated". +5. Each finding gets a stable ID (e.g. RD-014) that never changes across runs. +6. If interrupted, the file + status row is the full recovery state. + +On completion: +7. Mark your _status.md row "complete" only once every module in scope has a + corresponding section in your output file. + +Every output file starts with: +## Scope: [modules covered] +## Status: [not_started | in_progress | complete] +## Last updated: [timestamp] +## Depends on: [file(s)] +## --- + +AGENT: CQRS-light Agent + +Target: command/query separation at the application-service level (not event +sourcing or separate read models unless already present per baseline.md). Only +extend existing CQRS-light patterns — do not introduce the pattern into modules +where it's absent. Identify handlers/services mixing reads and writes within +modules that already show the pattern elsewhere. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/05-bdd.prompt.md b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/05-bdd.prompt.md new file mode 100644 index 0000000..d463b1f --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/05-bdd.prompt.md @@ -0,0 +1,38 @@ +MODEL: Opus +OUTPUT FILE: /refactor-backlog/05-bdd.md +DEPENDS ON: 00-baseline.md (complete) + +--- + +PERSISTENCE & RESUME PROTOCOL + +Before starting work: +1. Read /refactor-backlog/_status.md. If your row says "complete", stop — do not re-run. +2. If "in_progress", read your own output file. Treat modules already listed as done. + Resume from "Last module processed" + 1. +3. If "not_started", confirm your dependencies show "complete" in _status.md. If not, + stop and report a blocking dependency instead of guessing. + +While working: +4. Append findings incrementally, one module at a time. After each module, update + _status.md: "Last module processed" and "Last updated". +5. Each finding gets a stable ID (e.g. RD-014) that never changes across runs. +6. If interrupted, the file + status row is the full recovery state. + +On completion: +7. Mark your _status.md row "complete" only once every module in scope has a + corresponding section in your output file. + +Every output file starts with: +## Scope: [modules covered] +## Status: [not_started | in_progress | complete] +## Last updated: [timestamp] +## Depends on: [file(s)] +## --- + +AGENT: BDD Agent + +Check whether existing tests express behavior in domain/business language mapped +to acceptance criteria, or only technical steps. Flag test names/structure gaps. +Do not propose new BDD tooling if none is present — flag as a separate structural +item instead, not a per-module ticket. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/06-adr-conformance.prompt.md b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/06-adr-conformance.prompt.md new file mode 100644 index 0000000..71f2765 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/06-adr-conformance.prompt.md @@ -0,0 +1,40 @@ +MODEL: Opus +OUTPUT FILE: /refactor-backlog/06-adr-conformance.md +DEPENDS ON: 00-baseline.md (complete) + +--- + +PERSISTENCE & RESUME PROTOCOL + +Before starting work: +1. Read /refactor-backlog/_status.md. If your row says "complete", stop — do not re-run. +2. If "in_progress", read your own output file. Treat modules already listed as done. + Resume from "Last module processed" + 1. +3. If "not_started", confirm your dependencies show "complete" in _status.md. If not, + stop and report a blocking dependency instead of guessing. + +While working: +4. Append findings incrementally, one module at a time. After each module, update + _status.md: "Last module processed" and "Last updated". +5. Each finding gets a stable ID (e.g. RD-014) that never changes across runs. +6. If interrupted, the file + status row is the full recovery state. + +On completion: +7. Mark your _status.md row "complete" only once every module in scope has a + corresponding section in your output file. + +Every output file starts with: +## Scope: [modules covered] +## Status: [not_started | in_progress | complete] +## Last updated: [timestamp] +## Depends on: [file(s)] +## --- + +AGENT: ADR-Conformance Agent + +Read all ADRs/docs in the repo. Compare code against each. Two outcomes per +deviation: +(a) code violates a correct ADR → refactoring ticket, cite ADR. +(b) ADR itself appears outdated/wrong given current code or constraints → propose + an ADR amendment as a separate ticket type ("ADR-fix"), with rationale — not + a code ticket. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/07-bio2-compliance.prompt.md b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/07-bio2-compliance.prompt.md new file mode 100644 index 0000000..5d3f9e4 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/07-bio2-compliance.prompt.md @@ -0,0 +1,48 @@ +MODEL: Opus +OUTPUT FILE: /refactor-backlog/07-bio2-compliance.md +DEPENDS ON: 00-baseline.md (complete) + +--- + +PERSISTENCE & RESUME PROTOCOL + +Before starting work: +1. Read /refactor-backlog/_status.md. If your row says "complete", stop — do not re-run. +2. If "in_progress", read your own output file. Treat modules already listed as done. + Resume from "Last module processed" + 1. +3. If "not_started", confirm your dependencies show "complete" in _status.md. If not, + stop and report a blocking dependency instead of guessing. + +While working: +4. Append findings incrementally, one module at a time. After each module, update + _status.md: "Last module processed" and "Last updated". +5. Each finding gets a stable ID (e.g. RD-014) that never changes across runs. +6. If interrupted, the file + status row is the full recovery state. + +On completion: +7. Mark your _status.md row "complete" only once every module in scope has a + corresponding section in your output file. + +Every output file starts with: +## Scope: [modules covered] +## Status: [not_started | in_progress | complete] +## Last updated: [timestamp] +## Depends on: [file(s)] +## --- + +AGENT: BIO2/Compliance Agent + +No explicit control list supplied — using the following BIO2/ISO 27002:2022 +controls, selected for privacy and security relevance. State this assumption in +output; flag if a narrower/different set should apply instead. + +- Access control (9.1, 9.2, 9.4): authorization checks, RBAC, least privilege. +- Logging & monitoring (8.15, 8.16): audit trails, esp. BIG-register/DUO data access. +- Data classification & handling (5.12, 5.13): BSN, health data, AVG-sensitive fields. +- Cryptography (8.24): encryption at rest/in transit. +- Secure development (8.25, 8.28, 8.29): secure coding, review, security testing gates. +- Change control (8.32): deployment register / change approval exceptions. +- Input validation (8.26): boundary validation on public-facing forms/APIs. + +Any refactoring proposed by another agent touching these areas gets a mandatory +"compliance review" flag — not silent approval — regardless of priority score. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/08-consolidation.prompt.md b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/08-consolidation.prompt.md new file mode 100644 index 0000000..8f382b2 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/08-consolidation.prompt.md @@ -0,0 +1,59 @@ +MODEL: Opus +OUTPUT FILE: /refactor-backlog/99-backlog.md +DEPENDS ON: 01-readability.md through 07-bio2-compliance.md (all complete) + +--- + +PERSISTENCE & RESUME PROTOCOL + +Before starting work: +1. Read /refactor-backlog/_status.md. If your row says "complete", stop — do not re-run. +2. If "in_progress", read your own output file. Treat modules already listed as done. + Resume from "Last module processed" + 1. +3. If "not_started", confirm your dependencies show "complete" in _status.md. If not, + stop and report a blocking dependency instead of guessing. + +While working: +4. Append findings incrementally, one module at a time. After each module, update + _status.md: "Last module processed" and "Last updated". +5. Each finding gets a stable ID (e.g. RD-014) that never changes across runs. +6. If interrupted, the file + status row is the full recovery state. + +On completion: +7. Mark your _status.md row "complete" only once every module in scope has a + corresponding section in your output file. + +Every output file starts with: +## Scope: [modules covered] +## Status: [not_started | in_progress | complete] +## Last updated: [timestamp] +## Depends on: [file(s)] +## --- +Phase 1 file changed since last run] + +ROLE: Consolidation & CD-Sequencing Agent + +Input: all Phase 1 files (01–07) + 00-baseline.md. + +1. Deduplicate overlapping findings across agents — merge into one ticket, list all + contributing reasons/agents. +2. Score priority: + P1 = violates a correct ADR, blocks testability, or is a BIO2 compliance risk. + P2 = significant maintainability cost, moderate effort. + P3 = low urgency. +3. Sequence for continuous delivery: every ticket must be independently deployable + without a big-bang release. Reject/split any ticket that can't ship alone — + decompose into a dependency chain of smaller tickets. +4. Any ticket touched by the BIO2 agent requires compliance sign-off before merge, + regardless of priority score — mark explicitly. +5. Output final table: + +| ID | Module | Category | Description | Baseline metric improved | Effort (S/M/L) | +Risk | Priority | CD batch # | Depends on | Status | + +6. Separately list "ADR-fix" tickets — require human/architect approval before any + dependent code ticket proceeds. + +HALT CONDITION: after writing 99-backlog.md, stop and report to the human for +approval before any Implementation Agent (Phase 3) starts — even if no tickets +carry a compliance or ADR-fix flag. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/09-implementation.prompt.md b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/09-implementation.prompt.md new file mode 100644 index 0000000..ca8de86 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/09-implementation.prompt.md @@ -0,0 +1,57 @@ +MODEL: Sonnet +OUTPUT: status update in /refactor-backlog/99-backlog.md + + /refactor-backlog/implementation/[ticket-id].md +DEPENDS ON: ticket status = not_started, no unresolved compliance/ADR-fix flag, + all tickets in "Depends on" column = implemented or needs_review + +--- + +PERSISTENCE & RESUME PROTOCOL + +Before starting work: +1. Read /refactor-backlog/_status.md. If your row says "complete", stop — do not re-run. +2. If "in_progress", read your own output file. Treat modules already listed as done. + Resume from "Last module processed" + 1. +3. If "not_started", confirm your dependencies show "complete" in _status.md. If not, + stop and report a blocking dependency instead of guessing. + +While working: +4. Append findings incrementally, one module at a time. After each module, update + _status.md: "Last module processed" and "Last updated". +5. Each finding gets a stable ID (e.g. RD-014) that never changes across runs. +6. If interrupted, the file + status row is the full recovery state. + +On completion: +7. Mark your _status.md row "complete" only once every module in scope has a + corresponding section in your output file. + +Every output file starts with: +## Scope: [modules covered] +## Status: [not_started | in_progress | complete] +## Last updated: [timestamp] +## Depends on: [file(s)] +## --- +module list] + +AGENT: Implementation Agent + +Input: one ticket from 99-backlog.md (fill in TICKET-ID below), the Phase 1 +file(s) that produced it, and 00-baseline.md. + +TICKET-ID: [fill in before dispatching this agent] + +Scope discipline: +- Implement exactly the change described in the ticket. No scope expansion, no + incidental fixes. +- If the ticket is ambiguous or underspecified for implementation, do not guess — + write a blocker note to the ticket's status and stop. +- Do not modify architecture/pattern decisions (hexagonal boundaries, CQRS-light + structure) — those are Opus-level design calls already made in the ticket. If + implementation reveals the design call was wrong, flag back to Consolidation + rather than deciding unilaterally. +- Tickets touching a BIO2-flagged item are blocked until human compliance + sign-off is recorded in the ticket status — do not implement first and flag + after. + +Update ticket status in 99-backlog.md: not_started → in_progress → implemented +→ needs_review. Append implementation notes to implementation/[ticket-id].md. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-001.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-001.md new file mode 100644 index 0000000..ef3c39a --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-001.md @@ -0,0 +1,69 @@ +# ADR-C-001 — rewrite ADR-0001's worked example against the shipped system + +Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` §ADR-C-001 + +## What was wrong + +ADR-0001's §"Worked example in this POC" opened with _"This POC has no real backend (static +mock JSON + fake submit timers), so the 'BFF output' is a static file"_. That premise is +false and every path the section cited was gone. The decision itself was intact; only the +description had drifted. + +## What changed + +| File | Change | +| -------------------------------------------------------- | --------------------------------------------------------------------------- | +| `docs/reference/architecture/0001-...md` §Worked example | rewritten against `backend/src/BigRegister.Api`; all six paths repointed | +| same file, §Out of scope here | 4 bullets → 2, plus a paragraph recording which two were discharged and why | + +No code changed. No CLAUDE.md edit was required for this finding. + +## Paths corrected, each verified + +| Claimed | Actual | +| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| "no real backend … static file" | `backend/src/BigRegister.Api`, `var api = app.MapGroup("/api/v1")` at `Program.cs:168` | +| `public/mock/dashboard-view.json` | `GET /api/v1/dashboard-view` (`Program.cs:172`) | +| `public/mock/intake-policy.json` | `GET /api/v1/intake/policy` (`Program.cs:193`) | +| `src/app/registratie/contracts/dashboard-view.dto.ts` | `apps/ssp/src/app/registratie/contracts/dashboard-view.dto.ts` | +| `src/app/registratie/infrastructure/dashboard-view.adapter.ts` | `apps/ssp/.../infrastructure/dashboard-view.adapter.ts`, `parseDashboardView` at `:50` | +| `src/app/herregistratie/contracts/intake-policy.dto.ts` | **deleted** — the DTO is now the generated `IntakePolicyDto`; the adapter is `apps/ssp/src/app/herregistratie/infrastructure/intake-policy.adapter.ts` | + +`apps/ssp/public/mock/` does not exist (`ls`: no such directory). + +## The finding was wrong about one out-of-scope bullet + +ADR-C-001 said to _"reduce §Out of scope to the two items still genuinely open (the +`BigProfileStore` optimistic-update race, and session persistence / multi-tab sync)"_, +carrying the original bullet's parenthetical **"`SessionStore` is in-memory"**. That +parenthetical is no longer true, so the bullet could not be kept verbatim. + +- `apps/ssp/src/app/auth/application/session.store.ts:13` reads + `parseStoredPrincipal(localStorage.getItem(STORAGE_KEY))`, and `:41` writes it back. + Session persistence **has landed** (RB-10 extracted the parser, RB-13 renamed it + `parseStoredPrincipal`). The file even carries a `ponytail:` note explaining the choice of + `localStorage` over `sessionStorage`. +- Multi-tab sync has **not** landed: `grep` for a `storage` event listener across `apps` and + `libs` returns nothing. + +The bullet was therefore narrowed to multi-tab sync only, and states that the session itself +now persists. Recording this because the finding, taken literally, would have re-asserted a +false claim in the same edit that removed two others. + +The other two survivors were verified rather than assumed: `BigProfileStore` still holds +`pending` as a bare `signal(false)` with `begin`/`confirm`/`rollback` mutating it +(`big-profile.store.ts:61-74`), so the concurrent-submit race is real. + +## Discharged bullets, both verified + +- _"Runtime DTO validation on **every** endpoint (only the dashboard view has it)"_ — 33 + distinct `export function parse*` boundary functions exist across `apps` and `libs`. +- _"Real OpenAPI/TypeSpec codegen toolchain"_ — `npm run gen:api` (`package.json:12`) runs + `dotnet swagger tofile` then `nswag run`, emitting + `libs/shared/src/infrastructure/api-client.ts` (2329 lines). CI's `api-client-drift` job + regenerates and runs `git diff --exit-code` (`.github/workflows/ci.yml:319-321`). + +## Scope discipline + +Descriptive drift only, as the finding states. The decision, the options table, the two +policy shapes and the migration sequence are untouched. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-003.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-003.md new file mode 100644 index 0000000..c1731df --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-003.md @@ -0,0 +1,64 @@ +# ADR-C-003 — state that the generated client is the wire contract + +Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` §ADR-C-003 + +## What was wrong + +ADR-0001 set "one source of truth that generates types for both sides" as the target state. +The code reached it. CLAUDE.md §4 still stated the pre-codegen rule — _"DTO lives in +`contracts/`"_ — as standing law, so §4 could be cited to justify both deleting the four +survivors and adding new hand-written DTOs for already-generated endpoints. + +## What changed + +| File | Change | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `docs/reference/architecture/0001-...md` | **new** §"Where the contract lives, after codegen" | +| `CLAUDE.md` §4 | the flat "DTO lives in `contracts/`" rule replaced with the generated-client rule + the two exceptions | + +No code changed. Per CLAUDE.md's own precedence rule, the ADR was amended first and +CLAUDE.md corrected to match, in one diff. + +## The decision the finding asked for: the four survivors stay + +ADR-C-003 required an explicit, recorded decision on the four remaining hand-written DTOs. +**They stay**, all four under exception case 2 ("the generator types the shape too loosely"). +This is not a preference — adopting the generated shapes would violate CLAUDE.md §3. + +Evidence. NSwag emits every property as optional, and flattens a discriminated union into a +bag of optional fields: + +| | generated (`api-client.ts`) | hand-written (`dashboard-view.dto.ts`) | +| ----------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `DashboardViewDto` | `registration?`, `person?`, `decisions?` — all optional (`:2017`) | all three required | +| `RegistrationDto` | six optional fields (`:2202`) | six required fields | +| `RegistrationStatusDto` | **one flat record of five optional strings**, `tag?: string` (`:2211`) | a real union of three variants, `tag: 'Geregistreerd' \| 'Geschorst' \| 'Doorgehaald'`, per-variant fields required | + +The generated `RegistrationStatusDto` makes `{ tag: 'Geregistreerd', doorgehaaldOp: '…' }` +representable. That is precisely the illegal state CLAUDE.md §3 exists to forbid, and the +`parse*` boundary would have to reconstruct the union by hand anyway. + +The ADR therefore records that retiring these four is **not** a cleanup to schedule. It +becomes correct only if the backend annotates its DTOs so the generator emits required +properties and real unions — which names the actual prerequisite instead of leaving the +question open. + +## Verified counts, not carried over from the finding + +- Hand-written `contracts/*.dto.ts`: **4** — + `apps/ssp/src/app/registratie/contracts/{brp-address,dashboard-view,duo-diplomas}.dto.ts` + and `libs/beheer/src/contracts/stamdata.dto.ts`. +- All four duplicate generated types **by the same names**: `BrpAddressDto` (`:1997`), + `DashboardViewDto` (`:2017`), `DuoLookupDto` (`:2056`), `DuoDiplomaDto` (`:2047`), + `PolicyQuestionDto` (`:2168`), `ManualDiplomaPolicyDto` (`:2106`), `StamdataColumnDto` + (`:2246`), `StamdataTableDto` (`:2253`), `StamdataTableSummaryDto` (`:2261`). None is a + codegen gap — the finding's "case 1" has no occupant today, which is worth knowing. +- The `parse*` boundary is restated as mandatory regardless of type provenance. The amendment + says why in one line: a generated type is a compile-time claim about the wire, not a + runtime guarantee. + +## Gate released + +ADR-C-003 blocked any ticket that would delete the four `contracts/*.dto.ts` files or add a +hand-written DTO for a generated endpoint. No open ticket needed it. The rule is now written +down, so a future one can be judged against it rather than against a stale §4. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-006.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-006.md new file mode 100644 index 0000000..0b20dca --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-006.md @@ -0,0 +1,58 @@ +# ADR-C-006 — extract the actor-agnostic route guards to `libs/shared` + +Status: **implemented** · 2026-08-26 · Source finding: `06-adr-conformance.md` §ADR-C-006 + +## What changed + +| File | Change | +| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `libs/shared/src/application/auth.guard.ts` | **new** — `authGuard` + `capabilityGuard`, injecting `SESSION_PORT` instead of an app-local `SessionStore` | +| `libs/shared/src/application/auth.guard.spec.ts` | **new** — the single spec, provides `SESSION_PORT`; one case added asserting the guard reads the port | +| `libs/shared/src/application/session.port.ts` | widened by one member: `readonly isAuthenticated: Signal` | +| `apps/ssp/src/app/auth/auth.guard.ts` | now a re-export | +| `apps/behandelportal/src/app/auth/auth.guard.ts` | now a re-export | +| `apps/{ssp,behandelportal}/src/app/auth/auth.guard.spec.ts` | **deleted** — both were byte-identical to the new shared spec | + +Neither `app.routes.ts` was touched: both still `import { authGuard, capabilityGuard } +from '@auth/auth.guard'`. Routing asks the auth context for its guards, which is the +direction the boundary should read. + +## Why the port widening was free + +Both `SessionStore`s already exposed `readonly isAuthenticated = computed(() => +this._session() !== null)` (`session.store.ts:40` in each app), and both apps already +registered `{ provide: SESSION_PORT, useExisting: SessionStore }` +(`app.config.ts:64` / `:65`). `SessionPort` is satisfied structurally, so adding the +member required no change in either app — the seam existed, it was just narrower than +what it already carried. + +## Scope discipline + +Only the guards moved. Per the finding, **no** ticket to merge `session.store.ts`, +`session.ts`, `digid.adapter.ts`, `login-form.component.ts` or `login.page.ts`, and no +relaxation of ADR-0002 §3. Those five are identical because ADR-C-004 (`Session → +Principal`) was never executed; merging them would cement a citizen DigiD/BSN login as +the backoffice's shared login, which is the outcome §3 exists to prevent. + +## Measured effect + +Re-ran `tools/baseline-scan.mjs --dup` after the change: + +| Metric | Before | After | +| --------------------------- | -----: | --------: | +| `ssp/auth` duplicated lines | 211 | **151** | +| `bhp/auth` duplicated % | 86.8% | **82.5%** | +| Repo-wide duplication | 7.1% | **6.6%** | + +The `auth.guard.spec.ts` (36 windows) and `auth.guard.ts` (21 windows) clone pairs have +dropped out of the top-clones list entirely. The remaining `ssp/auth` ↔ `bhp/auth` +duplication is `session.store.ts` (39), `login-form.component.ts` (35) and `login.page.ts` +(23) — exactly the three ADR-C-004 is expected to differentiate. Re-measure BL-002 after +that lands; the finding's expectation is a drop to under 40 lines. + +## Verification + +`npm run lint` · `npm run typecheck` · `npm run dep:check` (0 violations, 224 modules) +· `npx prettier --check apps libs` — all clean. +Tests: shared 122, ssp 235, behandelportal 27, beheer 23 — **407 passed, 0 failed**. +`ng build --localize` for both apps. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-007.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-007.md new file mode 100644 index 0000000..a3f2a3c --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-007.md @@ -0,0 +1,65 @@ +# ADR-C-007 — repoint ADR-0003's WP-67 paths and fix its point 4 + +Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` §ADR-C-007 + +## What was wrong + +Two separate defects in one ADR. Every file path in ADR-0003 predated WP-67's monorepo move, +and decision point 4 made a claim about `app-alert` that the code contradicts. + +## What changed + +| File | Change | +| ---------------------------------------- | ---------------------------------------------------------------------------------- | +| `docs/reference/architecture/0003-...md` | points 1, 2, 4 and both §Consequences bullets rewritten | +| `CLAUDE.md` §2 | the `alert` parenthetical corrected to `skeleton`/`spinner` + a denial for `alert` | + +No code changed. ADR first, CLAUDE.md to match, one diff. + +## Paths, each verified + +| Claimed | Actual | +| ------------------------ | ---------------------------------------------------------------------- | +| `src/styles.scss` | `libs/shared/styles.scss` — one copy, both apps' `angular.json:41,169` | +| `src/index.html` | `apps/ssp/src/index.html` **and** `apps/behandelportal/src/index.html` | +| `.storybook/` | `.storybook-ssp/` and `.storybook-behandelportal/` | +| `src/docs/cibg-gaps.mdx` | `libs/shared/docs/cibg-gaps.mdx` | + +**One path in the finding's list needed no change.** ADR-C-007 implied point 1's +`public/cibg-huisstijl/` had moved with the rest. It has not: `public/` is still at the repo +root, and both apps' `angular.json` asset entries read `"input": "public"` (`:38`, `:166`). +Both Storybook configs serve it as `staticDirs: ['../public']`. Point 1's vendoring path is +left as written; only its `index.html` clause changed. + +## Point 4: the finding was right, and understated + +ADR-C-007 flagged the `.alert` half of point 4. Verified: `libs/shared/src/ui/alert/alert.component.ts` +documents itself as a _"Thin wrapper over the vendored `.feedback feedback-*` classes"_, its +template binds `.feedback-info/-success/-warning/-error`, its only local CSS is a 3-line flex +fix, and it carries **no** `CIBG-GAP EXTENSION` marker. `grep` confirms `feedback-error` is +present in `public/cibg-huisstijl/css/huisstijl.css` — the class is vendored, so `alert` is not +a gap. + +**The finding missed that the same sentence's second claim is also false.** Point 4 said "the +header/side-nav use `.nav` + a local blue bar". They do not: + +- `site-header.component.ts` composes the vendored `.titlebar` and `.logo__*` classes + (`grep` confirms `titlebar` in the vendored CSS) and its own comment says the titlebar + _"keeps its own robijn fill — `--ro-layout` — untouched"_. +- `shell.component.ts` emits only `.layout`, `.main`, `.content`, `.skip` — page scaffolding. +- No `.nav` class appears in either, and neither carries a gap marker. + +Both corrections are stated in the amended point 4 rather than silently dropped, so a reader +comparing the old text against the code can see which claim was retired and why. + +## Replacement example chosen + +`skeleton` and `spinner`, as the finding proposed. Both are in the gap register, both carry +markers reading "No loading-skeleton/spinner class in the vendored build", and both are +genuinely absent — the cleanest live illustration of the principle point 4 exists to state. + +## Noted, not fixed: the gap register is still one row short + +`grep` finds **9** `CIBG-GAP EXTENSION` markers; `libs/shared/docs/cibg-gaps.mdx` has **8** +rows. The missing one is `language-switcher`. That is **ADR-C-008 → RB-32** (batch 6), not +this ticket, and it was left alone. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-009.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-009.md new file mode 100644 index 0000000..9ed51ca --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/adr-c-009.md @@ -0,0 +1,71 @@ +# ADR-C-009 — state the runtime-editable-config exception as a test, not a list + +Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` §ADR-C-009 +· Gated on: **RB-07** (satisfied — batch 2) + +## What was wrong + +ADR-0004 said "never runtime-editable" and then named **one** exception in the singular, +justified narrowly ("specific to one sub-organization's identity"). WP-47 added a second +runtime-editable SQLite surface, `FeatureFlagStore`, whose own doc-comment states the +equivalence the ADR did not: _"SQLite-backed like `OrgTemplateStore`, same single-gate +idiom."_ + +The code is right; the ADR's text was wrong. A closed list of one leaves the next +operational-config surface with no principle to test itself against. + +## What changed + +| File | Change | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `docs/reference/architecture/0004-...md` | §"The deliberate exception: org-templates" → §"The deliberate exception: operational configuration" — a four-part test plus a table of the two passing surfaces | +| same file, §Context + the table | `src/locale/*.xlf` → `apps//src/locale/*.xlf` (two apps since WP-67) | +| `CLAUDE.md` §4 | the singular "Org-templates are the deliberate exception" replaced with the four-part test | + +No code changed — the finding says so outright, and verification confirmed it. + +## Why the RB-07 gate was real, verified clause by clause + +Clause (4) of the test is "writes are admin-capability-gated **and** audited". Signing this +ADR before RB-07 would have ratified a control the code did not implement. RB-07 has landed, +so the clause is now true. Read at `backend/src/BigRegister.Api/Program.cs:863-923`: each of +the five gates now computes `var ok = …`, calls `AuditAuthz(ctx, capability, resource, ok, +principal)` with the **real** boolean, and only then branches. `FlagsAdmin`'s own comment +names this ticket: _"this is the surface CQ-004/ADR-C-009 hinge on."_ + +All four clauses were checked against both surfaces rather than assumed: + +| Clause | `OrgTemplateStore` | `FeatureFlagStore` | +| ------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| (1) catalog in code | the `OrgTemplateDto` shape + `OrgTemplateRules` validate before save (`OrgTemplateStore.cs:49`) | `FeatureFlags.Catalog` (`Domain/Features/FeatureFlags.cs:15`) | +| (2) fails closed | unknown `subOrgId` → `null` → endpoint 404s (`:44-45,:55-56,:72-73,:94-96`) | `Set` returns false for an unlisted key (`:54`); `IsEnabled` returns false (`:42-43`) | +| (3) operational | one sub-organisation's letterhead | an on/off rollout switch | +| (4) gated + audited | `OrgAdmin` → `orgtemplate:edit` (`Program.cs:863`) | `FlagsAdmin` → `flags:manage` (`Program.cs:914`) | + +`FeatureFlagStore`'s own comment states clause (1) and (2) explicitly: _"The CATALOG … is +code … this store only holds the admin's on/off overrides. An unknown key is never +writable/enabled — the code catalog is the authority."_ + +## Judgement calls + +- **Clause (2) is about the write/enable path, not every read.** `OrgTemplateStore` has a + deliberate read-path fallback for briefs from before WP-23 (`:110-114`, its own `ponytail:` + comment): an empty `SubOrgId` falls back to the first seeded sub-org rather than failing a + whole screen. That is a preview convenience on a read; the four write entry points all + return `null` for an unknown sub-org. The clause is worded "cannot invent a setting, + enable a feature, or be written" so this read fallback is not caught by it. Recorded + because a reader checking clause (2) against `OrgTemplateStore.cs` will meet that + fallback first. +- **Org-templates' publish/rollback versioning is mentioned but excluded from the test.** It + is stronger than the test requires, and making it a fifth clause would block a legitimate + flag-style surface that has nothing to version. +- **The stale `src/locale/*.xlf` paths were fixed in the same diff**, though ADR-C-009 did + not flag them. They are two occurrences of the same WP-67 drift ADR-C-001 and ADR-C-007 + exist to correct, in the section being edited, and leaving a known-false path in a document + while amending it is the exact failure mode those two findings describe. Scope creep is + two words wide here; the alternative is filing a third ticket for it. + +## Gate released + +ADR-C-009 blocked "any ticket proposing a third runtime-editable config surface". Such a +ticket can now be judged against a written test rather than by analogy to org-templates. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-01.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-01.md new file mode 100644 index 0000000..402caf3 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-01.md @@ -0,0 +1,57 @@ +# RB-01 — authorize `GET /uploads/{id}/content` and `/uploads/status` + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-004 · `99-backlog.md` RB-01 + +## What was wrong + +`GET /uploads/{documentId}/content` took `(string documentId)` — no `HttpContext`, so no +authorization was possible at all. It streams diploma and identity scans; the only +protection was the unguessability of the document GUID. `DELETE` on the same resource has +been owner-scoped (`DocumentStore.DeleteOwned`) since it was written. + +`GET /uploads/status?localIds=` had the same shape, and leaks less but still confirms +whether a given client-chosen `localId` exists anywhere in the store, plus its documentId. + +## What changed + +| File | Change | +| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `Program.cs` `/uploads/{documentId}/content` | takes `HttpContext`; allowed for the owning `ZorgverlenerCaller` or a caller passing `Authz.CanBeoordelen`; else `404` | +| `Program.cs` `/uploads/status` | takes `HttpContext`; scoped to `ctx.Zorgverlener().Bsn` | +| `Data/DocumentStore.cs` `ByLocalIds` | second parameter `owner`; filters on it (the only call site is the endpoint above) | +| `tests/BigRegister.Tests/UploadAccessTests.cs` | **new** — 5 cases | + +The two actor kinds are matched, not branched on a boolean, because `ctx.Zorgverlener()` +**throws** for a `MedewerkerCaller` — a behandelaar reading an aanvraag's linked documents +(`beoordeling-documenten.component.ts`) is a legitimate caller here: + +```csharp +var allowed = ctx.Caller() switch +{ + ZorgverlenerCaller z => doc?.Owner == z.Bsn, + var caller => Authz.CanBeoordelen(caller), +}; +``` + +**404, not 403**, per the ticket: a foreign document id must not be distinguishable from +one that never existed. `doc is null || !allowed` collapses both to the same answer, and +`/uploads/status` reports a foreign `localId` as `"unknown"` — the same word an id that +never existed gets. + +## Known residual — this endpoint is reached without identity headers + +Both callers link to the URL directly (`` in `beoordeling-documenten.component.ts`, +`previewUrl` in `libs/shared/src/upload/upload.adapter.ts`), so the request is a plain +browser navigation that carries **no** `X-Medewerker` / `X-Subject` header and never passes +through an Angular interceptor. `StubIdentityProvider` therefore resolves it to the seeded +citizen, which owns every document in the POC, so both links keep working — by coincidence, +not by authorization. That coincidence **is** BIO-002, and it is fixed by **RB-09** (making +`IIdentityProvider` able to express "no identity"), not here. RB-09 will need this endpoint +to receive a real credential — a signed URL or a cookie — rather than the ambient default. + +## Verification + +`dotnet build` clean. `dotnet test`: **250 passed, 1 failed** — the failure is +`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`, +which needs a live OpenZaak container and **fails identically on a stashed tree**, i.e. it +pre-dates this change. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-02.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-02.md new file mode 100644 index 0000000..6a5d8fb --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-02.md @@ -0,0 +1,58 @@ +# RB-02 — stop concatenating the BSN into `AuthzAudit.Resource` + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-008 · `99-backlog.md` RB-02 + +## What was wrong + +`Program.cs` (was `:674`) built the reveal attempt's audit resource ref as +`"brief/" + ctx.Zorgverlener().Bsn`. `AuditAuthz` persists that string to the +`AuthzAudit.Resource` column in SQLite, and `GET /admin/audit` renders it on the admin +audit page — so a BSN was written to durable storage and shown in a UI, on the one trail +four documents describe as data-minimised and PII-free. + +The endpoint is the _BIG-nummer reveal_, whose own comment says the audit carries +"NO PII. Never the value that was (or wasn't) revealed" — and it did not carry the +BIG-nummer. It carried the BSN instead, in the adjacent argument. + +## Why the existing test did not catch it + +`AuthzAuditTests.The_audit_schema_carries_no_pii` asserts on **column names**: + +```csharp +Assert.DoesNotContain(names, n => Regex.IsMatch(n, "naam|name|bsn|value|waarde", …)); +``` + +A BSN inside a column called `Resource` is invisible to a regex over the word `Resource`. +The test was structurally incapable of failing on this defect, which is why the +value-asserting test is part of this ticket's definition of done rather than a follow-up. + +## What changed + +| File | Change | +| -------------------- | ------------------------------------------------------------------------------------------------ | +| `Program.cs` | resource ref is `"brief"`; a comment records why the id added nothing | +| `AuthzAuditTests.cs` | **new** `No_audit_row_carries_a_subjects_bsn` — asserts on stored **values**, every string field | + +No identifier was lost. `BriefStore` keys one brief per owner, so `brief/` named the +same thing the row's acting principal already implies; there is no second brief the ref +could have disambiguated. + +The new test drives a denied reveal as a **non-default** subject (`X-Subject: 999999990`), +then scans every string field of every audit row for that BSN and for +`DocumentStore.DemoOwner`. Asserting against the two BSNs actually in play, rather than a +`\d{9}` shape, keeps it deterministic — a hex correlation id can hold nine consecutive +digits by chance. + +**Confirmed it fails without the fix**: reverting only the `Program.cs` line turns +`No_audit_row_carries_a_subjects_bsn` red, and restoring it turns it green. + +## Not in scope + +`AuditEntry.Actor` on document audit rows also holds a raw BSN. That is a different store +(`DocumentStore.Audit`) and is **RB-04**, which is where the masking decision for it lives. + +## Verification + +`dotnet format --verify-no-changes` clean. `dotnet test`: **251 passed, 1 failed** — the +failure is `OpenZaakIntegrationTests.Admin_cases_…`, which needs a live OpenZaak container +and fails identically on a stashed tree. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-03.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-03.md new file mode 100644 index 0000000..7b669e0 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-03.md @@ -0,0 +1,45 @@ +# RB-03 — mask the owner BSN on the cross-owner case lists + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-003 · `99-backlog.md` RB-03 + +## What was wrong + +`Mappers.ToAdminSummaryDto` set `Owner = a.Owner` — the raw BSN. Two endpoints consume it, +both cross-owner lists read by someone who is **not** the subject: + +- `GET /admin/cases` (`cases:manage`) +- `GET /werkvoorraad` (`aanvraag:beoordelen`) + +`GET /beoordeling/{id}` — the _detail_ view of the same data — already masked. So the +detail screen showed `******782` while the list one click earlier showed the whole BSN. + +## What changed + +| File | Change | +| ---------------------- | ---------------------------------------------------------------- | +| `Domain/People/Pii.cs` | **new** — `Pii.MaskTail`, moved out of `Program.cs` | +| `Contracts/Mappers.cs` | `Owner = Pii.MaskTail(a.Owner, 3)` | +| `Program.cs` | local `MaskTail` deleted; two call sites point at `Pii.MaskTail` | +| `AdminCasesTests.cs` | asserts the masked value and that `DemoOwner` does not appear | +| `WerkvoorraadTests.cs` | same assertion, replacing the `IsNullOrEmpty` one | + +**Masked in the mapper, not at the endpoints.** The point of the ticket is that both +lists _inherit_ it, so a third cross-owner list cannot be added that forgets to mask. + +**`MaskTail` moved to `Domain/People/Pii.cs`** because it now has three callers across +three folders (`Contracts`, `Program.cs`, and `Data` once **RB-04** lands), and a second +hand-rolled copy is how one of them drifts into leaking. It is documented as idempotent, +which is what lets `/beoordeling/{id}` keep its own call: `IZaakSource` has a second +implementation (`OpenZaakZaakSource` → `ZgwZaakMapper`, which maps `Owner` from the zaak +`identificatie`), so that endpoint's guarantee should not depend on which source answered. + +## Blast radius on the frontend — none + +Both consumers use the value for display only (`admin-cases.page.ts:101`, +`beoordeling-view.ts:40`, `werkvoorraad-item-view.ts:28`); the `parse*` boundaries require +a non-empty string, which a masked BSN still is. Nothing keys, filters or looks up by owner. + +## Verification + +`dotnet format --verify-no-changes` clean. `dotnet test`: **251 passed, 1 failed** — the +pre-existing `OpenZaakIntegrationTests.Admin_cases_…`, which needs a live container. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-04.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-04.md new file mode 100644 index 0000000..890b5b2 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-04.md @@ -0,0 +1,37 @@ +# RB-04 — mask the BSN recorded as `AuditEntry.Actor` + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-005 · `99-backlog.md` RB-04 + +## What was wrong + +`DocumentStore` writes one audit row per upload and per user delete, with the acting +citizen's raw BSN as `AuditEntry.Actor`, persisted to SQLite. The class's own doc comment +says "The audit log holds metadata only (never file content **or other PII**)" — a BSN in +every row is precisely other PII. Same failure shape as RB-02, in a second store. + +## What changed + +| File | Change | +| ------------------------------------- | --------------------------------------------------------- | +| `Data/DocumentStore.cs` `Add` | `Audit("upload", …, Pii.MaskTail(owner, 3))` | +| `Data/DocumentStore.cs` `DeleteOwned` | `Audit("delete-user", …, Pii.MaskTail(owner, 3))` | +| `Data/DocumentStore.cs` `Audit` | doc comment: actors arrive **already redacted** | +| `UploadAccessTests.cs` | **new** `The_document_audit_trail_records_a_masked_actor` | + +**Masked at the two call sites, not inside `Audit`** — unlike RB-03, where masking in the +mapper was the point. `Audit`'s third actor is the literal `"admin"` (from `AdminDelete`), +and `MaskTail("admin", 3)` is `"**min"`: masking centrally would mean guessing which +actors are BSNs and which are role names. The contract is stated on `Audit` instead. + +**`StoredDocument.Owner` is untouched**, per the ticket. It is the authorization key — +`DeleteOwned`, `ForeignIds` and now the RB-01 content check all compare against it — so it +has to stay whole. The BSN remains where it is load-bearing and leaves the trail where it +was only decoration. + +Nothing reads `DocumentStore.AuditLog` today (no endpoint exposes it), so this is a +data-at-rest fix with no response-shape change. + +## Verification + +`dotnet format --verify-no-changes` clean. `dotnet test`: **252 passed, 1 failed** — the +pre-existing `OpenZaakIntegrationTests.Admin_cases_…`, which needs a live container. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-05.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-05.md new file mode 100644 index 0000000..47e9203 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-05.md @@ -0,0 +1,50 @@ +# RB-05 — drop the BSN-bearing query and body snippet from the ZGW failure message + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-009 · `99-backlog.md` RB-05 + +## What was wrong + +`ZgwHttpClient.SendWithRetryAsync` built its failure message as + +```csharp +$"ZGW {req.Method} {req.RequestUri} failed: {(int)res.StatusCode} {snippet}" +``` + +with `snippet` being up to 500 characters of the **response body**. That message is not +transient: `Program.cs`'s submit endpoint catches it and stores it as `Aanvraag.ZgwError` +in SQLite, and logs it. + +Two BSN paths into it: + +- **the query string.** ZGW filters travel as query parameters, and the citizen-scoped zaken + list filters on `rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn=`. +- **the body snippet.** OpenZaak's error responses echo the offending request, so a rejected + `POST /rollen` (whose body carries `BetrokkeneIdentificatie(aanvraag.Owner)`) comes back + with the BSN in it. + +The two `"returned null body"` throws in `GetAsync`/`PostAsync` interpolated the same url. + +## What changed + +| File | Change | +| ----------------------- | ---------------------------------------------------------------------------- | +| `Zgw/ZgwHttpClient.cs` | `Redact(url)` (path only) at all three sites; snippet → `res.ReasonPhrase` | +| `ZgwDivergenceTests.cs` | **new** `A_recorded_divergence_carries_no_response_body_and_no_query_string` | + +Status + path is enough to route a failure to the right endpoint. The diagnostic detail +that was lost already has a deliberate home: `ZGW_DEBUG_HTTP=1` wires +`ZgwDiagnosticHandler`, which logs the full url and request bytes — opt-in, dev-only, and +not persisted. + +## The test + +Fails the `statustypen` GET (the only call in that fixture whose url carries a query +string) after the zaak POST succeeds, then asserts on the persisted `ZgwError`: +no `"stub failure"` (the body snippet), no `"?"` (the query string), but still the path and +the `503`. **Confirmed it fails without the fix** — restoring the old interpolation turns it +red on both counts. + +## Verification + +`dotnet format --verify-no-changes` clean. `dotnet test`: **253 passed, 1 failed** — the +pre-existing `OpenZaakIntegrationTests.Admin_cases_…`, which needs a live container. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-06.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-06.md new file mode 100644 index 0000000..0e74589 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-06.md @@ -0,0 +1,69 @@ +# RB-06 — delete the dead `POST /registrations` + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-010 · `99-backlog.md` RB-06 + +## What was wrong + +`POST /registrations` took a `Documents` list and passed it straight to `Submit`, which +calls `DocumentStore.Link(...)` on every digital `documentId` in it. Linking a document +**blocks its owner from deleting it** (`DeleteOwned` → 409 `Linked`). + +There was no `ForeignIds` ownership check on that path. The real submit endpoint, +`POST /applications/{id}/submit`, has had one since it was written: + +```csharp +if (documentIds is { Count: > 0 } && DocumentStore.ForeignIds(documentIds, ctx.Zorgverlener().Bsn) is { Count: > 0 } foreignIds) + return Results.Problem(detail: $"Onbekend of niet-eigen document(en): …", statusCode: 400); +``` + +So any authenticated citizen could post another citizen's document id and permanently +block them from deleting their own diploma scan. + +## Deleted rather than guarded + +The ticket allowed either. Deleted, because the endpoint is dead: no frontend caller (the +generated client's `registrations` method was unreferenced), and the whole registratie flow +goes through `POST /applications/{id}/submit`. + +| File | Change | +| -------------------------------------------- | ------------------------------------------------- | +| `Program.cs` | endpoint deleted | +| `Contracts/Dtos.cs` | `RegistratieRequest` deleted (no other reference) | +| `Domain/Submissions/SubmissionRules.cs` | `RejectRegistratie` deleted — see below | +| `backend/swagger.json`, `api-client.ts` | regenerated (`npm run gen:api`) | +| `EndpointTests.cs`, `SubmissionRuleTests.cs` | retargeted, see below | + +### Why `RejectRegistratie` went with it + +It was reachable only from this endpoint, and the live path deliberately **contradicts** +it. `RejectRegistratie("handmatig")` returned a 422 rejection; the modern submit does + +```csharp +"registratie" => (null, req.DiplomaHerkomst == "duo"), +``` + +— a manual diploma is not rejected, it simply does not auto-approve and goes to a +behandelaar. Its own message even said so ("doorgestuurd voor handmatige beoordeling") +while being returned as a rejection. Leaving it behind would have left an obsolete rule +with a passing spec, which is exactly how it gets reintroduced. + +**This is the one judgement call in this ticket** — the backlog row says "delete the dead +endpoint", not "delete the rule". Reverting just the `SubmissionRules`/`SubmissionRuleTests` +hunks restores it without touching anything else. + +### Test coverage that moved rather than vanished + +- `Registration_with_manual_diploma_is_rejected_with_problem_details` was the only test + asserting the `Submit` helper's `application/problem+json` rejection shape. That assertion + moved into `Change_request_with_bad_phone_is_rejected_with_problem_details` — + `/change-requests` is the other endpoint on the same helper. +- `User_delete_blocked_with_409_once_linked_to_submission` covered `DocumentStore.Link` + blocking a delete. Retargeted to `POST /applications/{id}/submit`, i.e. the path that is + actually in use. `POST /registrations` was the only other caller of `Link`. +- `Registration_with_duo_diploma_succeeds` was deleted outright — `Change_request_with_valid_phone_succeeds` + is the same assertion on the same helper. + +## Verification + +`npm run ci`. `dotnet test`: **249 passed, 1 failed** — the pre-existing +`OpenZaakIntegrationTests.Admin_cases_…`, which needs a live container. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-07.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-07.md new file mode 100644 index 0000000..50e9e34 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-07.md @@ -0,0 +1,63 @@ +# RB-07 — audit the allow path, not just the denial + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-007 (+ the outstanding half of CQ-004) · `99-backlog.md` RB-07 + +## What was wrong + +All five authorization gates called `AuditAuthz(..., allowed: false, ...)` only on the deny +branch; the allow branch called `action()` and returned. So `/beheer/audit` — the queryable +trail the product ships as its audit surface — could answer "who was turned away" but never +"who changed this". + +Nothing recorded: `PUT /admin/flags/{key}`, `PUT /admin/org-template/{subOrgId}`, +`POST /admin/org-template/{subOrgId}/rollback/{version}`, `DELETE /admin/cases/{id}`, +`DELETE /admin/uploads/{documentId}`, `POST /brief/approve|reject|send`, and +`POST /beoordeling/{id}/besluit`. The comment above `OrgAdmin` claimed the endpoints logged +their own effect instead; publish and admin case delete do, the other six did not log at all. + +## What changed + +| File | Change | +| ------------------------- | ----------------------------------------------------------------------------- | +| `Program.cs` × 5 gates | `var ok = Authz.CanX(p); AuditAuthz(ctx, …, ok, p); if (ok) return action();` | +| `Program.cs` `FlagsAdmin` | takes a per-call `resource` (see below) | +| `Program.cs` `LogBrief` | takes `HttpContext`, writes the audit row alongside the log line | +| `Program.cs` besluit | one `aanvraag:besluit` row recording **what** was decided | +| `AuthzAuditTests.cs` | allow-path row; the flag key + value; a refused brief transition | +| `BriefEndpointTests.cs` | the allow side of `brief:submit` | +| `BeoordelingTests.cs` | the `aanvraag:besluit` row | + +**The row is written by the gate, not the endpoint.** That is the point: a new admin +endpoint cannot be added that forgets to audit itself. Same reasoning for the brief — every +transition already funnelled through `LogBrief` for its log line, so the audit call went +there too, which covers `submit`/`approve`/`reject`/`send` in one place and any fifth +transition automatically. The decision recorded is the transition's own outcome, so a 403 or +a 409 is as visible as a success. + +**`FlagsAdmin` gained a `resource` parameter** — the one deviation from BIO-007's minimal +remediation, and the reason is in the finding itself: the toggle endpoint writes no log line +of its own, so a constant `"feature-flags"` row would record that a flag changed without +recording _which_. It now writes `feature-flags/=`. One call site. +`OrgAdmin`/`CasesAdmin` keep their coarse refs because those endpoints do log the specific +object; **that asymmetry is deliberate, not an oversight.** + +**The besluit gets a second row.** The `Beoordelen` gate records that a behandelaar was +_allowed to act_; `aanvraag:besluit` records _what they decided_ +(`aanvraag//Goedkeuren`). Only the first would leave "who rejected this aanvraag" +unanswerable, which is the question the trail exists for. + +## Consequences worth knowing + +- **Row volume goes up.** `StamdataAdmin` gates read endpoints, so every admin page load now + writes rows. That is what "audit the allow path" means and BIO-007 asks for it explicitly; + if `AuthzAuditStore` ever needs retention or sampling, this is the change that made it + necessary. +- **This unblocks ADR-C-009.** Clause (4) of agent 06's four-part test is "writes are + admin-capability-gated **and** audited". Both surfaces now are, so the amendment can be + signed without ratifying a control the code does not implement. +- **CQ-004's outstanding half is closed.** `PUT /admin/flags/{key}` writes an audit row. + +## Verification + +`dotnet test`: **252 passed, 1 failed** — the pre-existing +`OpenZaakIntegrationTests.Admin_cases_…`, which needs a live container. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-08.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-08.md new file mode 100644 index 0000000..25687e5 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-08.md @@ -0,0 +1,75 @@ +# RB-08 — route `DELETE /admin/uploads/{documentId}` through `CasesAdmin`; delete the orphaned `IsAdmin` gate + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-003 · `99-backlog.md` RB-08 + +## What was wrong + +`Program.cs:790` (pre-change) had `static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";` +and `DELETE /admin/uploads/{documentId}` (`:274`) was gated by `IsAdmin` alone — outside +`Authz`, outside every wrapper the four sibling admin surfaces use, and writing no +`AuthzAuditStore` row at all. `DocumentStore.AdminDelete` bypasses ownership and deletes the +row and its bytes; the only record left behind was a `DocumentStore.Audit("delete-admin", …)` +metadata row, which never surfaces on `/beheer/audit`. + +`grep -rn "X-Admin"` over `apps`, `libs`, `backend`, `e2e` (re-verified before deleting the +gate, as the ticket asked) confirmed the finding: the only sender was +`backend/tests/BigRegister.Tests/EndpointTests.cs:231`. No frontend or e2e path uses this +header — it was an orphaned gate, not a live seam. + +## What changed + +| File | Change | +| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Program.cs` `DELETE /admin/uploads/{id}` | now `CasesAdmin(ctx, () => DocumentStore.AdminDelete(...) ? NoContent : NotFound)` — same wrapper the other four admin-cases endpoints use; response doc changed `Produces(403)` → `ProducesProblem(403)` to match `CasesAdmin`'s `Results.Problem` | +| `Program.cs` `IsAdmin` | **deleted** | +| `Program.cs` two stale comments | the endpoint's own comment rewritten to describe the new gate; the brief-section banner comment ("mirrors the X-Admin seam") no longer references a gate that doesn't exist | +| `tests/BigRegister.Tests/EndpointTests.cs` | `Admin_delete_requires_admin_role` sends `X-Role: admin` instead of `X-Admin: true` | +| `tests/BigRegister.Tests/AuthzAuditTests.cs` | **new** `An_admin_upload_delete_is_recorded` — asserts the `cases:manage`/`allow`/`Admin` row count increases by exactly one after the delete | + +No new `Authz` capability was added — `CasesAdmin`/`Authz.CanManageCases` is the wrapper the +ticket named as the expected outcome, and nothing about this endpoint needed a narrower +capability than "manage cases" already provides. + +**RB-07 already moved `AuditAuthz` onto the allow path for every `*Admin` wrapper**, so +routing through `CasesAdmin` gives BIO-003's missing audit row for free. No second +`AuditAuthz` call was added — confirmed by reading `CasesAdmin`'s body (`Program.cs`): it +calls `AuditAuthz(ctx, "cases:manage", "cases", ok, principal)` unconditionally before +branching on `ok`. + +## Judgement calls + +- **Test asserts a count delta, not mere presence.** `CasesAdmin` audits under a fixed + `"cases"` resource literal shared by every `cases:manage` call (`GET /admin/cases`, + `DELETE /admin/cases/{id}`, `GET /admin/audit` itself, and now this endpoint), so + `Assert.Contains(rows, cases:manage/allow/Admin)` would already be satisfied by this test + class's _other_ tests even without the fix. The new test counts matching rows before and + after the delete and asserts the count grew by exactly one. It reads `AuthzAuditStore.List()` + in-process rather than through `GET /admin/audit` — that endpoint is itself a `CasesAdmin` + read, so calling it to take the "before" measurement would have written its own + `cases:manage`/`allow` row and silently inflated the count by one every time it was called + (caught this by running the test once against the fix with an HTTP-based baseline: it + failed with an off-by-one before switching to the in-process read). +- **Two comments referencing the old gate were also updated**, not just the endpoint mapping + itself — one directly above the endpoint, one in the brief-section banner comment + ("dev-only stand-in via X-Role, mirrors the X-Admin seam") that would otherwise describe a + gate that no longer exists. + +## Known residual + +None new. RB-01's implementation note already records that `GET /uploads/{id}/content` is +reached with no identity header via plain browser navigation — that residual is RB-09's +territory, not this ticket's, and is untouched here. + +## Verification + +- Reverted `Program.cs`'s endpoint change only (`git stash push` on that one file, tests + left in place) and re-ran `dotnet test --filter "AuthzAuditTests|EndpointTests"`: **both** + `EndpointTests.Admin_delete_requires_admin_role` and + `AuthzAuditTests.An_admin_upload_delete_is_recorded` failed red (403 Forbidden — the old + gate rejects `X-Role: admin`, and the count-delta test throws on `EnsureSuccessStatusCode` + before it can assert). Restored the fix (`git stash pop`) and re-ran: both green. +- `dotnet build`: clean. +- `dotnet test` (full suite): **253 passed, 1 failed** — the pre-existing + `OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`, + which needs a live OpenZaak container and fails identically on a clean tree; not touched by + this ticket. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-09.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-09.md new file mode 100644 index 0000000..d62af76 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-09.md @@ -0,0 +1,168 @@ +# RB-09 — make "no identity" representable; stub Development-only; fail fast in Production + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-002 (folds in BIO-001(a)/(b)) · `99-backlog.md` RB-09 + +## What was wrong + +`IIdentityProvider.Resolve` returned a non-nullable `CallerIdentity` +(`IIdentityProvider.cs:12`, pre-change), so the interface could not express "no identity" — any +implementation, stub or real, was forced to invent one for an unauthenticated request. +`StubIdentityProvider` was registered unconditionally, for every environment. + +Consequence, traced end to end: `apps/behandelportal/src/app/app.config.ts:57-63` puts +`medewerkerInterceptor` inside the `isDevMode()` provider array, so a production +behandelportal build sends **no** `X-Medewerker`/`X-Rollen` header. With neither header, +`StubIdentityProvider.Resolve` fell through to +`new ZorgverlenerCaller(DocumentStore.DemoOwner, ..., PrincipalRole.Drafter)` — the single +seeded citizen, role `drafter`. That: + +- **Fails closed, correctly, on backoffice capabilities** — `Authz.CanBeoordelen` is + `caller is MedewerkerCaller`, so a zorgverlener caller is always `false` regardless of role. + This part of the design was right and is untouched. +- **Fails open on every citizen-scoped endpoint** — `GET/PUT/DELETE /applications*`, + `POST /applications/{id}/submit`, `DELETE /uploads/{id}`, `GET|PUT /brief`, + `POST /brief/submit|send|reset` all resolve `ctx.Zorgverlener().Bsn` to the seeded citizen's + BSN. An employee with no employee identity was granted a citizen's own read/write rights. +- **Holds `CanRevealBigNummer`** — that capability is `Role == PrincipalRole.Drafter`, and + `drafter` is exactly the no-header default. + +`CallerIdentityHttpContextExtensions.Caller()` (`CallerIdentity.cs:44-50`) already throws +loudly when the identity middleware didn't run — the codebase reached for fail-loud one layer +up and then defaulted one layer down, which is the shape of the bug. + +## What changed + +| File | Change | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `Domain/Authorization/IIdentityProvider.cs` | `Resolve` returns `CallerIdentity?`; doc comment states what null means and where it's turned into a response | +| `Domain/Authorization/StubIdentityProvider.cs` | **implementation signature unchanged** (`CallerIdentity`, non-nullable) — a valid, narrower override of the nullable interface method (return-type covariance; the compiler accepts it with zero warnings); doc comment records it is Development-only and never itself returns null | +| `Program.cs` — registration | `StubIdentityProvider` registered only under `builder.Environment.IsDevelopment()`; an `else if (builder.Environment.IsProduction())` branch throws `InvalidOperationException` immediately, before `builder.Build()` — the earliest possible failure point | +| `Program.cs` — identity middleware | resolves the identity once; if `null`, sets `401` and returns without calling `next()`, instead of passing a null (or invented) caller downstream | +| `tests/BigRegister.Tests/StubIdentityProviderTests.cs` | **new** `Never_returns_null_even_with_no_headers_at_all`; **new** `ProductionIdentityProviderTests.Production_environment_with_no_real_identity_provider_fails_at_startup` | + +No consumer beyond the middleware itself calls `IIdentityProvider.Resolve` (`grep -rn +"IIdentityProvider\|identityProvider\."` over `backend/src` confirms exactly one call site) — +`Authz.ResolvePrincipal`, `ZgwTokenProvider.Mint`, and every endpoint read `ctx.Caller()` / +`ctx.Zorgverlener()`, which already throw on a missing identity and are untouched. The 401 +now happens _before_ those are ever reached for a request the middleware rejects. + +## Judgement calls + +- **`StubIdentityProvider`'s own method signature stays `CallerIdentity`, not + `CallerIdentity?`.** The interface needed the nullable shape to make "no identity" + representable in general; the stub itself never has that case (it is a developer + convenience that always invents a caller by design) and returning a narrower, + non-nullable type from an override of a nullable-returning interface method is valid C# + nullable-reference-type covariance — verified with a clean `dotnet build` (0 warnings). + This kept every existing `StubIdentityProviderTests` call site (`private static +CallerIdentity Resolve(...)`) compiling with zero changes, rather than sprinkling + null-forgiving operators through a file whose entire point is "the stub always resolves." +- **The Production-only check is `IsProduction()`, not `!IsDevelopment()`.** The ticket and + BIO-002 both say "Production must fail at startup" specifically. A third environment (e.g. + a hypothetical `Staging`) falls through neither branch, registers no `IIdentityProvider` at + all, and would still fail — one line later, when `app.Services.GetRequiredService< +IIdentityProvider>()` throws .NET's own "no service for type" exception — just with a less + specific message than the one this ticket adds for Production. That fallback is a safety + net, not the intended fail-fast message; if a real non-Production, non-Development + environment is added later, giving it the same explicit message is a one-line follow-up, + not a design gap today. +- **The throw sits before `builder.Build()`**, not after (where `GetRequiredService` already + runs today). Both satisfy "throw during service registration / app build so a misconfigured + deploy never serves a request" — throwing earlier was free and gives a message naming the + actual cause (no real identity provider) rather than a generic DI resolution failure. +- **The 401 short-circuits before `ctx.SetCaller`, not after.** `next(ctx)` is never called, + so no downstream middleware or endpoint runs for a request with no identity — a citizen or + behandelaar endpoint reached this way now gets a clean 401 instead of ever executing. +- **`TestWebApplicationFactory` needed no change.** `WebApplicationFactory` defaults its + test host to the `Development` environment when nothing overrides it (confirmed + empirically: `dotnet test` — every non-Production test, all 253 of them pre-existing plus + 2 new, passed unchanged), so the entire existing test suite continues to exercise the + Development path exactly as before. The Production test builds its own + `WebApplicationFactory().WithWebHostBuilder(b => b.UseEnvironment("Production"))` + rather than touching the shared fixture. + +## Known residual — explicitly out of scope, confirmed and written up per the ticket + +**RB-01's residual is this ticket's territory but is explicitly out of scope for this +ticket**, per the task: `GET /uploads/{documentId}/content` is reached by a plain browser +navigation (`` in `beoordeling-documenten.component.ts`, `previewUrl` in +`libs/shared/src/upload/upload.adapter.ts`) that sends no identity header and never passes +through an Angular interceptor. + +- **In Development, this is unchanged** — verified by reading the endpoint + (`Program.cs:253-266`) and confirming `StubIdentityProvider` is still registered and still + resolves the same non-null seeded-citizen default it always did when no headers are + present. `dotnet test`'s full pass (255/255, excluding the pre-existing OpenZaak failure) + including `UploadAccessTests` — which exercises exactly this endpoint — confirms it + byte-for-byte. +- **The Production consequence, for the next ticket:** today Production cannot start at + all (this ticket's fail-fast), so the question is moot until a real `IIdentityProvider` + exists. Once one does, this endpoint's plain-navigation callers carry no credential a real + provider could resolve — the identity middleware would treat that as "no identity" and + return 401 before the endpoint ever runs, breaking both preview links outright. Making the + stub Development-only does not itself break anything (nothing in Production exists yet to + break), but it does mean **whoever builds the real provider must also solve this endpoint's + credential-carrying problem in the same change**, or ship it broken. This is not a + signed-URL or cookie scheme, and no such scheme was designed here, per the ticket's explicit + instruction — it is recorded so the next ticket (RB-13, or whichever lands the real + provider) picks it up deliberately rather than discovering it in a production incident. + +## Verification + +- **Reverted the registration change only** (kept `AddSingleton()` unconditional, left the new tests in place) and ran + `ProductionIdentityProviderTests`: it failed red — `Assert.ThrowsAny() Failure: No exception +was thrown` (the stub gets registered in every environment, so the host builds fine and + `factory.CreateClient()` never throws). Restored the fix and re-ran: green. +- `dotnet build`: clean, **0 warnings** (confirms the nullable-covariance judgement call + above compiles cleanly). +- `dotnet format BigRegister.slnx --verify-no-changes`: clean. +- `dotnet test` (full suite): **255 passed, 1 failed** — the pre-existing + `OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT` + (needs a live OpenZaak container; fails identically on a clean tree). CI's actual filter, + `dotnet test BigRegister.slnx --filter "Category!=Integration"`: **255 passed, 0 failed**. + +## A regression found by actually running `npm run ci`, and its fix + +`npm run gen:api` (`dotnet swagger tofile`) loads `BigRegister.Api.dll` through .NET's +design-time `HostFactoryResolver` — the same mechanism `dotnet ef` migrations use — which +executes this file's top-level statements, including the identity middleware's +pre-existing, unconditional `app.Services.GetRequiredService()`, without +ever setting `ASPNETCORE_ENVIRONMENT`. Unset defaults to `Production`. Before this ticket +that was harmless (`StubIdentityProvider` was registered unconditionally); after it, nothing +is registered for that default environment, so the tool crashed +(`dotnet swagger tofile` exited **134**, confirmed by running it directly, both before and +after the fix below). + +This is real breakage of a real workflow, not a false alarm from `ci-local.sh` — verified by +reading `.github/workflows/ci.yml`'s `api-client-drift` job: `npm run gen:api` and +`git diff --exit-code ...` are **two separate `- run:` steps** there, so the crash would fail +actual CI. `ci-local.sh` chains them as `npm run gen:api && git diff --exit-code ...` on one +line, and its first full run of `npm run ci` after this ticket's change **printed the crash +but still reported `✔ local CI passed`** — a bash `set -e` gotcha, not a false negative +specific to this fix: a failing command that is not the last element of an `&&`/`||` list is +exempt from triggering `errexit`, so `cmd1 && cmd2` silently "passes" whenever `cmd1` alone +fails. That is a pre-existing fragility in `ci-local.sh`'s three `step "X"; gen && git diff` +lines (snippets/behaviour-spec/api-client drift), unrelated to RB-09 and out of this +ticket's scope — flagged here rather than fixed, since fixing a local convenience script's +error handling is a different, standalone change. Running the failing command directly +(rather than trusting the local script) is what caught this. + +**Fix:** `package.json`'s `gen:api` script now sets `ASPNETCORE_ENVIRONMENT=Development` +on the `dotnet swagger tofile` invocation specifically — the same value +`backend/src/BigRegister.Api/Properties/launchSettings.json` already sets for `dotnet run`, +and the same value `docker-compose.yml` already sets for local Docker (confirmed by reading +both: `docker-compose.prod.yml` sets `Production` explicitly, `docker-compose.yml` sets +`Development` explicitly — the bare CLI tool invocation was the **one** place with no +environment variable set at all). Verified: `npm run gen:api` now exits 0 and produces the +regenerated `backend/swagger.json` / `libs/shared/src/infrastructure/api-client.ts` cleanly. + +This also surfaced a second, unrelated gap: RB-08 changed +`DELETE /admin/uploads/{documentId}`'s 403 mapping from `.Produces` to `.ProducesProblem` +but the generated OpenAPI doc/client were never regenerated for it (RB-08's own `npm run ci` +was run before this fix existed, so `gen:api` was already broken by the time RB-09 landed +and the drift went unnoticed). Regenerated and committed separately — see the two follow-up +commits **fix(api): regenerate client for RB-08's 403 response shape** and +**fix(tooling): keep gen:api working under RB-09's Development-only stub**. No frontend +consumes the admin-uploads-delete endpoint (confirmed by grep), so the client regeneration +has no consumer impact. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-10.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-10.md new file mode 100644 index 0000000..00ce89c --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-10.md @@ -0,0 +1,87 @@ +# RB-10 — extract `parseStoredSession` (both apps) and spec `redactProfile` + +Status: **implemented** · 2026-08-27 · Source findings: `02-testability.md` TE-001 (ssp/auth and bhp/auth) · `07-bio2-compliance.md` BIO-017 · `99-backlog.md` RB-10 + +## What was wrong + +`SessionStore.restore()` — identical in `apps/ssp/src/app/auth/application/session.store.ts` +and `apps/behandelportal/src/app/auth/application/session.store.ts` — called +`localStorage.getItem(STORAGE_KEY)` itself and did the parse + shape validation in the same +module-private function. It was invoked from a field initializer +(`private _session = signal(restore())`), so the storage read happened the +instant the singleton was constructed; a spec could not feed it a raw string without +stubbing the `localStorage` global before the injector built the store. + +The logic behind that guard is a trust boundary, not incidental validation — the comment +above it names two guarantees: **G1** (never persist the BSN) and **G2** (validate the shape +before trusting it). CLAUDE.md §5 mandates a spec for boundary `parse*` adapters, and none +existed. Baseline evidence: `02-testability.md` §3a cites `ssp/auth` and `bhp/auth` at +42.9% line / 46.2% branch — jointly the worst line coverage in the frontend table — with +this file's own lcov at LH 2/LF 20 (10.0% line), BRH 3/BRF 13 (23.1% branch). + +BIO-017 read the same code and confirmed G1 holds on every path by inspection (`restore()` +returns `{ bsn: '', naam }`, the persistence `effect()` writes only `naam`, `login()`/ +`logout()` never touch storage with a BSN) — but "correct, unverified by a test" is exactly +the gap TE-001 already targeted, so BIO-017 folds into it and adds one required assertion: +a stored `{"bsn":"…","naam":"…"}` must yield a session whose `bsn` is `''`. + +Separately, `apps/ssp/src/app/shell/debug-state/mask.ts` — confirmed at the path the finding +cites — has `redactProfile`, a pure, exported, directly callable PII-redaction function with +no spec. It redacts name, birthdate and address and masks the BIG-nummer; BIO-017 verified it +correct by reading, same "no regression net" gap. + +## What changed + +| File | Change | +| --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/ssp/src/app/auth/domain/session.ts` | added `export function parseStoredSession(raw: string \| null): Session \| null` — the exact parse+validate body `restore()` used to hold | +| `apps/ssp/src/app/auth/application/session.store.ts` | `restore()` collapses to `parseStoredSession(localStorage.getItem(STORAGE_KEY))` | +| `apps/ssp/src/app/auth/domain/session.spec.ts` | 4 new cases: absent, non-JSON, wrong shape, and the G1 assertion | +| `apps/behandelportal/src/app/auth/domain/session.ts` | identical extraction, second app | +| `apps/behandelportal/src/app/auth/application/session.store.ts` | identical collapse, second app | +| `apps/behandelportal/src/app/auth/domain/session.spec.ts` | identical 4 cases, second app | +| `apps/ssp/src/app/shell/debug-state/mask.spec.ts` | new file — spec for `redactProfile`: masks the BIG-nummer, redacts name/geboortedatum/adres on both `registration` and `person`, leaves `beroep`/`registratiedatum`/`status` untouched | + +The extracted function's body is a byte-for-byte move — same `try`/`catch`, same +`JSON.parse` cast, same `typeof parsed?.naam === 'string'` guard, same `{ bsn: '', naam }` +construction. Only its location and the doc comment (rewritten to explain the _why_ of G1/G2 +for a function now read on its own, rather than inline next to the `effect()` it used to sit +beside) changed. + +## The seam lands twice, on purpose + +`auth` is deliberately unshared per ADR-0002 / CLAUDE.md §1: Zorgverlener and Medewerker are +different `Principal` variants with different login flows, and the two `session.ts` files are +expected to diverge. TE-001 says this outright, and BL-002 flags any extract-to-`libs/shared` +here as contradicting an accepted ADR. `parseStoredSession` was therefore written twice, once +per app's own `domain/session.ts` — not factored into a shared helper, and not resisted only +in this note; the two functions are word-for-word identical today and that is expected to +change the moment `RB-13` (`Session → Principal`) lands. + +## Judgement calls + +- **`redactProfile`'s spec asserts on the concrete shape**, not just "not equal to the input" — + it pins `bigNummer` to `'********901'`, checks `REDACTED` on each PII field individually, and + separately asserts the non-PII fields (`beroep`, `registratiedatum`, `status`) survive + unchanged. A looser "no PII substring appears" assertion would have been weaker at catching + the regression this ticket exists to prevent (e.g. a future field added to `redactProfile`'s + output that is left unmasked by accident). +- **The G1 spec case uses `toEqual`, not `toBe`**, since the parser constructs a new object; + this matches the existing `isAuthenticated` spec's style in the same file. +- No production code beyond the `restore()` one-liner in each `session.store.ts` changed — + `login()`, `logout()`, and the persistence `effect()` were already correct and are + unaffected. + +## Verification + +Confirmed both new specs are red without the fix: + +- Temporarily changed `parseStoredSession` to keep a stored `bsn` (`bsn: parsed.bsn ?? ''` + instead of `bsn: ''`) — the new G1 test failed with + `expected { bsn: '19012345601', naam: 'Test' } to deeply equal { bsn: '', naam: 'Test' }`, + all 243 other tests stayed green. Reverted; `git diff` on the file is empty afterward. +- Temporarily changed `redactProfile` to pass `naam` through unmasked — the new "redacts the + name" test failed with `expected 'J. Jansen' to be '‹redacted›'`. Reverted; `git diff` on the + file is empty afterward. + +`npm run ci`: **green** (see PR/commit for the run this doc ships with). diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-11.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-11.md new file mode 100644 index 0000000..23c46c5 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-11.md @@ -0,0 +1,179 @@ +# RB-11 — dev hatches out of prod, trust boundaries exported, doc corrected + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-012, +TE-002, BIO-006(a)+(b) · `99-backlog.md` RB-11 + +## What was wrong + +**BIO-012 — the dev hatches were not actually dev-only.** The `roleInterceptor` / +`subjectInterceptor` chain is correctly registered only under `isDevMode()` +(`app.config.ts`), but three adapters bypass `HttpClient` entirely and set headers +themselves with no guard at all: + +- `reveal-bignummer.adapter.ts:26` — `'X-Role': currentRole(), 'X-Step-Up': 'true'` +- `letter-preview.adapter.ts:46` — `'X-Role': currentRole()`, plus `'X-Subject'` when present +- `org-template.adapter.ts:79` — `'X-Role': currentRole()` + +The readers underneath were ungated too: `role.ts:24` and `subject.ts:24` both read the +`?role=`/`?subject=` query param and **wrote it into `sessionStorage`** on any +navigation, in any build. For `?subject=` that value is a BSN — `subject.ts`'s own doc +comment argued at length that the BSN must never leave `SessionStore` and then routed it +through `sessionStorage` anyway. `docs/reference/roles-and-access.md:23` claimed "Both +are wired only under `isDevMode()` — they do not exist in a production build", which was +false for exactly these three call sites. + +**TE-002 — the reveal's trust boundary was not callable.** The response-shape validation +in `reveal-bignummer.adapter.ts` (the code's own comment called it a "Trust boundary") +lived inline inside `async reveal()`, after `await fetch(...)` on the global `fetch`. A +spec could not reach it without stubbing `globalThis.fetch`. The same shape recurred, +un-exported, in `letter-preview.adapter.ts`'s `errorMessage` and — contrary to the +finding's text, see "Judgement calls" below — as an inline `try/catch` (not yet a +function) in `org-template.adapter.ts`'s `proefbrief()`. + +**BIO-006(a) — the step-up stub was a constant.** `reveal-bignummer.adapter.ts` sent +`'X-Step-Up': 'true'` unconditionally, as a literal, so the backend's +`canReveal && X-Step-Up == "true"` precondition was satisfied by every call that reached +the endpoint and constrained nothing. + +**BIO-006(b) — the default role holds the PII-reveal capability, undocumented.** +`StubIdentityProvider`'s `_ =>` role-switch arm resolves any request with no (or an +unrecognised) `X-Role` header to `drafter` — the one role `Authz.CanRevealBigNummer` +grants. `roles-and-access.md` documented `drafter` as "the only role that may reveal a +BSN" without noting that it is also the fallback identity, so the least-privilege +consequence was invisible. + +## What changed + +| File | Change | +| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `libs/shared/src/infrastructure/role.ts` | `currentRole()` returns `'drafter'` immediately when `!isDevMode()` — no query-param read, no `sessionStorage` write | +| `libs/shared/src/infrastructure/subject.ts` | `currentSubject()` returns `undefined` immediately when `!isDevMode()` — same treatment for the BSN | +| `reveal-bignummer.adapter.ts` | `reveal(stepUp: boolean)`; `X-Role` sent only under `isDevMode()`, `X-Step-Up` sent only when `stepUp`; inline shape check moved to exported `parseRevealed(body)`; `REVEAL_FAILED` exported | +| `letter-preview.adapter.ts` | headers wrapped in `isDevMode() ? {...} : {}`; `errorMessage` exported | +| `org-template.adapter.ts` | `X-Role` sent only under `isDevMode()`; the inline `proefbrief()` error `try/catch` extracted to an exported `proefbriefErrorMessage`; `PROEFBRIEF_FAILED` exported | +| `apps/ssp/src/app/brief/application/brief.store.ts` | `revealBigNummer()` calls `this.revealAdapter.reveal(true)` — the literal now lives at the one call site reachable only after the UI's confirm gesture, not inside the adapter | +| `docs/reference/roles-and-access.md` | records which two places the `isDevMode()` gate now lives (interceptor registration **and** the reader functions) and why; adds the BIO-006(b) note that `drafter` is also the backend's fallback identity | +| 6 new/extended `*.spec.ts` | see "Verification" below | + +**The fix is two layers, not one, because the finding named both.** Gating only +`currentRole()`/`currentSubject()` would already stop the query param and the +`sessionStorage` write from working outside `isDevMode()` — the three adapters would +then send the safe default (`'X-Role': 'drafter'`, no `X-Subject`) even in production. +The adapters are _also_ wrapped in `isDevMode()` so a production request from any of the +three hand-written `fetch` calls carries no `X-Role`/`X-Subject` header at all, exactly +matching what a `HttpClient` request already does once `roleInterceptor` is not +registered — the two paths now agree on production behaviour instead of merely agreeing +on the resulting header value. + +**`X-Step-Up` is deliberately not folded into the same `isDevMode()` gate.** It is not a +`?role=`/`?subject=`-style dev override; it is the stub for a control BIO-006 says must +survive into production (in stubbed form) until a real step-up exists. Nesting it inside +`isDevMode()` would make the reveal endpoint permanently unreachable in a production +build. Instead it is gated on the `stepUp` parameter alone, which is `true` only when +`BriefStore.revealBigNummer()` — reachable only via `behandel-scherm.component.ts`'s +`onReveal()` confirm — calls it. + +## Judgement calls + +- **`org-template.adapter.ts`'s proefbrief error mapping was not "already a separate + function".** The finding's remediation text says "the proefbrief error mapping in + `org-template.adapter.ts` — both are already separate functions and only need + `export` and a spec", matching `letter-preview.adapter.ts`'s `errorMessage`. Reading + the file: the other two adapters do have a standalone `errorMessage`/similar function, + but `org-template.adapter.ts`'s proefbrief error handling was inlined directly in the + `try { … } catch { … }` block, not a named function. This is a minor factual + imprecision in the finding, not a blocker — I extracted the same inline logic into a + named `proefbriefErrorMessage`, exported it, and added the same spec shape as its two + siblings. The result matches the finding's intent (a callable, spec'd trust boundary) + even though the starting shape needed one extra step the finding didn't mention. +- **The BIO-006(a) literal moved to `BriefStore.revealBigNummer()`, not to the UI.** + `behandel-scherm.component.ts`'s `onReveal()` already gates the _only_ path that can + reach `store.revealBigNummer()` behind a `confirm()` dialog, and the store's own + docstring says the step-up gesture "is the UI's concern". Threading a boolean through + the component's `output()` and the page's template binding would touch three more + files for no behavioural change, since the call graph already guarantees confirmation + happened first. I moved the literal one layer up instead — out of the adapter (the + transport) and into the store (the command that is exclusively reachable via the + confirmed gesture) — which is the smallest change consistent with "not from the + adapter's literal" and with this repo's ui → application → infrastructure layering (ui + cannot call infrastructure directly to pass the flag down any other way). +- **Redundant-looking `isDevMode()` guards, kept anyway.** After gating + `currentRole()`/`currentSubject()`, the three adapters' own `isDevMode()` wrap around + the headers object is not strictly load-bearing for `X-Subject` (already `undefined` + outside dev) and only changes the _value sent_ for `X-Role` (a hardcoded `'drafter'` + vs. no header) rather than any security outcome (the backend treats both identically). + I kept the adapter-level gate anyway so the security posture is visible by inspection + at the fetch call site — matching `app.config.ts`'s `isDevMode() ? [...] : []` pattern + — rather than requiring a reviewer to trace into `role.ts`/`subject.ts` to confirm it. +- **No `proefbrief()`-level header spec.** `OrgTemplateAdapter` injects `ApiClient` via + `inject()`, so exercising `proefbrief()` itself needs a `TestBed` + a mock `ApiClient` + purely to reach a method that doesn't use either. I judged that disproportionate to the + marginal coverage gained, since the identical `isDevMode()` pattern is already + exercised end-to-end (via `fetch` stubbing) on the other two adapters + (`reveal-bignummer.adapter.spec.ts`, `letter-preview.adapter.spec.ts`), and the + underlying reader-level fix is covered directly in `role.spec.ts`. Noted here as a + residual rather than silently skipped. +- **`setRole()` (the dev-switcher writer) was left ungated.** BIO-012's evidence names + the two _readers_ (`currentRole`/`currentSubject`); `setRole()` is only ever invoked + from `debug-state.component.ts`, which is itself rendered only under + `shell.component.ts`'s `@if (isDev && debugPanel)`. Gating it too would be harmless but + wasn't asked for and has no reachable production call site to protect — left alone to + keep the diff to what the finding actually named. + +## Consequences worth knowing + +- **Doc correction, same diff.** `roles-and-access.md`'s "Both are wired only under + `isDevMode()`" line is accurate as of this commit — the gate now lives in the + interceptor registration **and** inside `currentRole()`/`currentSubject()` themselves. + Before this commit, the sentence was false for the three hand-written `fetch` paths; the + doc has been extended, not merely left as-is, to say _where_ the gate lives so a future + reader doesn't have to rediscover why the interceptor site alone wasn't sufficient. +- **CLAUDE.md needed no correction.** Its "Scenario toggle (dev-only, not wired in prod + builds)" and "Dev role stand-in (dev-only)" lines don't claim anything about the three + hand-written `fetch` adapters specifically (the accompanying sentence already says they + "bypass the interceptor", which stays true — they still don't go through + `HttpClient`). Those claims were already compatible with a fix landing here; they made + no false statement that needed walking back. +- **`?subject=` is still undocumented by name in `roles-and-access.md`.** The BIO-012 + evidence and this ticket's brief both discuss it, but the doc file never named + `?subject=`/`X-Subject` before this change and still doesn't get a dedicated section — + only the new paragraph under "How to switch role" mentions it in passing. A full + `?subject=` write-up (its own e2e-only purpose, `X-Medewerker`/`X-Rollen` parallel) is + arguably worth a follow-up doc pass, but out of scope for a security-focused ticket + about production leakage. +- **Behaviour spec regenerated.** `libs/shared/docs/behaviour-spec.mdx` is generated from + the suite (`npm run gen:behaviour-spec`) and is included in this diff — the CI gate's + drift check would otherwise fail on the 6 new `describe` blocks this ticket adds. + +## Verification + +Every fix below was confirmed **red without it** by temporarily reverting the source +change (tests unchanged) and re-running the affected suite, then restoring the fix: + +- `libs/shared/src/infrastructure/role.spec.ts` — removing the `if (!isDevMode())` guard + from `currentRole()` turned 3 "outside isDevMode()" tests red (`?role=` still honoured, + still written to `sessionStorage`). +- `libs/shared/src/infrastructure/subject.spec.ts` — same removal on `currentSubject()` + turned its 3 "outside isDevMode()" tests red (a BSN still read from the URL and written + to `sessionStorage`). +- `apps/ssp/src/app/brief/infrastructure/reveal-bignummer.adapter.spec.ts` — reverting + `reveal()` to the original unconditional `{ 'X-Role': currentRole(), 'X-Step-Up': 'true' }` + turned both `RevealBigNummerAdapter.reveal` tests red (`X-Step-Up` sent regardless of the + `stepUp` argument; `X-Role` sent regardless of `isDevMode()`). + +New specs, all pure/exported-boundary tests per house convention (no `TestBed`, no +`globalThis.fetch` stub needed for the pure halves): + +| File | Covers | +| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `reveal-bignummer.adapter.spec.ts` | `parseRevealed` (incl. the finding's own `{ bigNummer: 42 }` rejection case); `reveal()`'s `X-Step-Up`/`X-Role` gating via a stubbed `fetch` | +| `letter-preview.adapter.spec.ts` | `errorMessage`; `preview()`'s header gating via a stubbed `fetch` | +| `org-template.adapter.spec.ts` (extended) | `proefbriefErrorMessage` | +| `role.spec.ts` (new) | `currentRole()` dev behaviour + the `isDevMode()`-gated production behaviour | +| `subject.spec.ts` (new) | `currentSubject()` dev behaviour + the `isDevMode()`-gated production behaviour | + +`npm run ci` (lint, typecheck, `dep:check`, `format:check`, `check:tokens`, `check:seam`, +full test suite with coverage, `ng build --localize` for both apps, `npm audit`, backend +`dotnet format` + `dotnet test`, showcase-snippets/behaviour-spec/api-client drift +checks): **green**, including all 4 vitest projects (ssp/behandelportal/shared/beheer) and +`dotnet test` (241 passed). diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-12.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-12.md new file mode 100644 index 0000000..7b4d06b --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-12.md @@ -0,0 +1,115 @@ +# RB-12 — a route-table test: every route hits an authz wrapper or an explicit allow-list + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-016, `00-baseline.md` BL-006 · `99-backlog.md` RB-12 + +## What was wrong + +BL-006, verbatim: "the backend has zero automated architecture enforcement … `Domain/` +purity currently holds by convention." BIO-016 names the specific consequence for +authorization: nothing asserted the **set** of gated endpoints, so an endpoint added +without a gate (BIO-003's `X-Admin` gate outside `Authz`, BIO-004's two endpoints with +no gate at all) failed no test. Both were caught by a human reading `Program.cs`, not by +CI. + +## What changed + +| File | Change | +| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Program.cs` — 16 endpoint mappings | each chains a new `.Gate("XAdmin")` call, naming the admin wrapper (`OrgAdmin`, `StamdataAdmin`, `CasesAdmin`, `Beoordelen`, `FlagsAdmin`) already used inside its handler | +| `Program.cs` — new types, end of file | `public sealed record AuthzGateMetadata(string Wrapper)` + a `Gate(...)` extension method on `IEndpointConventionBuilder` that attaches it via `.WithMetadata(...)` | +| `tests/BigRegister.Tests/RouteInventoryTests.cs` | **new** — walks the real app's `EndpointDataSource`, asserts every route carries either an `AuthzGateMetadata` naming a known wrapper, or an entry in a written-down allow-list; a second test asserts every `.Gate(...)` name is one of the five known wrappers | + +## Design: metadata at mapping time, not reflection over the compiled lambda + +The ticket left the detection mechanism open, noting the wrappers are local functions +in `Program.cs`. Reflecting over a compiled minimal-API lambda to determine which local +function its closure calls is fragile-to-impossible (the call is inside IL a test would +have to disassemble, and a local function's identity isn't easily recoverable from the +delegate's `MethodInfo`). Endpoint **metadata**, attached at the same call site where the +route is mapped, is exactly what `EndpointDataSource` hands back to a test host and +doesn't depend on inspecting compiled code at all — so a `.Gate("XAdmin")` extension +method was added and chained onto each of the 16 mappings that call one of the five +wrappers. + +This is a **declaration**, not a **derivation**: the test does not verify that +`.Gate("CasesAdmin")` and an actual `CasesAdmin(ctx, …)` call inside the handler agree — +it only verifies that a marker is present. A handler that swapped its `CasesAdmin(ctx, +…)` call for a no-op without updating `.Gate(...)` would go undetected here. What _is_ +caught, reliably, is the actual BIO-003/BIO-004 failure mode: a new endpoint mapped with +**no** marker and **no** allow-list entry — verified below by adding one and watching the +test go red. + +## Judgement call: the allow-list is not "public routes" + +The ticket's literal framing — every route "goes through one of the authz wrappers … +or appears in an explicit, named allow-list of deliberately-public routes" — doesn't fit +this codebase as read. Only 16 of the app's 47 routes go through one of the five admin +wrappers. The other 31 are not uniformly public: + +- **10 are genuinely public** — orchestrator health probes and static/reference demo + data (`SeedData`, the DUO/BRP fixtures, the scholing-threshold config value, the + feature-flag catalog, `/me`'s reflection of the caller's own capabilities) that reads + the same for every caller in this one-seeded-citizen POC. +- **19 are ownership-scoped inline**, not public and not wrapper-gated: `GET +/applications/{id}`, the upload endpoints, every brief transition, etc. all key off + `ctx.Zorgverlener().Bsn` / `ctx.Caller()` — an authenticated citizen (or, for the + uploads-content endpoint, a behandelaar) reading or writing only their own resource. + Calling these "public" in an allow-list would misrepresent exactly the property + BIO-004 was about — object-level authorization existing at all. +- **1 (`POST /zgw/notificaties`) uses a different mechanism entirely** — a fixed-time + shared-secret comparison for a non-Principal external caller (OpenZaak's + notifications), audited the same way but never going through `Authz`. +- **1 (`POST /brief/reset`) is deliberately, literally unguarded** — the endpoint's own + pre-existing comment says so ("No guards — showcase affordance only"). + +The allow-list (`RouteInventoryTests.AllowList`) keeps all 31 as one array for the test's +sake, but every entry carries its own reason string rather than a blanket "public" label — +preserving BIO-016's actual intent ("makes 'this endpoint is public' a decision someone +wrote down rather than an omission") generalised to "this endpoint's access boundary is +_X_, deliberately," which is true of all 31 and false of "public" for 20 of them. This is +recorded here rather than silently reinterpreted, per this task's brief: implementing the +literal "public" framing would have been actively misleading about which endpoints have no +access control at all. + +## Other judgement calls + +- **`AuthzGateMetadata` and its extension method are `public`, not `internal`.** The test + project has no `InternalsVisibleTo` wired up for `BigRegister.Api` (checked — none + exists anywhere in `backend/`), and adding one for a single marker type was more + machinery than the alternative. Both types carry a comment stating why. +- **A second test (`Every_gate_marker_names_a_known_admin_wrapper`) guards against a typo + in a `.Gate(...)` call.** Without it, a call like `.Gate("CasesAdmn")` would just fall + through to "unaccounted for" in the main test with a less specific failure message — + fine, but a dedicated assertion names the actual mistake. +- **The main test also asserts the reverse direction: no stale allow-list entries.** An + allow-list entry for a route that was renamed or removed is exactly the kind of drift + a "decision someone wrote down" ledger needs to catch, not just silently keep. Verified + this fires: temporarily added one extra `AllowList` entry for a route that doesn't + exist (via Edit, not committed) — every real route was still covered, so only the + stale-entry assertion tripped, naming exactly that bogus entry. Reverted the same way. +- **`RouteInventoryTests` uses the house `TestWebApplicationFactory` + `IClassFixture` + idiom**, not a bare `new WebApplicationFactory()` per test. The first draft did + the latter and immediately hit `SQLite Error 1: 'table "Applications" already exists'` + — `Db.ConnectionString` (`Data/Db.cs`) is a shared mutable **static** field, and a bare + factory doesn't override `ConnectionStrings:AppDb`, so two such factories in the same + class end up pointed at the same file, and the second one's `Migrate()` collides with + the first's already-created tables (the first factory's default `Dispose()` doesn't + delete that file — only `TestWebApplicationFactory`'s override does, to its own + per-instance temp path). This is exactly the hazard `TestWebApplicationFactory`'s own + doc comment describes; switching to it (as every other endpoint-test class in this + suite already does) fixed it outright — no product code involved, purely a test-fixture + choice. + +## Verification + +- **Proved the test is hard to fool**, per the ticket's explicit ask: added a throwaway + `api.MapDelete("/rb12-throwaway-unguarded/{id}", …)` with no `.Gate(...)` and no + allow-list entry (via Edit, not `git checkout`) — `Every_mapped_route_is_authz_gated_or_ +on_the_named_allow_list` failed red, naming exactly that route. Reverted the same way; + re-ran green. Repeated once more after switching to `TestWebApplicationFactory` to + confirm the fixture change didn't weaken the check — same red, same green. +- `dotnet build` (both `src/BigRegister.Api` and `tests/BigRegister.Tests`): clean, 0 + warnings. +- `dotnet format BigRegister.slnx --verify-no-changes`: clean. +- `dotnet test --filter "Category!=Integration"`: **257 passed, 0 failed** (255 + pre-existing + 2 new). diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-13.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-13.md new file mode 100644 index 0000000..f2c2185 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-13.md @@ -0,0 +1,186 @@ +# RB-13 — land `Session → Principal`; `MedewerkerAdapter`; the backoffice login stops being a DigiD/BSN form + +Status: **implemented** · 2026-08-27 · Source findings: `06-adr-conformance.md` ADR-C-004 · `00-baseline.md` BL-002 · `docs/reference/architecture/0002-user-groups-and-bounded-contexts.md` §3, "Known debt" · `99-backlog.md` RB-13 + +## What was wrong + +ADR-0002 §3 ("Separate identity from authorization") specifies a discriminated +`Principal` union — `{ kind: 'zorgverlener'; bsn; naam } | { kind: 'medewerker'; +medewerkerId; naam; rollen }` — as "the one concrete FE change when actor #2 lands." +Actor #2 (`apps/behandelportal`) landed in WP-61/67; the union did not follow. + +Verified before this ticket: + +- `grep -rn "Principal" apps libs` returned exactly one hit — a comment in + `libs/shared/src/infrastructure/role.ts:8`. No such type existed. +- `apps/ssp/src/app/auth/domain/session.ts` and + `apps/behandelportal/src/app/auth/domain/session.ts` were byte-identical: + `interface Session { readonly bsn: string; readonly naam: string }` — a Behandelaar + carrying a `bsn`, which §3 names as precisely the state the union exists to make + unrepresentable. +- `apps/behandelportal/src/app/auth/ui/login.page.ts` rendered `intro="Log in op uw +persoonlijke BIG-register omgeving."` and called `SessionStore.login(bsn)` → + `DigidAdapter.authenticate(bsn)`, resolving `{ bsn: r.value, naam: 'Dr. A. (Anna) de +Vries' }` — a backoffice employee logging into the backoffice as a citizen, by DigiD, + under a citizen's name. +- `apps/behandelportal/src/app/auth/infrastructure/medewerker.interceptor.ts` already + stamps every backend request with `X-Medewerker`/`X-Rollen`, independently of + `SessionStore` — the divergence ADR-0002 predicted took this orthogonal side door + instead of the `Principal` union, which is why the two `auth` contexts still measured + as identical. +- `tools/baseline-scan.mjs --dup`, measured immediately before this ticket (after + ADR-C-006 shared the route guards): `ssp/auth` 168/168 dup lines (100.0%), + `bhp/auth` 168/200 (84.0%) — down from the original 211/211, but the WP-67 amendment's + "auth stays duplicated because it's expected to diverge" claim had never actually been + tested, only asserted. + +RB-09 (a prerequisite, landed the day before) made the backend's `IIdentityProvider` +able to say "no identity" and fail closed; this ticket is its stated FE half — without +it, a production behandelportal falls through to the seeded zorgverlener by default, +open on every citizen-scoped endpoint and holding `CanRevealBigNummer`. This ticket +does not touch that backend behaviour — it makes the FE identity model honest about +who is actually authenticating. + +## What changed + +| File | Change | +| -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/ssp/src/app/auth/domain/session.ts` → `principal.ts` | `Session` → `Principal`, `{ kind: 'zorgverlener'; bsn; naam }`; `parseStoredSession` → `parseStoredPrincipal` (G1/G2 unchanged) | +| `apps/ssp/src/app/auth/domain/session.spec.ts` → `principal.spec.ts` | renamed, updated to the `Principal`/`kind` shape | +| `apps/ssp/src/app/auth/application/session.store.ts` | `Session` → `Principal`; header doc rewritten to state _why_ G1 applies here and not in behandelportal (cross-reference, not shared prose) | +| `apps/ssp/src/app/auth/infrastructure/digid.adapter.ts` | resolves `{ kind: 'zorgverlener', bsn, naam }` | +| `apps/ssp/src/app/shell/debug-state/debug-state.component.ts` | `Session` → `Principal` (the one other consumer of the domain type) | +| `apps/behandelportal/src/app/auth/domain/session.ts` → `principal.ts` | new `medewerker` variant: `{ kind: 'medewerker'; medewerkerId; naam; rollen: readonly Rol[] }`; `parseStoredPrincipal` validates the full shape (no BSN to strip — G2 only); new `parseRollen(raw): Rol[]`, mirroring the backend's `StubIdentityProvider.ParseRollen` (comma-separated, case-insensitive, unrecognized tokens dropped) | +| `apps/behandelportal/src/app/auth/domain/session.spec.ts` → `principal.spec.ts` | rewritten: `isAuthenticated`, `parseStoredPrincipal` (5 cases including "kind is not medewerker" and "unrecognized rol"), `parseRollen` (4 cases) | +| `apps/behandelportal/src/app/auth/infrastructure/digid.adapter.ts` → `medewerker.adapter.ts` | **new `MedewerkerAdapter`** — resolves `MEDEWERKER_ID` + `currentRollen()` (`medewerker.ts`, unchanged) into a `Principal`; no input, returns the `Principal` directly (no `Result` — there is nothing for this stand-in to fail on) | +| `apps/behandelportal/src/app/auth/application/session.store.ts` | `MedewerkerAdapter` replaces `DigidAdapter`; `login()` takes no argument; the whole principal round-trips through `localStorage` (no G1 field to strip); header doc rewritten, cross-referencing the SSP's instead of repeating it | +| `apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts` | rewritten: no BSN/wachtwoord fields — one explainer line + one "Inloggen met SSO" button, `submitted = output()` | +| `apps/behandelportal/src/app/auth/ui/login.page.ts` | new heading/intro copy ("Inloggen bij het behandelportal" / "Voor medewerkers die aanvragen beoordelen."); `login()` takes no argument; the error-alert branch is gone (nothing can fail) | +| `apps/behandelportal/src/locale/messages.en.xlf` | new id `login.ssoExplainer`; `login.submit`/`login.heading`/`login.intro` updated to the new source text + English target; `login.bsnLabel`/`bsnDescription`/`wachtwoordLabel`/`form.verplichteVelden` removed (no longer reachable from this app — confirmed by grep and by a trial `extract-i18n:behandelportal` run) | +| `libs/shared/src/infrastructure/subject.ts`, `subject.interceptor.ts` | doc comments: `` `Session.bsn` `` → `` `Principal.bsn` `` (the type these comments cite renamed; the design they describe — `libs/shared` can't reach an app-local `auth` context, so `?subject=` exists instead — is unchanged) | +| `docs/reference/architecture/0002-user-groups-and-bounded-contexts.md` | new "Amendment (RB-13, 2026-08-27)" replacing the "Known debt" section it closes out; records what landed and the re-measured duplication figure | +| `libs/shared/docs/behaviour-spec.mdx` | regenerated (`npm run gen:behaviour-spec`) — reflects the renamed spec titles and the new `parseRollen`/medewerker `parseStoredPrincipal` cases | + +## Judgement calls + +- **Each app's `Principal` holds only the one variant it has an actor for**, not the + full two-member union ADR-0002 §3 writes as a single illustrative type. The ADR's own + proposed resolution under ADR-C-004 says this explicitly ("In `apps/behandelportal`: + replace `Session` with the `medewerker` variant … In `apps/ssp`: the `zorgverlener` + variant"), and it matches how the codebase already splits `auth` per app. `kind` stays + on both single-member types anyway — it is what makes the two types genuinely + different rather than a same-shaped coincidence, and it is where a third actor (§4 — + admin/auditor/institution-rep) would add a member. +- **`MedewerkerAdapter.authenticate()` returns `Promise`, not + `Promise>`.** The first draft mirrored `DigidAdapter`'s + `Result`-returning shape for symmetry, but that `Result`'s error variant could never + actually be produced — there is no credential to check, so wrapping the return in a + type that claims to have a failure mode was itself a small instance of the thing + CLAUDE.md §3 warns against (representing a state that can't happen). Reverted to a + direct `Promise` and dropped the now-dead error-handling branch from + `login.page.ts` (`error` signal, the ``, the `AlertComponent` + import) — a real SSO integration is where that branch would come back, not before. + This was also the change that did the most to bring the duplication figure down (see + below): `login.page.ts`'s 7-window overlap with the SSP's disappeared once the two + pages' control flow, not just their copy, actually differed. +- **`rollen` is typed `readonly Rol[]` with `Rol = 'behandelaar'`, and `parseRollen` + lives in `domain/`, not the adapter.** The raw `currentRollen()` stand-in returns an + unvalidated string (`medewerker.ts`, untouched by this ticket); turning it into typed + `Rol[]` is pure string logic with no Angular dependency, so it belongs in + `domain/principal.ts` per CLAUDE.md §1's layer table — the adapter (`infrastructure/`) + stays a thin wire-up that only reaches for `MEDEWERKER_ID`/`currentRollen()` and + hands them to a pure function. `parseRollen` deliberately mirrors the backend's own + `StubIdentityProvider.ParseRollen` (comma-separated, unrecognized tokens dropped, so + `?rollen=geen` yields `[]`) — this is not the FE recomputing a business rule + (ADR-0001's boundary is about _authorization decisions_, which still come only from + `GET /me`/`AccessStore`); it is the FE's own dev-only identity stand-in echoing the + same header value it is about to send, for display, the same way `DigidAdapter` + already fabricates its own fake identity. +- **`SessionStore` (bhp) persists the whole `Principal` to `localStorage`, not a + stripped-down `{ naam }` copy.** The SSP's G1 guarantee ("never persist the BSN") + doesn't apply here — a `medewerker` principal has no national identifier — so there is + nothing to strip. `parseStoredPrincipal` validates the full shape (G2 only) and + restores it as-is. This was a deliberate choice against an alternative: reconstructing + `medewerkerId`/`rollen` from the live `MEDEWERKER_ID`/`currentRollen()` on every + restore, which would have made `domain/principal.ts` depend on + `infrastructure/medewerker.ts` — backwards per CLAUDE.md §1's inward-only dependency + rule, and it would have made `parseStoredPrincipal` impure. Consequence: changing + `?rollen=` mid-session does not retroactively change an already-restored `Principal` + until the next `login()`/`logout()` — the same way changing the DigiD demo BSN + requires a fresh login in the SSP. The backend's own authorization is unaffected + either way, since `medewerkerInterceptor` reads `currentRollen()` fresh on every HTTP + request regardless of what `SessionStore` holds. +- **Session/store class names (`SessionStore`, `SESSION_PORT`, `SessionPort`) were left + unchanged.** ADR-0002 §3's own Consequences section names `SessionStore` — alongside + `auth.guard.ts` — as one of the _seams that localise_ the `Session → Principal` change, + not as something the change renames. `libs/shared/src/application/session.port.ts`'s + `SessionPort` (ADR-C-006) is unaffected: it only ever exposed `{ naam }` and + `isAuthenticated`, neither of which is `kind`-dependent. +- **`libs/shared/src/infrastructure/subject.ts`/`subject.interceptor.ts` doc comments + updated, code untouched.** Both cite `` `Session.bsn` `` by name to explain why + `?subject=` exists instead of reading the store directly; renaming the type these + comments describe without updating the comment would have left them citing a type + that no longer exists. +- **`auth.guard.ts`'s verbatim re-export in both apps was left alone.** ADR-C-006 is + explicit that a route guard is actor-agnostic and out of ADR-0002 §3's scope — it + reads only `SESSION_PORT`/`AccessStore`, never `Principal`, so there was nothing for + this ticket to change there. +- **No backend change.** RB-09 already made `IIdentityProvider` nullable and + Production-fail-fast; this ticket is purely the frontend counterpart it named. The + residual RB-09 flagged (`GET /uploads/{documentId}/content`'s plain-navigation + callers carrying no identity header once a real, non-stub `IIdentityProvider` exists) + is unaffected by anything here — it is about a _future_ real provider replacing the + Development-only stub, which this ticket does not touch. + +## Duplication, measured (`tools/baseline-scan.mjs --dup`) + +| When | `ssp/auth` dup lines | `bhp/auth` dup lines | +| ----------------------------------- | -------------------: | -------------------: | +| Before ADR-C-006 (baseline, BL-002) | 211/211 (100%) | — | +| After ADR-C-006, before this ticket | 168/168 (100.0%) | 168/200 (84.0%) | +| **After this ticket** | **32/179 (17.9%)** | **32/259 (12.4%)** | + +Expected by the backlog: "<40 after this." Measured: **32 lines each side** — under +target. The full clone-pair listing (the script's own output truncates to the top 15 +pairs repo-wide; re-run with the pair filter widened to confirm nothing auth-related was +hiding below that cut) resolves to exactly four remaining pairs: + +- `principal.spec.ts` (6 windows) — both files test the same G2 "validate before + trusting a stored shape" concept with a parallel `describe`/`it` structure (including + the shared `import { describe, it, expect } from 'vitest';` line); the assertions + themselves differ (BSN-stripping vs. kind/rollen validation). +- `login-form.stories.ts` (3 windows) — the generic Storybook `Meta`/`StoryObj`/`Default` + scaffold, unavoidable for any two co-located `.stories.ts` files regardless of subject. +- `auth.guard.ts` (2 windows) — the intentional verbatim re-export (ADR-C-006); this is + meant to stay identical. +- `session.store.ts` (1 window) — down from 33 windows before this ticket to one small + shared fragment (the `@Injectable`/signal/`asReadonly`/`computed` wiring any root + singleton store in this codebase shares). + +None of what remains is re-converged identity or login-flow logic — the domain type, +the adapter, and the login UI all now differ in kind, not just in copy. §3's prediction +("the two groups authenticate differently") has been tested for the first time by this +ticket, not just asserted, and it held. + +## Verification + +Confirmed each non-trivial change is red without its fix (edited in place, verified red, +edited back — never `git checkout`): + +- **ssp `parseStoredPrincipal` (G1):** changed `bsn: ''` to `bsn: parsed.bsn ?? ''` → + `G1: a stored bsn is never restored…` failed with `expected { bsn: '19012345601', …} +to deeply equal { bsn: '', … }`. Reverted; all other tests unaffected. +- **bhp `parseStoredPrincipal` (kind guard):** dropped the `parsed?.kind === 'medewerker'` + clause → `returns null when kind is not medewerker` failed, returning the parsed + zorgverlener-shaped object instead of `null`. Reverted. +- **bhp `parseRollen`:** dropped `.filter(isRol)` → `drops unrecognized tokens` and + `returns an empty list for an empty string` both failed (`['geen']`/`['']` returned + instead of `[]`). Reverted. + +`npm test` (both apps + both libraries): all green, 258 (ssp) + 37 (behandelportal) + +133 (shared) + 23 (beheer) tests passing, including the new/renamed auth specs. +`npm run lint`: clean. `npm run dep:check`: 0 violations, both apps. `ng build ssp +--localize` and `ng build behandelportal --localize`: both succeed (the new +`login.ssoExplainer` id and the updated `login.submit`/`login.heading`/`login.intro` +sources all resolve to an English ``). `npm run ci`: green (see the commit this +doc ships with). diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-14.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-14.md new file mode 100644 index 0000000..89b6c71 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-14.md @@ -0,0 +1,57 @@ +# RB-14 — scan the .NET dependency tree for known advisories + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-016 · `99-backlog.md` RB-14 + +## What was wrong + +`npm audit --omit=dev` gates the shipped frontend bundle. Nothing equivalent existed for the +backend, so **the entire .NET dependency tree — direct and transitive — was unscanned.** BIO-016 +lists it first under "Absent". + +## The trap the ticket walked into + +The backlog row says: `dotnet list package --vulnerable --include-transitive` **as a failing +step**. Implemented literally, that step cannot fail. `dotnet list package --vulnerable` is a +_reporting_ command: it prints the advisory table and exits 0 regardless. + +Verified rather than assumed — a throwaway project with `System.Net.Http 4.3.0`: + +``` +Project `vulntest` has the following vulnerable packages + > System.Net.Http 4.3.0 4.3.0 High https://github.com/advisories/GHSA-7jgj-8wvc-jh57 +EXITCODE=0 +``` + +A **High** severity advisory, exit code **0**. A bare `- run: dotnet list package --vulnerable` +would have added a line to `ci.yml` that reads like coverage in a compliance review and enforces +nothing — which is worse than leaving the gap visible. + +## What changed + +| File | Change | +| -------------------------- | -------------------------------------------------------------------------- | +| `scripts/dotnet-audit.sh` | **new** — runs the scan, matches its output, exits 1 on a hit | +| `.github/workflows/ci.yml` | new backend step calling the script (same `changes.outputs.backend` guard) | +| `scripts/ci-local.sh` | new `backend dependency audit` step calling the same script | + +**One script, two callers**, rather than the same four lines pasted into a workflow and a shell +script that would then drift. The guard matches `has the following vulnerable packages` — the +exact sentence `dotnet list` prints per project on a hit; the clean case prints +`has no vulnerable packages given the current sources` instead. + +## Verification + +- Against the real solution: passes, both projects clean (exit 0). +- Against the marker sentence `dotnet list` actually emits: the guard fires and exits 1. +- The exit-0-on-High behaviour that motivates the whole script is reproduced above. + +## Residual + +`--include-transitive` means a vulnerable package pulled in by a dependency turns CI red with no +direct upgrade available. The fix in that case is a direct `PackageReference` pinning a patched +version; the script's failure message says so. There is deliberately **no severity threshold and +no suppression list** — adding one before a real advisory forces the question would be guessing +at a policy nobody has needed yet. + +Secret scanning (gitleaks/trufflehog), BIO-016's other named absence, is **not** in this ticket +and remains on the pre-production checklist. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-15.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-15.md new file mode 100644 index 0000000..a555e25 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-15.md @@ -0,0 +1,93 @@ +# RB-15 — Swagger and the OpenAPI document behind `IsDevelopment()` + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-015 · `99-backlog.md` RB-15 + +## What was wrong + +`Program.cs:145-146` (pre-change) ran `app.UseSwagger(); app.UseSwaggerUI();` +unconditionally — the full OpenAPI document (every route, every request/response shape) +and SwaggerUI's interactive "Try it out" were reachable in every environment, including a +real deployment, with no `app.Environment.IsDevelopment()` guard. BIO-015's own evidence +notes this is one line of genuine attack-surface reduction with no POC cost. + +## What changed + +| File | Change | +| --------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| `Program.cs` | `app.UseSwagger(); app.UseSwaggerUI();` now run only inside `if (app.Environment.IsDevelopment()) { … }` | +| `tests/BigRegister.Tests/SwaggerGateTests.cs` | **new** — asserts `/swagger/v1/swagger.json` is served in Development and 404s outside it | + +`builder.Services.AddSwaggerGen(...)` and `AddEndpointsApiExplorer()` were left +unconditional — they only register DI services (the swagger-generation machinery), +expose nothing over HTTP by themselves, and (see below) are exactly what `npm run +gen:api` depends on staying registered in every environment it might run against. + +## The hazard, checked rather than assumed + +RB-09 made a non-Development environment throw during `builder.Build()` (no +`IIdentityProvider` registered for a bare/unset environment, which defaults to +Production), which crashed `dotnet swagger tofile` until `package.json`'s `gen:api` +script was pinned to `ASPNETCORE_ENVIRONMENT=Development` for that one invocation +(`docs/.../implementation/rb-09.md`). This ticket's change sits in exactly the same +pipeline, so it needed the same empirical check, not an assumption. + +**Mechanism, confirmed by reading Swashbuckle's CLI behaviour and then proving it:** +`dotnet swagger tofile` (`Swashbuckle.AspNetCore.Cli`) loads the built DLL through +.NET's design-time `HostFactoryResolver`, builds the host, and then resolves +`ISwaggerProvider` **directly out of the DI container** to produce `swagger.json` — it +never issues an HTTP request through the ASP.NET Core middleware pipeline this ticket's +`if (app.Environment.IsDevelopment())` guard lives in. Gating `UseSwagger()`/ +`UseSwaggerUI()` therefore cannot affect it, in any environment, by construction — those +are pipeline middleware; the CLI tool bypasses the pipeline entirely. + +**Verified, not assumed:** ran `npm run gen:api` for real. It exited 0, printed "Swagger +JSON/YAML successfully written to …/backend/swagger.json", and regenerated the NSwag +client. `git status`/`git diff` on both `backend/swagger.json` and +`libs/shared/src/infrastructure/api-client.ts` showed **zero changes** — the regenerated +files are byte-identical to what's already committed, confirming the gate has no effect +on the generated contract at all. + +## Judgement calls + +- **The guard wraps both `UseSwagger()` and `UseSwaggerUI()` together**, not just one — + the ticket's own wording lists both, and gating only the document while leaving the UI + reachable (or vice versa) would be a strange half-measure: SwaggerUI without the + document 404s on load anyway, and the document without the UI still leaks the same + route/shape enumeration BIO-015 is about. +- **`AddSwaggerGen`/`AddEndpointsApiExplorer` were left unconditional.** They're + DI-registration-time calls with no HTTP surface, and — now confirmed rather than + assumed — `dotnet swagger tofile` needs `ISwaggerProvider` registered in whatever + environment it runs the host under (pinned to Development by `gen:api`'s own script, + but nothing stops a future non-Development invocation), so conditioning those + registrations on `IsDevelopment()` would risk breaking the CLI tool for no + attack-surface benefit — nobody can reach a DI-registered-but-never-routed service + over HTTP. +- **New tests build the "non-Development" case on a third environment name + ("Staging"), not `"Production"`.** RB-09 already made Production fail at startup + entirely (no real `IIdentityProvider` exists yet) — a stronger guarantee than "no + Swagger in Production," but one that means a `UseEnvironment("Production")` host + never reaches this middleware to prove the gate itself works; it only proves RB-09's + unrelated startup throw, which already has its own test. A `"Staging"` environment + satisfies neither `IsDevelopment()` nor `IsProduction()`, so `Program.cs` registers no + `IIdentityProvider` for it — the test supplies one via `ConfigureTestServices` + (`StubIdentityProvider`, the same one Development uses) so the host actually boots, + and the test exercises this ticket's real gate rather than a different ticket's. +- **The Staging host is built via `factory.WithWebHostBuilder(...)`** (layering on the + shared `TestWebApplicationFactory` fixture), not a bare `new +WebApplicationFactory()` — RB-12's implementation note already records the + "table already exists" collision a bare factory hits by sharing the mutable static + `Db.ConnectionString` instead of the fixture's own per-class isolated temp path; + layering avoids repeating that mistake here. + +## Verification + +- **Reverted the guard only** (unwrapped `UseSwagger()`/`UseSwaggerUI()` back to + unconditional, via Edit, tests left in place) and ran `SwaggerGateTests`: + `Swagger_document_is_not_served_outside_development` failed red (`Expected: NotFound, +Actual: OK`). Restored the fix (via Edit) and re-ran: both green. +- **`npm run gen:api`, run for real**: exit 0; `backend/swagger.json` and + `libs/shared/src/infrastructure/api-client.ts` both unchanged (`git status` clean on + both) — see "The hazard" above. +- `dotnet build` (both projects): clean, 0 warnings. +- `dotnet format BigRegister.slnx --verify-no-changes`: clean. +- `dotnet test --filter "Category!=Integration"`: **259 passed, 0 failed** (257 + 2 new). diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-16.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-16.md new file mode 100644 index 0000000..557c4ff --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-16.md @@ -0,0 +1,82 @@ +# RB-16 — `DateOnly.TryParse` on `?peildatum=` + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-019 · `99-backlog.md` RB-16 + +## What was wrong + +`Program.cs:215` (pre-change) — +`var rows = peildatum is { Length: > 0 } p ? t.RowsOn(DateOnly.Parse(p)) : t.Rows();`. +`DateOnly.Parse` throws `FormatException` on anything unparseable; there was no +`TryParse`, no 400 path, and `.Produces` on the endpoint declared only 200/403/404 — so +an unparseable `?peildatum=` value 500'd, and in Development the exception detail was +returned to the caller. §3c's baseline named `backend/Stamdata` 96.8% line but **71.7% +branch** (BL-005) — this was one of the unentered branches. + +## What changed + +| File | Change | +| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Program.cs` — `GET /stamdata/{table}` | `DateOnly.Parse` replaced with `DateOnly.TryParse`; an unparseable value now returns `Results.Problem(detail: …, statusCode: 400)` instead of throwing; endpoint doc gained `.ProducesProblem(StatusCodes.Status400BadRequest)` | +| `tests/BigRegister.Tests/StamdataEndpointTests.cs` | **new** `Unparseable_peildatum_is_400_not_500` | +| `backend/swagger.json`, `libs/shared/src/infrastructure/api-client.ts` | regenerated (`npm run gen:api`) — the new 400 response is now part of the documented contract | + +## What the fix looks like + +```csharp +DateOnly? peildatumWaarde = null; +if (peildatum is { Length: > 0 } p) +{ + if (!DateOnly.TryParse(p, out var parsed)) + return Results.Problem(detail: $"Ongeldige peildatum '{p}'.", statusCode: StatusCodes.Status400BadRequest); + peildatumWaarde = parsed; +} +var rows = peildatumWaarde is { } d ? t.RowsOn(d) : t.Rows(); +``` + +Matches the shape every other bad-input check in this file already uses (e.g. the +upload endpoint's `Results.Problem(detail: …, statusCode: 400)` for a malformed +multipart request) — a `Results.Problem` with a Dutch detail message, not a bespoke +response shape. + +## Judgement calls + +- **No FE change needed, and none made.** `libs/beheer/src/infrastructure/ +stamdata.adapter.ts`'s `load()` already routes every call through `runSubmit` + (`libs/shared/src/application/submit.ts`), which try/catches any thrown + `ApiException` — including the client's new 400 branch — into a generic `Result` + error string via `problemDetail`. There is no status-code-specific branching to + extend; ADR-0001's "the FE renders the decision, it does not recompute the rule" + already covers "the server rejected this input" as a case the generic error path + handles, same as the existing 403. +- **Regenerated the API client and committed it in this ticket's diff**, rather than + leaving it to drift. RB-09's implementation note records a real prior incident where + a response-shape change (RB-08's 403 → `ProducesProblem`) landed without a + regeneration and the drift went unnoticed until the next ticket's `gen:api` run. This + ticket's `.ProducesProblem(400)` is exactly that same category of change, so + `npm run gen:api` was run immediately as part of implementing it, not deferred. +- **The Dutch detail message follows the file's own convention** (`$"Ongeldige +peildatum '{p}'."`) rather than English — every other `Results.Problem(detail: …)` + call in `Program.cs` (change-request rejection, upload validation, submit rejection) + is Dutch; this is server-internal wire text, not `$localize`-wrapped UI copy (the FE + never renders it verbatim — CLAUDE.md's `$localize` rule is about user-facing copy + the FE owns, not backend `ProblemDetails.detail` strings), so no locale entry was + needed. + +## Verification + +- **Reverted the fix only** (`DateOnly.Parse` restored, ternary un-nested, via Edit — + test left in place) and ran `StamdataEndpointTests`: + `Unparseable_peildatum_is_400_not_500` failed red (`Expected: BadRequest, Actual: +InternalServerError` — confirming the endpoint really did 500, not some other status). + Restored the fix (via Edit) and re-ran: all 6 tests in the class green. +- `dotnet build`: clean, 0 warnings. +- `dotnet format BigRegister.slnx --verify-no-changes`: clean. +- `dotnet test --filter "Category!=Integration"`: **260 passed, 0 failed** (259 + 1 + new). +- `npm run gen:api`: exit 0; `backend/swagger.json` gained the 400 response shape for + this one endpoint; `libs/shared/src/infrastructure/api-client.ts` gained the + matching `status === 400` branch. Both regenerated files committed alongside the + code change. +- `npm test` (all four Vitest projects — ssp/behandelportal/shared/beheer): **445 + passed, 0 failed**, confirming the regenerated client doesn't break any existing FE + consumer of `stamdataTable(...)`. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-17.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-17.md new file mode 100644 index 0000000..e38c6e5 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-17.md @@ -0,0 +1,130 @@ +# RB-17 — split `runResult` (fold) from `runSubmit` (fold + idempotency mint) + +Status: **implemented** · 2026-08-27 · Source findings: `04-cqrs-light.md` CQ-003 + CQ-005 · +`00-baseline.md` BL-007 (+ its §10 amendment) · `99-backlog.md` RB-17 + +## What was wrong + +`runSubmit` (`libs/shared/src/application/submit.ts`) did two things in one function: fold a +call into a `Result`, and mint an Idempotency-Key for it +(`withIdempotencyKey(crypto.randomUUID(), fn)`). Its own docstring called that mint "the one +place a logical submit's Idempotency-Key is minted". Five call sites are reads and had no +business minting one: + +| Adapter | Method | Wire call | +| ---------------------------- | -------------------- | --------------------- | +| `brief.adapter.ts:56` | `load()` | `briefGET()` | +| `org-template.adapter.ts:42` | `list()` | `orgTemplates()` | +| `org-template.adapter.ts:54` | `load(subOrgId)` | `orgTemplateGET(...)` | +| `stamdata.adapter.ts:27` | `list()` | `stamdataTables()` | +| `stamdata.adapter.ts:42` | `load(tableId, ...)` | `stamdataTable(...)` | + +That is **exactly five** — verified by grepping every `runSubmit` call site in +`libs/shared/src/application` plus the `brief` and `beheer` scopes (13 call sites total) and +reading each one's wire call for a request body / non-GET verb. The other 8 are genuine +writes (`brief.adapter.ts` save/submit/approve/reject/send/reset, +`org-template.adapter.ts` save/publish/rollback) and stay on `runSubmit` unchanged. + +`stamdata.adapter.ts`'s own module docstring already said "Both endpoints are reads … There +is no write method" while both called `runSubmit` — the sharpest instance of the mismatch, +and the one BL-007's original "~13 mutations" count mis-classified because the count was +derived from the helper's name, not from what the call actually does. + +**Not this ticket, seen while auditing:** `ApplicationsStore.cancel`, `AdminCasesStore.delete` +(RB-20) and `FeatureFlagStore.set` reach `ApiClient` more directly; the baseline's §10 +amendment flags these as writes the original "~13" count missed. Grepping confirms +`FeatureFlagStore.set` (`libs/shared/src/application/feature-flags.store.ts:63`) already +calls `runSubmit` correctly and returns a `Result` — it is not broken, just outside this +ticket's five. `ApplicationsStore.cancel`/`AdminCasesStore.delete` were not touched; they are +RB-20's. + +## What changed + +| File | Change | +| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `libs/shared/src/application/submit.ts` | Split: `runResult` = the try/catch + `problemDetail` fold, no mint. `runSubmit` = `runResult` wrapping `withIdempotencyKey`. | +| `libs/shared/src/application/submit.spec.ts` | Specs for both, including one that would catch a read minting a key again (see below). | +| `apps/ssp/src/app/brief/infrastructure/brief.adapter.ts` | `load()` → `runResult`; docstring updated to name both halves. | +| `apps/ssp/src/app/brief/infrastructure/org-template.adapter.ts` | `list()`, `load()` → `runResult`. | +| `libs/beheer/src/infrastructure/stamdata.adapter.ts` | `list()`, `load()` → `runResult`; import trimmed to `runResult` only (no writes in this file). | +| `libs/shared/docs/behaviour-spec.mdx` | Regenerated (`npm run gen:behaviour-spec`) — picks up the new `runResult` describe block and the extra `runSubmit` case. | + +`runSubmit`'s new body is exactly the minimal composition the ticket asked for: + +```ts +export function runSubmit(fn: () => Promise, fallback: string): Promise> { + return runResult(() => withIdempotencyKey(crypto.randomUUID(), fn), fallback); +} +``` + +Zero behaviour change for the 8 write call sites — same fold, same mint, same timing (the key +is still minted before `fn` runs and cleared in `withIdempotencyKey`'s `.finally`). The five +reads now run the fold with no `pendingIdempotencyKey` touched at all. + +## The spec that would catch a regression + +`currentIdempotencyKey()` (`api-client.provider.ts`) returns the pending key while one is +"in flight" for the duration of a `withIdempotencyKey` call, and a fresh `crypto.randomUUID()` +on every call otherwise. That gives a real, mock-free way to assert "no key was minted": call +`currentIdempotencyKey()` twice inside the function passed to `runResult`/`runSubmit` — two +different reads means no pending key existed (each fell back to its own random UUID); two +equal reads means one pending key was minted and reused. + +```ts +it('mints no Idempotency-Key — the read fold', async () => { + let first = '', + second = ''; + await runResult(async () => { + first = currentIdempotencyKey(); + second = currentIdempotencyKey(); + return 'x'; + }, 'fallback'); + expect(first).not.toBe(second); +}); +``` + +This mirrors the house convention of not mocking relative imports under this repo's +Angular/vitest setup (see `role.interceptor.spec.ts`'s comment) — it asserts on real, +exported behaviour instead of a spy. + +**Verified red without the fix**: temporarily changed `runResult` to also call +`withIdempotencyKey` (i.e. reintroduced the bug it exists to prevent) and reran `ng test +shared`. Result: `runResult > mints no Idempotency-Key — the read fold` failed +(`expected 'd185d827-...' not to be 'd185d827-...'`), all 137 other tests stayed green. Then +reverted the temporary edit back to the real fix (an `Edit` undo, not `git checkout`, so the +rest of the change stayed in place) and reran — 138/138 green. + +## Judgement calls + +- **Docstring on `brief.adapter.ts`** was rewritten (it previously said only "Mutations go + through `runSubmit`") to name `load`'s `runResult` path explicitly, since the file mixes + both now and a future reader needs the split spelled out at the top, not just per-method. + `org-template.adapter.ts` and `stamdata.adapter.ts`'s docstrings needed no change — neither + named `runSubmit` specifically (`stamdata.adapter.ts`'s already correctly said "no write + method"). +- **No new concept, per the ticket's "minimal" framing** — `runSubmit` stays exported with + the same signature and the same call sites for the 8 real mutations; only its body changed + to delegate. +- **Left `runResult`'s JSDoc pointing at `runSubmit`** ("never route a read through that + one") rather than duplicating the Idempotency-Key explanation, so the two docs stay + synchronized by cross-reference instead of by copy. + +## Residuals (not this ticket) + +- RB-18 (key the `IdempotencyStore` on `{SubjectId}:{idemKey}`) is sequenced behind this one + per `99-backlog.md` and is unaffected by this split beyond it now landing on a correctly + write-only call set. +- RB-20 (`ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`) is + untouched, as scoped. + +## Verification + +`npm run ci` (foreground): **green** — lint, typecheck, `dep:check` (341 + 226 modules, 0 +violations), `format:check`, `check:tokens`, `check:seam`, tests (ssp 258/258, behandelportal +31/31, shared 138/138, beheer 23/23 — 450 total), `ng build --localize` (both apps), `npm +audit` (0 vulnerabilities), backend `dotnet test` (255/255 — the known +`OpenZaakIntegrationTests.Admin_cases_…` container-dependent failure did not reproduce on +this run), `gen:snippets` drift clean, `gen:behaviour-spec` drift clean once the regenerated +file is committed alongside the code (the local gate compares the working tree to `HEAD`, so +it necessarily shows a diff pre-commit — this is the documented "will conflict at merge time" +behaviour, not a defect). diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-18.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-18.md new file mode 100644 index 0000000..749c4ab --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-18.md @@ -0,0 +1,115 @@ +# RB-18 — key `IdempotencyStore` on `{SubjectId}:{idemKey}` + +Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-018 · +`00-baseline.md` §7 (`IdempotencyStore` listed among the 7 stores "Not behind any port"), +agent 02's `backend/Data` note ("no `Reset()` and no TTL") · `99-backlog.md` RB-18 + +## What was wrong + +`Data/IdempotencyStore.cs` is a process-global `Dictionary` keyed only on +the raw `Idempotency-Key` header value. `Program.cs`'s `Submit` helper read and wrote it +with that raw value, never composed with the caller's identity: + +```csharp +var idemKey = ctx.Request.Headers.TryGetValue("Idempotency-Key", out var k) && !string.IsNullOrEmpty(k) + ? k.ToString() + : null; +``` + +The client picks the header value. Two different callers who happen to send the same +value shared one cache slot: the second caller's request short-circuited to the first +caller's cached `IResult` instead of running its own submission. BIO-018 rates this +**severity low** — the cached value is only a `ReferentieResponse` (a reference number) or +a `ProblemDetails`, never personal data — but flags it as a defect in an access-control +path with a trivial fix. + +**Location check against the finding.** BIO-018 cites `Program.cs:901-909` for the read/ +write and `Data/IdempotencyStore.cs:11-27` for the store. RB-17 (landed the day before, +same file, unrelated change) shifted line numbers; the real call sites are +`Program.cs:994` (read) and `:1028` (write), inside the local `Submit` helper starting at +`:991`. The store file itself is untouched by RB-17 and matches the finding's shape +exactly. `Submit` has exactly one call site (`POST /change-requests`, `:239`) — the +`ChangeRequestRequest` → `telefoonwijziging` endpoint — so the scoping change lands on a +single endpoint, not the "smaller call set" RB-17 was sequenced ahead of this ticket to +produce; RB-17 removed idempotency-key minting from 5 read call sites, none of which used +this helper in the first place, so its ordering benefit does not change what this ticket +touches. Reported for completeness, not as a discrepancy: RB-17's own note already scoped +its residual to "this ticket is unaffected by this split beyond it now landing on a +correctly write-only call set" — true, and the call set was already this one endpoint +before and after RB-17. + +## What changed + +| File | Change | +| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/BigRegister.Api/Program.cs` | `Submit`'s `idemKey` is now `$"{ctx.Caller().SubjectId}:{k}"` instead of the raw header value `k.ToString()`; doc comment above `Submit` states the scoping and cites RB-18/BIO-018 | +| `tests/BigRegister.Tests/IdempotencyTests.cs` | **new** `A_caller_replaying_another_callers_idempotency_key_does_not_get_their_cached_result` | + +`ctx.Caller()` (`Domain/Authorization/CallerIdentity.cs`) is already in scope in +`Program.cs` — `ctx.Zorgverlener()` is used elsewhere in the same file — and it throws if +the identity middleware did not run, so this composition cannot silently fall back to an +unscoped key. `SubjectId` is the BSN for a `ZorgverlenerCaller` and the medewerkerId for a +`MedewerkerCaller`; either way it is stable per caller and never empty. + +This is exactly the ticket's minimal remediation, no more: no TTL, no eviction, no bound, +no `Reset()`, no port/interface extraction. `IdempotencyStore`'s own `ponytail:` comment +("no TTL/eviction … an unbounded dictionary keyed on client-supplied strings is a memory +leak at scale") is untouched — the store is still unbounded and still keyed on a +client-supplied string, only now composed with a server-resolved one first. The comment +stays accurate; this ticket did not touch the part it would need to correct. + +## The test + +`IdempotencyTests.cs` already existed (RB-17's predecessor work, not this ticket) with +three cases exercising same-caller replay/independence. Added a fourth: + +```csharp +[Fact] +public async Task A_caller_replaying_another_callers_idempotency_key_does_not_get_their_cached_result() +{ + var sharedKey = Guid.NewGuid().ToString(); + + var callerARequest = ChangeRequestWithKey(sharedKey); + callerARequest.Headers.Add("X-Subject", "111222333"); + var callerA = await _client.SendAsync(callerARequest); + callerA.EnsureSuccessStatusCode(); + var callerABody = await callerA.Content.ReadFromJsonAsync(); + + var callerBRequest = ChangeRequestWithKey(sharedKey); + callerBRequest.Headers.Add("X-Subject", "999888777"); + var callerB = await _client.SendAsync(callerBRequest); + callerB.EnsureSuccessStatusCode(); + var callerBBody = await callerB.Content.ReadFromJsonAsync(); + + Assert.NotEqual(callerABody!.Referentie, callerBBody!.Referentie); +} +``` + +`X-Subject` is `StubIdentityProvider`'s existing header for setting the caller's BSN in a +test (the same idiom `ApplicationTests.cs` and `UploadAccessTests.cs` use), so caller A and +caller B are two different `ZorgverlenerCaller`s sending the identical `Idempotency-Key`. + +**Verified red without the fix.** Reverted `Program.cs`'s `idemKey` line to +`k.ToString()` with an `Edit` (not `git checkout`, so the rest of the working tree stayed +intact), reran `dotnet test --filter "FullyQualifiedName~IdempotencyTests"`: + +``` +Failed BigRegister.Tests.IdempotencyTests.A_caller_replaying_another_callers_idempotency_key_does_not_get_their_cached_result [4 ms] + Error Message: + Assert.NotEqual() Failure: Strings are equal +Expected: Not "BIG-2026-476969" +Actual: "BIG-2026-476969" +Failed! - Failed: 1, Passed: 3, Skipped: 0, Total: 4 +``` + +Caller B received caller A's cached reference. Then reapplied the fix with a second +`Edit` and reran: `Passed! - Failed: 0, Passed: 4, Skipped: 0, Total: 4`. + +## Verification + +`dotnet test` (full suite): **261 passed, 1 failed** — the failure is +`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`, +which needs a live OpenZaak container and fails identically on a stashed tree; it predates +this change and is not run by `npm run ci`. + +`npm run ci` (foreground): green — see the commit's own record for the full step list. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-19.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-19.md new file mode 100644 index 0000000..65afb53 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-19.md @@ -0,0 +1,220 @@ +# RB-19 — reorder `Program.cs`: reads before writes per section, regroup admin-cases + org-template preview + +Status: **implemented** · 2026-08-27 · Source findings: `04-cqrs-light.md` CQ-006 · +`99-backlog.md` RB-19 · Depends on `implementation/rb-12.md` (the route-table test this +ticket leans on as its regression net) + +This is a **pure reorder**. No route, signature, DTO, or handler-body text changed. The +sorted list of `HTTP METHOD + path` mapping calls is byte-identical before and after (see +"Verification" below) — that identity is the strongest evidence this ticket did what it +says and nothing else. + +## What was wrong + +CQ-006, verbatim: `Program.cs` opens by declaring direction as its organising principle +(a "GET: screen-shaped reads" banner, then a "POST: submits" banner), then from the +Document-upload section onward switches to feature grouping without saying so, and every +subsequent section interleaves reads and writes. One feature (Beoordeling/Besluit, WP-65) +already got the fix — a `:441`/`:464`-style banner pair splitting its query endpoint from +its command endpoint — and CQ-006 asks for the same treatment on the five sections that +predate that pattern: Document upload, Applications, Admin cases, Brief, and Organization +templates. Separately, `DELETE /admin/cases/{id}` sat 129 lines away from `GET +/admin/cases`, with werkvoorraad, beoordeling, besluit and the ZGW notification hook in +between; and `GET /admin/org-template/{subOrgId}/preview` was filed under the Brief +section's banner instead of the Org-templates section it actually belongs to. + +## What changed + +| Section (banner) | Before | After | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Document upload | categories, **POST /uploads**, content, status, DELETE, admin-DELETE | categories, content, status, `--- reads ---`/`--- writes ---` sub-banners, **POST /uploads** moved after the reads, DELETE, admin-DELETE | +| Applications | already reads-first (2 GETs, then POST/PUT/DELETE/POST-submit) | unchanged order; sub-banners inserted only | +| Admin cases | GET /admin/cases, _(werkvoorraad/beoordeling/besluit/zgw-notificaties in between)_, **DELETE /admin/cases/{id}**, **GET /admin/audit** | GET /admin/cases, **GET /admin/audit** (moved up), `--- writes ---`, **DELETE /admin/cases/{id}** (moved up) — all three now contiguous; werkvoorraad/beoordeling/besluit/zgw-notificaties follow, unmoved and unchanged | +| Brief | GET /brief, PUT, submit, approve, reject, send, reveal-bignummer, **GET /brief/preview**, _(org-template preview)_, POST /reset | GET /brief, **GET /brief/preview** (moved up beside GET /brief), `--- writes ---`, PUT, submit, approve, reject, send, reveal-bignummer, POST /reset — org-template preview removed from this section | +| Organization templates | list, detail, PUT, publish, rollback | list, detail, **GET /admin/org-template/{subOrgId}/preview** (moved in from Brief), `--- writes ---`, PUT, publish, rollback | + +Every section above got a `// --- reads ---` / `// --- writes ---` sub-banner pair +(matching the short, bare form already used at the file's top-level `:170`/`:236` +banners) inserted at the reads→writes boundary. Werkvoorraad, Beoordeling and Besluit — +not named by CQ-006 as mixed, and already correctly split (Beoordeling is the read, +Besluit is the write, each with its own WP-65 banner) — were left exactly as they were, +including their absolute position relative to each other; only the block ahead of them +(admin-cases) grew, pushing their line numbers down without touching their content. + +`backend/swagger.json` and `libs/shared/src/infrastructure/api-client.ts` were +regenerated (`npm run gen:api`) and are part of this commit — see "The regenerated pair" +below. + +## Design: line-range slicing, not manual retyping + +Every moved block was cut with a Python script operating on exact 1-indexed line ranges +against the file as it stood after merging in `refactor/adr-c-006-shared-route-guards` +(this branch's actual base — see "Base commit" below), then reassembled in the new order. +No handler body was retyped by hand. This is the same guarantee the ticket's "cut/paste, +not retype" instruction asks for, made structural rather than a promise to be careful: +a line-range slice cannot silently change a character inside a block it does not touch. +The script is not part of this commit (a one-shot tool, not project code); the diff it +produced is what is being reviewed. + +## Judgement calls + +- **Sub-banner wording is bare `// --- reads ---` / `// --- writes ---`, not prose + matching WP-65's descriptive style.** The ticket asks for ":441/:464-style" banners; + WP-65's actual banners are long, feature-specific paragraphs ("read side only + (recording a decision is WP-65's second half)…"). Inventing five more paragraphs like + that would mean writing new explanatory prose about code this ticket is not meant to + re-explain — CQ-006 is explicit that this is "a structure finding, not a correctness + one," and the ticket itself forbids "no fixed comments beyond the banners this ticket + adds." The file's own top-level banners (`:170` "GET: screen-shaped reads", `:236` + "POST: submits") already establish a bare, label-only banner as a legitimate style in + this exact file — the sub-banners here are that same style, nested one level deeper. +- **Werkvoorraad/Beoordeling/Besluit end up sandwiched between Admin-cases and + zgw/notificaties, in that order, unmoved.** Moving `GET /admin/audit` and `DELETE +/admin/cases/{id}` up next to `GET /admin/cases` (as instructed) necessarily pushes + everything that used to sit between them — werkvoorraad, beoordeling, besluit, + zgw/notificaties — down, but does not reorder those four relative to each other. They + were not named as mixed by CQ-006 and were not touched beyond their line numbers + changing. +- **`GET /brief/preview` and `POST /uploads` are both `.ExcludeFromDescription()`-marked + (hand-written FE `fetch`/XHR calls, never through the generated client) — moving them + produced zero diff in `swagger.json`.** This is not a coincidence being reported as + one: an excluded endpoint has no OpenAPI operation to reorder in the first place, so + the regenerated pair's diff below is smaller than "every moved route" might suggest — + it only shows the two endpoints that are both documented and reordered relative to + each other (`GET /admin/audit`, `DELETE /admin/cases/{id}`). +- **No handler types, no `Features/` folder, no mediator** — out of mandate per CQ-006's + own text (filed separately as OOM-A) and the ticket's explicit "out of scope" section. + Nothing beyond comments and mapping order changed. + +## Base commit + +Step zero's warning matched this worktree's actual starting state: `git log --oneline -8` +showed `ae7781e` at HEAD, not `edd20c0`, and `edd20c0 docs(backlog): mark RB-23 done after +merge` was absent from the log entirely — the bad-base lineage named in the ticket. `git +merge refactor/adr-c-006-shared-route-guards` was run, after which `edd20c0` appeared as +`HEAD~0`'s direct ancestor and every RB-01..RB-23 commit was present. All work in this +ticket happened after that merge. + +## Verification + +**The sorted-route-list diff (the key evidence).** Extracted every `.Map(Get|Post|Put| +Delete)("...")` call from `Program.cs` before and after, sorted each list, and diffed +them: + +``` +$ grep -oE '\.Map(Get|Post|Put|Delete)\("[^"]*"' Program.cs.before-reorder | sort > before.txt +$ grep -oE '\.Map(Get|Post|Put|Delete)\("[^"]*"' Program.cs | sort > after.txt +$ diff before.txt after.txt +$ echo "exit=$?" +exit=0 +$ wc -l before.txt after.txt + 47 before.txt + 47 after.txt +``` + +Empty diff, same count (47 `api.Map*` calls — the two `app.MapGet` health probes are +outside the `/api/v1` group and were never in scope for this reorder; they were untouched +either way). The set of routes is provably unchanged. + +**`.Gate(...)` count, before/after, by wrapper name:** + +``` + 3 .Gate("Beoordelen") + 4 .Gate("CasesAdmin") + 1 .Gate("FlagsAdmin") + 6 .Gate("OrgAdmin") + 2 .Gate("StamdataAdmin") +``` + +Identical in both directions — no gate call was added, removed, or renamed. + +**Per-route eyeball check of every route that changed position, per RB-12's stated +limitation** (the route-table test only proves a `.Gate(...)` marker is present, not that +it still names the wrapper the handler body actually calls): + +| Route | Moved | `.Gate(...)` after | Wrapper actually called inside the handler | Match | +| -------------------------------------------- | ------------------------------------------------------- | ----------------------------------------- | ------------------------------------------ | ----- | +| `GET /admin/audit` | up, beside `GET /admin/cases` | `CasesAdmin` | `CasesAdmin(ctx, () => ...)` | yes | +| `DELETE /admin/cases/{id}` | up, beside `GET /admin/cases` | `CasesAdmin` | `CasesAdmin(ctx, () => { ... })` | yes | +| `GET /admin/org-template/{subOrgId}/preview` | Brief section → Org-templates section | `OrgAdmin` | `OrgAdmin(ctx, () => { ... })` | yes | +| `POST /uploads` | within Document-upload, past the three reads | _(none — allow-listed, ownership-scoped)_ | — | n/a | +| `GET /brief/preview` | within Brief, up beside `GET /brief` | _(none — allow-listed, ownership-scoped)_ | — | n/a | +| `GET /uploads/{documentId}/content` | incidental one-slot shift (POST /uploads moved past it) | _(none — allow-listed)_ | — | n/a | +| `GET /uploads/status` | incidental one-slot shift (POST /uploads moved past it) | _(none — allow-listed)_ | — | n/a | + +Five routes were deliberately relocated by this ticket; two more shifted position only as +a byproduct of `POST /uploads` moving past them (their own order relative to each other +is unchanged). All three gated routes among these were checked by eye against the +handler body they wrap, not just against `RouteInventoryTests`' marker check — all three +match. + +**`RouteInventoryTests`:** + +``` +Passed! - Failed: 0, Passed: 2, Skipped: 0, Total: 2, Duration: 770 ms +``` + +Both `Every_mapped_route_is_authz_gated_or_on_the_named_allow_list` and +`Every_gate_marker_names_a_known_admin_wrapper` pass. + +**Full backend suite:** `dotnet test --filter "Category!=Integration"` — **262/262 +passing**, plus the one known, pre-existing, container-dependent failure +(`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`), +which does not run under `npm run ci` and reproduces on a clean tree with no OpenZaak +container running — not this ticket's bug. + +**`dotnet build`** (both projects): 0 warnings, 0 errors. **`dotnet format +BigRegister.slnx --verify-no-changes`**: clean. + +**No new test was added.** Per the ticket's Definition of Done: this is a zero-semantic- +change commit, and §3c's pre-existing 97.4% line / 84.8% branch coverage of `Program.cs` +is the regression net CQ-006 itself names. Nothing about this diff needs a new test to be +trustworthy — a passing pre-existing suite plus an empty sorted-route diff is stronger +evidence for "nothing changed" than a new test asserting the same thing would be. + +## The regenerated pair + +`npm run gen:api` was run after the reorder. It produced a diff in both +`backend/swagger.json` (2 hunks) and `libs/shared/src/infrastructure/api-client.ts` (5 +hunks) — both **pure reordering, zero content change**. Confirmed by sorting every line of +each file (before vs. after) and diffing the sorted output: empty in both cases. The only +two OpenAPI paths that moved position in the document are `/api/v1/admin/audit` and +`/api/v1/admin/cases/{id}` — the two documented (non-`ExcludeFromDescription`) endpoints +this ticket actually reordered relative to their OpenAPI-document neighbours; the +generated client's `audit()`/`cases()` methods and their `process*` helpers moved by the +same amount, unchanged in every other respect (parameters, return types, status-code +branches, JSDoc). Both regenerated files are committed alongside `Program.cs`, per the +ticket's explicit instruction: "if the only change is ordering inside swagger.json, say +so explicitly and commit the regenerated pair rather than leaving CI's drift job to +fail." + +**`npm run ci`**: every job through "backend dependency audit" passed before this +ticket's files were committed; the one job that legitimately failed pre-commit was "api- +client drift" (`git diff --exit-code` against the not-yet-committed regenerated files — +expected, since that step compares the working tree to `HEAD`, and `HEAD` still had the +pre-reorder client at that point). After committing, `npm run ci` was re-run to confirm a +clean, fully green result against the committed tree — see the final PASS/exit-code +reported in this ticket's closing message. + +## What a reviewer should check + +This diff is too large to read top-to-bottom without guidance. The fastest way to review +it with confidence: + +1. **Trust the sorted-route diff, not a manual read of every hunk.** The "Verification" + section above shows the set of `HTTP METHOD + path` strings is byte-identical before + and after. If you want to reproduce it yourself: check out this commit's parent, + extract the same `grep -oE` pattern from both revisions of `Program.cs`, sort, diff. +2. **Spot-check the five per-route table entries above**, not the whole file — those are + the only routes whose position (and, for three of them, gate-vs-handler match) + actually matters for this ticket's correctness claim. +3. **Diff `git show -- backend/src/BigRegister.Api/Program.cs` with + whitespace-insensitive word diff** (`git diff -w --color-words`) if you want to + confirm no character inside a moved handler body changed — the line-range-slicing + approach in "Design" above makes this a formality rather than a real risk, but it is + cheap to re-check. +4. **Do not expect Werkvoorraad/Beoordeling/Besluit/zgw-notificaties to have moved + position relative to each other** — only their absolute line numbers shifted, as a + side effect of the admin-cases block growing above them. +5. **The regenerated `swagger.json`/`api-client.ts` diff is expected and pre-verified as + ordering-only** (sorted-file diff is empty) — it does not need a second manual read. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-20.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-20.md new file mode 100644 index 0000000..c4b242b --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-20.md @@ -0,0 +1,117 @@ +# RB-20 — route `ApplicationsStore.cancel` / `AdminCasesStore.delete` through `runSubmit`, surface the error + +Status: **implemented** · 2026-08-27 · Source finding: `04-cqrs-light.md` CQ-002 · +`00-baseline.md` BL-007 · `99-backlog.md` RB-20 · SIGN-OFF: consolidation approved +2026-08-27, HALT lifted + +## What was wrong + +`ApplicationsStore.cancel` and `AdminCasesStore.delete` both owned an optimistic write next +to their `RemoteData` read, and both reached `ApplicationsAdapter` directly instead of going +through `runSubmit` (the fold + Idempotency-Key mint every other mutation in the repo uses, +including `createSubmitChangeRequest` in the same folder). The failure path was a bare +`catch { this.state.set(before); }`: a failed cancel or delete rolled the row back, but the +user saw no message at all — no `ActionState`, no ProblemDetails `detail`, nothing. The +`Idempotency-Key` on the wire was also a fresh UUID per HTTP attempt (minted by +`api-client.provider.ts`'s default), not the per-logical-submit key `runSubmit` promises — +harmless today only because `Program.cs` happens to ignore the header outside the `Submit` +helper (CQ-005's note). + +## What changed + +CQ-002's option (a) — the smallest fix, applied identically to both stores. No new command +factory, no adapter split (CQ-002's own "Not filed" note reserves that split for option (b), +which this ticket does not take). + +| File | Change | +| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/ssp/src/app/registratie/application/applications.store.ts` | `cancel` now calls `runSubmit(() => this.adapter.cancel(id), SUBMIT_FAILED)`; added a private `error` signal, exposed read-only as `lastError`. On failure: roll back AND `this.error.set(r.error)`. On the next attempt, the error is cleared before the call so a stale message never survives a fresh action. | +| `apps/ssp/src/app/registratie/application/applications.store.spec.ts` (new) | 4 specs: load+parse, optimistic cancel, roll-back-and-surface-error on failure, stale-error-clears-on-next-attempt. No spec file existed for this store before RB-20. | +| `apps/ssp/src/app/registratie/application/admin-cases.store.ts` | Same shape as `applications.store.ts`: `delete` through `runSubmit`, `error`/`lastError` signal pair. | +| `apps/ssp/src/app/registratie/application/admin-cases.store.spec.ts` | Existing "rolls back … when the delete fails" spec extended to also assert `lastError()`; one new stale-error-clears spec added. | +| `apps/ssp/src/app/registratie/ui/dashboard.page.ts` | One `@if (cancelError(); as err) { {{ err }} }` above the aanvragen list, mirroring `brief.page.ts`'s `lastError` rendering. `cancelError` is a `computed(() => this.apps.lastError())`. | +| `apps/ssp/src/app/registratie/ui/admin-cases.page.ts` | Same `@if (store.lastError(); as err) { {{ err }} }`, placed above `` inside the `canManage()` branch (`store` was already `protected`, so no new exposure needed). | + +`applications.adapter.ts` (`cancel`, `deleteAny`) is **unchanged** — the fix is entirely in +the two stores, which now wrap the existing thin adapter calls in `runSubmit` at the call +site, exactly as `createSubmitChangeRequest` wraps `ChangeRequestAdapter.changeRequest`. The +adapter methods still return a bare `Promise`; `runSubmit` is what folds that into a +`Result`. + +Neither UI change introduces a new user-facing string: the rendered text is either the +existing `SUBMIT_FAILED` constant (`@@submit.failed`, already translated in +`messages.en.xlf` since RB-17) or, when the backend sends one, a ProblemDetails `detail` +string carried verbatim from the server — never a new `$localize` id. `messages.en.xlf` did +not need a new ``. + +## The tests, and their red failures + +Both specs assert `store.lastError()` after a rejected adapter call, which only the fix can +satisfy — the old bare `catch { this.state.set(before) }` never touched an error signal, so +`lastError()` stayed `null` forever. + +**Verified red without the fix** (an `Edit` undo of the store method, not `git checkout`, so +the rest of the change — imports, the other store, the UI, the specs — stayed in place): + +- `applications.store.ts`: reverted `cancel` to `try { await this.adapter.cancel(id); } catch +{ this.state.set(before); }`. Reran `ng test ssp --include applications.store.spec.ts`: + 2 of 4 failed — + `rolls back the removal and surfaces the error when the cancel fails` and + `clears a stale error on the next cancel attempt`, both with + `AssertionError: expected null to be 'Het indienen is niet gelukt. Probeer het later opnieuw.'`. + The other two specs (load, optimistic-cancel-success) stayed green, as expected — they + don't touch the error path. Re-applied the fix (`Edit` back to the `runSubmit` version); + reran: 4/4 green. +- `admin-cases.store.ts`: same procedure on `delete`. Reran + `ng test ssp --include admin-cases.store.spec.ts`: 2 of 4 failed with the identical + `expected null to be '...'` shape. Reverted to the fix; reran: 4/4 green. + +## Judgement calls + +- **Signal naming**: private backing field `error`, public readonly `lastError` — matching + the name `BriefStore`/`OrgTemplateStore` already expose for exactly this purpose (CQ-002's + own citation), rather than inventing a new name per store. +- **Error cleared at the start of each write**, not only on success, so a second cancel/delete + attempt after a failure doesn't leave a stale banner up if the retry itself is still in + flight. Covered by the "clears a stale error on the next attempt" spec in each file. +- **No `ActionState`/`SaveState` pair** (the fuller shape `BriefStore` uses for busy-state and + save-state together) — CQ-002 explicitly scoped option (a) to "one `error` signal", and + neither store needs a busy indicator: the row already disappears optimistically the instant + the click happens, so there is nothing for a spinner to cover. +- **UI placement**: one alert per page, above the list the mutated row belongs to, using the + same `@if (x(); as err) { {{ err }} }` shape as + `brief.page.ts` — composition of an existing atom, no new building block (CLAUDE.md §2). +- **`applications.adapter.ts` left untouched, on purpose** — CQ-002's "Not filed" note ties + the read/write file split to option (b) only; taking option (a) means this ticket changes + no adapter code at all, matching the ticket's own framing ("(a) touches 2 files plus a UI + line each"). + +## Ticket accuracy + +CQ-002's description matched the code as found: both stores' `cancel`/`delete` reached the +adapter directly with a bare `catch { this.state.set(before); }`, no `Result`, no error +channel — no discrepancy to flag. + +## Residuals (not this ticket) + +- RB-18 (key `IdempotencyStore` on `{SubjectId}:{idemKey}`) is unaffected: `cancel`/`delete` + now mint a key through `runSubmit` like every other mutation, so it lands on the same + write-only call set RB-18 already targets. +- RB-21 (extract `createDraftSync`'s read half) is a separate CQRS-light finding in the same + context, untouched by this ticket. + +## Verification + +`npm run ci` (foreground, `timeout: 600000`): **green** — `✔ local CI passed`. Lint, +typecheck, `dep:check` (342 + 226 modules, 0 violations), `format:check`, `check:tokens`, +`check:seam`, tests (ssp 263/263 — 5 more than the pre-RB-20 258, from the new/extended +specs above — behandelportal 37/37, shared 138/138, beheer 23/23), `ng build --localize` +(both apps), `npm audit` (0 vulnerabilities), backend `dotnet format --verify-no-changes` + +`dotnet test --filter "Category!=Integration"` (260/260 — this filter is what keeps the +known `OpenZaakIntegrationTests.Admin_cases_…` container-dependent test, which needs a live +OpenZaak container, out of `npm run ci` entirely; it is a standing caveat, not introduced by +this change, and not exercised by this run), backend dependency audit (0 vulnerable +packages), `gen:snippets` / `gen:behaviour-spec` / `gen:api` drift checks all clean once the +regenerated `behaviour-spec.mdx` was staged alongside the code (the local gate's +`git diff --exit-code` compares the working tree to the index, so it is clean once the file +is staged — this is the documented pre-commit behaviour from RB-17's note, not a defect). diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-21.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-21.md new file mode 100644 index 0000000..c908e0a --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-21.md @@ -0,0 +1,119 @@ +# RB-21 — extract the read half of `createDraftSync` into `find-concept.ts` + +Status: **implemented** · 2026-08-27 · Source finding: `04-cqrs-light.md` CQ-001 · +`00-baseline.md` §4a (`createDraftSync` 143 lines, the largest function in the repo), §9 +(`fn > 40` threshold) · `99-backlog.md` RB-21 + +## What was wrong + +`createDraftSync` (`apps/ssp/src/app/registratie/application/draft-sync.ts`) was registered +as a command factory but owned three read paths (`load`, `findConcept`, and the read half of +`resume`) mixed into the same function as the write path (`ensureId`, `flush`, `submit`, +`reset`). CQ-001 named three pieces of shared mutable closure state — `id`, `ensuring`, +`resumeGate` — as load-bearing: `resumeGate` exists only so the write path (`ensureId`) can +wait for the read path (`resume`) to finish. That coupling is genuine and stays in place. + +## What changed + +CQ-001's proposal, applied as a pure move, no redesign. + +| File | Change | +| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/ssp/src/app/registratie/application/find-concept.ts` (new) | `findConcept(adapter, type)` and `loadConcept(adapter, id)` — free functions taking `ApplicationsAdapter`, no `inject()`. `loadConcept` returns a `LoadedConcept` union (`{tag:'concept', draft}` \| `{tag:'not-concept'}`) instead of the boolean-shaped branching the inline version had. | +| `apps/ssp/src/app/registratie/application/find-concept.spec.ts` (new) | Direct spec, no TestBed — a fake `ApplicationsAdapter` object passed straight to the functions. | +| `apps/ssp/src/app/registratie/application/draft-sync.ts` | Removed the inline `findConcept` closure and the body of `load`; both now call the free functions. `createDraftSync` keeps `id`, `ensuring`, `resumeGate`, and the whole write path, unchanged. | +| `libs/shared/docs/behaviour-spec.mdx` | Regenerated (`npm run gen:behaviour-spec`) — picks up the new `find-concept.spec.ts` describe blocks. | + +`createDraftSync` shrank from 187 lines (`export function createDraftSync` to its closing +brace, HEAD~1) to 169 lines. The whole file went from 236 to 216 lines. + +The two call sites that used the old inline `findConcept()` now pass the adapter and type +explicitly: + +```ts +// ensureId's 409-recovery catch (WP-35) +const existing = await findConcept(adapter, deps.type); +``` + +```ts +// resume(), no ?aanvraag in the URL +const existing = await findConcept(adapter, deps.type); +``` + +`load` keeps setting the closure `id` and calling `applyResume` (both closure-dependent), but +delegates the actual read to `loadConcept`: + +```ts +const load = (linked: string): Promise => { + id = linked; + return loadConcept(adapter, linked).then((result) => { + if (result.tag === 'not-concept') { + id = undefined; + applyResume(null); + return; + } + applyResume(result.draft); + }); +}; +``` + +## `draft-sync.spec.ts` — unchanged + +`draft-sync.spec.ts` was not edited. It never called `resume()`/`load()` directly — its +coverage is the debounce, `submit()` (including the 409-recovery path, which exercises the +extracted `findConcept` indirectly through `ensureId`'s catch), and `flushPending`. All of +that stayed in `createDraftSync`, so the spec is unchanged and still exercises the wiring +between `createDraftSync` and the two new free functions (the 409-recovery test would fail if +that wiring were wrong). It passed unchanged, 8/8. + +## The new spec, and its verified red + +`find-concept.spec.ts` covers the branches CQ-001 named: + +- `findConcept`: match found → id returned; no match of that type → `undefined`; match found + but not `Concept` status → `undefined`; `adapter.list()` resolves to an unparsable shape + (`parseApplications` fails) → `undefined`; `adapter.list()` rejects → `undefined`. +- `loadConcept`: `Concept` with a draft → `{tag:'concept', draft}`; `Concept` with no draft → + `{tag:'concept', draft:null}`; a non-`Concept` status (e.g. `Ingediend`, submitted) → + `{tag:'not-concept'}`; `adapter.detail()` rejects (unknown/deleted id) → + `{tag:'not-concept'}`. + +**Verified red without the fix.** Used `Edit` (not `git checkout`) to invert one condition in +`loadConcept` — `dto.status.tag !== 'Concept'` → `dto.status.tag === 'Concept'` — reran `ng +test ssp --include find-concept.spec.ts`. Result: 3 of 9 tests failed — + +``` +loadConcept > reads the draft off a Concept + AssertionError: expected { tag: 'not-concept' } to deeply equal { tag: 'concept', draft: { step: 1 } } +loadConcept > reports a missing draft as null + AssertionError: expected { tag: 'not-concept' } to deeply equal { tag: 'concept', draft: null } +loadConcept > reports not-concept when the id has moved past Concept (submitted) + AssertionError: expected { tag: 'concept', draft: null } to deeply equal { tag: 'not-concept' } +``` + +Then used `Edit` again to flip the condition back to `!==`, reran the same command: 9/9 +green. `findConcept`'s and `loadConcept`'s other branches were not separately mutated — the +inverted condition alone was enough to prove the spec is sensitive to the extraction being +correct, and re-verifying full green after the revert confirmed no collateral change was left +in the file. + +## Scope held + +- No restructuring of the write path (`ensureId`, `flush`, `submit`, `reset`) — untouched + beyond the two call-site updates shown above. +- `applications.adapter.ts` was not split (CQ-002's "Not filed" note rules that out for this + design; out of scope here regardless). +- `resume()`'s semantics (URL-param precedence, the `resumeGate` release-in-`finally`, the + navigate-to-stamp-the-id side effect) are unchanged — only its two `findConcept()`/`load()` + calls now go through the free functions. +- No wire change, no DTO change, no behaviour change. + +## Verification + +`npm run ci` (foreground): **green** — lint, typecheck, `dep:check`, `format:check`, +`check:tokens`, `check:seam`, tests (ssp includes `find-concept.spec.ts` 9/9 new, +`draft-sync.spec.ts` 8/8 unchanged), `ng build --localize` (both apps), `npm audit`, backend +`dotnet test` (the known `OpenZaakIntegrationTests.Admin_cases_…` container-dependent failure +is expected and outside `npm run ci`'s scope), `gen:snippets` drift clean, `gen:behaviour-spec` +drift clean once the regenerated file is committed alongside the code. Full counts are in the +commit's `npm run ci` run — see the session note for the exact step-by-step output. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-22.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-22.md new file mode 100644 index 0000000..bce59a1 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-22.md @@ -0,0 +1,139 @@ +# RB-22 — `BriefStore.load()` tolerates a 404, calling `reset()` exactly once + +Status: **implemented** · 2026-08-27 · Source findings: `04-cqrs-light.md` CQ-007 · +`99-backlog.md` RB-22, "Tickets that were rejected and split" · `implementation/rb-17.md` +(the `runResult`/`runSubmit` seam this store already sits on) + +This is the **expand** half of an expand/contract pair. RB-23 (backend: `GET /brief` 404s +when absent; `BriefStore.GetOrCreate` splits into `Get` + `ResetAndCreate`) ships after this +ticket, in a later merge. Today's backend never 404s `GET /brief`, so this ticket's new +branch is dead code in the running app — provably backend-frontend-safe by construction. + +## What was wrong + +CQ-007 flags `GET /brief` (`Program.cs:603` → `BriefStore.GetOrCreate`, +`Data/BriefStore.cs:50`) as the one endpoint in the backend where a GET performs a +persisted write, breaking the read/write split every other endpoint respects. The fix is +split across both sides of the seam because the FE must be ready to receive a 404 before +the backend can safely start sending one. This ticket is the FE half: `BriefStore.load()` +(`apps/ssp/src/app/brief/application/brief.store.ts`) had no notion of "no brief exists +yet" — every adapter failure, 404 included, dispatched `BriefLoadFailed` and showed the +generic error banner. `BriefAdapter.load()` (`brief.adapter.ts`) also had no way to tell +the store a failure was specifically an HTTP 404: it folded every failure through the +shared `runResult` helper (RB-17), which keeps only a human-readable string and throws +away the HTTP status. + +The ticket read as filed against the current code: `BriefStore.load()` is exactly where +CQ-007 says it is, `BriefAdapter.load()` is exactly the read `runResult` call RB-17 pointed +at it, and nothing about either file was factually wrong. Nothing to flag here. + +## What changed + +| File | Change | +| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `apps/ssp/src/app/brief/infrastructure/brief.adapter.ts` | `load()`'s error channel becomes `BriefLoadFailure` (`{tag:'notFound'} \| {tag:'error', reason:string}`) instead of a plain `string`. `load()` no longer routes through the shared `runResult` — it does its own try/catch so it can read the thrown value's HTTP `status` before folding it away, via the new local `isHttpNotFound` predicate. Every other method (`save`/`submit`/`approve`/`reject`/`send`/`reset`) is untouched, still on `runSubmit`. | +| `apps/ssp/src/app/brief/application/brief.store.ts` | `load()` branches on `BriefLoadFailure`: `notFound` (and not already recovered) calls the existing `reset()` command directly and applies its result; every other failure (including a repeated `notFound`) dispatches `BriefLoadFailed` as before. Extracted `applyLoadedView` (the success-path body shared by `load()` and the new recovery path) and added `recoverFromMissingBrief`. | +| `apps/ssp/src/app/brief/application/brief.store.spec.ts` | New `describe('BriefStore.load — 404 tolerance (RB-22)')` with the two required cases. Six pre-existing `load:` fakes' explicit `Result` return-type annotations updated to `Result` (they only ever produce the `ok: true` branch, so this is a type-only change); two shared `ok(v)` test helpers that build fakes for both `load` and `save` had their return-type annotation dropped in favour of `as const` inference, since one helper now serves two different error-channel types. | +| `libs/shared/docs/behaviour-spec.mdx` | Regenerated (`npm run gen:behaviour-spec`) — picks up the two new `it()` titles. | + +No `backend/` file was touched — `Program.cs` and `Data/BriefStore.cs` are RB-23's, per the +ticket's explicit scope. + +## How the once-only bound is structural + +`BriefStore` gains one field: `private hasRecoveredFromMissingBrief = false`. `load()` +takes the recovery branch only when `r.error.tag === 'notFound' && !this.hasRecoveredFromMissingBrief`, +and the branch's first statement sets the flag before doing anything else. A second 404 — +whether from a second `load()` call, or in principle from `reset()` itself somehow also +404ing — falls through to the plain `BriefLoadFailed` branch instead, on every subsequent +call, for the life of the store instance. This is a field on the singleton store, not a +comment: nothing in the reachable call graph can flip it back to `false`. + +The loop CQ-007's proposed change warns about ("the reset's own load must not be able to +loop") is not merely bounded, it is **structurally absent**: `recoverFromMissingBrief` +calls `this.adapter.reset()` and applies its `BriefView` result directly (the same +`applyLoadedView` the success path uses) — it never calls `this.load()` again. There is no +recursive edge from the recovery path back into `load()` for the once-only flag to have to +stop; the flag exists only to stop a **second, separate** `load()` invocation (e.g. a +caller retrying navigation) from reaching `reset()` again. + +## Judgement calls + +- **`load()` no longer uses `runResult`, only for this one method.** `runResult` + (`libs/shared/src/application/submit.ts`, RB-17) intentionally keeps only a string — + every other read in the app is fine with that. This is the first read that needs one + more bit (the HTTP status) than `runResult` exposes, so `load()` does its own + try/catch instead, matching `runResult`'s shape (`problemDetail(e, fallback)` on the + non-404 path) but adding the 404 branch first. `libs/shared/src/application/submit.ts` + itself is untouched — changing a shared helper used by many call sites for one adapter's + need was out of scope and unjustified. +- **404 detection reads `(e as {status?:unknown}).status === 404`, not + `SwaggerException.isSwaggerException`.** The generated client throws a plain + `SwaggerException` for `GET /brief` today (no OpenAPI 404 response is declared for it + yet), but throws the parsed `ProblemDetails` object instead for an endpoint whose spec + **does** declare a 404 (both shapes carry a `status` field). Checking `status` alone, + not the `SwaggerException` type, means this predicate keeps working unchanged once + RB-23 regenerates the client with a documented 404 response for `briefGET()` — no + follow-up FE edit needed for detection to keep working. +- **`BriefLoadFailure` is a new exported type, not a sentinel string.** CLAUDE.md's + default reflex is a discriminated union over a second flag; a magic string + (`'__not_found__'`) compared by identity would have kept `load()`'s signature at + `Result` and touched fewer test lines, but it is exactly the kind + of stringly-typed control flow the union tool exists to avoid. The touched-test cost + was six type annotations plus two helper signatures, all in the one already-scoped + spec file — judged worth it for the correct shape. + `BriefLoadFailure` is exported. +- **On a repeated 404, the store shows `BRIEF_LOAD_FAILED`** (the same generic banner + text `load()` already used for every other failure), not a distinct "still missing" + message. No new user-facing copy was needed or added, so no new `$localize` id and no + `messages.en.xlf` change — confirmed by diffing for `$localize` occurrences: both + hits in the diff are unchanged context lines, not new additions. +- **`resetDemo()` (the "start over" button) was left untouched**, even though it + duplicates part of the same apply-a-fresh-view logic now factored into + `applyLoadedView`. It also manages `actionState`/`saveState`/`rejectionSnapshot` that + `recoverFromMissingBrief` correctly does not touch (an automatic recovery on first + load is not a user-initiated "start over" action), and refactoring it was not asked + for by this ticket. + +## Verification + +- **Verified red without the fix.** Temporarily replaced `load()`'s body (via `Edit`, + not `git checkout`) with the pre-fix shape — every failure, `notFound` included, + dispatches `BriefLoadFailed` straight away, no `reset()` call — and reran the spec + file. Both new tests failed: + `expected "vi.fn()" to be called 1 times, but got 0 times` on `reset`, for both "a 404 + drives exactly one reset()" and "a second 404 does not drive a second reset()"; the + other 18 tests in the file stayed green. Restored the real fix with a second `Edit` + and reran: all 20 tests in the file green, 30/30 across both touched spec files. +- `npm run ci` (foreground, no background/Monitor): **green**, exit 0 — lint, + typecheck, `dep:check` (341 + 226 modules, 0 violations), `format:check`, + `check:tokens`, `check:seam`, tests (ssp 260/260, behandelportal 37/37, shared + 138/138, beheer 23/23 — 458 total), `ng build --localize` (both apps), `npm audit` + (0 vulnerabilities), backend `dotnet test` (260/260 — the known + `OpenZaakIntegrationTests.Admin_cases_…` container-dependent failure did not + reproduce on this run, matching the standing caveat that it needs a live OpenZaak + container and is not this ticket's bug), backend dependency audit clean, `gen:snippets` + drift clean, `gen:behaviour-spec` drift clean once the regenerated file was staged (the + local gate diffs the working tree against the index, so it necessarily shows a diff + before the file is staged/committed — the same documented, expected behaviour RB-17 + recorded, not a defect). +- `npx prettier --check` on every touched file (including the reformatted + `99-backlog.md` table and the regenerated `behaviour-spec.mdx`): clean. + +## What RB-23 must do + +Once `GET /brief` in `Program.cs` returns a real 404 (no `ProblemDetails` body is +required — `BriefAdapter.load()`'s `isHttpNotFound` only reads the HTTP `status`, not the +response body), and `BriefStore.GetOrCreate` splits into `Get` (query) + the existing +`ResetAndCreate` (already there, already used by `POST /brief/reset`), this ticket's +`notFound` branch stops being dead code and starts running on first-ever page load for any +owner with no persisted brief. Run `npm run gen:api` as part of RB-23 so `briefGET()` +regenerates with a documented `status === 404` branch throwing the parsed `ProblemDetails` +(matching the shape most other endpoints already use) — the detection predicate here +already tolerates that shape and needs no FE follow-up change. Two things worth +re-verifying once RB-23 lands, not fixing preemptively here: first, that the resulting +double round-trip (404, then `reset()`) is an acceptable UX cost on a first visit, per +CQ-007's own framing of this as its least certain finding; second, that this store's +`hasRecoveredFromMissingBrief` field — private to one store instance, reset only by a full +reload — is still the right lifetime for the once-only guard once a real 404 can occur in +production traffic, not only in a test's fake adapter. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-23.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-23.md new file mode 100644 index 0000000..faf9fa4 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-23.md @@ -0,0 +1,183 @@ +# RB-23 — `GET /brief` 404s when absent; `BriefStore.GetOrCreate` splits into `Get` + `ResetAndCreate` + +Status: **implemented** · 2026-08-27 · Source findings: `04-cqrs-light.md` CQ-007 · +`99-backlog.md` RB-23, "Tickets that were rejected and split" · `implementation/rb-22.md` +(the FE **expand** half this ticket **contracts** against) + +This is the **contract** half of the RB-22/RB-23 expand/contract pair. RB-22 shipped first +and made `BriefStore.load()` tolerate a 404 by calling `reset()` once, as a no-op against +the (then) still-seeding backend. This ticket is what makes that branch live: `GET /brief` +now 404s when the owner has no brief yet, and the endpoint no longer performs a persisted +write on a read. + +## What was wrong + +CQ-007 flagged `GET /brief` (`Program.cs:676` → `BriefStore.GetOrCreate`, +`Data/BriefStore.cs:50`) as the one endpoint in the backend where a GET performs a +persisted write, breaking the read/write split every other endpoint respects. The FE +retries GETs automatically (`api-client.provider.ts`, `retry({ count: 2, delay: 500 })`, +GET-only, precisely because GETs are assumed safe), so a transient failure could enter the +create path more than once; `GetOrCreate`'s `lock` prevented a duplicate row today, but the +safety depended on the lock rather than on the endpoint being a query. + +The ticket read as filed against the current code: `GetOrCreate` was exactly at +`BriefStore.cs:50`, `GET /brief` called it exactly as described, and `ResetAndCreate` +already existed and was already the sole body of `POST /brief/reset`. One thing the +ticket's own text did not mention: `BriefStore.GetOrCreate` had a **second** call site, +`GET /brief/preview` (`Program.cs:769`, excluded from the OpenAPI doc — a hand-written FE +`fetch`, same seam as uploads). Splitting `GetOrCreate` away necessarily touches that +call site too, or the file does not compile. See "What changed" below — this was a forced +consequence of the split, not a new business decision, and it is reported here rather than +silently worked around. + +## What changed + +| File | Change | +| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `backend/src/BigRegister.Api/Data/BriefStore.cs` | `GetOrCreate` removed. New `Get(string owner): BriefEntity?` — pure query, `lock`-guarded like every other method in this file for consistency, no write. `ResetAndCreate` is untouched. | +| `backend/src/BigRegister.Api/Program.cs` | `GET /brief`: calls `BriefStore.Get`; returns `Results.NotFound()` when null, `Results.Ok(ToView(ctx, e))` otherwise; declares `.Produces(StatusCodes.Status404NotFound)` (the same bare-404 pattern already used at 17 other call sites in this file). `GET /brief/preview`: same `Get` + 404 treatment — forced by the split (see above), not a scope decision made independently. | +| `backend/src/BigRegister.Api/Data/AppDbContext.cs` | One comment updated (`GetOrCreate's invariant` → `ResetAndCreate's invariant`) — the unique index on `Owner` it annotates is unchanged. | +| `backend/tests/BigRegister.Tests/BriefEndpointTests.cs` | New `Get_returns_404_and_writes_no_row_when_no_brief_exists_for_the_owner` (the DoD-required test). The `Get()` seeding helper, used by nearly every other test in the file, renamed to `SeedBrief()` and changed to create the brief explicitly via `POST /brief/reset` instead of relying on `GET /brief`'s old side effect. One test renamed (`Get_creates_a_draft_with_expected_sections_locked_and_empty` → `SeedBrief_creates_a_draft_with_expected_sections_locked_and_empty`) — it asserts on the shape of a freshly created brief, which is now `SeedBrief()`'s job, not `GET`'s. | +| `backend/tests/BigRegister.Tests/PreviewEndpointTests.cs` | Two tests explicitly create the brief (`POST /brief/reset`) before hitting `/brief/preview`, instead of relying on the old `GET /brief` implicit create. | +| `backend/tests/BigRegister.Tests/OrgTemplateEndpointTests.cs` | Five call sites (three bare seeding `GetAsync` calls, two `GetFromJsonAsync` calls used as seeding) changed to an explicit `POST /brief/reset` first. One call site (`Sent_brief_keeps_its_pinned_template_after_a_republish`, reading a brief already created and sent by the shared `WalkBriefToSentThenRepublish` helper) needed no change — a brief already exists by the time it runs. | +| `backend/tests/BigRegister.Tests/RouteInventoryTests.cs` | Two `AllowList` reason strings updated (`GetOrCreate` → `Get`, 404 noted) — documentation text only, not itself a check the test enforces beyond "some reason is on record". | +| `e2e/brief-v2.spec.ts` | One header comment updated to name the current methods and to state explicitly that this spec's own first click ("Opnieuw beginnen (demo)") is fixture setup, not a workaround for the new 404 — see "e2e and seeding paths" below. | +| `libs/shared/src/infrastructure/api-client.ts` | Regenerated (`npm run gen:api`). `briefGET()` gains a `status === 404` branch. See "The generated client" below for the shape it actually took. | +| `docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md` | RB-23's status cell: `open` → `implemented`. | + +No `apps/ssp/src/app/brief/**` file was touched — RB-22's `BriefStore.load()` recovery and +`BriefAdapter.load()`'s `BriefLoadFailure`/`isHttpNotFound` are unchanged, per this +ticket's explicit scope. + +## The generated client + +RB-22's handoff note predicted `briefGET()` would regenerate "throwing the parsed +`ProblemDetails` (matching the shape most other endpoints already use)". That did not +happen, and the actual result is still correct. `Results.NotFound()` (this ticket's +implementation, and the pattern used at every one of the 17 other bare-404 call sites in +`Program.cs` — none of them use `ProducesProblem`/a typed body) declares a 404 with **no** +response body schema. With nothing to parse into, NSwag emits a generic branch that throws +a plain `SwaggerException` carrying `status: 404` — the same shape `briefGET()` already +threw before this ticket, for the same reason (no declared 404 body). `BriefAdapter.load()`'s +`isHttpNotFound` predicate (`(e as {status?:unknown}).status === 404`) already tolerates +both a `SwaggerException` and a parsed `ProblemDetails`, by design, precisely so this +detail would not matter — RB-22's own comment says as much. No FE follow-up was needed, and +none was made. + +## Judgement calls + +- **`GET /brief/preview` also moved off `GetOrCreate`, to `Get` + 404.** Not mentioned in + the ticket text, but unavoidable: `GetOrCreate` no longer exists once split, and this + was its only other caller. The alternative — leaving a private, undocumented + `GetOrCreate`-shaped helper only for this one endpoint — would have reintroduced + exactly the GET-writes-on-read pattern CQ-007 is about, in the one place nobody would + think to look for it. Returning 404 there too keeps both `/brief` GETs behaving the + same way. In the running app this is unreachable in practice: the preview button + only renders inside the brief page's `@if (loaded(); as s)` block + (`apps/ssp/src/app/brief/ui/brief.page.ts`), which by construction only shows once + `BriefStore.load()` has already succeeded — including via RB-22's 404-recovery branch. + So a brief always exists by the time a real user can trigger `/brief/preview`; the 404 + path there is a defensive consequence of the type split, not a new user-facing + behaviour anyone will hit. +- **`BriefStore.Get` keeps the `lock (_gate)` wrap**, even though a plain SQLite read + does not strictly need the same mutual exclusion a write does. Every other method in + this file, including the pre-existing `ApplicationStore.Get`-style query in the + sibling store, locks unconditionally — matching that convention was judged more + valuable than a lock-free read this ticket did not need to justify removing. +- **Existing test changes create the brief via `POST /brief/reset`, not a new + `BriefStore.Get`/`ResetAndCreate` direct call from the test.** Going through the HTTP + endpoint (as the old `Get()` helper always did) keeps the tests exercising the real + request pipeline (identity resolution, `ToView` mapping) rather than reaching around + it — the same reasoning that already justified an `IClassFixture` + HTTP-level test suite in the first place. + +## e2e and seeding paths + +- **`e2e/brief-v2.spec.ts`** is the only e2e spec that reaches `/brief`. It already opens + `/brief?role=drafter` and immediately clicks "Opnieuw beginnen (demo)" (`POST +/brief/reset`) before asserting anything — a deliberate fixture reset, not a + workaround. With this ticket live, the page's first `GET /brief` on the fresh + per-run database (WP-74) now 404s; RB-22's `BriefStore.load()` recovers from that by + calling `reset()` once, so the page still renders correctly, and the spec's own + explicit reset click still runs on top of that (harmless — resetting an + already-fresh brief). No behavioural change to the spec was needed; one comment was + updated to say this explicitly rather than leave it to be re-derived. +- **Storybook**: no `brief.page.stories.ts` exists, and none of the eleven `brief/ui/**` + component stories call `HttpClient`/`fetch`/`ApiClient` — every story supplies data + through component `input()`s, per the house convention (design-system/component + stories are not live-network integration tests). Nothing in Storybook depended on + `GET /brief`'s old seeding behaviour. + +## The double round-trip — verdict + +CQ-007 named this its least certain point: a first-ever visit to `/brief` now costs a 404 +followed by a `reset()` call, instead of one request that both creates and returns the +brief. **Shipped as-is; the cost is acceptable.** Three reasons: + +1. **It happens once per browser tab, ever, for one demo entity.** `BriefStore`'s + `hasRecoveredFromMissingBrief` flag (RB-22) makes the 404 unreachable again for the + life of the store instance; a real deployment has one brief per zorgverlener, created + the first time that person ever opens the page. This is not a cost paid on every + page load, or even every session — a page reload still 404s once if the flag reset + with the page, but the underlying row is already there by then, so the _second_ call + in the pair — `reset()` — is now hitting an existing row rather than truly + first-creating one, and returns just as fast as `Get` would have. +2. **An extra round-trip is not an extra spinner.** `BriefStore.load()`'s failure + handling for `notFound` calls `reset()` and applies the result through the same + `applyLoadedView` the success path uses — there is no intermediate "not found" UI + state rendered to the user between the two calls; the page shows its loading state + once, for the combined duration of both requests. +3. **The alternative was rejected, not merely deprioritized.** CQ-007's own + documentation-only alternative — leave `GetOrCreate` in place, just write down that + the GET seeds on first call — was rejected outright by agent 07 in `99-backlog.md`: + "a non-idempotent GET must be visible in the code, not only in a ticket." Given that, + the only way to remove the mixing is some version of this two-call shape; a + single-call alternative would mean either GET creates (the defect) or `POST +/brief/reset` runs unconditionally on load (destructive — it deletes an existing + brief, unacceptable for anyone with real content already saved). + +## The once-only guard's lifetime — re-verified + +RB-22 flagged this as worth re-checking once a real 404 could occur in production +traffic, not only in a test's fake adapter. Having now made the 404 real: `hasRecoveredFromMissingBrief` +is a private field on `BriefStore`, which is `providedIn: 'root'` — one instance per +browser tab (per CLAUDE.md's "shared cross-page state = one root singleton" convention), +reset only by a full page reload. That lifetime is still correct for what the flag +guards: it exists to stop a _second, separate_ `load()` call in the same tab session from +re-triggering `reset()` (e.g. a caller retrying navigation after the first recovery +already ran) — not to remember "this owner has a brief" across reloads or across owners, +which is the server's job (`BriefStore.Get` returning non-null). A page reload correctly +starts the guard over: the first `load()` after a reload will find the now-existing row +via a plain `GET` (no 404, no `reset()` call at all), so the flag never actually gets +exercised a second time in the reload case either. No FE change was needed or made. + +## Verification + +- **Verified red without the fix.** Temporarily (via `Edit`, never `git checkout`) + restored `BriefStore.GetOrCreate` alongside the new `Get`, and pointed `GET /brief` in + `Program.cs` back at `GetOrCreate`. Ran the new test alone: + ``` + BigRegister.Tests.BriefEndpointTests.Get_returns_404_and_writes_no_row_when_no_brief_exists_for_the_owner [FAIL] + Assert.Equal() Failure: Values differ + Expected: NotFound + Actual: OK + ``` + Restored the real fix with a second `Edit` (removed the temporary `GetOrCreate`, + pointed `GET /brief` back at `Get` + 404) and reran: green. +- Full backend suite after the fix: **262/262 passing**, plus the one known, + pre-existing, container-dependent failure + (`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`, + "Connection refused (localhost:8000)") — not this ticket's bug, does not run under + `npm run ci`, reproduces on a clean tree with no OpenZaak container running. +- `npm run gen:api`: the client changed (`libs/shared/src/infrastructure/api-client.ts`, + `briefGET()` gains a `status === 404` branch — 4 lines). Regenerated and committed; + see "The generated client" above for why the shape differs from RB-22's prediction and + why that difference is harmless. +- `npm run ci` (foreground, no background/Monitor): see result below. + +## What this ticket did not touch + +`apps/ssp/src/app/brief/application/brief.store.ts`, `brief.store.spec.ts`, and +`apps/ssp/src/app/brief/infrastructure/brief.adapter.ts` are unchanged — RB-22's FE logic +was already correct and already tested against exactly this contract, per this ticket's +explicit scope. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-24.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-24.md new file mode 100644 index 0000000..599034b --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-24.md @@ -0,0 +1,178 @@ +# RB-24 — `libs/shared/upload` moves into `infrastructure/`/`domain/`/`application/`; the depcruise carve-out is deleted + +Status: **implemented** · 2026-08-27 · Source finding: `06-adr-conformance.md` ADR-C-002 · +`99-backlog.md` RB-24, "Merges" table row for RB-25/26/27 + +## What was wrong + +`libs/shared/src/upload/` held five files outside the folder-per-layer convention every +other context follows. `upload.adapter.ts` injects `ApiClient` and opens a raw +`XMLHttpRequest` — a genuine network adapter — yet sat outside `infrastructure/`. +`upload.machine.ts` was the only Elm-style machine (of 9 in the repo) outside a `domain/` +folder. The exception was hard-coded into the enforcement itself: +`.dependency-cruiser.base.js`'s `apiclient-infrastructure-only` rule read +`from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' }` — carved around the +violation instead of the violation being fixed, which is why the baseline scan reported 0 +violations despite this. + +## What changed + +| From `libs/shared/src/upload/` | To | +| -------------------------------- | ----------------------------------------------------- | +| `upload.adapter.ts` | `libs/shared/src/infrastructure/upload.adapter.ts` | +| `upload.machine.ts` + `.spec.ts` | `libs/shared/src/domain/upload.machine.ts` (+ spec) | +| `upload-controller.ts` | `libs/shared/src/application/upload-controller.ts` | +| `upload-shell.service.ts` | `libs/shared/src/application/upload-shell.service.ts` | + +All five moves used `git mv`. `libs/shared/src/upload/` no longer exists. + +**Import updates.** 24 consumer files import from `@shared/upload/*` (found with +`grep -rln "shared/upload" apps libs --include=*.ts`, filtered to exclude the unrelated +`@shared/ui/upload/*` component folder, which was not touched). All 24 files' import paths +were rewritten to the new locations (30 import statements total, some files import more +than one symbol). No export was renamed, no file was split, no logic changed in any of +these 24 files beyond the import path string. + +**Within the five moved files**, three had relative imports (`./upload.adapter`, +`./upload.machine`) that now crossed layers and were rewritten to `@shared/*` aliases: +`upload.adapter.ts`'s import of `DocumentCategory` from `./upload.machine` → +`@shared/domain/upload.machine`; `upload-controller.ts`'s imports of `UploadAdapter` and +`upload.machine` symbols → `@shared/infrastructure/...` / `@shared/domain/...`; +`upload-shell.service.ts` likewise. `upload.machine.spec.ts` needed no import change — it +and `upload.machine.ts` moved into the same `domain/` folder together, so its `./upload.machine` +import stayed correct; `git diff --find-renames` confirms this file as a 0-line-changed +pure rename. + +**The carve-out.** `.dependency-cruiser.base.js`'s `apiclient-infrastructure-only` rule: +`from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' }` → `from: { pathNot: '/infrastructure/' }`, +comment updated to drop the now-false "(+ shared/upload)" parenthetical. One further +consequence: `docs/reference/architecture/dependencies.md`'s "Atomic-layer rules" +paragraph stated the same carve-out in prose ("the generated `ApiClient` is a value only +inside `infrastructure/` (+ `libs/shared/src/upload`)") — corrected in the same diff, since +leaving it would document a rule that no longer exists. + +## A second, real violation the move exposed — fixed, not just reported + +Deleting the carve-out did not by itself make `dep:check` pass. A **separate, +pre-existing** rule — `ui-not-infrastructure` (`ui/`+`layout/` may not import +`infrastructure/` as a value) — had never fired against `upload.adapter.ts`, because +before this move the file's path did not contain `/infrastructure/` at all. Three UI +components were injecting `UploadAdapter` directly: +`apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts`, +`apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts`, +and `apps/ssp/src/app/brief/ui/org-template.page.ts`. Once `upload.adapter.ts` physically +moved into `infrastructure/`, `dep:check` correctly flagged all three: + +``` +error ui-not-infrastructure: .../registratie-wizard.component.ts → libs/shared/src/infrastructure/upload.adapter.ts +error ui-not-infrastructure: .../herregistratie-wizard.component.ts → libs/shared/src/infrastructure/upload.adapter.ts +error ui-not-infrastructure: .../org-template.page.ts → libs/shared/src/infrastructure/upload.adapter.ts +``` + +This is judged in-scope to fix, not a second unrelated finding to merely report, for three +reasons. First, the ticket's own DoD is explicit: "if `dep:check` fails after the +deletion, the move is incomplete, so fix the move rather than restoring the clause." +Second, all three call sites used `UploadAdapter` for exactly one thing — +`.contentUrl(documentId)`, a thin wrapper around the adapter's own already-exported, +injection-free pure function `uploadContentUrl(documentId)` (its doc comment: "Pure (no +injection) so a store can build a letterhead-logo `src` without pulling `ApiClient` into +its dependency graph" — written for precisely this case). `apps/ssp/src/app/brief/application/brief.store.ts` +already used that pure function directly; the three UI files had independently reinvented +`inject(UploadAdapter)` + `.contentUrl()` instead. Third, the fix is mechanical and stays +inside the ADR's own established idiom — no new architecture, no touch to any RB-25/26/27 +target: + +- `libs/shared/src/application/upload-controller.ts` — the object `createUploadController` + returns gained one more method, `previewUrlFor(documentId)`, built on the existing pure + `uploadContentUrl`. Both wizard components already hold a `createUploadController` + instance (`uploadCtl`) for their other upload effects; their `previewUrlFor` field now + delegates to `uploadCtl.previewUrlFor` instead of injecting `UploadAdapter` itself. +- `apps/ssp/src/app/brief/application/org-template.store.ts` (already injects + `UploadAdapter` legitimately — it's application layer) gained one more computed-style + field, `previewUrlFor`, on the same pure `uploadContentUrl`. `org-template.page.ts` now + reads `this.store.previewUrlFor` instead of injecting `UploadAdapter`. + +No behaviour changed: `uploadContentUrl(id)` and `uploadAdapter.contentUrl(id)` return the +identical string (the method is a one-line pass-through to the function), and the +`demo-*` short-circuit in the two wizards moved into `upload-controller.ts`'s new method +verbatim. + +## Verification + +- **`upload.machine.spec.ts` passes unchanged.** `git diff --find-renames=30%` shows it as + a 0-insertion/0-deletion pure rename — no content changed, including its own imports + (both files moved into `domain/` together, so its `./upload.machine` relative import + needed no edit). No spec content changed anywhere in this ticket. +- `npm run dep:check`: **passes for both apps** with the carve-out clause removed — + `✔ no dependency violations found (344 modules, 1200 dependencies cruised)` (ssp), + `✔ no dependency violations found (226 modules, 588 dependencies cruised)` (behandelportal). +- `npm run lint`: clean. +- `npm test`: **43+6+24+4 = 77 test files, 274+37+138+23 = 472 tests, all passing** + (ssp / behandelportal / shared / beheer). +- `npm run build`: both apps build (pre-existing, unrelated warnings about + `/cibg-huisstijl/css/huisstijl.min.css` and `/letter.css` not being found at build time — + present before this ticket, vendored assets resolved at serve/deploy time, not a + regression from this move). +- **Coverage, `libs/shared/src/domain/`** (`npm run test:coverage` narrowed to `shared`): + the folder now includes `upload.machine.ts` at 98.82% statements / 91.8% branches / 100% + functions / 98.36% lines (84/85, 56/61, 28/28, 60/61) — the "well-specced machine" ADR-C-002 + predicted landing in a folder the baseline reported at "0% spec reach across 3 files" + (`capability.ts`, `feature-flag.ts`, `role.ts`, which this ticket does not touch and which + remain unspecced — that gap is pre-existing and out of this ticket's scope). + +## Non-TypeScript references to the old path — findings + +Checked `.storybook-ssp/`, `.storybook-behandelportal/`, `angular.json`, no vitest config +file exists separately (Angular's builder owns test config), both `.dependency-cruiser.*.js` +files, and `libs/shared/docs/*.mdx`. + +- **Storybook config, angular.json, dependency-cruiser app configs**: no reference to + `shared/upload` or `libs/shared/src/upload` in any of these. Nothing to change. +- **`.dependency-cruiser.base.js`**: the one real reference — the carve-out clause itself, + deleted (see above). +- **`docs/reference/architecture/dependencies.md`**: one prose reference to the same + carve-out, corrected in this diff (see above) since it directly describes the rule this + ticket edits. +- **`libs/shared/docs/*.mdx`**: no `.mdx` file references `libs/shared/src/upload` or + `@shared/upload`. `atomic-design.mdx` and `machines.mdx` mention `upload.machine.ts` and + `shared/ui/upload/...` by filename/short-path only, never the full old directory path — + both remain accurate (the filename didn't change; `ui/upload/` is the untouched sibling + folder). +- **`apps/ssp/src/locale/messages.xlf`, `messages.en.xlf`, `apps/behandelportal/src/locale/messages.en.xlf`**: + each carries a handful of `src/app/shared/upload/upload.machine.ts` + /`upload.adapter.ts` annotations — auto-generated by Angular's `$localize` extractor, + informational only (they tell a translator where a string originated; they are not + read by the build or by `i18nMissingTranslation`). Left as-is: regenerating them is + `npm run extract-i18n`'s job for the source-locale file and does not touch the + hand-maintained `messages.en.xlf` translations at all, and this ticket's scope is the + move plus import updates, not a translation-tooling refresh. They will self-correct + the next time `extract-i18n` runs for an unrelated reason. +- **`docs/project/backlog/*.md`, `docs/project/refactor-backlog-setup/refactor-backlog/*.md`**: + several planning/history documents (WP-25, WP-74, the baseline scan, `02-testability.md`, + `06-adr-conformance.md`, `07-bio2-compliance.md`, `99-backlog.md`, `rb-01.md`, `rb-09.md`) + reference the old path — expected, since most of them describe or cite the violation + this ticket resolves, as history. Not edited, except `99-backlog.md`'s RB-24 status cell + (see below). + +## What RB-25/26/27 now find where + +- **RB-25** (`UPLOAD_TRANSPORT` injection token, replacing `inject(KeepaliveTransport)`): + `KeepaliveTransport` and `UploadShellService` are both now in + `libs/shared/src/application/upload-shell.service.ts` (unchanged content, new path). The + token belongs in `application/` alongside them — nothing about the token's shape or + location changes because of this move. +- **RB-26** (`planFileSelection` in `upload.machine.ts`): the machine is now + `libs/shared/src/domain/upload.machine.ts`. `createUploadController`'s `onFileSelected` + callback — the accept/reject decision RB-26 targets — is in + `libs/shared/src/application/upload-controller.ts` (also renumbered, otherwise + unchanged; it also now exports one more method, `previewUrlFor`, added by this ticket — + see above). RB-26 should extend `upload.machine.ts` in its new location; no import path + in that file needs touching beyond what this ticket already did. +- **RB-27** (`uploadOutcome(status, responseText)` out of the XHR closure): the XHR closure + is in `libs/shared/src/infrastructure/upload.adapter.ts`'s `xhrUpload` method — same + file, same method, new path only. `load`/`error`/`abort` handlers, `parseError`, and + `genericError` are all still exactly where they were, just under `infrastructure/`. + +## `npm run ci` + +Result and step count reported in the final answer. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-25.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-25.md new file mode 100644 index 0000000..bc4949e --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-25.md @@ -0,0 +1,130 @@ +# RB-25 — `UPLOAD_TRANSPORT` injection token replaces `inject(KeepaliveTransport)` + +Status: **implemented** · 2026-08-28 · Source finding: `02-testability.md` TE-003 · +`99-backlog.md` RB-25, "Merges" table row for RB-25/26/27 · Depends on +`implementation/rb-24.md` (the move that put this file at its current path) + +## What was wrong + +`libs/shared/src/application/upload-shell.service.ts` defines `export interface +UploadTransport` and documents it as the swap seam for upload transport. It then binds +`private transport: UploadTransport = inject(KeepaliveTransport)` — the concrete class, +which is `@Injectable` but not exported. A spec cannot reference the class to override its +provider, and cannot provide against the interface either, because an interface is not a +DI token. The port existed on paper only. + +The consequence TE-003 measures: `upload()`, `delete()`, `cancel()`, and `pollReturning()` +— the methods that translate transport and adapter outcomes into `UploadMsg`s — had no +spec at all. `libs/shared/upload` sat at 52.0% line / 50.0% branch, and +`upload-shell.service.ts` was one of the two unreached non-`ui/` files. + +## What changed + +One file plus one new spec, exactly as scoped: + +- `libs/shared/src/application/upload-shell.service.ts`: added + + ```ts + export const UPLOAD_TRANSPORT = new InjectionToken('UPLOAD_TRANSPORT', { + providedIn: 'root', + factory: () => inject(KeepaliveTransport), + }); + ``` + + copied verbatim from TE-003's own fix, placed directly under the `KeepaliveTransport` + class it wraps. `UploadShellService.transport` now reads + `inject(UPLOAD_TRANSPORT)` instead of `inject(KeepaliveTransport)`. This is the same + interface-plus-token shape as `SessionPort`/`SESSION_PORT` + (`libs/shared/src/application/session.port.ts`), the repo's one other explicit port. + `KeepaliveTransport` itself is untouched: still a private, unexported `@Injectable`, and + still the default factory's target — a real app gets the exact same singleton instance + it always did. + +- `libs/shared/src/application/upload-shell.service.spec.ts` (new): a recording fake + `UploadTransport` (records every `send()` call, exposes `resolveDone`/`rejectDone` per + call so a test drives the returned `Promise` by hand) provided against `UPLOAD_TRANSPORT`, + plus a fake `UploadAdapter` (a plain object with `vi.fn()` for `status`/`deleteDocument`) + provided against the already-exported `UploadAdapter` class. 16 specs across all four + target methods: + - `upload()` — `UploadQueued` carries the transport's `backgroundSyncAvailable`; + `onProgress` → `UploadProgress`; a resolved transport → `UploadComplete`; a rejected + transport → `UploadFailed` with the rejection reason; a rejection with the + `UPLOAD_ABORTED` sentinel dispatches nothing. + - `cancel()` — calls the stored cancel function for an in-flight upload and forgets it + (a second `cancel()` on the same id is a no-op); an unknown id is a no-op. + - `delete()` — `UploadDeleting` then `UploadDeleteComplete` on success; + `UploadDeleteFailed` with the server's `detail` on a ProblemDetails rejection; falls + back to an empty reason when the rejection carries no `detail`. + - `pollReturning()` — skips the adapter call entirely for an empty upload list; + dispatches `BackgroundUploadsReturned` filtered to only the items the server reports + `complete` with a `documentId`; dispatches nothing when nothing has arrived. + +No other file changed. `UploadAdapter`, `upload.machine.ts`, and `upload-controller.ts` +are untouched, per the ticket's file-scope fence (RB-26 and RB-28 are concurrently in +adjacent files). + +## Verification + +- **Coverage, `upload-shell.service.ts`** (`npm run test:coverage` narrowed to `shared`, + read from `coverage/shared/lcov.info`): + + | Metric | Before | After | + | --------- | ------ | -------------- | + | Lines | 0% | 88.57% (31/35) | + | Branches | 0% | 85.00% (17/20) | + | Functions | 0% | 87.50% (14/16) | + + "Before" is 0% across the board: no spec file for this service existed prior to this + ticket (confirmed by `grep -rln UploadShellService --include=*.spec.ts`, which returns + only the new spec), matching TE-003's "unreached" classification. The remaining + uncovered lines are the `KeepaliveTransport` class body (`send()`, its `inject`) and the + `UPLOAD_TRANSPORT` factory closure itself — both require a real `XMLHttpRequest`/real DI + resolution to exercise and are intentionally out of this ticket's scope: TE-003's fix is + the seam, not a rewrite of the transport it wraps. + +- **Red-proof.** Edited `upload()`'s success branch from + `dispatch({ type: 'UploadComplete', localId: req.localId, documentId })` to + `dispatch({ type: 'UploadFailed', localId: req.localId, reason: 'BROKEN-FOR-RED-PROOF' })`, + ran `ng test shared`. Result: 1 failed / 150 passed, with + + ``` + AssertionError: expected "vi.fn()" to be called with arguments: [ { type: 'UploadComplete', …(2) } ] + Received: + 1st vi.fn() call: [{ "backgroundSync": false, "localId": "l1", "type": "UploadQueued" }] + 2nd vi.fn() call: [{ "localId": "l1", "reason": "BROKEN-FOR-RED-PROOF", "type": "UploadFailed" }] + ``` + + at `upload-shell.service.spec.ts:79` (the `UploadComplete` assertion). Re-applied the + original line with a second edit (not `git checkout`); `git diff` against HEAD shows + only the intended token change — the red edit left no trace. Re-ran: 151/151 green. + +- `npm run ci`: result and step count in the final answer. + +## Judgement call + +- **The fake `UploadAdapter` is a plain object, not a class extending `UploadAdapter`.** + `UploadAdapter` is exported and already usable as a DI token (it always was — TE-003's + gap was specific to `KeepaliveTransport`, not `UploadAdapter`), so `delete()` and + `pollReturning()` (which never touch `this.transport`) were technically fakeable before + this ticket by providing a fake `UploadAdapter`. Nobody had written that spec, though, + and `upload()`/`cancel()` still needed `UPLOAD_TRANSPORT` regardless (they populate and + drain the `inflight` map via `transport.send()`). The spec fakes both seams together so + all four methods are exercised as one coherent suite, per the ticket's own framing + ("provide a recording fake transport and assert the message translation in `upload()`, + `delete()`, `cancel()` and `pollReturning()`"). + +## Handoff to RB-27 + +RB-27 extracts `uploadOutcome(status, responseText)` out of the XHR closure in +`libs/shared/src/infrastructure/upload.adapter.ts`'s `xhrUpload` — a different file, +untouched by this ticket. The token makes RB-27's optional half (moving the +`currentScenario()` branch into `KeepaliveTransport.send()`) no easier and no harder than +before: `KeepaliveTransport` is still unexported and its `send()` body is unchanged, one +line (`inject(UploadAdapter)`, `return this.adapter.xhrUpload(req, onProgress)`). If RB-27 +takes that optional move, it can inject `UPLOAD_TRANSPORT` in its own spec to assert the +scenario branch without touching this file — the seam is there and provided-in-root, but +RB-27 does not need to change anything here to use it. + +## `npm run ci` + +Result and step count reported in the final answer. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-26.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-26.md new file mode 100644 index 0000000..a1653dc --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-26.md @@ -0,0 +1,109 @@ +# RB-26 — move the accept/reject decision into `planFileSelection` (`upload.machine.ts`) + +Status: **implemented** · 2026-08-28 · Source finding: `02-testability.md` TE-004 · +`99-backlog.md` RB-26 · Depends on `implementation/rb-24.md` (moved the upload files into +`infrastructure`/`domain`/`application`) + +## What was wrong + +TE-004: `createUploadController` does three `inject()` calls, registers an `effect()`, and +adds a `window` focus listener, all before it returns. A spec must run inside a `TestBed` +injection context with `UploadAdapter`, `UploadShellService`, and `DestroyRef` all +satisfied to reach anything inside it. What sits behind that cost is real policy: +`onFileSelected` decides, per file, whether to reject it with reason `'multiple'`, reject +it with a `rejectReason` result, or start its upload — a decision over +`(categories, categoryId, files)` with no I/O in it. `rejectReason`, the predicate that +decision calls, was already exported and spec'd; the decision that calls it was not. + +## What changed + +`libs/shared/src/domain/upload.machine.ts` gains one pure export: + +```ts +export function planFileSelection( + state: UploadState, + categoryId: string, + files: { name: string; type: string; size: number }[], +): UploadMsg[]; +``` + +It takes plain `{ name, type, size }` objects, not `File` — a spec needs no DOM. The body +is the old `onFileSelected` decision, moved: an unknown category plans nothing; too many +files for a single-file category plans one `FileRejected` with reason `'multiple'` and +skips the per-file checks; otherwise each file is judged by `rejectReason` and plans +either a `FileRejected` or a `FileSelected` entry, one entry per input file, in order. + +`libs/shared/src/application/upload-controller.ts`'s `onFileSelected` now maps `selected: +File[]` to plain candidates, calls `planFileSelection`, and executes the result: a +`FileRejected` entry dispatches as-is; anything else starts the upload for the file at +that same array index (`crypto.randomUUID()`, `files.set()`, `shell.upload()` — the three +things that must stay impure and stay in the controller). No other method changed. +`previewUrlFor` (added by RB-24) is untouched. + +## The `localId` placeholder — a deliberate, contained choice + +An accepted file's planned `FileSelected` entry carries `localId: ''`. A real id needs +`crypto.randomUUID()`, and the ticket is explicit that call stays in the controller, not +the domain. The controller reads only each entry's `.type` to route it — it dispatches a +`FileRejected` entry verbatim, but for a `FileSelected` entry it discards the entry and +calls `start(categoryId, selected[i])`, which builds its own message with a real id. +The placeholder is therefore never dispatched. This was the only way found to keep the +return type exactly `UploadMsg[]` (as the ticket's own code sketch specifies) while still +letting the plan carry a per-file, order-preserving "start this one" signal — the +`FileRejected` variant carries no file identity (state keys rejections by category only), +so position in the returned array is what the controller uses to find the matching +original `File`. A discriminated `{ kind: 'reject' | 'start'; msg? }` return would avoid +the placeholder but was not built, since the ticket's signature is explicit and the +placeholder design meets it without changing behaviour. + +## Behaviour + +Same messages, same order, for the same inputs. Tracing all three original branches: + +- Unknown category: original returns without dispatching; new code calls `planFileSelection` + (returns `[]`), then `forEach` over an empty array — no dispatch, no start. +- Too many files for a single-file category: original dispatches one `FileRejected` + ('multiple') and returns; new code gets a one-entry plan and dispatches that one entry — + `forEach` never reaches indices past the plan's length, so no file starts. +- Per-file loop: original dispatches `FileRejected` or calls `start` for each file, in + order; new code's plan has one entry per file, in the same order, and the controller + dispatches or starts at each index identically. + +## Testing + +`libs/shared/src/domain/upload.machine.spec.ts` gained a `planFileSelection` describe +block: unknown category (plans nothing), the `'multiple'` batch rejection, a passing +single file against a single-file category, `rejectReason`'s two reject cases (`'type'`, +`'size'`) reached through the plan, the accept case's exact `FileSelected` shape +(including the `localId: ''` placeholder), and a mixed multiple-file case asserting +order (`['FileSelected', 'FileRejected', 'FileSelected']`). + +**Proved red before green**, per the ticket's instruction not to use `git checkout`: +temporarily replaced the function body with a stub returning `[]` unconditionally (an +edit, not a revert), ran `ng test shared`, and got: + +``` +Test Files 1 failed | 23 passed (24) + Tests 6 failed | 139 passed (145) +``` + +The 6 failures were the `'multiple'` rejection, both `rejectReason` cases, the accepted- +file shape, and the mixed-order case — every outcome that depends on the real branching, +each failing with `expected [] to deeply equal [...]`. The unknown-category case passed +even against the stub, since both the stub and the real implementation return `[]` there +— expected, not a gap, since that branch has no policy to exercise. A second edit restored +the real body; the same run returned to `24 passed / 145 passed`. + +## Scope held + +No change to `createUploadController`'s construction, the `effect()`, or the `window` +listener — those are RB-25/RB-27's targets (RB-25 is `UploadShellService`, running +concurrently in the same commit window; RB-27 is `upload.adapter.ts`'s XHR closure). +Neither file was touched. The controller's public surface (`previewUrlFor`, +`onFileSelected`, `onRemove`, `onRetry`, `onDelete`, `onChannelChange`) is unchanged in +name and signature, and the organism that calls it (``) needed no +change. + +## `npm run ci` + +Result and step count reported in the closing message. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-27.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-27.md new file mode 100644 index 0000000..596a495 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-27.md @@ -0,0 +1,193 @@ +# RB-27 — `uploadOutcome` extracted from the XHR `load` closure + +Status: **implemented** · 2026-08-28 · Source finding: `02-testability.md` TE-005 · +`99-backlog.md` RB-27, "Merges" table row for RB-25/26/27 · Depends on +`implementation/rb-24.md` (the move that put this file at its current path) and +`implementation/rb-25.md` (handoff paragraph read before deciding the optional half) + +## What was wrong + +`libs/shared/src/infrastructure/upload.adapter.ts`'s `xhrUpload` constructs +`new XMLHttpRequest()` directly and attaches its `load` listener inline. The listener +body held the actual decisions: 2xx-vs-not, `JSON.parse` of the response body with a +fallback to a generic error, and (on a non-2xx status) ProblemDetails mapping via the +un-exported `parseError`. None of it is reachable without stubbing the XHR global, so +the interpretation logic had no spec. + +TE-005's baseline citation: **LH 5 / LF 64 (7.8% line), BRH 3 / BRF 57 (5.3% branch)**. +The file was counted "reached" in the module total only because another spec imports +it — essentially nothing in it executed. + +## What changed + +One function extracted from the `load` listener, in the same file: + +```ts +export function uploadOutcome( + status: number, + responseText: string, +): Result { + if (status < 200 || status >= 300) return err(parseError(responseText)); + try { + return ok({ documentId: JSON.parse(responseText).documentId }); + } catch { + return err(genericError()); + } +} +``` + +placed next to `genericError`/`parseError` (below the class, above the dev +`simulateUpload`). It contains exactly the 2xx-vs-not check, the `JSON.parse`-with- +fallback, and the ProblemDetails mapping — the three decisions TE-005 names. The `load` +listener is now a two-line dispatch: + +```ts +xhr.addEventListener('load', () => { + const outcome = uploadOutcome(xhr.status, xhr.responseText); + outcome.ok ? resolve(outcome.value) : reject(outcome.error); +}); +``` + +`Result`, `ok`, `err` are imported from `@shared/kernel/fp` (the repo's one `Result` +type, already used the same way by `libs/shared`'s other infrastructure adapters). +`parseError` and `genericError` are untouched — `uploadOutcome` calls them exactly as +the old listener body did, so their own behavior (ProblemDetails detail extraction, +generic fallback) is unchanged. + +## Abort-vs-error: left as a separate, smaller concern + +TE-005 names abort-vs-error disambiguation in the same sentence as the extraction +target, but its proposed signature — `uploadOutcome(status: number, responseText: +string)` — has no way to express "the request was aborted before any response +arrived." That is a real, structural mismatch, not an oversight to route around: + +- `uploadOutcome` runs inside the `load` listener, which fires only when the browser + received a complete HTTP response — it has a `status` and a `responseText` by + construction. +- The `abort` listener fires instead of `load` when `xhr.abort()` was called + client-side. There is no HTTP response at that point — no status, no body — so + folding it into `uploadOutcome`'s signature would mean inventing a fake status (e.g. + `0`) to stand for "not actually a response," which trades one implicit convention for + another and makes the pure function's contract lie about what it receives. + +The existing code already expresses this as the smallest form it can take: + +```ts +xhr.addEventListener('abort', () => (aborted ? reject(UPLOAD_ABORTED) : reject(genericError()))); +``` + +one ternary, deciding between two sentinels based on which native event fired and +whether `cancel()` was called first — not on response content. It is not a second +`uploadOutcome`-shaped decision hiding in a closure; it is a one-line dispatch already. +Extracting it into its own named function would add a call site and an import for a +single ternary with no reachable-only-via-DOM logic left inside it. Left in place, as +DoD point 2 allows. + +## Spec added, verified red + +`libs/shared/src/infrastructure/upload.adapter.spec.ts` (new file) — plain +`describe`/`it`, no `TestBed`, no DOM, no XHR stub, matching the DoD's explicit +"that is the entire point." Five cases: + +1. 2xx status with a valid JSON body → `{ ok: true, value: { documentId } }`. +2. 2xx status with an unparseable body → falls back to the generic `UPLOAD_FAILED` + text (the `JSON.parse`-with-fallback branch). +3. Non-2xx status with a ProblemDetails body → the `detail` field, via `parseError`. +4. Non-2xx status with a body that is not ProblemDetails-shaped → falls back to the + generic text. +5. The 200/300 boundary: 299 is success, 300 is not. + +`UPLOAD_FAILED`'s text is not exported (unchanged by this ticket), so the spec holds +its own copy of the Dutch string as a local constant with a comment pointing at the +source — the same trade every other spec makes when asserting against `$localize` +constants that never leave their module ($localize`strings are English-first prose +only where the source is`nl`, so this is the source text as written, not a stand-in). + +**Red-proof.** Edited `uploadOutcome`'s body down to a single line — +`return ok({ documentId: JSON.parse(responseText).documentId });`, dropping the +status check and the try/catch — with an `Edit` (not `git checkout`). Ran +`ng test shared`. Result: 4 of the 5 new specs failed: + +``` +SyntaxError: Unexpected token 'o', "not json" is not valid JSON + ❯ uploadOutcome libs/shared/src/infrastructure/upload.adapter.ts:168:32 + +AssertionError: expected { ok: true, value: { …(1) } } to deeply equal { ok: false, …(1) } +- Expected "error": "Document is al aan een aanvraag gekoppeld.", "ok": false, ++ Received "ok": true, "value": { "documentId": undefined }, + +SyntaxError: Unexpected token 'I', "Internal S"... is not valid JSON + +AssertionError: expected true to be false // Object.is equality +``` + +(only the plain 2xx-valid-JSON case still passed, as expected of a mutant that always +reports success). Re-applied the real body with a second `Edit`; `git diff` against +HEAD shows only the intended net change — the red edit left no trace. Re-ran: +163/163 green. + +## Coverage, `upload.adapter.ts` + +| Metric | Before (TE-005 baseline) | After | +| -------- | ------------------------ | ---------------------- | +| Lines | LH 5 / LF 64 (7.8%) | LH 12 / LF 65 (18.5%) | +| Branches | BRH 3 / BRF 57 (5.3%) | BRH 7 / BRF 59 (11.9%) | + +(`LF`/`BRF` grew by one line and two branches because `uploadOutcome` is new source; +`npm run test:coverage`'s shared run, `coverage/shared/lcov.info`, narrowed to this +file's `SF:` block.) The jump is real but modest in absolute percentage: `uploadOutcome` +itself is now fully exercised (`FNDA:6,uploadOutcome`, both branches of the status +check hit, both the try and the catch path hit), but the class methods +(`categoriesResource`, `status`, `deleteDocument`, `xhrUpload`'s own body, +`simulateUpload`) remain unreached — they need DI/XHR/timers to test and are +out of this ticket's scope, exactly as TE-005 scopes it ("extract the interpretation, +not the transport"). + +## Optional scenario-branch move: not taken + +TE-005 suggests, as an explicitly optional second half, moving the `currentScenario()` +branch from `xhrUpload` up into `KeepaliveTransport.send()` +(`libs/shared/src/application/upload-shell.service.ts`) so `xhrUpload` becomes +transport-only. RB-25's handoff confirms the seam is available (`KeepaliveTransport` +is still unexported, `send()` is still an unchanged one-liner) but not required. + +This ticket does not take that half, for a reason RB-25's handoff does not settle: +the ticket's own **Scope** section restricts this ticket to `upload.adapter.ts` and its +spec only ("RB-24, RB-25, RB-26, RB-28 have all already merged — nothing else in the +upload module is in flight, so you have the folder to yourself"). Moving the scenario +branch requires editing `upload-shell.service.ts` too — exporting `simulateUpload` (or +moving it) out of `upload.adapter.ts` and importing it into the application-layer +`send()` — which is a second file, outside the stated scope. Doing it anyway would also +widen this single-file ticket's diff for an explicitly optional half the ticket itself +says to skip when it "complicates the diff." The dev simulator's behavior is therefore +byte-for-byte unchanged: `xhrUpload` still checks `currentScenario()` first and still +delegates to the untouched `simulateUpload` for `upload-slow`/`upload-fail`, verified by +inspection (the only edit inside `xhrUpload` is the `load`-listener dispatch) and by the +full `shared` suite staying green, including `upload-shell.service.spec.ts`'s existing +scenario-adjacent assertions. + +## Verification + +- `npm run lint`: clean. +- `npm run dep:check`: unaffected — the only new import is `@shared/kernel/fp`, already + the repo's shared `Result` module, imported the same way by other `libs/shared` + infrastructure adapters (no new import direction). +- `npm test` / `ng test shared`: 163/163, across 26 spec files — 5 of those tests are + the new `upload.adapter.spec.ts`, the other 158 across 25 pre-existing files are + unchanged by this ticket. +- `npm run ci`: result and step count reported in the implementing agent's final answer. + +## Batch 5 close-out + +Batch 5 (RB-25 through RB-30) is now fully implemented. For `libs/shared/upload` +(moved to its layered home by RB-24) specifically: `upload.machine.ts` (domain) has its +own spec and `planFileSelection` extracted by RB-26; `upload-shell.service.ts` +(application) has a full spec covering `upload()`/`cancel()`/`delete()`/ +`pollReturning()` via the `UPLOAD_TRANSPORT` token RB-25 added; `upload-controller.ts` +(application) was already spec'd before this batch; `upload.adapter.ts` +(infrastructure) now has `uploadOutcome` as a pure, spec'd seam, though the class's +HTTP-bound methods (categories/status/delete/the XHR transport itself) remain +untested by design — XHR is the one boundary this batch deliberately does not +abstract, per TE-005's own instruction. End to end, every layer of the upload module +that can hold pure logic now does, and has a spec proving it; what is left uncovered is +exactly the DOM/network edge the module exists to wrap, not logic hiding behind it. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-28.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-28.md new file mode 100644 index 0000000..a915435 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-28.md @@ -0,0 +1,180 @@ +# RB-28 — `BLOB_PRESENTER` token unlocks the three blob-to-browser success paths + +Status: **implemented** · 2026-08-28 · Source finding: `02-testability.md` TE-006 · +`99-backlog.md` RB-28 + +## What was wrong + +Three application-layer commands each ended in raw DOM/browser calls that jsdom cannot +meaningfully execute: `StamdataStore.download()` +(`libs/beheer/src/application/stamdata.store.ts`) did `URL.createObjectURL` → +`document.createElement('a')` → `a.click()` → `URL.revokeObjectURL`; +`BriefStore.previewLetter()` (`apps/ssp/src/app/brief/application/brief.store.ts`) and +`OrgTemplateStore.proefbrief()` (`apps/ssp/src/app/brief/application/org-template.store.ts`) +both did `window.open(URL.createObjectURL(blob), '_blank')`. Because the call was the +last statement of each command, TE-006 recorded the whole success path as effectively +unassertable, and `download()`'s two-clause guard (`if (!s || !this.canDownload()) +return;`) as permanently dark on its true branch. + +## What changed + +One new file, `libs/shared/src/application/blob-presenter.ts`, mirroring the +`SESSION_PORT` token already in that folder — an interface, a production +implementation, and an `InjectionToken`: + +```ts +export interface BlobPresenter { + open(blob: Blob): void; + download(blob: Blob, filename: string): void; +} + +const realBlobPresenter: BlobPresenter = { + open(blob) { + window.open(URL.createObjectURL(blob), '_blank'); + }, + download(blob, filename) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + }, +}; + +export const BLOB_PRESENTER = new InjectionToken('BLOB_PRESENTER', { + providedIn: 'root', + factory: () => realBlobPresenter, +}); +``` + +`open()` never revokes the object URL (the tab it opens outlives the call — +`BriefStore.previewLetter`'s original comment already said so and is preserved, +moved onto the token's own doc comment); `download()` does revoke, once the click has +fired. This asymmetry is preserved deliberately, not unified — the two call sites +behaved differently before this ticket and still do. + +Each of the three commands now injects `BLOB_PRESENTER` and calls it instead of the DOM +directly: + +| File | Before (last statement) | After | +| ----------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| `stamdata.store.ts` | `createObjectURL` → `createElement('a')` → `click()` → `revokeObjectURL` (6 lines) | `this.blobPresenter.download(blob, \`${s.table.id}.json\`);` | +| `brief.store.ts` | `window.open(URL.createObjectURL(r.value), '_blank')` | `this.blobPresenter.open(r.value);` | +| `org-template.store.ts` | `window.open(URL.createObjectURL(r.value), '_blank')` | `this.blobPresenter.open(r.value);` | + +`OrgTemplateStore.previewUrlFor` (added by RB-24, a different seam — a document +content URL for an ``, not a blob handoff) is untouched. + +## Tests added + +**`libs/beheer/src/application/stamdata.store.spec.ts`** — a new +`StamdataStore.download (RB-28)` describe block with a recording fake `BlobPresenter`: + +1. Does not call the presenter while `canDownload()` is false because nothing is dirty + yet — the guard's previously-dark true branch, first clause. +2. Does not call the presenter while previewing a date, even with a real edit present — + the guard's true branch, second clause. +3. **The success path**, asserting `toJson(...)`'s exact output reaches the file: reads + the recorded blob's text and compares it byte-for-byte against a direct call to + `toJson(store.table()!, store.rows())`, and asserts the filename is + `professions.json`. + +**`apps/ssp/src/app/brief/application/brief.store.spec.ts`** — the existing +`BriefStore.previewLetter` describe block's success test previously spied directly on +`window.open`/`URL.createObjectURL` (both already jsdom-spyable, since the properties +exist even though calling them for real throws "not implemented"). It now provides the +recording fake via `BLOB_PRESENTER` and asserts `opened` holds exactly the resolved +blob — the same outcome, reached through the new seam instead of monkey-patching two +global browser objects. + +**`apps/ssp/src/app/brief/application/org-template.store.spec.ts`** (new file — +`OrgTemplateStore` had no spec at all before this ticket) — a +`OrgTemplateStore.proefbrief (RB-28)` describe block: the success path (presenter +receives the resolved blob, no error) and the failure path (presenter never reached, +error surfaced). A `Partial` stub with a no-op `categoriesResource` +(status `'idle'`) satisfies the store's constructor effect without touching the +logo-upload sub-state, which these tests do not exercise. + +## Verified red without the fix + +Broke `StamdataStore.download()` with an `Edit` (not `git checkout`): changed the +filename from `` `${s.table.id}.json` `` to `` `${s.table.id}.csv` ``. Ran the new +success-path spec: + +``` +AssertionError: expected 'professions.csv' to be 'professions.json' // Object.is equality + +Expected: "professions.json" +Received: "professions.csv" + ❯ libs/beheer/src/application/stamdata.store.spec.ts:134:36 +``` + +Re-applied the correct filename with a second `Edit`; the full `stamdata.store.spec.ts` +file (6 tests) went green again. + +## Verification + +- **`grep` for remaining DOM blob calls** in all three stores — + `grep -nE "window\.open|createObjectURL|revokeObjectURL|createElement\('a'\)|\.click\(\)"` — + zero matches. The only occurrences of those calls anywhere in `apps`/`libs` are inside + `blob-presenter.ts` itself (checked with a second, unscoped grep — no fourth inlined + handoff exists). +- `npm run lint`: clean. +- `npm run dep:check`: unaffected (no new import direction — `libs/shared` still does not + depend on `libs/beheer`; both `libs/beheer` and `apps/ssp/brief` import the new token + from `libs/shared`, never the reverse). +- `npm test` (all four projects): all pass — ssp 276, behandelportal 37, shared 138, + beheer 26 (up from 23; +3 for the new `download()` describe block). +- Coverage, `npm run test:coverage` narrowed per project: + - `libs/beheer/src/application/stamdata.store.ts` — **before** BRH 15 / BRF 37 + (40.5% branch, confirmed against the current tree, matching TE-006's citation + exactly); **after** BRH 25 / BRF 37 (**67.6% branch**). `libs/beheer/src/application` + has exactly this one file, so the module figure moves the same way. + - `apps/ssp/src/app/brief/application/brief.store.ts` — **before** BRH 39 / BRF 72 + (54.2% branch). This is higher than TE-006's cited 32/64 (50%) because RB-22/RB-23 + already added branches (the 404-tolerance path) since the finding was written — see + "What TE-006 got wrong" below. **After**: BRH 39 / BRF 72, unchanged — swapping the + global-spy assertions for the injected fake changes how the success branch is + reached in the spec, not whether it is reached; it was already covered before this + ticket (see below). + - `apps/ssp/src/app/brief/application/org-template.store.ts` — no spec existed before + this ticket, so there is no meaningful "before" branch figure for it specifically. + **After**: BRH 17 / BRF 77, including both `proefbrief()` branches newly covered. +- `npm run ci` (foreground, `timeout: 600000`, no background/Monitor): result reported + in the implementing agent's final answer. + +## What TE-006 got wrong + +TE-006 states: "`brief.store.spec.ts` demonstrates this exactly: it tests +`previewLetter`'s failure case ... and cannot test the success case." This is not +accurate for the code as it stood at the start of this ticket. The spec already had an +`'opens the composed letter in a new tab on success'` test that used +`vi.spyOn(URL, 'createObjectURL')` and `vi.spyOn(window, 'open')` to assert the success +path — jsdom defines both properties (as functions that throw "not implemented" if +actually invoked), so `vi.spyOn` can already replace them, and the pre-existing test +did. That test passed both before and after this ticket's change; this ticket did not +newly unlock `previewLetter`'s success path, it moved an already-passing assertion off +two hand-spied global browser objects and onto the new injectable seam. `git log +--follow -p` on the spec file shows this test dates to the WP-67 monorepo merge, not to +any of RB-22/23/24. + +The seam is still worth having: `StamdataStore.download()`'s success path (five +DOM/API calls in a row: `createObjectURL`, `createElement`, `.href`, `.download`, +`.click()`, `revokeObjectURL`) is a materially harder thing to spy on faithfully than a +single `window.open` call, and was in fact still dark before this ticket (no +`download()` test of any kind existed). `OrgTemplateStore.proefbrief()` also had no +spec at all. TE-006's diagnosis (three commands share the same class of problem, one +token fixes all three) is sound; only the specific "cannot test" claim about +`previewLetter` overstates what was true for that one call site. Scope was not reduced +because of this — all three call sites are migrated per the ticket's own instruction to +ship them together rather than half-adopt the seam. + +## What this ticket did not touch + +`OrgTemplateStore.previewUrlFor` (RB-24) — confirmed present and unchanged at +`org-template.store.ts:78`. `libs/shared/src/application/upload-shell.service.ts` +(RB-25) and `upload-controller.ts` (RB-26) — not read beyond what RB-24's own note +already described, not edited. `libs/shared/docs/behaviour-spec.mdx` — regenerated by +`npm run gen:behaviour-spec` (part of `npm run ci`) to reflect the new/renamed test +names; never hand-edited. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-29.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-29.md new file mode 100644 index 0000000..e5181c4 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-29.md @@ -0,0 +1,125 @@ +# RB-29 — Thread `at` through `LetterHtml.ResolveAuto`'s `datum` case + +Status: **implemented** · 2026-08-27 · Source findings: `02-testability.md` TE-007 · +`99-backlog.md` RB-29 + +## What was wrong + +`LetterHtml.Render(BriefEntity brief, OrgTemplateDto template, string at, bool watermark)` +(`backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs`) already took the letter's +instant and used it correctly for the letterhead date +(`sb.Append(Enc(FormatDatumNl(at)))`). The body's `datum` placeholder resolved through +the private `ResolveAuto(string key, string label)`, which ignored `at` and called +`FormatDatumNl(DateTimeOffset.UtcNow.ToString("o"))` instead — a pure `Domain/` rule +class reading the wall clock. `ResolveAuto` is reached only through the private chain +`RenderNode` ← `RenderParagraphs` ← `Render`, so no caller outside this file could pin +the value a test would see. + +The ticket read as filed against the current code: `Render`'s signature, the letterhead's +correct use of `at`, and `ResolveAuto`'s `UtcNow` read were all exactly as TE-007 +described (line numbers had moved — CC around the file has grown since the finding was +written — but the code shape had not). One thing TE-007 named as the visible symptom +also checked out: `LetterHtmlTests.cs` already declares a +`new PlaceholderDefDto("datum", "Datum", true)` in its golden-file fixture, but no +`RichTextNodeDto` in that fixture's `Sections` actually references the `datum` key in +the body — it is declared but never rendered there, so the existing golden-file test +could not have caught this even if it asserted on dates (which it does not either). + +## What changed + +| File | Change | +| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs` | `ResolveAuto(string key, string label)` → `ResolveAuto(string key, string label, string at)`; `"datum" => FormatDatumNl(at)`. `at` threaded down through the two private call sites in the chain: `RenderParagraphs` and `RenderNode` both gained an `at` parameter, passed from `Render`'s own `at`. | +| `backend/tests/BigRegister.Tests/LetterHtmlTests.cs` | New fixture `FixtureBriefWithDatumInBody()` — a minimal brief whose body actually references the `datum` placeholder (the golden fixture never does). Two new `[Fact]`s (see below) plus two small extraction helpers. | +| `docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md` | RB-29 status cell: `open` → `implemented`. | + +`Render`'s own signature is unchanged — TE-007's "zero public API change, zero +call-site change" held exactly. `Program.cs:697`, `:708`, and `BriefStore.cs:120` (the +three callers) needed no edit. + +## What the fix looks like + +```csharp +private static void RenderParagraphs( + StringBuilder sb, IReadOnlyList paragraphs, IReadOnlyDictionary defs, + string at) +{ + // ... unchanged body, forwards `at` to RenderNode ... +} + +private static void RenderNode( + StringBuilder sb, RichTextNodeDto node, IReadOnlyDictionary defs, string at) +{ + // ... unchanged body, forwards `at` to ResolveAuto ... +} + +private static string ResolveAuto(string key, string label, string at) => key switch +{ + "naam_zorgverlener" => SeedData.Registration.Naam, + "big_nummer" => SeedData.Registration.BigNummer, + "datum" => FormatDatumNl(at), + _ => label, +}; +``` + +Both call sites already had `at` in scope (`Render`'s own parameter), so this is a pure +threading change — no new state, no new dependency. + +## Tests added + +TE-007 named the exact gap: the golden-file fixture declares the `datum` placeholder but +never renders it in the body, so no existing assertion could catch a body/letterhead +mismatch. A new fixture and two focused tests close it: + +1. **`Render_resolves_the_body_datum_placeholder_from_the_given_at_not_the_wall_clock`** — + renders `FixtureBriefWithDatumInBody()` with a fixed historical `at` + (`2019-03-14T08:00:00.0000000+00:00`) and asserts the body's rendered paragraph is the + exact string `"14 maart 2019"`. A test using today's date would have passed before + and after the fix and proven nothing — this one pins a date nowhere near "now", so it + fails whenever the resolver reads the wall clock instead of `at`. +2. **`Render_keeps_the_letterhead_date_and_the_body_datum_in_agreement_for_a_historical_at`** + — same fixture and historical `at`, asserts the letterhead `
    ` date and the body's + rendered `datum` paragraph are equal. This is TE-007's stated payoff: not a shipped + bug today (every current caller passes `Now()` at render time, so the two dates always + coincided even with the bug present), but a latent one — the moment `Render` is ever + called with a historical `at` (re-rendering an archive, back-dating a letter), the + letterhead and body would disagree within a single document. This test is the one + that would have caught that. + +Both tests use the repo's one date formatter (`FormatDatumNl`, already used by both call +sites under test) only indirectly, through the literal expected string `"14 maart +2019"` — no second hand-rolled `ToString` format was introduced in the test file either. + +## Verification + +- **Verified red without the fix.** Reverted only the `ResolveAuto` expression (via + `Edit`, not `git checkout`) back to + `"datum" => FormatDatumNl(DateTimeOffset.UtcNow.ToString("o"))`, leaving the new + tests and the threaded signatures in place. Ran the two new tests: + ``` + Render_resolves_the_body_datum_placeholder_from_the_given_at_not_the_wall_clock [FAIL] + Assert.Equal() Failure: Strings differ + Expected: "14 maart 2019" + Actual: "27 augustus 2026" + + Render_keeps_the_letterhead_date_and_the_body_datum_in_agreement_for_a_historical_at [FAIL] + Assert.Equal() Failure: Strings differ + Expected: "14 maart 2019" + Actual: "27 augustus 2026" + ``` + Both failures show the body rendering the run's actual wall-clock date (today, + 2026-08-27) instead of the pinned historical `at` — the precise defect TE-007 + describes. Restored the fix with a second `Edit` and re-ran: all 4 tests in + `LetterHtmlTests` green (2 pre-existing + 2 new). +- `grep -n "UtcNow\|DateTime.Now\|DateTime.Today\|DateTimeOffset.Now" +backend/src/BigRegister.Api/Domain/Letters/LetterHtml.cs` — no matches. No ambient + clock read remains anywhere in the file. +- `npm run ci` (foreground, no background/Monitor): see result reported alongside this + ticket. + +## What this ticket did not touch + +`LetterHtml.cs`'s overall structure and CC (TE-007 records it at 21, the third-highest +in the backend) are unchanged — reducing that is out of scope for this ticket, per its +own text. `Data/BriefStore.cs` and any `BriefRules.cs` file were not touched — a +concurrent ticket owns that file. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-30.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-30.md new file mode 100644 index 0000000..207d4f3 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-30.md @@ -0,0 +1,210 @@ +# RB-30 — extract `BriefStore`'s guards into `Domain/Letters/BriefRules.cs` + +Status: **implemented** · 2026-08-27 · Source finding: `02-testability.md` TE-008 · +`99-backlog.md` RB-30 + +RB-30 moves the brief workflow's five guard decisions out of `BriefStore` (a +lock-held, DB-opening static store) into a pure `Domain/Letters/BriefRules.cs`, and +adds a free-running unit test file for them. This is a pure extraction: the store +keeps its lock, its `Db.Create()`, its static shape, and every method's signature. + +## What was wrong + +Five guard clusters in `Data/BriefStore.cs` are pure decisions over `(status tag, +actor role, entity completeness)` — Save, Submit, Send, and the shared Approve/Reject +review path each start with an `if` cascade that is a function of two enums and a +bool. But every one of those `if`s sat inside a method that had already done `lock +(_gate) { using var db = Db.Create(); ... }`, so a spec could not exercise the +decision without a booted host and a real SQLite file. `Domain/Letters/` held only +`LetterHtml.cs` and `OrgTemplateRules.cs`; there was no `BriefRules` class, even +though `Authz.CanActOn` — a pure `Domain/Authorization/` call one line away from +three of the guards — already proved the pattern worked for this exact file. + +## What changed + +| File | Change | +| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `backend/src/BigRegister.Api/Domain/Letters/BriefRules.cs` | New. Five pure statics: `CanSave`, `StatusAfterSave`, `RequiredFilled` + `CanSubmit`, `CanSend`, `CanDecide`. All take `BriefStatusDto`/`bool`/`Principal`/`string`, never `BriefEntity` — no persistence type reaches this file. | +| `backend/src/BigRegister.Api/Data/BriefStore.cs` | `Save`, `Submit`, `Send`, and the private `Review` (the Approve/Reject shared path) each replace their inline `if` cascade with one call into `BriefRules`, then branch only on the returned `Outcome`. The private `RequiredFilled(BriefEntity e)` helper is deleted — `BriefRules.RequiredFilled(IReadOnlyList)` replaces it. Lock, `Db.Create()`, method signatures, and the public `Outcome` enum are all unchanged. | +| `backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs` | New. 29 `[Fact]`/`[Theory]` assertions covering every branch of all five rules — see "Tests added" below. | +| `docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md` | RB-30's status cell: `open` → `done`. | + +## The surface, as built — and where it differs from TE-008's proposal + +TE-008 proposed: + +``` +CanSave(BriefStatusDto status, bool isDrafter) → Outcome +StatusAfterSave(BriefStatusDto) → BriefStatusDto +CanSubmit(status, isDrafter, bool requiredFilled) → Outcome +CanSend(status) +CanDecide(status, Principal, drafterId) +``` + +The ticket explicitly names this a proposal, not a specification. What was built +matches it almost exactly, with two adjustments forced by the real code: + +- **`Outcome` is `BriefStore.Outcome`, not a new type.** `BriefStore` already + exposes a public `enum Outcome { Ok, Forbidden, Conflict }`, and `Program.cs`'s + `BriefResult` switches on it directly across every brief endpoint. TE-008 itself + says: "if `Outcome` does not already exist as a domain concept, use whatever the + sibling rule classes already return" — it does exist, so `BriefRules` returns it + rather than inventing a second result shape. This does mean `Domain/Letters/` + references a type nested in `Api.Data`; the same cross-reference already exists in + this file's neighbor, `LetterHtml.cs` (`using BigRegister.Api.Data;`, for + `BriefEntity`), and in `Authz.cs` (for `BriefStore`'s role-id constants) — both in + the same single-assembly project, so this is a namespace convention, not an + assembly boundary. `Outcome` itself is a plain three-value enum with no EF/ASP.NET + attached, so this does not pull a persistence type into `Domain/`. +- **`CanDecide` takes an explicit `BriefAction action` parameter**, not just + `(status, Principal, drafterId)`. The real guard — `BriefStore.Review` — is one + private method shared by both `Approve` and `Reject`, and it calls + `Authz.CanActOn(action, principal, drafterId)`, which needs to know which action is + being attempted. `BriefRules.CanDecide` composes that existing pure + `Authz.CanActOn` call with the status check, rather than re-implementing the SoD + logic a second time — so the four-eyes rule still has exactly one source of truth. + +The `RequiredFilled` predicate is a sixth pure static, not one of the five guards +proper — TE-008 names it separately ("plus the `RequiredFilled(e)` predicate") and it +is built the same way: `RequiredFilled(IReadOnlyList sections) → +bool`, taking the section list rather than the entity. + +## Order and behaviour preserved + +Every rule keeps the original check order, which matters because `Outcome.Forbidden` +must outrank `Outcome.Conflict` (a non-drafter or non-entitled caller sees Forbidden +even against an otherwise-invalid status): + +- `CanSave`: `!isDrafter` (Forbidden) before the status-tag check (Conflict). +- `CanSubmit`: `!isDrafter` (Forbidden) before `status.Tag != "draft" || +!requiredFilled` (Conflict). +- `CanDecide`: `!Authz.CanActOn(...)` (Forbidden) before `status.Tag != "submitted"` + (Conflict) — the exact order the old inline check in `Review` used, per its own + comment ("checked BEFORE the status guard"). +- `CanSave`'s entity-not-found branch (`e is null → Conflict`) stays inline in + `BriefStore` — it is a persistence fact ("no row for this owner"), not one of the + three business axes TE-008 names (status tag, actor role, entity completeness), so + it was left where it was rather than forced into a rule that would then need to + accept a nullable entity. + +## Tests added + +`backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs`, 29 assertions, alongside +the seven Domain test files that already existed: + +- **`CanSave`** — drafter saves draft/rejected (Ok, `[Theory]`); drafter saves + submitted/approved/sent (Conflict, `[Theory]`); non-drafter saves draft or submitted + (Forbidden both times — proves role beats status). +- **`StatusAfterSave`** — rejected → draft; draft stays draft. +- **`RequiredFilled`** — no sections; an unfilled optional section; a filled required + section; an unfilled required section; one filled + one unfilled required section + (proves one bad section blocks the whole letter). +- **`CanSubmit`** — filled draft (Ok); unfilled draft (Conflict — **the required-filled + gate the ticket explicitly asked for**); non-draft status (Conflict); non-drafter + on a filled draft (Forbidden — role beats completeness). +- **`CanSend`** — approved (Ok); draft/submitted/rejected/sent (Conflict, `[Theory]`). +- **`CanDecide`** — approver decides a submitted letter drafted by someone else, for + both Approve and Reject (Ok, `[Theory]`); a drafter attempting to decide (Forbidden + — **the non-drafter denial the ticket asked for**); an approver whose acting id + equals the drafter id, i.e. self-review (Forbidden — the four-eyes/SoD case); an + approver deciding a non-submitted letter (Conflict); an approver who is also the + drafter AND the status is non-submitted (Forbidden, not Conflict — proves the + priority order survived the extraction). + +## Verified red without the fix + +Inverted `CanSubmit`'s completeness check (`!requiredFilled` → `requiredFilled`) with +an `Edit`, ran `BriefRuleTests` alone: + +``` +[xUnit.net] BigRegister.Tests.Domain.BriefRuleTests.A_drafter_may_not_submit_an_unfilled_draft [FAIL] + Assert.Equal() Failure: Values differ +Expected: Conflict +Actual: Ok +[xUnit.net] BigRegister.Tests.Domain.BriefRuleTests.A_drafter_may_submit_a_filled_draft [FAIL] + Assert.Equal() Failure: Values differ +Expected: Ok +Actual: Conflict + +Failed! - Failed: 2, Passed: 27, Skipped: 0, Total: 29 +``` + +Reverted with a second `Edit` (never `git checkout` — that would have discarded the +whole file). Reran: 29/29 green. + +## Existing tests — unchanged + +`BriefEndpointTests.cs`, `PreviewEndpointTests.cs`, and `OrgTemplateEndpointTests.cs` +(the three host-booting suites that exercise the brief endpoints) needed **no +changes**. Ran together: 32/32 passing, proving the extraction preserved every HTTP +outcome (`Save_is_drafter_only`, `Submit_blocks_on_empty_required_section`, +`Submit_succeeds_when_required_sections_filled`, +`Drafter_cannot_approve_own_letter_but_a_different_reviewer_can`, +`Reject_returns_comments`, `Editing_a_rejected_letter_reopens_it_to_draft`, +`Send_only_from_approved`, and the rest, all unmodified). + +## The metric TE-008 cares about: host-booting brief-rule assertions + +Before this ticket, the five guard decisions had **zero** free-running unit +assertions. Every branch of every guard was reachable only through the seven +host-booting endpoint test methods above (six of them containing an explicit +`Assert.Equal(HttpStatusCode.Forbidden/Conflict, ...)`, each paying a full +`TestWebApplicationFactory` host boot plus a real SQLite round-trip, run serially +process-wide because of `[assembly: DisableTestParallelization]`). + +After this ticket: + +- **0 → 29** free-running unit assertions covering these branches + (`BriefRuleTests.cs`, `dotnet test --filter FullyQualifiedName~BriefRuleTests` + completes in **~120 ms**, no host, no SQLite file). +- **7 → 7** host-booting endpoint tests, unchanged. They stay — they are now the + proof that `BriefStore` wires `BriefRules`'s answer to the right HTTP status, not + the only place the business decision itself is checked. That split (wiring proven + at the integration layer, decision logic proven at the unit layer) is the seam + TE-008 argued for. +- New branches this ticket made assertable that the endpoint suite never covered + directly: the SoD self-review case (`An_approver_may_not_decide_a_letter_they_drafted_themselves`) + and the Forbidden-beats-Conflict priority ordering for both `CanSave`/`CanSubmit` + (role checked first) and `CanDecide` (entitlement checked first) — these existed as + implicit behaviour in the original `if` cascades but had no assertion pinning them + before RB-30. + +## What was not extracted + +Nothing — all five guards named in TE-008, plus the `RequiredFilled` predicate, moved +cleanly. None needed the `DbContext`: each was already a function of values already +resident on the in-memory `BriefEntity` (its `Status`, `Sections`, `DrafterId`), never +of a query against the database itself. + +## Scope respected + +- `Domain/Letters/LetterHtml.cs` was not touched (a concurrent agent owns it). +- `BriefEntity.ToDto()` was not touched — its CC 16 is a separate, out-of-scope + finding per the ticket. +- `Data/Db.cs`'s static-store decision and `TestWebApplicationFactory`'s serialized-test + position were not challenged; the store's lock, `Db.Create()`, and public shape are + byte-for-byte the same as before this ticket, other than the `if` cascades moving + out. + +## Verification + +- `dotnet build`: 0 warnings, 0 errors. +- `dotnet test --filter FullyQualifiedName~BriefRuleTests`: 29/29, ~120 ms. +- `dotnet test --filter FullyQualifiedName~BriefEndpointTests|...PreviewEndpointTests|...OrgTemplateEndpointTests`: + 32/32, unchanged. +- Full backend suite: **291/292 passing**, plus the one known, pre-existing, + container-dependent failure + (`OpenZaakIntegrationTests.Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT`, + "Connection refused (localhost:8000)") — not this ticket's bug, does not run under + `npm run ci`, reproduces on a clean tree with no OpenZaak container running. +- `npm run ci` (foreground, no background/Monitor): see the commit message / session + report for the exit code and step count. + +## What this ticket did not touch + +No frontend file was touched — the brief workflow's status machine is server- +authoritative, and the FE's own pure reducer (mirroring these same transitions for +UX) was already out of this ticket's scope. No file outside `backend/Data/BriefStore.cs`, +`backend/Domain/Letters/BriefRules.cs`, +`backend/tests/BigRegister.Tests/Domain/BriefRuleTests.cs`, and `99-backlog.md` was +changed. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-31.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-31.md new file mode 100644 index 0000000..704ced1 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-31.md @@ -0,0 +1,155 @@ +# RB-31 — replay real messages in the four hand-rolling machine specs + +Status: **implemented** · 2026-08-28 · Source finding: `06-adr-conformance.md` ADR-C-010 · +`99-backlog.md` RB-31 + +RB-31 replaces four hand-rolled state-literal fixtures with `given(reduce, initial)` +replays, per ADR-0006 §2 ("no object is built directly; a fixture is the result of +running real `Msg`s through the real `reduce`"). This is a fixture-construction change +only. No `*.machine.ts` production file was touched. + +## What was wrong + +Four machine specs built their starting `Answering`/`Invullen`/`Editing`/`loaded` state +with a local object-literal helper instead of replaying messages: + +- `intake.machine.spec.ts` — `answering(answers, cursor, scholingThreshold)` hardcoded + `errors: {}`. `intake.testing.ts` (exporting `givenIntake`) already existed next to + it and was already correct, but was imported only by `intake.acceptance.spec.ts`. +- `registratie-wizard.machine.spec.ts` — `invullen(draft, cursor)` hardcoded `errors: {}` + and `upload: initialUpload`. +- `besluit.machine.spec.ts` — `editingWith(besluit, toelichting)` hardcoded `errors: {}`. +- `brief.machine.spec.ts` — `loaded(status, sections)` built the `'loaded'` tag object + directly (no `errors` field on this union, so this one did not hardcode `errors: {}`, + but it still skipped the reducer). + +## What changed + +| File | Change | +| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `apps/ssp/src/app/herregistratie/domain/intake.machine.spec.ts` | Removed the local `answering(...)` helper. Every fixture is now built with the existing `givenIntake` (imported from `intake.testing.ts`), matching `intake.acceptance.spec.ts`'s own style. | +| `apps/ssp/src/app/registratie/domain/registratie-wizard.testing.ts` | New. One-liner: `export const givenRegistratieWizard = given(reduce, initial)`. | +| `apps/ssp/src/app/registratie/domain/registratie-wizard.machine.spec.ts` | Removed the local `invullen(...)` helper and the now-unused `validAdres`/`validDraft`/`Draft`/`initialUpload` fixtures. Added module-level replay helpers (`toAdresValid`, `toBeroepStep`, `toBeroepStepWithDiploma`, `toControleStep`, `toIndienen`, `toFullDraftAtCursor0`) built from `givenRegistratieWizard` + `reduce`, reused across every `describe` block (the file's pre-existing `reduce (message-driven happy path)` block already had three of these, scoped locally; they are now module-level and shared, removing the duplication). | +| `apps/behandelportal/src/app/behandeling/domain/besluit.testing.ts` | New. One-liner: `export const givenBesluit = given(reduce, initial)`. | +| `apps/behandelportal/src/app/behandeling/domain/besluit.machine.spec.ts` | Removed the local `editingWith(...)` helper. Every fixture is now built with `givenBesluit` (or, for the empty-draft case, the machine's own `initial` — see below). | +| `apps/ssp/src/app/brief/domain/brief.testing.ts` | New. One-liner: `export const givenBrief = given(reduce, initial)`. | +| `apps/ssp/src/app/brief/domain/brief.machine.spec.ts` | Rewrote the `loaded(...)` helper to replay a real `BriefLoaded` message through `givenBrief` instead of building the `'loaded'` tag object directly. Also converted one further inline `BriefState` literal in the "deep-copies content" test to the same replay (same anti-pattern, same file, not named individually by the finding's evidence list but visibly the same shape — see "Beyond the letter of the finding" below). | +| `docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md` | RB-31's status cell: `open` → `implemented`. | + +## Message sequence used per machine + +### `intake.machine.spec.ts` + +Every fixture in this file has `cursor` 0, 1, or 2 and the default or an overridden +`scholingThreshold`. All are built as direct sequences of `SetAnswer`/`Next`/`SetPolicy` +through `givenIntake`, mirroring `intake.acceptance.spec.ts`'s own explicit style (no new +generic wrapper was added — the finding's own resolution is "wire the spec to +`givenIntake`", not "invent a second helper"). Representative sequences: + +- Cursor 0, plain answers (most tests): `givenIntake({SetAnswer buitenlandGewerkt}, {SetAnswer uren}, ...)`. +- Cursor 0 with an overridden threshold: adds a trailing `{tag:'SetPolicy', scholingThreshold: N}`. +- Cursor 1 ("editing an answer leaves the cursor fixed"): `SetAnswer buitenlandGewerkt=ja`, + `SetAnswer land`, `SetAnswer buitenlandseUren`, `Next` (buitenland step now valid -> + cursor 1), then the edit under test. +- Cursor 2 ("gaNaarStap jumps back..."): the above sequence continued with + `SetAnswer uren=4160`, `Next` (werk step valid -> cursor 2). + +No drift found here: `intake.testing.ts` already existed correctly, and every one of the +nine cursor/threshold combinations the old literal used turned out to be reachable by a +real message sequence. + +### `registratie-wizard.machine.spec.ts` + +- `toAdresValid()` = `PrefillAdres(straat, postcode, woonplaats)`, `SetCorrespondentie('post')` + — cursor 0, matches the old `invullen(validAdres)`. +- `toBeroepStep()` = `reduce(toAdresValid(), Next)` — cursor 0 -> 1, no diploma. Matches + `invullen(validAdres, 1)`. +- `toBeroepStepWithDiploma()` = `reduce(toBeroepStep(), KiesDiploma('d1','Arts',[]))` — + cursor 1, diploma set. Matches `invullen(validDraft, 1)`. +- `toControleStep()` = `reduce(toBeroepStepWithDiploma(), Next)` — cursor 1 -> 2. Matches + `invullen(validDraft, 2)`. +- `toFullDraftAtCursor0()` = `PrefillAdres`, `SetCorrespondentie('post')`, `KiesDiploma(...)`, + never advancing the cursor — matches `invullen(validDraft)` (cursor 0). `SetField`/ + `SetCorrespondentie`/`KiesDiploma` carry no cursor gate, so setting every field before + ever pressing `Next` is a genuinely reachable cursor-0 state with a complete draft. +- `invullen({})` (five call sites) is exactly the machine's own `initial` value + (`{tag:'Invullen', draft:{antwoorden:{}}, cursor:0, errors:{}, upload:initialUpload}`) + — replaced with `initial` directly, no message needed. + +### `besluit.machine.spec.ts` + +- `editingWith('')` is exactly `initial` (`draft:{besluit:'',toelichting:''}`) — replaced + with `initial` directly. +- `editingWith('Afwijzen')` / `editingWith('Goedkeuren')` = `givenBesluit({SetField besluit})`. +- `editingWith('Afwijzen', ' niet erkend ')` = `givenBesluit({SetField besluit=Afwijzen}, {SetField toelichting=' niet erkend '})`. +- Every `Submitting`/`Failed` fixture is now `givenBesluit({SetField besluit}, {Submit})` + composed further with `reduce(..., {SubmitFailed}/{Retry}/{Reset})`. + +### `brief.machine.spec.ts` + +- `loaded(status, sections)` = `givenBrief({tag:'BriefLoaded', brief: briefWith(status, sections), availablePassages: lib, decisions})`. + This is a 1:1 replacement: the `'BriefLoaded'` reducer case sets exactly + `{tag:'loaded', brief: m.brief, availablePassages: m.availablePassages, decisions: m.decisions}` + — the same three fields the old literal built by hand, with the same values. No drift. + +## Drift found + +Two tests in `registratie-wizard.machine.spec.ts` asserted against a cursor value the +real reducer cannot reach: + +- `'validateAll keeps only the answers to the questions that applied'` built + `invullen(validAdres, 2)` then called `kiesDiploma(...)` on it — i.e. a wizard already + at cursor 2 (`controle`) with **no diploma chosen yet**. That is impossible by replay: + advancing past `beroep` (cursor 1 -> 2) requires `validateStep('beroep', ...)` to pass, + which requires `diplomaId` and `beroep` to already be set. The literal encoded a state + the reducer can never produce. +- `'requires a declared beroep + all maximal questions before submit'` had the same + problem: `invullen(validAdres, 2)` then `kiesHandmatig(...)`, which leaves `beroep` + `undefined` — again a cursor-2 state that could never have been reached via `Next`. + +In both cases the cursor value is not actually load-bearing for the test: `submit()` +calls `validateAll(s.draft, s.upload)`, which validates every step regardless of +`s.cursor`. Both tests were re-pointed at the reachable **cursor-1** equivalent +(`toBeroepStep()` then `kiesDiploma`/`kiesHandmatig`), with an inline `// DRIFT (see +rb-31.md)` comment at each site. No assertion changed — both tests still check the same +`submit(...)` outcome on the same field values; only the now-irrelevant cursor number +in the starting fixture moved from an unreachable 2 to a reachable 1. + +No other named state, across any of the four machines, turned out to be unreachable. + +## Beyond the letter of the finding + +`brief.machine.spec.ts`'s `'BesluitSelected deep-copies content...'` test built a second, +separate `BriefState` literal inline (not through the `loaded(...)` helper the finding +cited) — same anti-pattern, same file, not itself named in ADR-C-010's evidence list. +Since it sits inside one of the four files already being brought into line, and the fix +is the identical one-line `BriefLoaded` replay, it was converted too rather than left as +a residual violation in a file this ticket otherwise fixed. No other spec, in any other +file, was touched. + +## `intake.acceptance.spec.ts` — confirmed unaffected + +`intake.testing.ts` and its `givenIntake` export were not modified. The acceptance spec +still imports and uses `givenIntake` exactly as before; it was not read or edited by +this ticket beyond confirming (by running it) that it still passes. + +## Verification + +- `npx ng test ssp`: 44 test files, 276 tests, all passing (includes + `intake.machine.spec.ts`, `intake.acceptance.spec.ts`, + `registratie-wizard.machine.spec.ts`, `brief.machine.spec.ts`, and every other ssp + spec, unmodified ones included). +- `npx ng test behandelportal`: 6 test files, 37 tests, all passing (includes + `besluit.machine.spec.ts`). +- `npx eslint` on all seven touched/added files: clean. +- `npx prettier --check` on all seven touched/added files: clean (one file needed + `--write` once, then verified clean). +- `npm run ci`: see the commit message / session report for the exit code and step + count. + +## What this ticket did not touch + +No `*.machine.ts` reducer or production domain file was changed — every fixture change +is confined to the four `*.spec.ts` files and the three new `*.testing.ts` files listed +above. No other machine spec (including `change-request.machine.spec.ts`, which the +finding notes already honours the idiom inline) was touched. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-32.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-32.md new file mode 100644 index 0000000..25ff422 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-32.md @@ -0,0 +1,86 @@ +# RB-32 — add the missing `language-switcher` row to the CIBG gap register + +Status: **implemented** · 2026-08-28 · Source finding: `06-adr-conformance.md` +ADR-C-008 · `99-backlog.md` RB-32 · Depends on +`implementation/adr-c-007.md` (the same file, left one row short on purpose, +filed forward as this ticket) + +## What was wrong + +ADR-0003 §Consequences' final bullet requires every `// CIBG-GAP EXTENSION:` +marker in code to have a row in `libs/shared/docs/cibg-gaps.mdx`, "so it's +auditable rather than silently drifting." `libs/shared/src/layout/language-switcher/ +language-switcher.component.ts:7-9` carries a full, well-formed marker +("Taal instellen" — no vendored Huisstijl class ships for it — see +`cibg-gaps.mdx`) but the register table had no row for it. + +## Verified before editing + +``` +grep -rln "CIBG-GAP EXTENSION" apps libs --include=*.ts | wc -l # 9 +``` + +Nine files carry the marker: `debug-state`, `language-switcher`, `wizard-shell`, +`application-link`, `placeholder-chip`, `rich-text-editor`, `skeleton`, +`spinner`, `status-badge`. The register table had 8 rows, and `language-switcher` +was the one missing — matching ADR-C-008's finding exactly, re-verified rather +than trusted from the finding's own snapshot (per this ticket's DoD point 1, and +per `adr-c-007.md`'s own note that it left this exact row for a later ticket). + +## What changed + +One row added to `libs/shared/docs/cibg-gaps.mdx`'s register table, matching the +existing rows' two-column shape (component name, closest CIBG concept, reason — +wording taken from the component's own marker comment, not invented): + +| Component | Closest CIBG concept | Why hand-rolled | +| ------------------- | -------------------- | -------------------------------------------------------------------------------------------------- | +| `language-switcher` | Taal instellen | No vendored Huisstijl class ships for it; a small hand-rolled surface built from the token bridge. | + +No other row was touched. The 8 existing rows were each re-checked against their +component's current marker comment while the file was open; none needed a change. + +## Optional CI script: taken + +ADR-C-008 flags a ~10-line `grep -l CIBG-GAP | diff`-style script folded into +`check:tokens` as an explicitly optional second step. It is genuinely small and +fits the existing script's shape, so `scripts/check-tokens.sh` gained one more +guard after its existing hardcoded-colour check: + +```bash +gap_register='libs/shared/docs/cibg-gaps.mdx' +markers=$(grep -rl 'CIBG-GAP EXTENSION' apps libs --include='*.component.ts' | xargs -n1 dirname | xargs -n1 basename | sort -u) +rows=$(grep -oP '^\| `\K[^`]+' "$gap_register" | sort -u) +missing=$(comm -23 <(echo "$markers") <(echo "$rows")) +if [ -n "$missing" ]; then + echo "$missing" + echo "FAIL: CIBG-GAP EXTENSION marker(s) with no row in $gap_register" + exit 1 +fi +echo 'OK: every CIBG-GAP EXTENSION marker has a cibg-gaps.mdx row' +``` + +The marker's component directory basename (`dirname` of the flagged file, +`.component.ts` files only) is compared against the table's backtick-quoted +first column, extracted with `grep -oP`. This matches for all 9 current +markers, including the two rows with a parenthetical suffix +(`` `wizard-shell` (error summary only) ``, `` `application-link` (non-navigating +row) ``) — the regex stops at the closing backtick, before the parenthetical. + +**Verified working, not just written.** Added a scratch component +(`libs/shared/src/ui/scratch-gap-test/scratch-gap-test.component.ts`, a single +`// CIBG-GAP EXTENSION:` line plus a dummy export) with no matching row. Ran +`bash scripts/check-tokens.sh`: failed with exit 1, printing `scratch-gap-test` +and the expected `FAIL:` line. Deleted the scratch file and its directory (`rm`, +not `git checkout` — it was never tracked). Re-ran the script: passed, exit 0. +`git status` confirms no trace of the scratch file remains. + +## Verification + +- `npm run check:tokens` (includes the new guard): passes — 9 markers, 9 rows, + after the register row was added. +- `npm run ci`: result and step count reported in the implementing agent's final + answer. +- No code changed outside `libs/shared/docs/cibg-gaps.mdx`, + `scripts/check-tokens.sh`, `99-backlog.md`, and this note — a doc-only ticket + plus its optional, self-verifying guard script. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-33.md b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-33.md new file mode 100644 index 0000000..c4eed16 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/implementation/rb-33.md @@ -0,0 +1,100 @@ +# RB-33 — `unwrapOk`: adopt or delete + +Status: **implemented** · 2026-08-28 · Source finding: `06-adr-conformance.md` ADR-C-011 · +`99-backlog.md` RB-33 + +## Decision: delete + +The ticket names this "adopt or delete", not "adopt", and asks for the judgment call, not +the default. I deleted `unwrapOk`. + +## Why delete, not adopt + +`unwrapOk` (`libs/shared/src/testing/value-object.ts`) has had zero consumers across the +whole codebase since ADR-0006 shipped it, except its own definition and one sentence in +`libs/shared/docs/testing.mdx`. I verified this before changing anything: + +``` +grep -rn "unwrapOk" apps libs --include=*.ts --include=*.mdx +libs/shared/docs/testing.mdx:92: ...unwrapOk(parseX(raw))... +libs/shared/src/testing/value-object.ts:9:export function unwrapOk(...) +libs/shared/src/testing/value-object.ts:11: throw new Error(`unwrapOk: ...`); +``` + +The one call site the finding names, +`apps/ssp/src/app/registratie/application/submit-change-request.spec.ts`, still has the +exact hand-rolled guard the finding quotes: + +```ts +const telefoon = parseTelefoonnummer('0612345678'); +if (!telefoon.ok) throw new Error('fixture phone should parse'); +``` + +I also checked whether any other spec has the same shape, in case the finding's "one call +site" undercounted the real duplication: + +``` +grep -rln "if (!.*\.ok)\s*throw" apps libs --include=*.spec.ts +apps/ssp/src/app/registratie/application/submit-change-request.spec.ts +``` + +Only this one file, anywhere. There is no cast (`'x' as Telefoonnummer`) to close off +either — the spec already calls the real `parseTelefoonnummer` and checks `.ok` before +touching `.value`. ADR-0006 §3's actual requirement ("never a cast") is already met by the +inline code, with or without the helper. + +Weighing it honestly: + +- **For adopt:** it is a one-line change, and the ADR's own worked example literally shows + this exact call. Doing it would make the finding's "zero adopters" claim technically + false. +- **For delete:** a helper that gains its _only_ real-codebase consumer by an agent adding + that one call site as an act of ticket compliance is not organic adoption — it is + manufacturing a usage to justify keeping the file. `unwrapOk` has sat available, exported, + and documented since ADR-0006 (well before this session) without a single spec reaching + for it on its own. One caller, forever, is not "removing duplication" (the stated point + of a shared test helper) — there is no duplication with only one occurrence. The inline + guard is also arguably clearer here: its error message (`'fixture phone should parse'`) + names the actual fixture, where `unwrapOk`'s generic message + (`unwrapOk: expected ok, got error: ...`) does not. + +Delete wins: it removes dead, unadopted code and its stale doc reference, changes no +runtime behaviour anywhere, and costs nothing to reverse if a second real need for this +idiom shows up later (three lines, trivial to re-add against actual duplication instead of +a single hypothetical site). + +## What changed + +| File | Change | +| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `libs/shared/src/testing/value-object.ts` | Deleted. Its only export, `unwrapOk`, is what this ticket removes; the file had nothing else in it. | +| `libs/shared/docs/testing.mdx` | Rewrote the sentence that named `unwrapOk` and the deleted file's path. It now states the same rule in plain terms — call the real `parse*` and check `.ok`, never a cast — and keeps the `RemoteData` half of the sentence pointing at `remote-data.ts` (unchanged, still in use). | +| `apps/ssp/src/app/registratie/application/submit-change-request.spec.ts` | **Not touched.** Its inline guard already satisfies ADR-0006 §3; this is the "delete" branch, so the fixture-construction behaviour stays exactly as it was. | +| `99-backlog.md` | RB-33's status cell: `open` → `implemented`. | + +## What this ticket did not touch + +`docs/reference/architecture/0006-test-data-builders.md` (the ADR itself) still shows +`unwrapOk` in its worked example and decision table. That is deliberate: RB-33 is a code +ticket, not one of the five ADR-fix tickets that need architect sign-off +(`06-adr-conformance.md`'s "ADR-fix tickets" section). The ADR's illustrated pattern +("call the real parser, unwrap through a checked path, never a cast") is still the correct +principle — this ticket only removes one now-unused concrete implementation of it, which +the inline guard in `submit-change-request.spec.ts` already satisfies without the named +helper. Amending the ADR's own text is out of this ticket's scope and is left for a future +ADR-fix ticket if one is ever raised. The finding document (`06-adr-conformance.md`) and the +historical WP-70/WP-71 backlog notes that mention `unwrapOk` are left as-is — they are +records of what was true when written, not living code. + +No other file in `libs/shared/src/testing/` was touched (`expect-tag.ts`, `machine.ts`, +`remote-data.ts` are all unrelated and still have real consumers). + +## Verification + +- `grep -rn "unwrapOk" apps libs --include=*.ts --include=*.mdx` — zero occurrences. +- `apps/ssp/src/app/registratie/application/submit-change-request.spec.ts` — unchanged file, + still passes (see `npm run ci` result below). +- No new test added. The ticket is a deletion of unused code plus a doc-sentence rewrite; + the surviving inline guard in the spec is exercised the same way it always was, by the + spec's three existing `it` blocks. +- `npm run ci` (foreground): see the session report for the exit code and step count. diff --git a/docs/project/refactor-backlog-setup/refactor-backlog/tools/baseline-scan.mjs b/docs/project/refactor-backlog-setup/refactor-backlog/tools/baseline-scan.mjs new file mode 100644 index 0000000..d8cbed3 --- /dev/null +++ b/docs/project/refactor-backlog-setup/refactor-backlog/tools/baseline-scan.mjs @@ -0,0 +1,255 @@ +#!/usr/bin/env node +// Baseline scanner for 00-baseline.md: duplication % and C# cyclomatic complexity. +// Both are the same crude text scan, so they share one file. +// +// ponytail: line-window hashing, not token-based like jscpd, and regex method +// detection that does not understand C# expression-bodied members or nested +// lambdas. Deterministic and zero-install, which is what a *baseline* needs. +// Upgrade path: jscpd for duplication, a Roslyn analyzer for C# complexity — +// only if a ticket's before/after needs more precision than "did it move". +// +// Usage: node baseline-scan.mjs [--dup] [--complexity] (default: both) + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { join, relative } from 'node:path'; + +const ROOT = new URL('../../../../..', import.meta.url).pathname.replace(/\/$/, ''); +const SKIP_DIR = + /(^|\/)(node_modules|dist|coverage|bin|obj|\.angular|\.git|storybook-static.*|TestResults|public|openzaak)$/; +const SKIP_FILE = /(api-client\.ts|\.Designer\.cs|AppDbContextModelSnapshot\.cs)$/; +const SCAN = ['apps', 'libs', 'backend/src', 'backend/tests', 'e2e']; +const WINDOW = 6; // duplicate = >= 6 consecutive normalized lines seen elsewhere + +function walk(dir, out = []) { + for (const e of readdirSync(dir)) { + const p = join(dir, e); + if (SKIP_DIR.test(p)) continue; + if (statSync(p).isDirectory()) walk(p, out); + else if (/\.(ts|cs)$/.test(p) && !SKIP_FILE.test(p)) out.push(p); + } + return out; +} + +// Module attribution: the unit the baseline table reports on. +function moduleOf(rel) { + let m; + if ((m = rel.match(/^apps\/(ssp|behandelportal)\/src\/app\/([^/]+)\//))) + return `${m[1] === 'ssp' ? 'ssp' : 'bhp'}/${m[2]}`; + if (rel.startsWith('apps/ssp/')) return 'ssp/root'; + if (rel.startsWith('apps/behandelportal/')) return 'bhp/root'; + if ((m = rel.match(/^libs\/(shared|beheer)\/src\/([^/]+)\//))) return `libs/${m[1]}/${m[2]}`; + if (rel.startsWith('libs/')) return rel.split('/').slice(0, 2).join('/'); + if (rel === 'backend/src/BigRegister.Api/Program.cs') return 'backend/Program.cs'; + if ((m = rel.match(/^backend\/src\/BigRegister\.Api\/([^/]+)\//))) return `backend/${m[1]}`; + if (rel.startsWith('backend/tests/')) return 'backend/tests'; + if (rel.startsWith('e2e/')) return 'e2e'; + return 'other'; +} + +const files = SCAN.flatMap((d) => { + try { + return walk(join(ROOT, d)); + } catch { + return []; + } +}).map((p) => ({ abs: p, rel: relative(ROOT, p) })); + +// --- Duplication ------------------------------------------------------------ +// Normalize away formatting/comments, hash every WINDOW-line run, and mark a run +// duplicated when its hash occurs in more than one place. +function duplication() { + const norm = new Map(); // rel -> [{line, text}] + for (const f of files) { + const kept = []; + readFileSync(f.abs, 'utf8') + .split('\n') + .forEach((raw, i) => { + const t = raw + .replace(/\/\/.*$/, '') + .replace(/\s+/g, ' ') + .trim(); + if (t.length > 3) kept.push({ line: i + 1, text: t }); + }); + norm.set(f.rel, kept); + } + + const seen = new Map(); // hash -> [{rel, idx}] + for (const [rel, lines] of norm) + for (let i = 0; i + WINDOW <= lines.length; i++) { + const h = createHash('sha1') + .update( + lines + .slice(i, i + WINDOW) + .map((l) => l.text) + .join('\n'), + ) + .digest('hex'); + (seen.get(h) ?? seen.set(h, []).get(h)).push({ rel, idx: i }); + } + + const dupLines = new Map(); // rel -> Set(line) + const clones = new Map(); // "relA|relB" -> count + for (const occ of seen.values()) { + if (occ.length < 2) continue; + for (const { rel, idx } of occ) { + const set = dupLines.get(rel) ?? dupLines.set(rel, new Set()).get(rel); + for (let k = 0; k < WINDOW; k++) set.add(norm.get(rel)[idx + k].line); + } + const pair = [...new Set(occ.map((o) => o.rel))].sort(); + if (pair.length > 1) { + const key = pair.slice(0, 2).join(' | '); + clones.set(key, (clones.get(key) ?? 0) + 1); + } + } + + const per = new Map(); // module -> {total, dup} + for (const [rel, lines] of norm) { + const mod = moduleOf(rel); + const e = per.get(mod) ?? per.set(mod, { total: 0, dup: 0 }).get(mod); + e.total += lines.length; + e.dup += dupLines.get(rel)?.size ?? 0; + } + + console.log('## Duplication (normalized lines, window=' + WINDOW + ')\n'); + console.log('| Module | Sig. lines | Duplicated | % |'); + console.log('|---|---:|---:|---:|'); + let T = 0, + D = 0; + for (const [mod, e] of [...per].sort((a, b) => b[1].dup / b[1].total - a[1].dup / a[1].total)) { + T += e.total; + D += e.dup; + console.log(`| ${mod} | ${e.total} | ${e.dup} | ${((100 * e.dup) / e.total).toFixed(1)}% |`); + } + console.log(`| **TOTAL** | **${T}** | **${D}** | **${((100 * D) / T).toFixed(1)}%** |`); + + console.log('\n### Top clone pairs (distinct duplicated windows)\n'); + for (const [pair, n] of [...clones].sort((a, b) => b[1] - a[1]).slice(0, 15)) + console.log(`- ${n} × — ${pair}`); +} + +// --- C# cyclomatic complexity (approximate) --------------------------------- +// Two numbers per file. FILE CC (sum of decision points) is exact enough to trust. +// Per-METHOD CC uses depth-aware regex detection and is the approximate one. +const BRANCH = + /\bif\s*\(|\bwhile\s*\(|\bfor\s*\(|\bforeach\s*\(|\bcase\s+|\bcatch\s*[({]|\?\?|&&|\|\||\?\.|\bwhen\s+/g; +const TYPE_DECL = + /^\s*(?:\[[^\]]*\]\s*)*(?:public|private|internal|protected|static|sealed|abstract|partial|file|\s)*\b(?:class|record|struct|interface|enum|namespace)\b/; +const NOT_A_CALL = + /^(if|for|foreach|while|switch|catch|using|lock|return|throw|new|await|yield|else|do|fixed|checked)$/; +// A member: optional attrs/modifiers, a return type, a name, then `(`. +const SIG = + /^\s*(?:\[[^\]]*\]\s*)*(?:(?:public|private|internal|protected|static|async|override|virtual|sealed|partial|extern|new|unsafe)\s+)*[\w<>,\[\]?.]+\s+(\w+)\s*(?:<[^>()]*>)?\s*\(/; + +function branchesIn(line) { + return (line.replace(/\/\/.*$/, '').match(BRANCH) ?? []).length; +} + +function csComplexity() { + const rows = []; + const fileCc = []; + for (const f of files.filter((f) => f.rel.endsWith('.cs'))) { + const lines = readFileSync(f.abs, 'utf8').split('\n'); + let depth = 0, + cur = null, + total = 1, + code = 0; + for (let i = 0; i < lines.length; i++) { + const l = lines[i]; + const bare = l.replace(/\/\/.*$/, ''); + const b = branchesIn(l); + total += b; + if (bare.trim().length > 1 && !/^\s*(\/\/|\/\*|\*)/.test(l)) code++; + + const opens = (bare.match(/{/g) ?? []).length; + const closes = (bare.match(/}/g) ?? []).length; + + if (cur) { + cur.lines++; + cur.cc += b; + // Expression-bodied member: `=> expr;` with no block of its own. + if (cur.depth === null && /=>/.test(bare)) { + if (/;\s*$/.test(bare) && opens === closes) { + rows.push(cur); + cur = null; + } else if (opens > closes) cur.depth = depth; + } else if (cur.depth === null && opens > closes) cur.depth = depth; + else if (cur.depth !== null && depth + opens - closes <= cur.depth) { + rows.push(cur); + cur = null; + } + } else if (!TYPE_DECL.test(l)) { + const m = SIG.exec(bare); + // `Name(` must not be a call/keyword, and the line must not be a statement. + if ( + m && + !NOT_A_CALL.test(m[1]) && + !/^\s*(var|return|await)\b/.test(bare) && + !/;\s*$/.test(bare.replace(/=>.*/, '')) + ) + cur = { + file: f.rel, + name: m[1], + line: i + 1, + cc: 1 + b, + lines: 1, + depth: opens > closes ? depth : null, + }; + } + depth += opens - closes; + } + if (cur) rows.push(cur); + fileCc.push({ file: f.rel, cc: total, code }); + } + + const p = (arr, q) => + arr.slice().sort((a, b) => a - b)[Math.min(arr.length - 1, Math.floor(q * arr.length))] ?? 0; + + const perFile = new Map(); + for (const r of fileCc) { + const mod = moduleOf(r.file); + (perFile.get(mod) ?? perFile.set(mod, []).get(mod)).push(r); + } + console.log('\n\n## C# complexity — per module\n'); + console.log( + '| Module | Files | Σ file CC | max file CC | Methods | max method CC | p90 method CC | CC>10 |', + ); + console.log('|---|---:|---:|---:|---:|---:|---:|---:|'); + const perMethod = new Map(); + for (const r of rows) { + const mod = moduleOf(r.file); + (perMethod.get(mod) ?? perMethod.set(mod, []).get(mod)).push(r); + } + for (const [mod, fs] of [...perFile].sort( + (a, b) => Math.max(...b[1].map((r) => r.cc)) - Math.max(...a[1].map((r) => r.cc)), + )) { + const ms = perMethod.get(mod) ?? []; + const ccs = ms.map((r) => r.cc); + console.log( + `| ${mod} | ${fs.length} | ${fs.reduce((a, r) => a + r.cc, 0)} | ${Math.max(...fs.map((r) => r.cc))} | ` + + `${ms.length} | ${ccs.length ? Math.max(...ccs) : 0} | ${p(ccs, 0.9)} | ${ms.filter((r) => r.cc > 10).length} |`, + ); + } + + console.log('\n### C# files by CC (top 12)\n'); + console.log('| File CC | Code lines | File |'); + console.log('|---:|---:|---|'); + for (const r of fileCc.sort((a, b) => b.cc - a.cc).slice(0, 12)) + console.log(`| ${r.cc} | ${r.code} | ${r.file} |`); + + console.log('\n### C# methods over CC 10 (approximate detection)\n'); + console.log('| CC | Lines | Method | Location |'); + console.log('|---:|---:|---|---|'); + for (const r of rows.filter((r) => r.cc > 10).sort((a, b) => b.cc - a.cc)) + console.log(`| ${r.cc} | ${r.lines} | \`${r.name}\` | ${r.file}:${r.line} |`); + + const all = rows.map((r) => r.lines); + console.log( + `\nMethod-length distribution (n=${rows.length}): p50 ${p(all, 0.5)}, p90 ${p(all, 0.9)}, p99 ${p(all, 0.99)}, max ${Math.max(...all)}`, + ); +} + +const args = process.argv.slice(2); +const all = args.length === 0; +if (all || args.includes('--dup')) duplication(); +if (all || args.includes('--complexity')) csComplexity(); diff --git a/docs/project/refactor-backlog-setup/setup.sh b/docs/project/refactor-backlog-setup/setup.sh new file mode 100755 index 0000000..5e8c2b1 --- /dev/null +++ b/docs/project/refactor-backlog-setup/setup.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Sets up the multi-agent refactoring backlog workspace. +# Run from the root of the target repo: bash setup.sh +set -euo pipefail + +WORK_DIR="./refactor-backlog" +AGENTS_SRC="$(dirname "$0")/agents" +PROTOCOL="$AGENTS_SRC/_persistence-protocol.md" + +echo "Setting up $WORK_DIR ..." +mkdir -p "$WORK_DIR/implementation" +mkdir -p "$WORK_DIR/final-prompts" + +# 1. Initialize _status.md +cat > "$WORK_DIR/_status.md" << 'EOF' +# Agent run status + +| Agent | Status | Last module processed | Last updated | Notes | +|---|---|---|---|---| +| baseline | not_started | - | - | | +| readability | not_started | - | - | | +| testability | not_started | - | - | | +| ddd-hexagonal | not_started | - | - | | +| cqrs-light | not_started | - | - | | +| bdd | not_started | - | - | | +| adr-conformance | not_started | - | - | | +| bio2-compliance | not_started | - | - | | +| consolidation | not_started | - | - | | +EOF +echo " created _status.md" + +# 2. Initialize empty output files with headers +for f in 00-baseline 01-readability 02-testability 03-ddd-hexagonal \ + 04-cqrs-light 05-bdd 06-adr-conformance 07-bio2-compliance; do + cat > "$WORK_DIR/${f}.md" << EOF +## Scope: [to be filled by agent] +## Status: not_started +## Last updated: - +## Depends on: [see agent prompt] +## --- +EOF +done +touch "$WORK_DIR/99-backlog.md" +echo " initialized 8 phase-1 output files + 99-backlog.md" + +# 3. Assemble final prompts: inject persistence protocol into each agent prompt +# at the "[Insert contents of _persistence-protocol.md here...]" marker. +for src in "$AGENTS_SRC"/*.prompt.md; do + name="$(basename "$src")" + out="$WORK_DIR/final-prompts/$name" + awk -v protofile="$PROTOCOL" ' + /\[Insert contents of _persistence-protocol\.md here/ { + while ((getline line < protofile) > 0) print line + close(protofile) + next + } + { print } + ' "$src" > "$out" + echo " assembled final-prompts/$name" +done + +echo "" +echo "Done. Structure:" +echo " $WORK_DIR/_status.md (run tracker — all agents not_started)" +echo " $WORK_DIR/00-baseline.md ... 07-bio2-compliance.md (empty, agent-writable)" +echo " $WORK_DIR/99-backlog.md (empty, Consolidation writes here)" +echo " $WORK_DIR/implementation/ (Phase 3 notes land here)" +echo " $WORK_DIR/final-prompts/ (ready-to-dispatch prompts, protocol" +echo " already merged in — no manual copy-paste)" +echo "" +echo "Next: dispatch final-prompts/00-baseline.prompt.md first (blocks everything)," +echo "then the 7 Phase 1 prompts in parallel, then 08-consolidation.prompt.md," +echo "then per-ticket 09-implementation.prompt.md (fill in TICKET-ID each time)." diff --git a/docs/reference/architecture/0001-bff-lite-decision-dtos.md b/docs/reference/architecture/0001-bff-lite-decision-dtos.md index 7e18872..7d68c69 100644 --- a/docs/reference/architecture/0001-bff-lite-decision-dtos.md +++ b/docs/reference/architecture/0001-bff-lite-decision-dtos.md @@ -69,44 +69,96 @@ the governance/transparency artifact. The frontend keeps only **format** validation (postcode shape, integer parsing) for instant feedback — never as the authority. +### Where the contract lives, after codegen + +The paragraph above says "manage it with one source of truth that generates types for +both sides". That target state has arrived, so this section states which artifact is now +the contract. + +**The generated client is the wire contract.** `libs/shared/src/infrastructure/api-client.ts` +is regenerated from the backend's OpenAPI document by `npm run gen:api`, and CI fails on +drift (the `api-client-drift` job regenerates it and runs `git diff --exit-code`). It is the +single source of truth for the shape of every endpoint. An adapter consumes its types +directly; 19 of the 20 infrastructure adapters do. + +**A hand-written `contracts/*.dto.ts` is the exception, for two cases only:** + +1. **Codegen does not reach the endpoint** — a hand-rolled `fetch`/XHR path that the + generator never sees. +2. **The generator types the shape too loosely** — the generated type compiles but is + weaker than the wire really is. + +In either case the hand-written file must still import nothing. It describes the wire, not +the domain. + +**The `parse*` trust boundary is unchanged and stays mandatory**, whichever way the type +arrived. A generated type is a compile-time claim about the wire, not a runtime guarantee: +the server can send anything. `infrastructure/` validates the untrusted shape and maps it +onto the domain, exactly as before. + +**The four surviving hand-written contracts stay.** They are +`apps/ssp/src/app/registratie/contracts/{brp-address,dashboard-view,duo-diplomas}.dto.ts` +and `libs/beheer/src/contracts/stamdata.dto.ts`. All four fall under case 2, and the +dashboard view shows why: the generator emits every property as optional, and it flattens +a discriminated union into a bag of optional fields. + +```ts +// generated — every field optional, `tag` a bare string, all variants merged +interface RegistrationStatusDto { + tag?: string | undefined; + herregistratieDatum?: string | undefined; + geschorstTot?: string | undefined; + reden?: string | undefined; + doorgehaaldOp?: string | undefined; +} + +// hand-written — a real discriminated union, per-variant fields required +type RegistrationStatusDto = + | { tag: 'Geregistreerd'; herregistratieDatum: string } + | { tag: 'Geschorst'; geschorstTot: string; reden: string } + | { tag: 'Doorgehaald'; doorgehaaldOp: string; reden: string }; +``` + +Adopting the generated shape here would push `undefined` handling into every consumer and +make an illegal state representable, which CLAUDE.md §3 forbids. Retiring these four is +therefore **not** a cleanup to schedule; it becomes correct only if the backend annotates +its DTOs so the generator emits required properties and real unions. + ## Worked example in this POC -This POC has no real backend (static mock JSON + fake submit timers), so the -"BFF output" is a static file; the `decisions` block stands in for what the backend -would compute. Two slices were implemented to demonstrate **both** policy shapes: +Implemented against the real backend, `backend/src/BigRegister.Api`. Two slices demonstrate +**both** policy shapes. **A. Dashboard profile → one aggregated, decision-enriched call (decision-flag).** -- Contract: `src/app/registratie/contracts/dashboard-view.dto.ts` +- Endpoint: `GET /api/v1/dashboard-view` (`Program.cs`), one call replacing three. +- Contract: `apps/ssp/src/app/registratie/contracts/dashboard-view.dto.ts` (`DashboardViewDto` = registration + person + `decisions`). -- Endpoint: `public/mock/dashboard-view.json` (one call replaces three). - Boundary parse: `parseDashboardView()` in - `src/app/registratie/infrastructure/dashboard-view.adapter.ts` validates the - untrusted shape and maps DTO → domain (hand-written; no schema lib for one - contract). -- `BigProfileStore` now derives `profile` and `decisions` from the single - validated view (was a 3-resource `map2`). One request → one consistent snapshot. -- `herregistratie.page.ts` reads `decisions.eligibleForHerregistratie` instead of - computing it client-side. That rule is server-owned: it lives only in - `HerregistratieRule.cs`, with no FE mirror to drift from it (WP-75). -- The unused upstream adapters/mocks (`brp.adapter.ts`, `registration.json`, - `brp.json`) were deleted — those calls live behind the BFF now. + `apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.ts` validates the + untrusted shape and maps DTO → domain (hand-written; no schema lib). +- `BigProfileStore` derives `profile` and `decisions` from the single validated view (was a + 3-resource `map2`). One request → one consistent snapshot. +- `herregistratie.page.ts` reads `decisions.eligibleForHerregistratie` instead of computing + it client-side. That rule is server-owned: it lives only in `HerregistratieRule.cs`, with + no FE mirror to drift from it (WP-75). **B. Intake scholing threshold → config value.** -- Contract: `src/app/herregistratie/contracts/intake-policy.dto.ts`. -- Endpoint: `public/mock/intake-policy.json` (`{ "scholingThreshold": 1000 }`). +- Endpoint: `GET /api/v1/intake/policy` (`Program.cs`), serving + `IntakePolicy.ScholingThreshold`. +- Contract: the generated `IntakePolicyDto`; the adapter is + `apps/ssp/src/app/herregistratie/infrastructure/intake-policy.adapter.ts`. - `intake.machine.ts`: the hardcoded `LAGE_UREN_DREMPEL` constant is gone; - `lageUren(a, scholingThreshold)` and validation take the value, which lives in - machine state and is set via a `SetPolicy` message. A `SCHOLING_THRESHOLD_DEFAULT` - remains only as the offline fallback. + `lageUren(a, scholingThreshold)` and validation take the value, which lives in machine + state and is set via a `SetPolicy` message. A `SCHOLING_THRESHOLD_DEFAULT` remains only + as the offline fallback. - `intake-wizard.component.ts` fetches the policy and dispatches `SetPolicy`. - WP-69: the backend re-validates the threshold as the authority on submit — `IntakePolicy.RejectIncompleteScholing` runs before `POST /applications/{id}/submit` - (intake-typed) writes anything, 400ing an incomplete scholing answer instead of - silently accepting a crafted POST that skips it. (WP-72 deleted the legacy - `POST /intakes` endpoint this once also covered — deleting the surface is a stronger - fix than 400ing on it.) + (intake-typed) writes anything, 400ing an incomplete scholing answer instead of silently + accepting a crafted POST that skips it. (WP-72 deleted the legacy `POST /intakes` endpoint + this once also covered — deleting the surface is a stronger fix than 400ing on it.) ## Migration sequence (for the real app) @@ -119,12 +171,17 @@ would compute. Two slices were implemented to demonstrate **both** policy shapes ## Out of scope here (next steps, not built in the worked example) -- Runtime DTO validation on **every** endpoint (only the dashboard view has it). - Optimistic-update race fix in `BigProfileStore` (`beginHerregistratie`/`rollbackHerregistratie` can leave `pending` wrong under concurrent submits). -- Session persistence / multi-tab sync (`SessionStore` is in-memory). -- Real OpenAPI/TypeSpec codegen toolchain. +- Multi-tab session sync. The session itself now persists (`localStorage`, read back + through `parseStoredPrincipal`), but a change in one tab does not reach another — no + `storage` listener exists. + +Two bullets were discharged and removed. Runtime DTO validation is no longer "only the +dashboard view": 33 `parse*` boundary functions exist. The OpenAPI codegen toolchain is +real: `npm run gen:api` generates `libs/shared/src/infrastructure/api-client.ts` and CI +drift-checks it. ponytail: build the pattern once on one slice; copy it across screens when the real backend lands, rather than scaffolding all of it up front. diff --git a/docs/reference/architecture/0002-user-groups-and-bounded-contexts.md b/docs/reference/architecture/0002-user-groups-and-bounded-contexts.md index c5cb2c8..f4c12e6 100644 --- a/docs/reference/architecture/0002-user-groups-and-bounded-contexts.md +++ b/docs/reference/architecture/0002-user-groups-and-bounded-contexts.md @@ -1,6 +1,6 @@ # ADR 0002 — User groups as actors, not bounded contexts -Status: Proposed · Date: 2026-07-01 +Status: Accepted · Date: 2026-07-01 · Amended 2026-08-01 (WP-67), 2026-08-27 (RB-13) ## Problem @@ -160,11 +160,41 @@ changes is purely the _packaging_: ## Out of scope here (next steps, not built) -- Building the Behandeling backoffice application. - Real authentication: DigiD (SSP) and employee SSO / eHerkenning (backoffice). -- The `auth` `Session → Principal` refactor — deferred until a second actor is actually introduced. -- The backend aanvraag status lifecycle + authorization endpoints/DTOs. -ponytail: this ADR draws the boundaries so nothing has to be undone later; it does **not** scaffold a -second app or a role system now. Introduce the `Principal` union and the status lifecycle when the -backoffice work actually starts — YAGNI until then. +Two bullets that stood here — building the Behandeling backoffice, and the backend aanvraag +status lifecycle + authorization endpoints/DTOs — **shipped** (WP-61…WP-67): `apps/behandelportal`, +`AanvraagStatusTag` (`Domain/Applications/AanvraagStatus.cs`), `GET /me` (`Program.cs:578`), +`Domain/Authorization/Authz.cs`. + +A third bullet stood here too — `Session → Principal` — from 2026-08-26 until it was paid +off by RB-13 the next day. See the amendment below for the historical record and what +landed. + +## Amendment (RB-13, 2026-08-27): `Session → Principal` landed + +§3's `Principal` union was accepted on 2026-07-01 and not executed until now — see the +"Known debt" record this replaces, added 2026-08-26 by the refactor-backlog audit +(`ADR-C-004`) that found it. `apps/ssp/src/app/auth/domain/principal.ts` now exports the +`zorgverlener` variant (`{ kind: 'zorgverlener'; bsn; naam }`); +`apps/behandelportal/src/app/auth/domain/principal.ts` exports the `medewerker` variant +(`{ kind: 'medewerker'; medewerkerId; naam; rollen }`) — each app holds only the one +member of the union it actually has an actor for, per this ADR's own proposed resolution. +`apps/behandelportal`'s `DigidAdapter` is gone; a `MedewerkerAdapter` resolves the +dev-stand-in medewerker identity (`medewerker.ts`'s `MEDEWERKER_ID`/`currentRollen()` — +unchanged, still the mechanism `medewerkerInterceptor` uses for the backend headers) into +a `Principal` instead, and `login.page.ts` is an SSO-stand-in entry (one button, no BSN +field) rather than the citizen DigiD form it used to share with the SSP verbatim. + +The two `auth` contexts, measured 2026-08-27 after the change +(`tools/baseline-scan.mjs --dup`): **32 duplicated lines each** (from 168 at the +2026-08-26 measurement above; from 211 before ADR-C-006 shared the route guards). What +remains is not re-converged identity/login-flow code — it is `auth.guard.ts`'s intentional +verbatim re-export (ADR-C-006: a route guard is actor-agnostic, not in this ADR's scope) +plus ordinary test/story-file boilerplate (`describe`/`it` shape, a `Meta`/`StoryObj` +scaffold) that any two spec or story files share regardless of subject. The prediction in +§3 — that Zorgverlener and Medewerker, modelled as distinct `Principal` variants, would +turn out to authenticate differently enough that sharing `auth` would have been the wrong +call — has now actually been tested, not just asserted, and held: the two contexts diverge +in domain type, adapter, and login UI as soon as the union exists to make that +divergence possible. diff --git a/docs/reference/architecture/0003-cibg-huisstijl.md b/docs/reference/architecture/0003-cibg-huisstijl.md index 78edb54..8ddc4d5 100644 --- a/docs/reference/architecture/0003-cibg-huisstijl.md +++ b/docs/reference/architecture/0003-cibg-huisstijl.md @@ -19,28 +19,47 @@ layer — not a palette swap. ## Decision 1. **Vendor the package** under `public/cibg-huisstijl/` (not an npm dep — it was delivered as files), - loaded via a `` in `src/index.html` so the CSS's relative `url(../fonts|icons|images)` - references resolve at runtime. Storybook serves the same via `staticDirs`. -2. **Token bridge over token rewrite.** `src/styles.scss` redefines the app's ~54 `--rhc-*` tokens + loaded via a `` in each app's `index.html` (`apps/ssp/src/index.html` and + `apps/behandelportal/src/index.html` — two since WP-67) so the CSS's relative + `url(../fonts|icons|images)` references resolve at runtime. `public/` stays at the repo + root and both apps' `angular.json` targets copy it. Both Storybook instances serve the + same via `staticDirs: ['../public']`. +2. **Token bridge over token rewrite.** `libs/shared/styles.scss` — one copy, both apps' + `angular.json` point at it (WP-67) — redefines the app's ~54 `--rhc-*` tokens onto CIBG values (`--bs-*` where one exists, CIBG palette hex otherwise). The `--rhc-*` names are now an internal alias set; the _values_ are CIBG. This avoided rewriting 300+ token references and - keeps the "components reference tokens" convention intact. (`styles.scss` is exempt from - `check:tokens`, so palette hex lives in that one file only.) + keeps the "components reference tokens" convention intact. (`libs/shared/styles.scss` is + exempt from `check:tokens`, so palette hex lives in that one file only.) 3. **Re-skin atoms, keep their `input()` APIs.** Each `shared/ui` atom now emits Bootstrap/CIBG classes (`app-button` → `btn btn-primary`, `text-input` → `form-control`, radio/checkbox → `form-check-*`); domain pages compose the same atoms and barely changed. -4. **Hand-roll what CIBG's build drops.** CIBG omits Bootstrap's `.alert` and `.navbar`, so `app-alert` - is a small token-styled surface and the header/side-nav use `.nav` + a local blue bar. Local class - names that collide with Bootstrap components were renamed (`.card` → `.app-card`, badge → `.status-badge`). +4. **Hand-roll what CIBG's build drops, and mark it.** Where the vendored build has no class for a + concept, the component is a small token-styled surface carrying a `// CIBG-GAP EXTENSION:` marker. + The clearest live examples are `skeleton` and `spinner`: CIBG documents "Laadindicatie" but the + vendored build ships no loading-skeleton or loading-spinner class, so both are built from the token + bridge. Local class names that collide with Bootstrap components were renamed (`.card` → `.app-card`, + badge → `.status-badge`). + + Two claims this point used to make were wrong and are corrected here. **`.alert` is not a gap:** + `app-alert` is a thin wrapper over the vendored `.feedback feedback-*` classes — the design system + owns surface and icon, and the component adds only the icon's a11y label and a flex fix. It carries + no gap marker, correctly. **The header is not hand-rolled either:** `site-header` composes the + vendored `.titlebar` and `.logo__*` classes and leaves the robijn fill (`--ro-layout`) untouched. + The `shell` template's `.layout`/`.main`/`.content` classes are page scaffolding, not a substitute + for a missing design-system component, so they carry no marker either. + 5. **System-font stack; no licensed fonts.** `--bs-font-sans-serif` is overridden to `system-ui`; the licensed RO/Rijks **text** woffs are removed from the vendored copy (CIBG icon font kept). Logo stays a text wordmark. Interactivity stays Angular-driven (no Bootstrap JS). ## Consequences -- Wiring the design system touches `styles.scss` (token bridge), `index.html`, `angular.json` - (`public/` already copied), and `.storybook/` — plus the class strings in ~40 `shared/ui` + - `shared/layout` + a few domain components. The `@rijkshuisstijl-community/*` deps are dropped. +- Wiring the design system touches `libs/shared/styles.scss` (token bridge), both apps' + `index.html`, both `angular.json` targets (`public/` already copied), and both Storybook config + dirs (`.storybook-ssp/` and `.storybook-behandelportal/` — separate since WP-67, because a single + merged tsconfig cannot resolve both apps' `@auth/*` at once) — plus the class strings in ~40 + `libs/shared/ui` + `libs/shared/layout` + a few domain components. The + `@rijkshuisstijl-community/*` deps are dropped. - `check:tokens` still guards raw hex in components; the token bridge + hand-rolled surfaces comply. - Known benign build warning: _"Unable to locate stylesheet: /cibg-huisstijl/css/huisstijl.min.css"_ — Angular's index optimizer doesn't process a `public/` stylesheet at build time. The asset is copied @@ -49,6 +68,6 @@ layer — not a palette swap. intentionally dropped, so we accept the warning. - Renaming the internal token names from `--rhc-*` to `--app-*` is possible later but out of scope. - Hand-rolled components (point 4) are tracked in the **CIBG gap register** - (`src/docs/cibg-gaps.mdx`, Storybook "Foundations/CIBG Gap Register"): every deviation from the - design system carries a `// CIBG-GAP EXTENSION:` marker so it's auditable rather than silently - drifting. + (`libs/shared/docs/cibg-gaps.mdx`, Storybook "Foundations/CIBG Gap Register"): every deviation + from the design system carries a `// CIBG-GAP EXTENSION:` marker so it's auditable rather than + silently drifting. diff --git a/docs/reference/architecture/0004-stamdata-as-code.md b/docs/reference/architecture/0004-stamdata-as-code.md index 1e7d362..1a05976 100644 --- a/docs/reference/architecture/0004-stamdata-as-code.md +++ b/docs/reference/architecture/0004-stamdata-as-code.md @@ -20,7 +20,7 @@ was neither isolated nor validated: - All reference data and thresholds are **compiled-in C# constants**, served through screen-shaped BFF-lite endpoints; the frontend renders decisions and holds no reference data (ADR-0001). -- User-facing UI copy is already **`$localize`** (`src/locale/*.xlf`) — git-tracked, and a +- User-facing UI copy is already **`$localize`** (`apps//src/locale/*.xlf`) — git-tracked, and a second locale is a translation file, not a code change. That is already the compile-time model for text. - The profession↔diploma map lived as a _private_ `Dictionary` inside `DiplomaRules`, mixed @@ -62,17 +62,49 @@ production database, never runtime-editable. | Kind | Home | Gate | | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | Reference tables + tunable numbers (professions↔diplomas, thresholds, policy questions, document categories) | `Stamdata/` typed C# **or** typed JSON data-file (`professions.json`), optionally valid-timed | compiler (shape; + values when C#) + `StamdataValidationTests` (values, references, validity windows) | -| User-facing UI copy | `$localize` → `src/locale/*.xlf` | build (`i18nMissingTranslation: error`) | +| User-facing UI copy | `$localize` → `apps//src/locale/*.xlf` | build (`i18nMissingTranslation: error`) | | Letter / brief passage content | config-as-code in the backend (seed content), **not** the DB | compiler + endpoint tests | -### The deliberate exception: org-templates +### The deliberate exception: operational configuration -Per-organization letterhead (return address, footer, signature, margins) **is** -runtime-editable in SQLite, via the org-template admin editor (WP-23/26). That is -intentional and does not contradict this ADR: it is _operational configuration_ owned by an -admin persona, versioned with publish/rollback inside the app, and specific to one -sub-organization's identity — not the shared business rules a wrong value would break for -everyone. Stamdata (the rules and reference tables the whole register runs on) stays code. +"Never runtime-editable" above is the rule for **stamdata** — the shared reference tables +and business rules the whole register runs on. It is not a ban on all persisted +configuration. Some configuration is operational rather than business-rule, and belongs to +an admin persona at runtime. + +This section states the **test** rather than a list, so the next surface can check itself +instead of arguing by analogy. Runtime-editable persistence is permitted only when all four +hold: + +1. **The catalog lives in code.** What may be set — the keys, the schema, the defaults, + the descriptions — is compiled in and reviewed through git. The store holds values, never + the definition of what a value means. +2. **An unknown or unlisted key fails closed.** A row the code catalog does not know cannot + invent a setting, enable a feature, or be written. A bad row is inert, not authoritative. +3. **The value is operational.** Per-organisation identity, or an on/off rollout switch — + not a shared business rule whose wrong value breaks the register for everyone. This is the + clause that keeps stamdata out. +4. **Writes are admin-capability-gated and audited.** The write path goes through an `Authz` + capability gate, and the gate records the decision — allow as well as deny — in + `AuthzAuditStore`. + +**Two surfaces pass this test today.** + +| Surface | (1) catalog in code | (2) fails closed | (3) operational | (4) gated + audited | +| ----------------------------- | ----------------------------------------------- | ------------------------------------------------------------ | --------------------------------- | ------------------------------- | +| `OrgTemplateStore` (WP-23/26) | the `OrgTemplateDto` shape + `OrgTemplateRules` | unknown `subOrgId` → `null` → the endpoint 404s | one sub-organisation's letterhead | `OrgAdmin` → `orgtemplate:edit` | +| `FeatureFlagStore` (WP-47) | `Domain/Features/FeatureFlags.Catalog` | unknown key → `Set` returns false (404); `IsEnabled` → false | an on/off rollout switch | `FlagsAdmin` → `flags:manage` | + +Clause (4) became true for both only with RB-07, which moved `AuditAuthz` from each gate's +deny branch into the gate itself so the allow path is recorded too. Before that, both +surfaces were gated and **not** audited, and this ADR would have ratified a control the code +did not implement. + +Org-templates also carry publish/rollback versioning inside the app, which is stronger than +the test requires but not part of it. + +Stamdata itself — the rules and reference tables — fails clause (3) by construction and +stays code. ## Consequences diff --git a/docs/reference/architecture/dependencies.md b/docs/reference/architecture/dependencies.md index 8b97476..cb878e2 100644 --- a/docs/reference/architecture/dependencies.md +++ b/docs/reference/architecture/dependencies.md @@ -35,7 +35,7 @@ reverse. An app may not import the other app's source directly. **Atomic-layer rules:** `domain/` is framework-free (no Angular); `contracts/` import nothing (pure wire DTOs, ADR-0001); `ui/` + `layout/` never import `infrastructure/` directly (reach data through an application store/command — type-only DTO imports are fine); the generated `ApiClient` -is a value only inside `infrastructure/` (+ `libs/shared/src/upload`). Plus **no circular** +is a value only inside `infrastructure/`. Plus **no circular** dependencies. These apply uniformly across an app's tree and both libraries — no debug-state exception anymore (WP-67 moved the dev panel component out of `libs/shared` into `apps/ssp` since it's genuinely SSP-specific, coupled to `BigProfileStore`; the shared `ShellComponent` hosts diff --git a/docs/reference/roles-and-access.md b/docs/reference/roles-and-access.md index 5348023..6613c2f 100644 --- a/docs/reference/roles-and-access.md +++ b/docs/reference/roles-and-access.md @@ -20,7 +20,15 @@ acting role to exercise the drafter/approver/admin flows. ## How to switch role (dev only) -Both are wired only under `isDevMode()` — they do not exist in a production build. +Both are wired only under `isDevMode()` — they do not exist in a production build. That +gate lives in two places: the `roleInterceptor` registration (`app.config.ts`) for every +`HttpClient` request, **and** inside `role.ts`'s `currentRole()` itself, because three +hand-written `fetch` calls (`reveal-bignummer.adapter.ts`, `letter-preview.adapter.ts`, +`org-template.adapter.ts`'s proefbrief) read the role directly and bypass the +interceptor entirely (RB-11/BIO-012). Before RB-11, `currentRole()` had no such gate, so +`?role=` kept working through those three calls in a production build even though this +page said otherwise; the same defect applied to `?subject=` and `subject.ts`, which is +how a BSN reached `sessionStorage` in any build. - **Dev switcher (easiest):** open the `⚙ state` panel (bottom-right in a dev build) and pick a role from the **role** dropdown. The page reloads with the new role. @@ -66,6 +74,16 @@ The admin pages appear in the header nav and in the dashboard **"Beheer"** secti matching capability is present — otherwise they are reachable only by URL (and the route guard redirects a user who lacks the capability back to `/dashboard`). +**`drafter` is also the backend's fallback identity (BIO-006).** It is not only the dev +switcher's initial selection — `StubIdentityProvider`'s role switch resolves **any** +request with no `X-Role` header at all (or an unrecognised one) to `drafter` too. Because +`drafter` is also the _only_ role that may reveal a BIG-nummer, the least-privilege +consequence is real: an unauthenticated or misconfigured caller inherits the PII-reveal +capability by default, rather than the weakest one. This is acceptable only because the +POC has no real identity or step-up yet (see the pre-production compliance checklist — +binding the reveal to an app-overlay attribute instead of the coarse role is a named, +not-yet-built item); it must not survive real identity and step-up. + ## The one principle Identity (AD/OIDC, faked here) supplies **coarse roles**; the app owns a **fine-grained capability** diff --git a/e2e/brief-v2.spec.ts b/e2e/brief-v2.spec.ts index 25c8742..4e696a7 100644 --- a/e2e/brief-v2.spec.ts +++ b/e2e/brief-v2.spec.ts @@ -7,9 +7,14 @@ import { Actors, loginAs } from './support/actors'; // Preview assertions are content-type/body-level (text/html + watermark marker), not // pixel, per WP-28's decision. // -// This test mutates real state (a letter, keyed per-owner by `BriefStore.GetOrCreate`), -// and WP-74 gives it a fresh throwaway backend DB every `npm run e2e` run, so a -// leftover/in-progress letter from a PREVIOUS RUN is never an issue any more. It +// This test mutates real state (a letter, keyed per-owner by `BriefStore.Get`/ +// `ResetAndCreate` — RB-23 split the old `GetOrCreate`), and WP-74 gives it a fresh +// throwaway backend DB every `npm run e2e` run, so a leftover/in-progress letter from +// a PREVIOUS RUN is never an issue any more. `GET /brief` 404s on that fresh DB until +// the "Opnieuw beginnen (demo)" click below creates the first row — RB-22's +// `BriefStore.load()` already tolerates that 404 by calling `reset()` once, so the +// page renders correctly either way; the explicit click is this test's own fixture +// setup, not a workaround for the 404. It // deliberately still logs in as the shared `Actors.zorgverlener` rather than its own // BSN, though: giving it a distinct BSN (as `smoke.spec.ts` does) hit a real, // reproducible bug in this repo's own e2e run — `GET /brief/preview`'s sent-letter diff --git a/libs/beheer/src/application/stamdata.store.spec.ts b/libs/beheer/src/application/stamdata.store.spec.ts index d81c350..14958ec 100644 --- a/libs/beheer/src/application/stamdata.store.spec.ts +++ b/libs/beheer/src/application/stamdata.store.spec.ts @@ -1,7 +1,8 @@ import { TestBed } from '@angular/core/testing'; import { describe, it, expect } from 'vitest'; import { Result, ok } from '@shared/kernel/fp'; -import { StamRow, StamTable } from '@beheer/domain/stamdata'; +import { BLOB_PRESENTER, BlobPresenter } from '@shared/application/blob-presenter'; +import { StamRow, StamTable, toJson } from '@beheer/domain/stamdata'; import { StamdataAdapter } from '@beheer/infrastructure/stamdata.adapter'; import { StamdataStore } from './stamdata.store'; @@ -16,13 +17,30 @@ const table: StamTable = { }; const rows: StamRow[] = [{ program: 'geneeskunde', beroep: 'Arts' }]; -function setup(): StamdataStore { +/** A recording fake of BLOB_PRESENTER — records every call instead of touching the DOM, + which is what TE-006's seam is for: the store's success path becomes assertable. */ +function fakeBlobPresenter() { + const opened: Blob[] = []; + const downloaded: { blob: Blob; filename: string }[] = []; + const presenter: BlobPresenter = { + open: (blob) => opened.push(blob), + download: (blob, filename) => downloaded.push({ blob, filename }), + }; + return { presenter, opened, downloaded }; +} + +function setup(blobPresenter?: BlobPresenter): StamdataStore { const adapter: Partial = { list: (): Promise> => Promise.resolve(ok([table])), load: (): Promise> => Promise.resolve(ok({ table, rows: rows.map((r) => ({ ...r })) })), }; - TestBed.configureTestingModule({ providers: [{ provide: StamdataAdapter, useValue: adapter }] }); + TestBed.configureTestingModule({ + providers: [ + { provide: StamdataAdapter, useValue: adapter }, + ...(blobPresenter ? [{ provide: BLOB_PRESENTER, useValue: blobPresenter }] : []), + ], + }); return TestBed.inject(StamdataStore); } @@ -63,3 +81,57 @@ describe('StamdataStore undo/redo (WP-32)', () => { expect(store.canUndo()).toBe(false); }); }); + +// --- RB-28 (TE-006): download() ends in BLOB_PRESENTER.download, not raw DOM calls, +// so the seam makes both the guard's branches and the success path assertable. --- + +describe('StamdataStore.download (RB-28)', () => { + it('does not call the presenter while the two-clause guard blocks (nothing dirty yet)', async () => { + // Given a freshly loaded table with no edits — canDownload() is false. + const { presenter, downloaded } = fakeBlobPresenter(); + const store = setup(presenter); + await store.load(); + expect(store.canDownload()).toBe(false); + + // When download() is called... + store.download(); + + // Then the guard's true branch fires and the presenter is never reached. + expect(downloaded).toHaveLength(0); + }); + + it('does not call the presenter while previewing a date, even with edits', async () => { + // Given a loaded table with a real edit, but a preview date filter active. + const { presenter, downloaded } = fakeBlobPresenter(); + const store = setup(presenter); + await store.load(); + store.editCell(0, 'beroep', 'Chirurg'); + store.setPreviewDate('2024-01-01'); + expect(store.canDownload()).toBe(false); + + // When download() is called... + store.download(); + + // Then the guard still blocks it. + expect(downloaded).toHaveLength(0); + }); + + it("passes toJson(...)'s exact output and the table id as the filename (success path)", async () => { + // Given a loaded table with a valid, dirty edit — canDownload() is true. + const { presenter, downloaded } = fakeBlobPresenter(); + const store = setup(presenter); + await store.load(); + store.editCell(0, 'beroep', 'Chirurg'); + expect(store.canDownload()).toBe(true); + const expectedJson = toJson(store.table()!, store.rows()); + + // When download() is called... + store.download(); + + // Then the presenter receives exactly one call, with toJson's output reaching the + // file byte-for-byte and the table id as the file name. + expect(downloaded).toHaveLength(1); + expect(downloaded[0].filename).toBe('professions.json'); + await expect(downloaded[0].blob.text()).resolves.toBe(expectedJson); + }); +}); diff --git a/libs/beheer/src/application/stamdata.store.ts b/libs/beheer/src/application/stamdata.store.ts index c3f9154..0574b13 100644 --- a/libs/beheer/src/application/stamdata.store.ts +++ b/libs/beheer/src/application/stamdata.store.ts @@ -18,6 +18,7 @@ import { reduce, } from '@beheer/domain/stamdata-editor.machine'; import { StamdataAdapter } from '@beheer/infrastructure/stamdata.adapter'; +import { BLOB_PRESENTER } from '@shared/application/blob-presenter'; type LoadedState = Extract; @@ -30,6 +31,7 @@ type LoadedState = Extract; @Injectable({ providedIn: 'root' }) export class StamdataStore { private adapter = inject(StamdataAdapter); + private blobPresenter = inject(BLOB_PRESENTER); private store = createStore(initial, reduce); readonly model = this.store.model; @@ -138,12 +140,7 @@ export class StamdataStore { const s = this.loaded(); if (!s || !this.canDownload()) return; const blob = new Blob([toJson(s.table, s.rows)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${s.table.id}.json`; - a.click(); - URL.revokeObjectURL(url); + this.blobPresenter.download(blob, `${s.table.id}.json`); } } diff --git a/libs/beheer/src/infrastructure/stamdata.adapter.ts b/libs/beheer/src/infrastructure/stamdata.adapter.ts index a4070ab..7728ae6 100644 --- a/libs/beheer/src/infrastructure/stamdata.adapter.ts +++ b/libs/beheer/src/infrastructure/stamdata.adapter.ts @@ -1,6 +1,6 @@ import { Injectable, inject } from '@angular/core'; import { Result, ok, err } from '@shared/kernel/fp'; -import { runSubmit } from '@shared/application/submit'; +import { runResult } from '@shared/application/submit'; import { ApiClient, StamdataColumnDto } from '@shared/infrastructure/api-client'; import { ColumnType, StamColumn, StamRow, StamTable } from '@beheer/domain/stamdata'; @@ -24,7 +24,7 @@ export class StamdataAdapter { /** The tables in the catalog (schema only, no rows) — for the table switcher. */ async list(): Promise> { - const r = await runSubmit(() => this.client.stamdataTables(), FAILED); + const r = await runResult(() => this.client.stamdataTables(), FAILED); if (!r.ok) return r; const out: StamTable[] = []; for (const t of r.value ?? []) { @@ -39,7 +39,7 @@ export class StamdataAdapter { valid on that date; the editor uses it for a server-side cross-check, previewing locally for instant feedback (see `activeOn`). */ async load(tableId: string, peildatum?: string): Promise> { - const r = await runSubmit(() => this.client.stamdataTable(tableId, peildatum), FAILED); + const r = await runResult(() => this.client.stamdataTable(tableId, peildatum), FAILED); return r.ok ? parseStamdataTable(r.value) : r; } } diff --git a/libs/beheer/src/ui/feature-flags.page.ts b/libs/beheer/src/ui/feature-flags.page.ts index d97c404..f8db815 100644 --- a/libs/beheer/src/ui/feature-flags.page.ts +++ b/libs/beheer/src/ui/feature-flags.page.ts @@ -1,4 +1,4 @@ -import { Component, computed, inject } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; import { AlertComponent } from '@shared/ui/alert/alert.component'; import { ButtonComponent } from '@shared/ui/button/button.component'; @@ -45,6 +45,9 @@ import { FeatureFlagStore } from '@shared/application/feature-flags.store'; } @else if (!canManage()) { {{ deniedText }} } @else { + @if (setError(); as e) { + {{ e }} + } {{ failedText }} @@ -89,8 +92,13 @@ export class FeatureFlagsPage { protected enableText = $localize`:@@flags.enable:Aanzetten`; protected disableText = $localize`:@@flags.disable:Uitzetten`; - protected toggle(key: string, enabled: boolean) { - void this.store.set(key, enabled); + /** The last failed toggle, so a rejected PUT is visible instead of silently snapping back. */ + protected setError = signal(null); + + protected async toggle(key: string, enabled: boolean) { + this.setError.set(null); + const r = await this.store.set(key, enabled); + if (!r.ok) this.setError.set(r.error); } protected reload() { void this.store.load(); diff --git a/libs/shared/docs/behaviour-spec.mdx b/libs/shared/docs/behaviour-spec.mdx index 34118ac..2d7c71d 100644 --- a/libs/shared/docs/behaviour-spec.mdx +++ b/libs/shared/docs/behaviour-spec.mdx @@ -20,36 +20,40 @@ tested where._ Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test method name (backend), read as a sentence. Nothing here is hand-written prose: this page -**is** the suite, reshaped for a business reader. 406 frontend behaviours across -8 contexts; 217 backend behaviours across 36 test +**is** the suite, reshaped for a business reader. 497 frontend behaviours across +9 contexts; 261 backend behaviours across 42 test classes. ## Frontend (by context) ### auth -#### authGuard - -- allows an authenticated user -- redirects an anonymous user to /login -- allows an authenticated user -- redirects an anonymous user to /login - -#### capabilityGuard - -- waits for /me, then allows an entitled admin -- sends an authenticated-but-unentitled user to /dashboard (not a login loop) -- redirects an anonymous user to /login without waiting for caps -- waits for /me, then allows an entitled admin -- sends an authenticated-but-unentitled user to /dashboard (not a login loop) -- redirects an anonymous user to /login without waiting for caps - #### isAuthenticated -- narrows a present session to Session -- reports no session as not authenticated -- narrows a present session to Session -- reports no session as not authenticated +- narrows a present principal to Principal +- reports no principal as not authenticated +- narrows a present principal to Principal +- reports no principal as not authenticated + +#### parseRollen + +- parses a single recognized rol +- is case-insensitive and trims whitespace +- drops unrecognized tokens (the deny-path toggle, e.g. ?rollen=geen) +- returns an empty list for an empty string + +#### parseStoredPrincipal + +- returns null when nothing is stored +- returns null for a non-JSON string +- returns null when the stored shape is wrong (no naam) +- returns null when kind is not medewerker +- returns null when rollen holds an unrecognized token +- restores a well-shaped stored principal as-is (no BSN to strip) +- returns null when nothing is stored +- returns null for a non-JSON string +- returns null when the stored shape is wrong (no naam) +- G1: a stored bsn is never restored, even if present in the raw value ### behandeling @@ -110,6 +114,12 @@ classes. - records addRow and undoes it - clears history when switching table +#### StamdataStore.download (RB-28) + +- does not call the presenter while the two-clause guard blocks (nothing dirty yet) +- does not call the presenter while previewing a date, even with edits +- passes toJson(...)'s exact output and the table id as the filename (success path) + #### activeOn (valid-time, half-open [van, tot)) - includes a row whose window covers the date @@ -176,9 +186,14 @@ classes. - flushes a pending debounced edit immediately and clears the pending flag - is a no-op when no edit is pending +#### BriefStore.load — 404 tolerance (RB-22) + +- a 404 drives exactly one reset(), which populates the store +- a second 404 does not drive a second reset() + #### BriefStore.previewLetter -- opens the composed letter in a new tab on success +- opens the composed letter via BLOB_PRESENTER on success (RB-28) - surfaces the error without opening a tab on failure #### BriefStore.revealBigNummer (PRD-0002 §5c) @@ -186,6 +201,21 @@ classes. - swaps the masked value for the revealed one on success - keeps the value masked and surfaces the error on failure +#### LetterPreviewAdapter.preview (BIO-012) + +- sends no X-Role/X-Subject headers outside isDevMode() +- sends X-Role (and X-Subject when known) under isDevMode() + +#### OrgTemplateStore.proefbrief (RB-28) + +- opens the rendered proefbrief via BLOB_PRESENTER on success +- surfaces the error without opening a tab on failure + +#### RevealBigNummerAdapter.reveal (BIO-006a + BIO-012) + +- sends X-Step-Up only when the caller passes stepUp: true +- sends X-Role only under isDevMode() + #### besluitGuidance - positief: counts inserted passages, no reden needed (positief has no redenen) @@ -237,6 +267,12 @@ classes. - marks added, removed, changed and unchanged by blockId - changedBlocks drops unchanged and keeps added/removed/changed +#### errorMessage (TE-002 trust boundary) + +- surfaces the ProblemDetails detail when present +- falls back to PREVIEW_FAILED when the body has no detail +- falls back to PREVIEW_FAILED when the body is not JSON + #### inferSelection - round-trips a positief selection @@ -275,6 +311,13 @@ classes. - rejects a missing count field - rejects a malformed history entry +#### parseRevealed (TE-002 trust boundary) + +- accepts a well-formed body +- rejects a bigNummer sent as a number +- rejects a missing bigNummer field +- rejects null and non-object bodies + #### passagesForBesluit - positief = shared intro + the positief passage, no negatief/reason passages @@ -283,6 +326,12 @@ classes. - preserves library order (= reading order) - never offers non-kern passages +#### proefbriefErrorMessage (TE-002 trust boundary) + +- surfaces the ProblemDetails detail when present +- falls back to PROEFBRIEF_FAILED when the body has no detail +- falls back to PROEFBRIEF_FAILED when the body is not JSON + #### redenenFor - derives reason checkboxes (code + label) from the negatief reason passages @@ -373,7 +422,15 @@ classes. - loads and parses the cross-owner list - deletes optimistically and confirms via the admin endpoint -- rolls back the removal when the delete fails +- rolls back the removal and surfaces the error when the delete fails +- clears a stale error on the next delete attempt + +#### ApplicationsStore + +- loads and parses the list +- cancels optimistically and confirms via the DELETE endpoint +- rolls back the removal and surfaces the error when the cancel fails +- clears a stale error on the next cancel attempt #### STEPS (fixed) @@ -437,6 +494,14 @@ classes. - lists soort/waarvoor/status/referentie/ingediend, plus reason when rejected - reference falls back to em dash for a Concept +#### findConcept + +- returns the id of the existing Concept of the given type +- returns undefined when the list has no application of the given type +- returns undefined when the matching type is not a Concept +- returns undefined when adapter.list() resolves with an unparsable shape +- returns undefined when adapter.list() rejects + #### hasProgress - is false for a fresh wizard @@ -453,6 +518,13 @@ classes. - derives the beroep from the chosen diploma and flags origin duo +#### loadConcept + +- reads the draft off a Concept +- reports a missing draft as null +- reports not-concept when the id has moved past Concept (submitted) +- reports not-concept when the id is unknown or deleted (detail rejects) + #### manual diploma fallback - KiesHandmatig flags handmatig with the maximal question set and no beroep yet @@ -614,6 +686,43 @@ classes. - map only touches Success - map2 precedence: Failure > Loading > Success +#### UploadShellService.cancel + +- calls the transport cancel function for an in-flight upload and forgets it +- is a no-op for a localId with nothing in flight + +#### UploadShellService.delete + +- dispatches UploadDeleting, then UploadDeleteComplete on success +- dispatches UploadDeleteFailed with the server detail on failure +- falls back to an empty reason when the server sends no detail + +#### UploadShellService.pollReturning + +- does nothing when there are no uploads to poll +- dispatches BackgroundUploadsReturned for uploads the server reports complete +- does not dispatch when nothing has arrived yet + +#### UploadShellService.upload + +- dispatches UploadQueued with the transport backgroundSync flag, then sends via the transport +- translates a progress callback into UploadProgress +- translates a resolved transport into UploadComplete +- translates a rejected transport into UploadFailed with the reason +- does not dispatch UploadFailed on a user-initiated abort + +#### authGuard + +- allows an authenticated user +- redirects an anonymous user to /login + +#### capabilityGuard + +- waits for /me, then allows an entitled admin +- sends an authenticated-but-unentitled user to /dashboard (not a login loop) +- redirects an anonymous user to /login without waiting for caps +- reads authentication through the port, not an app-local store + #### createDebouncedSave - flushes after the delay when canSave is true @@ -635,6 +744,29 @@ classes. - applies the pure update on dispatch - dispatch from inside an effect does not self-loop +#### currentRole (dev mechanism) + +- lists the three roles +- reads a valid ?role= from the URL and persists it for the tab +- falls back to drafter when nothing is set or the value is invalid + +#### currentRole (dev mechanism) › outside isDevMode() (production build) + +- ignores a ?role= in the URL and returns the default +- never touches sessionStorage +- ignores a role already sitting in sessionStorage from a prior dev session + +#### currentSubject (dev mechanism) + +- reads a ?subject= from the URL and persists it for the tab +- returns undefined when nothing has ever been set + +#### currentSubject (dev mechanism) › outside isDevMode() (production build) + +- ignores a ?subject= (a BSN) in the URL and returns undefined +- never writes the BSN into sessionStorage +- ignores a subject already sitting in sessionStorage from a prior dev session + #### delete flow (optimistic, revertible) - UploadDeleteRequested keeps the documentId for revert @@ -702,6 +834,16 @@ classes. - maskTail keeps the requested tail length - masks the whole value when it is not longer than the kept tail +#### planFileSelection + +- plans nothing for an unknown category +- rejects the whole batch with reason "multiple" for a single-file category +- does not reject a single file against a single-file category +- rejects one file via rejectReason (wrong type) +- rejects one file via rejectReason (too large) +- plans a FileSelected entry for a file that passes format validation +- judges each file independently and preserves order for a mixed multiple-file category + #### problemDetail - extracts the detail from an RFC-7807 ProblemDetails @@ -741,11 +883,19 @@ classes. - leaves an unrelated endpoint untouched +#### runResult + +- folds a resolved call into ok(value) +- maps a ProblemDetails rejection to err(detail) +- falls back when the rejection has no detail +- mints no Idempotency-Key — the read fold + #### runSubmit - folds a resolved call into ok(value) - maps a ProblemDetails rejection to err(detail) - falls back when the rejection has no detail +- mints exactly one Idempotency-Key for the whole call — the write fold #### satisfaction helpers @@ -779,12 +929,30 @@ classes. - failed then retried returns to queued - UploadRemoved drops the upload +#### uploadOutcome + +- resolves a 2xx response with a valid JSON body to the document id +- falls back to the generic error when a 2xx body is not valid JSON +- maps a non-2xx ProblemDetails body to its detail +- falls back to the generic error for a non-2xx body without a ProblemDetails detail +- treats status 200-299 as success and everything else as failure + #### withIdempotencyKey / currentIdempotencyKey - threads the key to every read made inside the wrapped fn - clears the key once the wrapped fn settles - falls back to a generated uuid-shaped key when none is pending +### shell + +#### redactProfile + +- masks the BIG-nummer to its last 3 digits +- redacts the name on both the registration and the person +- redacts every date of birth +- redacts the address +- keeps structural/decision-relevant fields untouched + ### showcase #### highlightTs @@ -829,6 +997,11 @@ classes. - A denied admin action is recorded - A reveal attempt is recorded +- An allowed admin action is recorded +- An admin upload delete is recorded +- A feature flag toggle records which flag changed +- A refused brief transition is recorded +- No audit row carries a subjects bsn - The audit schema carries no pii ### AuthzTests @@ -880,7 +1053,8 @@ classes. ### BriefEndpointTests -- Get creates a draft with expected sections locked and empty +- Get returns 404 and writes no row when no brief exists for the owner +- SeedBrief creates a draft with expected sections locked and empty - Get offers only global and arts scoped besluit tagged passages - Get joins the case context with the BIG nummer masked - Reveal returns the unmasked BIG nummer for the drafter with step up @@ -897,6 +1071,30 @@ classes. - Me returns no capabilities for drafter and the brief set for approver - Reset recreates a fresh draft with locked prefilled sections +### BriefRuleTests + +- A drafter may save a draft or rejected letter +- A drafter may not save a non editable letter +- A non drafter is forbidden to save regardless of status +- Saving a rejected letter reopens it to draft +- Saving a draft letter leaves its status unchanged +- No required sections means nothing to fill +- An optional empty section does not block submission +- A required section with a block is filled +- A required section with no blocks is not filled +- One unfilled required section blocks submission even if others are filled +- A drafter may submit a filled draft +- A drafter may not submit an unfilled draft +- A drafter may not submit a letter that is not a draft +- A non drafter is forbidden to submit even a filled draft +- An approved letter may be sent +- Only an approved letter may be sent +- An approver may decide a submitted letter drafted by someone else +- A drafter may not approve or reject +- An approver may not decide a letter they drafted themselves +- An approver may not decide a letter that is not submitted +- Entitlement is checked before status forbidden wins over conflict + ### DiplomaRuleTests - Profession is derived from program @@ -925,10 +1123,8 @@ classes. - Brp returns address - Duo lookup carries server decided questions and professions - IntakePolicy returns scholing threshold -- Registration with duo diploma succeeds -- Registration with manual diploma is rejected with problem details - Change request with valid phone succeeds -- Change request with bad phone is rejected +- Change request with bad phone is rejected with problem details - Health endpoint is ok - Correlation id supplied by the caller is echoed back - Correlation id is generated when the caller omits it @@ -960,6 +1156,7 @@ classes. - Replaying the same idempotency key returns the same reference not a new one - Different idempotency keys are independent submissions +- A caller replaying another callers idempotency key does not get their cached result - A rejected submission replays the same rejection not a retry ### IntakeRuleTests @@ -983,6 +1180,8 @@ classes. ### LetterHtmlTests - Render matches the golden file +- Render resolves the body datum placeholder from the given at not the wall clock +- Render keeps the letterhead date and the body datum in agreement for a historical at - Every letter prefixed class exists in letter css ### NotificatieTests @@ -1053,12 +1252,29 @@ classes. - Proefbrief is admin only - Proefbrief renders the draft template with a watermark +### ProductionIdentityProviderTests + +- Production environment with no real identity provider fails at startup + +### ProfessionsTests + +- A mapping is absent before its geldigVan +- A mapping is present on and after its geldigVan +- A closed mapping is absent from its geldigTot onwards +- ByProgram is evaluated per call not captured at type load + +### RouteInventoryTests + +- Every mapped route is authz gated or on the named allow list +- Every gate marker names a known admin wrapper + ### StamdataEndpointTests - Stamdata reads are admin only - Table list exposes the reflected schema - Table returns all rows without a peildatum - Peildatum before the seed windows hides every row +- Unparseable peildatum is 400 not 500 - Unknown table is 404 ### StamdataValidationTests @@ -1081,15 +1297,28 @@ classes. - Empty x medewerker falls through to the zorgverlener default - X rollen parses known tokens and drops unknown ones - X role still applies to a medewerker +- Never returns null even with no headers at all ### SubmissionRuleTests -- Manual diploma is rejected -- Duo diploma is accepted - Zero hours is rejected - Worked hours are accepted - Phone change is validated +### SwaggerGateTests + +- Swagger document is served in development +- Swagger document is not served outside development + +### UploadAccessTests + +- The owner can read the bytes +- Another citizen gets 404 not 403 +- A behandelaar can read a linked document +- A medewerker without the behandelaar rol does not +- The document audit trail records a masked actor +- Status reports another citizens localId as unknown + ### WerkvoorraadTests - Behandelaar sees submitted cases in the queue @@ -1106,6 +1335,7 @@ classes. - Submit with a failing zgw flags the divergence instead of diverging silently - Submit with a healthy zgw leaves no divergence flag +- A recorded divergence carries no response body and no query string ### ZgwTokenProviderTests diff --git a/libs/shared/docs/cibg-gaps.mdx b/libs/shared/docs/cibg-gaps.mdx index de8849b..577d764 100644 --- a/libs/shared/docs/cibg-gaps.mdx +++ b/libs/shared/docs/cibg-gaps.mdx @@ -31,6 +31,7 @@ placed above the `@Component` decorator, plus `parameters: { cibgGap: true }` an | `debug-state` | n/a | Dev-only tool, deliberately off-theme — see the component's own `ponytail:` note. | | `status-badge` | n/a | Deliberate custom status dot, not Bootstrap's `.badge` (pill padding/colour don't fit). | | `placeholder-chip` | n/a | No vendored inline-chip/tag class. | +| `language-switcher` | Taal instellen | No vendored Huisstijl class ships for it; a small hand-rolled surface built from the token bridge. | Not a gap: `confirmation` renders entirely with vendored `.confirmation*` classes (no `styles: [...]` block) — its header comment names the pattern, no marker needed. The `upload/` suite diff --git a/libs/shared/docs/testing.mdx b/libs/shared/docs/testing.mdx index 6249f3b..aad8f86 100644 --- a/libs/shared/docs/testing.mdx +++ b/libs/shared/docs/testing.mdx @@ -89,11 +89,11 @@ can only ever be a state the real reducer actually produces: const atStep3 = givenIntake(Start(), SetUren('1200'), Next(), SetDiplomaHerkomst('NL'), Next()); ``` -The same rule extends to value objects (`unwrapOk(parseX(raw))` instead of a cast) and to -`RemoteData` (`loading()` / `success(v)` / `failure(e)` in -`libs/shared/src/testing/{value-object,remote-data}.ts` instead of a redefined-per-file -literal). **Never** a `.withX().withY()` builder over an open constructor — that just -re-opens whatever illegal state the domain closed. +The same rule extends to value objects — call the real `parse*` and check `.ok` before use, +never a cast — and to `RemoteData` (`loading()` / `success(v)` / `failure(e)` in +`libs/shared/src/testing/remote-data.ts` instead of a redefined-per-file literal). **Never** +a `.withX().withY()` builder over an open constructor — that just re-opens whatever illegal +state the domain closed. ## UI = Storybook, not heavy component tests diff --git a/apps/behandelportal/src/app/auth/auth.guard.spec.ts b/libs/shared/src/application/auth.guard.spec.ts similarity index 82% rename from apps/behandelportal/src/app/auth/auth.guard.spec.ts rename to libs/shared/src/application/auth.guard.spec.ts index dfc3fb5..0487283 100644 --- a/apps/behandelportal/src/app/auth/auth.guard.spec.ts +++ b/libs/shared/src/application/auth.guard.spec.ts @@ -2,7 +2,7 @@ import { TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; import { describe, it, expect, vi } from 'vitest'; import { AccessStore } from '@shared/application/access.store'; -import { SessionStore } from './application/session.store'; +import { SESSION_PORT } from '@shared/application/session.port'; import { authGuard, capabilityGuard } from './auth.guard'; type Opts = { @@ -16,7 +16,7 @@ function setup({ authed, can = () => false, whenReady = () => Promise.resolve() const readySpy = vi.fn(whenReady); TestBed.configureTestingModule({ providers: [ - { provide: SessionStore, useValue: { isAuthenticated: () => authed } }, + { provide: SESSION_PORT, useValue: { isAuthenticated: () => authed } }, { provide: AccessStore, useValue: { whenReady: readySpy, can } }, { provide: Router, useValue: { createUrlTree } }, ], @@ -59,4 +59,11 @@ describe('capabilityGuard', () => { await expect(call>(guard())).resolves.toEqual({ tree: ['/login'] }); expect(readySpy).not.toHaveBeenCalled(); }); + + // The guard reads SESSION_PORT, never a concrete SessionStore — that is what lets one + // copy serve both apps while ADR-0002 §3 keeps their `auth` contexts separate. + it('reads authentication through the port, not an app-local store', () => { + setup({ authed: true }); + expect(TestBed.inject(SESSION_PORT).isAuthenticated()).toBe(true); + }); }); diff --git a/libs/shared/src/application/auth.guard.ts b/libs/shared/src/application/auth.guard.ts new file mode 100644 index 0000000..a252a39 --- /dev/null +++ b/libs/shared/src/application/auth.guard.ts @@ -0,0 +1,48 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; +import { AccessStore } from '@shared/application/access.store'; +import { SESSION_PORT } from '@shared/application/session.port'; +import { Capability } from '@shared/domain/capability'; + +/** + * Route guards, shared by both apps (ADR-C-006). + * + * These are deliberately **not** part of the `auth` context that ADR-0002 §3 keeps + * duplicated per app. That decision scopes to *identity and login flow* — `Principal`, + * DigiD vs. employee SSO. A route guard is neither: it asks only "is anyone logged in" + * and "may they do X", never "who are you or how did you get here". Both questions are + * answered through seams that already live here — `SESSION_PORT` and `AccessStore` — so + * the guards never see an actor type and have nothing to diverge on. + * + * Each app re-exports these from its own `auth/auth.guard.ts`, so `app.routes.ts` keeps + * importing `@auth/auth.guard` and the context boundary reads unchanged. + */ + +/** Route guard: only let authenticated users in; otherwise redirect to /login. */ +export const authGuard: CanActivateFn = () => { + const session = inject(SESSION_PORT); + const router = inject(Router); + return session.isAuthenticated() ? true : router.createUrlTree(['/login']); +}; + +/** + * Route guard factory (PRD-0002 §6): authenticated AND holding `capability`, else + * redirect. Used by the admin pages (`/brief/huisstijl`, `/beheer/stamdata`). + * + * **Async on purpose:** `can()` is deny-by-default, so it must not be read while `/me` + * is still loading — it would deny an entitled admin and bounce them. We await + * `AccessStore.whenReady()` (caps resolved) before deciding. An unauthenticated user + * goes to `/login`; an authenticated-but-unentitled user goes to `/dashboard` (they're + * logged in, just not allowed here — no re-login loop). The backend re-enforces + * regardless (403); this guard is the UX pre-gate. + */ +export function capabilityGuard(capability: Capability): CanActivateFn { + return async () => { + const session = inject(SESSION_PORT); + const access = inject(AccessStore); + const router = inject(Router); + if (!session.isAuthenticated()) return router.createUrlTree(['/login']); + await access.whenReady(); + return access.can(capability) ? true : router.createUrlTree(['/dashboard']); + }; +} diff --git a/libs/shared/src/application/blob-presenter.ts b/libs/shared/src/application/blob-presenter.ts new file mode 100644 index 0000000..a2b841d --- /dev/null +++ b/libs/shared/src/application/blob-presenter.ts @@ -0,0 +1,37 @@ +import { InjectionToken } from '@angular/core'; + +/** + * A shared seam for handing a generated `Blob` to the browser, WITHOUT the calling + * command inlining `URL.createObjectURL`/`window.open`/`document.createElement('a')` + * as its own last statement (TE-006) — those calls are unassertable in jsdom because + * they are the end of the command, not a value the spec can intercept. A recording + * fake satisfies this shape in specs; `realBlobPresenter` is the production default. + */ +export interface BlobPresenter { + /** Open a blob in a new tab (e.g. a rendered letter preview). Never revokes the + object URL — the tab outlives this call, and the POC treats the leak as cheap + (see `BriefStore.previewLetter`'s original comment). */ + open(blob: Blob): void; + /** Trigger a browser download of a blob under the given file name, then revoke the + object URL once the click has been dispatched. */ + download(blob: Blob, filename: string): void; +} + +const realBlobPresenter: BlobPresenter = { + open(blob: Blob) { + window.open(URL.createObjectURL(blob), '_blank'); + }, + download(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + }, +}; + +export const BLOB_PRESENTER = new InjectionToken('BLOB_PRESENTER', { + providedIn: 'root', + factory: () => realBlobPresenter, +}); diff --git a/libs/shared/src/application/feature-flags.store.ts b/libs/shared/src/application/feature-flags.store.ts index 94806e2..5f57b5f 100644 --- a/libs/shared/src/application/feature-flags.store.ts +++ b/libs/shared/src/application/feature-flags.store.ts @@ -1,10 +1,14 @@ import { Injectable, computed, inject, signal } from '@angular/core'; import { RemoteData } from '@shared/application/remote-data'; +import { runSubmit } from '@shared/application/submit'; +import { Result, ok, err } from '@shared/kernel/fp'; import { FeatureFlag } from '@shared/domain/feature-flag'; import { FeatureFlagsAdapter, parseFlags } from '@shared/infrastructure/feature-flags.adapter'; type Err = Error | undefined; +const SET_FAILED = $localize`:@@flags.set.failed:De functievlag kon niet worden opgeslagen.`; + /** * Runtime feature-flag state (WP-47) — one root singleton, mirroring `AccessStore`. Loads the * resolved flag set once from `GET /flags`; `enabled(key)` gates a feature (deny-by-default: @@ -47,12 +51,17 @@ export class FeatureFlagStore { return rd.tag === 'Success' && (rd.value.find((f) => f.key === key)?.enabled ?? false); } - /** Admin toggle: persist then reload so the state reflects the server. */ - async set(key: string, enabled: boolean) { - try { - await this.adapter.set(key, enabled); - } finally { - await this.load(); - } + /** + * Admin toggle: persist, then reload so the state reflects the server either way. + * + * Returns the failure rather than throwing. The previous `try/finally` had no `catch`, + * so a rejected PUT escaped into the `void store.set(...)` call site as an unhandled + * rejection: the reload then snapped the toggle back to its old value and the admin saw + * a control that silently refused to move, with no error anywhere. + */ + async set(key: string, enabled: boolean): Promise> { + const r = await runSubmit(() => this.adapter.set(key, enabled), SET_FAILED); + await this.load(); + return r.ok ? ok(undefined) : err(r.error); } } diff --git a/libs/shared/src/application/session.port.ts b/libs/shared/src/application/session.port.ts index da78cad..c94db44 100644 --- a/libs/shared/src/application/session.port.ts +++ b/libs/shared/src/application/session.port.ts @@ -8,6 +8,8 @@ import { InjectionToken, Signal } from '@angular/core'; */ export interface SessionPort { readonly session: Signal<{ naam: string } | null>; + /** Whether anyone is logged in. The shared route guards read only this — never who. */ + readonly isAuthenticated: Signal; logout(): void; } diff --git a/libs/shared/src/application/submit.spec.ts b/libs/shared/src/application/submit.spec.ts index 32abe86..1a770d6 100644 --- a/libs/shared/src/application/submit.spec.ts +++ b/libs/shared/src/application/submit.spec.ts @@ -1,5 +1,48 @@ import { describe, it, expect } from 'vitest'; -import { runSubmit } from './submit'; +import { runResult, runSubmit } from './submit'; +import { currentIdempotencyKey } from '@shared/infrastructure/api-client.provider'; + +// currentIdempotencyKey() returns the pending key minted by withIdempotencyKey while +// one is in flight, else a fresh random UUID on every call (see api-client.provider.ts). +// So calling it twice inside the same `fn` tells us, behaviourally, whether a key was +// minted for this call: two reads agreeing means one pending key was reused; two reads +// disagreeing means there was no pending key at all — each call fell back to its own +// random one. This is the seam RB-17 exists to keep separated, so it is asserted +// directly rather than via a mock (relative-import mocking is off-limits under this +// repo's Angular/vitest setup — see role.interceptor.spec.ts). + +describe('runResult', () => { + it('folds a resolved call into ok(value)', async () => { + const r = await runResult(async () => 'BIG-123', 'fallback'); + expect(r).toEqual({ ok: true, value: 'BIG-123' }); + }); + + it('maps a ProblemDetails rejection to err(detail)', async () => { + const r = await runResult(async () => { + throw { detail: 'Aanvraag afgewezen.' }; + }, 'fallback'); + expect(r).toEqual({ ok: false, error: 'Aanvraag afgewezen.' }); + }); + + it('falls back when the rejection has no detail', async () => { + const r = await runResult(async () => { + throw new Error('network'); + }, 'fallback'); + expect(r).toEqual({ ok: false, error: 'fallback' }); + }); + + it('mints no Idempotency-Key — the read fold', async () => { + let first = ''; + let second = ''; + await runResult(async () => { + first = currentIdempotencyKey(); + second = currentIdempotencyKey(); + return 'x'; + }, 'fallback'); + // No pending key: each read falls back to its own fresh random UUID. + expect(first).not.toBe(second); + }); +}); describe('runSubmit', () => { it('folds a resolved call into ok(value)', async () => { @@ -20,4 +63,17 @@ describe('runSubmit', () => { }, 'fallback'); expect(r).toEqual({ ok: false, error: 'fallback' }); }); + + it('mints exactly one Idempotency-Key for the whole call — the write fold', async () => { + let first = ''; + let second = ''; + await runSubmit(async () => { + first = currentIdempotencyKey(); + second = currentIdempotencyKey(); + return 'x'; + }, 'fallback'); + // One pending key reused across both reads inside this logical submit. + expect(first).toBe(second); + expect(first).not.toBe(''); + }); }); diff --git a/libs/shared/src/application/submit.ts b/libs/shared/src/application/submit.ts index 4ad4acf..6d5f59c 100644 --- a/libs/shared/src/application/submit.ts +++ b/libs/shared/src/application/submit.ts @@ -3,26 +3,36 @@ import { problemDetail } from '@shared/infrastructure/api-error'; import { withIdempotencyKey } from '@shared/infrastructure/api-client.provider'; /** - * Run a mutating API call and fold it into a `Result` — the one place the - * try/catch + ProblemDetails-mapping lives, so every `submit-*` command is just - * its own payload mapping. The backend re-validates and returns a 422 - * ProblemDetails on rejection, surfaced here as the error string. + * Run an API call and fold it into a `Result` — the one place the try/catch + + * ProblemDetails-mapping lives, so every adapter method is just its own payload + * mapping. The backend re-validates and returns a 422 ProblemDetails on + * rejection, surfaced here as the error string. * - * Also the one place a logical submit's Idempotency-Key is minted — once per - * `runSubmit` call, not per HTTP attempt — so a retry of this same submit - * dedupes on the backend (see `withIdempotencyKey`). + * This is the **read** half: it mints no Idempotency-Key. Use it for GETs. + * `runSubmit` below wraps it for mutations — never route a read through that + * one, it mints a key for nothing. */ -export async function runSubmit( +export async function runResult( fn: () => Promise, fallback: string, ): Promise> { try { - return ok(await withIdempotencyKey(crypto.randomUUID(), fn)); + return ok(await fn()); } catch (e) { return err(problemDetail(e, fallback)); } } +/** + * `runResult` for a **mutating** call: also mints the Idempotency-Key for this + * logical submit — once per `runSubmit` call, not per HTTP attempt — so a + * retry of this same submit dedupes on the backend (see `withIdempotencyKey`). + * Reads must go through `runResult` instead, which mints nothing. + */ +export function runSubmit(fn: () => Promise, fallback: string): Promise> { + return runResult(() => withIdempotencyKey(crypto.randomUUID(), fn), fallback); +} + // Single shared default for a failed submit; the @@id dedupes it at the // translation layer. export const SUBMIT_FAILED = $localize`:@@submit.failed:Het indienen is niet gelukt. Probeer het later opnieuw.`; diff --git a/libs/shared/src/upload/upload-controller.ts b/libs/shared/src/application/upload-controller.ts similarity index 74% rename from libs/shared/src/upload/upload-controller.ts rename to libs/shared/src/application/upload-controller.ts index cf6cb56..4e04579 100644 --- a/libs/shared/src/upload/upload-controller.ts +++ b/libs/shared/src/application/upload-controller.ts @@ -1,9 +1,19 @@ import { DestroyRef, effect, inject } from '@angular/core'; -import { CategoryParams, UploadAdapter } from './upload.adapter'; +import { + CategoryParams, + UploadAdapter, + uploadContentUrl, +} from '@shared/infrastructure/upload.adapter'; import { UploadShellService } from './upload-shell.service'; import { problemDetail } from '@shared/infrastructure/api-error'; import { SUBMIT_FAILED } from '@shared/application/submit'; -import { DeliveryChannel, UploadMsg, UploadState, inFlight, rejectReason } from './upload.machine'; +import { + DeliveryChannel, + UploadMsg, + UploadState, + inFlight, + planFileSelection, +} from '@shared/domain/upload.machine'; export interface UploadControllerDeps { wizardId: string; @@ -59,18 +69,22 @@ export function createUploadController(deps: UploadControllerDeps) { } return { + /** Preview/download link for a completed upload; the dev-simulation `demo-*` ids + have no stored bytes, so they get no link. */ + previewUrlFor(documentId: string): string | undefined { + return documentId.startsWith('demo-') ? undefined : uploadContentUrl(documentId); + }, onFileSelected(categoryId: string, selected: File[]) { - const cat = deps.getUpload().categories.find((c) => c.categoryId === categoryId); - if (!cat) return; - if (!cat.multiple && selected.length > 1) { - deps.dispatch({ type: 'FileRejected', categoryId, reason: 'multiple' }); - return; - } - for (const file of selected) { - const reason = rejectReason(cat, { type: file.type, sizeMb: file.size / 1e6 }); - if (reason) deps.dispatch({ type: 'FileRejected', categoryId, reason }); - else start(categoryId, file); - } + // The accept/reject decision lives in upload.machine.ts (planFileSelection), + // so it's testable without a DOM File. Each plan entry lines up by index with + // `selected`: a rejection dispatches as-is; anything else means "start", so the + // controller runs the one impure step the plan can't (crypto.randomUUID()). + const candidates = selected.map((f) => ({ name: f.name, type: f.type, size: f.size })); + const plan = planFileSelection(deps.getUpload(), categoryId, candidates); + plan.forEach((msg, i) => { + if (msg.type === 'FileRejected') deps.dispatch(msg); + else start(categoryId, selected[i]); + }); }, onRemove(localId: string) { shell.cancel([localId]); diff --git a/libs/shared/src/application/upload-shell.service.spec.ts b/libs/shared/src/application/upload-shell.service.spec.ts new file mode 100644 index 0000000..4a56652 --- /dev/null +++ b/libs/shared/src/application/upload-shell.service.spec.ts @@ -0,0 +1,230 @@ +import { TestBed } from '@angular/core/testing'; +import { describe, it, expect, vi } from 'vitest'; +import { + UploadAdapter, + UPLOAD_ABORTED, + XhrUploadHandle, +} from '@shared/infrastructure/upload.adapter'; +import { UploadMsg } from '@shared/domain/upload.machine'; +import { UploadShellService, UploadTransport, UPLOAD_TRANSPORT } from './upload-shell.service'; + +/** Records every `send()` call and lets a test resolve/reject/report progress by hand. */ +function fakeTransport(backgroundSyncAvailable = false) { + const handles: Array<{ + req: Parameters[0]; + onProgress: (pct: number) => void; + resolveDone: (v: { documentId: string }) => void; + rejectDone: (e: unknown) => void; + cancel: ReturnType; + }> = []; + const transport: UploadTransport = { + backgroundSyncAvailable, + send: vi.fn((req, onProgress) => { + let resolveDone!: (v: { documentId: string }) => void; + let rejectDone!: (e: unknown) => void; + const done = new Promise<{ documentId: string }>((res, rej) => { + resolveDone = res; + rejectDone = rej; + }); + const cancel = vi.fn(); + handles.push({ req, onProgress, resolveDone, rejectDone, cancel }); + const handle: XhrUploadHandle = { done, cancel }; + return handle; + }), + }; + return { transport, handles }; +} + +function setup(opts: { backgroundSync?: boolean; adapter?: Partial } = {}) { + const { transport, handles } = fakeTransport(opts.backgroundSync ?? false); + const adapter: Partial = { + status: vi.fn().mockResolvedValue([]), + deleteDocument: vi.fn().mockResolvedValue(undefined), + ...opts.adapter, + }; + TestBed.configureTestingModule({ + providers: [ + { provide: UPLOAD_TRANSPORT, useValue: transport }, + { provide: UploadAdapter, useValue: adapter }, + ], + }); + const service = TestBed.inject(UploadShellService); + const dispatch = vi.fn<(m: UploadMsg) => void>(); + return { service, dispatch, transport, handles, adapter }; +} + +const req = { localId: 'l1', categoryId: 'c1', wizardId: 'w1', file: new File(['x'], 'x.pdf') }; + +describe('UploadShellService.upload', () => { + it('dispatches UploadQueued with the transport backgroundSync flag, then sends via the transport', () => { + const { service, dispatch, transport } = setup({ backgroundSync: true }); + service.upload(req, dispatch); + expect(dispatch).toHaveBeenCalledWith({ + type: 'UploadQueued', + localId: 'l1', + backgroundSync: true, + }); + expect(transport.send).toHaveBeenCalledOnce(); + }); + + it('translates a progress callback into UploadProgress', () => { + const { service, dispatch, handles } = setup(); + service.upload(req, dispatch); + handles[0].onProgress(42); + expect(dispatch).toHaveBeenCalledWith({ + type: 'UploadProgress', + localId: 'l1', + progressPct: 42, + }); + }); + + it('translates a resolved transport into UploadComplete', async () => { + const { service, dispatch, handles } = setup(); + service.upload(req, dispatch); + handles[0].resolveDone({ documentId: 'doc-1' }); + await Promise.resolve(); + await Promise.resolve(); + expect(dispatch).toHaveBeenCalledWith({ + type: 'UploadComplete', + localId: 'l1', + documentId: 'doc-1', + }); + }); + + it('translates a rejected transport into UploadFailed with the reason', async () => { + const { service, dispatch, handles } = setup(); + service.upload(req, dispatch); + handles[0].rejectDone('network down'); + await Promise.resolve(); + await Promise.resolve(); + expect(dispatch).toHaveBeenCalledWith({ + type: 'UploadFailed', + localId: 'l1', + reason: 'network down', + }); + }); + + it('does not dispatch UploadFailed on a user-initiated abort', async () => { + const { service, dispatch, handles } = setup(); + service.upload(req, dispatch); + handles[0].rejectDone(UPLOAD_ABORTED); + await Promise.resolve(); + await Promise.resolve(); + expect(dispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'UploadFailed' })); + }); +}); + +describe('UploadShellService.cancel', () => { + it('calls the transport cancel function for an in-flight upload and forgets it', async () => { + const { service, dispatch, handles } = setup(); + service.upload(req, dispatch); + service.cancel(['l1']); + expect(handles[0].cancel).toHaveBeenCalledOnce(); + // Cancelling twice is a no-op the second time — the entry is already forgotten. + service.cancel(['l1']); + expect(handles[0].cancel).toHaveBeenCalledOnce(); + }); + + it('is a no-op for a localId with nothing in flight', () => { + const { service } = setup(); + expect(() => service.cancel(['unknown'])).not.toThrow(); + }); +}); + +describe('UploadShellService.delete', () => { + it('dispatches UploadDeleting, then UploadDeleteComplete on success', async () => { + const { service, dispatch } = setup(); + service.delete('l1', 'doc-1', dispatch); + expect(dispatch).toHaveBeenCalledWith({ type: 'UploadDeleting', localId: 'l1' }); + await Promise.resolve(); + await Promise.resolve(); + expect(dispatch).toHaveBeenCalledWith({ type: 'UploadDeleteComplete', localId: 'l1' }); + }); + + it('dispatches UploadDeleteFailed with the server detail on failure', async () => { + const { service, dispatch } = setup({ + adapter: { deleteDocument: vi.fn().mockRejectedValue({ detail: 'Document is gekoppeld.' }) }, + }); + service.delete('l1', 'doc-1', dispatch); + await Promise.resolve(); + await Promise.resolve(); + expect(dispatch).toHaveBeenCalledWith({ + type: 'UploadDeleteFailed', + localId: 'l1', + reason: 'Document is gekoppeld.', + }); + }); + + it('falls back to an empty reason when the server sends no detail', async () => { + const { service, dispatch } = setup({ + adapter: { deleteDocument: vi.fn().mockRejectedValue(new Error('boom')) }, + }); + service.delete('l1', 'doc-1', dispatch); + await Promise.resolve(); + await Promise.resolve(); + expect(dispatch).toHaveBeenCalledWith({ + type: 'UploadDeleteFailed', + localId: 'l1', + reason: '', + }); + }); +}); + +describe('UploadShellService.pollReturning', () => { + it('does nothing when there are no uploads to poll', async () => { + const status = vi.fn().mockResolvedValue([]); + const { service, dispatch } = setup({ adapter: { status } }); + await service.pollReturning([], dispatch); + expect(status).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('dispatches BackgroundUploadsReturned for uploads the server reports complete', async () => { + const status = vi.fn().mockResolvedValue([ + { localId: 'l1', status: 'complete', documentId: 'doc-1' }, + { localId: 'l2', status: 'unknown' }, + ]); + const { service, dispatch } = setup({ adapter: { status } }); + const uploads = [ + { + localId: 'l1', + categoryId: 'c1', + fileName: 'a.pdf', + fileSizeMb: 1, + status: { type: 'queued' as const }, + backgroundSync: true, + }, + { + localId: 'l2', + categoryId: 'c1', + fileName: 'b.pdf', + fileSizeMb: 1, + status: { type: 'queued' as const }, + backgroundSync: true, + }, + ]; + await service.pollReturning(uploads, dispatch); + expect(status).toHaveBeenCalledWith(['l1', 'l2']); + expect(dispatch).toHaveBeenCalledWith({ + type: 'BackgroundUploadsReturned', + results: [{ localId: 'l1', success: true, documentId: 'doc-1' }], + }); + }); + + it('does not dispatch when nothing has arrived yet', async () => { + const status = vi.fn().mockResolvedValue([{ localId: 'l1', status: 'unknown' }]); + const { service, dispatch } = setup({ adapter: { status } }); + const uploads = [ + { + localId: 'l1', + categoryId: 'c1', + fileName: 'a.pdf', + fileSizeMb: 1, + status: { type: 'queued' as const }, + backgroundSync: true, + }, + ]; + await service.pollReturning(uploads, dispatch); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/shared/src/upload/upload-shell.service.ts b/libs/shared/src/application/upload-shell.service.ts similarity index 80% rename from libs/shared/src/upload/upload-shell.service.ts rename to libs/shared/src/application/upload-shell.service.ts index 4b88329..6014cbc 100644 --- a/libs/shared/src/upload/upload-shell.service.ts +++ b/libs/shared/src/application/upload-shell.service.ts @@ -1,7 +1,12 @@ -import { Injectable, inject } from '@angular/core'; -import { UploadAdapter, XhrUploadRequest, XhrUploadHandle, UPLOAD_ABORTED } from './upload.adapter'; +import { Injectable, InjectionToken, inject } from '@angular/core'; +import { + UploadAdapter, + XhrUploadRequest, + XhrUploadHandle, + UPLOAD_ABORTED, +} from '@shared/infrastructure/upload.adapter'; import { problemDetail } from '@shared/infrastructure/api-error'; -import { UploadMsg, Upload } from './upload.machine'; +import { UploadMsg, Upload } from '@shared/domain/upload.machine'; /** * Transport seam (PRD §6): how upload bytes leave the browser. The shipped impl is @@ -23,6 +28,18 @@ class KeepaliveTransport implements UploadTransport { } } +/** + * The swap seam (see UploadTransport above), made real: a spec provides a fake + * transport against this token instead of the concrete class. This copies the + * `SessionPort` / `SESSION_PORT` shape (session.port.ts), the repo's one other + * explicit port. The default factory returns the same KeepaliveTransport + * instance the class-injection used to, so runtime behaviour is unchanged. + */ +export const UPLOAD_TRANSPORT = new InjectionToken('UPLOAD_TRANSPORT', { + providedIn: 'root', + factory: () => inject(KeepaliveTransport), +}); + type Dispatch = (m: UploadMsg) => void; /** @@ -32,7 +49,7 @@ type Dispatch = (m: UploadMsg) => void; */ @Injectable({ providedIn: 'root' }) export class UploadShellService { - private transport: UploadTransport = inject(KeepaliveTransport); + private transport: UploadTransport = inject(UPLOAD_TRANSPORT); private adapter = inject(UploadAdapter); private inflight = new Map void>(); // localId → cancel diff --git a/libs/shared/src/upload/upload.machine.spec.ts b/libs/shared/src/domain/upload.machine.spec.ts similarity index 79% rename from libs/shared/src/upload/upload.machine.spec.ts rename to libs/shared/src/domain/upload.machine.spec.ts index 25542ba..c81f811 100644 --- a/libs/shared/src/upload/upload.machine.spec.ts +++ b/libs/shared/src/domain/upload.machine.spec.ts @@ -10,6 +10,7 @@ import { deliveryRefs, inFlight, rejectReason, + planFileSelection, } from './upload.machine'; const cat = (over: Partial = {}): DocumentCategory => ({ @@ -309,6 +310,86 @@ describe('rejectReason', () => { }); }); +describe('planFileSelection', () => { + const file = (over: Partial<{ name: string; type: string; size: number }> = {}) => ({ + name: 'diploma.pdf', + type: 'application/pdf', + size: 1_000_000, + ...over, + }); + + it('plans nothing for an unknown category', () => { + const s = stateWith([cat({ categoryId: 'diploma' })]); + expect(planFileSelection(s, 'unknown', [file()])).toEqual([]); + }); + + it('rejects the whole batch with reason "multiple" for a single-file category', () => { + const s = stateWith([cat({ categoryId: 'diploma', multiple: false })]); + const plan = planFileSelection(s, 'diploma', [file(), file({ name: 'second.pdf' })]); + expect(plan).toEqual([{ type: 'FileRejected', categoryId: 'diploma', reason: 'multiple' }]); + }); + + it('does not reject a single file against a single-file category', () => { + const s = stateWith([cat({ categoryId: 'diploma', multiple: false })]); + const plan = planFileSelection(s, 'diploma', [file()]); + expect(plan).toHaveLength(1); + expect(plan[0].type).toBe('FileSelected'); + }); + + it('rejects one file via rejectReason (wrong type)', () => { + const s = stateWith([ + cat({ categoryId: 'diploma', multiple: true, acceptedTypes: ['application/pdf'] }), + ]); + const plan = planFileSelection(s, 'diploma', [file({ type: 'image/png' })]); + expect(plan).toEqual([{ type: 'FileRejected', categoryId: 'diploma', reason: 'type' }]); + }); + + it('rejects one file via rejectReason (too large)', () => { + const s = stateWith([cat({ categoryId: 'diploma', multiple: true, maxSizeMb: 1 })]); + const plan = planFileSelection(s, 'diploma', [file({ size: 2_000_000 })]); + expect(plan).toEqual([{ type: 'FileRejected', categoryId: 'diploma', reason: 'size' }]); + }); + + it('plans a FileSelected entry for a file that passes format validation', () => { + const s = stateWith([ + cat({ + categoryId: 'diploma', + multiple: true, + acceptedTypes: ['application/pdf'], + maxSizeMb: 10, + }), + ]); + const plan = planFileSelection(s, 'diploma', [file()]); + expect(plan).toEqual([ + { + type: 'FileSelected', + categoryId: 'diploma', + localId: '', + fileName: 'diploma.pdf', + fileSizeMb: 1, + }, + ]); + }); + + it('judges each file independently and preserves order for a mixed multiple-file category', () => { + const s = stateWith([ + cat({ + categoryId: 'diploma', + multiple: true, + acceptedTypes: ['application/pdf'], + maxSizeMb: 10, + }), + ]); + const plan = planFileSelection(s, 'diploma', [ + file({ name: 'a.pdf' }), + file({ name: 'b.png', type: 'image/png' }), + file({ name: 'c.pdf' }), + ]); + expect(plan.map((m) => m.type)).toEqual(['FileSelected', 'FileRejected', 'FileSelected']); + expect((plan[1] as { reason: string }).reason).toBe('type'); + }); +}); + describe('inFlight', () => { it('returns only queued/uploading uploads', () => { let s = select(stateWith([cat({ multiple: true })]), 'diploma', 'u1'); diff --git a/libs/shared/src/upload/upload.machine.ts b/libs/shared/src/domain/upload.machine.ts similarity index 86% rename from libs/shared/src/upload/upload.machine.ts rename to libs/shared/src/domain/upload.machine.ts index 1efe0f4..90d2898 100644 --- a/libs/shared/src/upload/upload.machine.ts +++ b/libs/shared/src/domain/upload.machine.ts @@ -119,6 +119,43 @@ export function rejectReason( return null; } +/** + * The file-selection policy behind ``: for each candidate file, + * decide whether to reject it or accept it, with no I/O. An unknown category plans + * nothing (mirrors the controller's own "unknown category" guard). Choosing more than + * one file for a single-file category rejects the whole batch with reason `'multiple'` + * and skips the per-file checks; otherwise each file is judged by `rejectReason`. + * + * An accepted file plans a `'FileSelected'` entry with a placeholder `localId: ''` — + * a real id needs `crypto.randomUUID()`, an impure call that stays in + * `createUploadController`. The controller reads only each entry's `type` (never its + * fields) to decide, at that same array index, whether to dispatch the rejection as-is + * or start the upload for the original file — so the placeholder is never dispatched. + */ +export function planFileSelection( + state: UploadState, + categoryId: string, + files: { name: string; type: string; size: number }[], +): UploadMsg[] { + const cat = state.categories.find((c) => c.categoryId === categoryId); + if (!cat) return []; + if (!cat.multiple && files.length > 1) { + return [{ type: 'FileRejected', categoryId, reason: 'multiple' }]; + } + return files.map((file): UploadMsg => { + const reason = rejectReason(cat, { type: file.type, sizeMb: file.size / 1e6 }); + return reason + ? { type: 'FileRejected', categoryId, reason } + : { + type: 'FileSelected', + categoryId, + localId: '', + fileName: file.name, + fileSizeMb: file.size / 1e6, + }; + }); +} + /** Map one upload's status, leaving the rest of the list untouched. */ function mapUpload(s: UploadState, localId: string, f: (u: Upload) => Upload): UploadState { return { ...s, uploads: s.uploads.map((u) => (u.localId === localId ? f(u) : u)) }; diff --git a/libs/shared/src/infrastructure/api-client.ts b/libs/shared/src/infrastructure/api-client.ts index ee5e57f..835788c 100644 --- a/libs/shared/src/infrastructure/api-client.ts +++ b/libs/shared/src/infrastructure/api-client.ts @@ -341,6 +341,12 @@ export class ApiClient { result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as StamdataTableDto; return result200; }); + } else if (status === 400) { + return response.text().then((_responseText) => { + let result400: any = null; + result400 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("Bad Request", status, _responseText, _headers, result400); + }); } else if (status === 403) { return response.text().then((_responseText) => { let result403: any = null; @@ -359,52 +365,6 @@ export class ApiClient { return Promise.resolve(null as any); } - /** - * @return OK - */ - registrations(body: RegistratieRequest): Promise { - let url_ = this.baseUrl + "/api/v1/registrations"; - url_ = url_.replace(/[?&]$/, ""); - - const content_ = JSON.stringify(body); - - let options_: RequestInit = { - body: content_, - method: "POST", - headers: { - "Content-Type": "application/json", - "Accept": "application/json" - } - }; - - return this.http.fetch(url_, options_).then((_response: Response) => { - return this.processRegistrations(_response); - }); - } - - protected processRegistrations(response: Response): Promise { - const status = response.status; - let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; - if (status === 200) { - return response.text().then((_responseText) => { - let result200: any = null; - result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ReferentieResponse; - return result200; - }); - } else if (status === 422) { - return response.text().then((_responseText) => { - let result422: any = null; - result422 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; - return throwException("Unprocessable Content", status, _responseText, _headers, result422); - }); - } else if (status !== 200 && status !== 204) { - return response.text().then((_responseText) => { - return throwException("An unexpected server error occurred.", status, _responseText, _headers); - }); - } - return Promise.resolve(null as any); - } - /** * @return OK */ @@ -658,7 +618,9 @@ export class ApiClient { }); } else if (status === 403) { return response.text().then((_responseText) => { - return throwException("Forbidden", status, _responseText, _headers); + let result403: any = null; + result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("Forbidden", status, _responseText, _headers, result403); }); } else if (status === 404) { return response.text().then((_responseText) => { @@ -994,6 +956,94 @@ export class ApiClient { return Promise.resolve(null as any); } + /** + * @return OK + */ + audit(): Promise { + let url_ = this.baseUrl + "/api/v1/admin/audit"; + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "GET", + headers: { + "Accept": "application/json" + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processAudit(_response); + }); + } + + protected processAudit(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 200) { + return response.text().then((_responseText) => { + let result200: any = null; + result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AuthzAuditDto[]; + return result200; + }); + } else if (status === 403) { + return response.text().then((_responseText) => { + let result403: any = null; + result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("Forbidden", status, _responseText, _headers, result403); + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + + /** + * @return No Content + */ + cases(id: string): Promise { + let url_ = this.baseUrl + "/api/v1/admin/cases/{id}"; + if (id === undefined || id === null) + throw new globalThis.Error("The parameter 'id' must be defined."); + url_ = url_.replace("{id}", encodeURIComponent("" + id)); + url_ = url_.replace(/[?&]$/, ""); + + let options_: RequestInit = { + method: "DELETE", + headers: { + } + }; + + return this.http.fetch(url_, options_).then((_response: Response) => { + return this.processCases(_response); + }); + } + + protected processCases(response: Response): Promise { + const status = response.status; + let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; + if (status === 204) { + return response.text().then((_responseText) => { + return; + }); + } else if (status === 403) { + return response.text().then((_responseText) => { + let result403: any = null; + result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; + return throwException("Forbidden", status, _responseText, _headers, result403); + }); + } else if (status === 404) { + return response.text().then((_responseText) => { + return throwException("Not Found", status, _responseText, _headers); + }); + } else if (status !== 200 && status !== 204) { + return response.text().then((_responseText) => { + return throwException("An unexpected server error occurred.", status, _responseText, _headers); + }); + } + return Promise.resolve(null as any); + } + /** * @return OK */ @@ -1150,94 +1200,6 @@ export class ApiClient { return Promise.resolve(null as any); } - /** - * @return No Content - */ - cases(id: string): Promise { - let url_ = this.baseUrl + "/api/v1/admin/cases/{id}"; - if (id === undefined || id === null) - throw new globalThis.Error("The parameter 'id' must be defined."); - url_ = url_.replace("{id}", encodeURIComponent("" + id)); - url_ = url_.replace(/[?&]$/, ""); - - let options_: RequestInit = { - method: "DELETE", - headers: { - } - }; - - return this.http.fetch(url_, options_).then((_response: Response) => { - return this.processCases(_response); - }); - } - - protected processCases(response: Response): Promise { - const status = response.status; - let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; - if (status === 204) { - return response.text().then((_responseText) => { - return; - }); - } else if (status === 403) { - return response.text().then((_responseText) => { - let result403: any = null; - result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; - return throwException("Forbidden", status, _responseText, _headers, result403); - }); - } else if (status === 404) { - return response.text().then((_responseText) => { - return throwException("Not Found", status, _responseText, _headers); - }); - } else if (status !== 200 && status !== 204) { - return response.text().then((_responseText) => { - return throwException("An unexpected server error occurred.", status, _responseText, _headers); - }); - } - return Promise.resolve(null as any); - } - - /** - * @return OK - */ - audit(): Promise { - let url_ = this.baseUrl + "/api/v1/admin/audit"; - url_ = url_.replace(/[?&]$/, ""); - - let options_: RequestInit = { - method: "GET", - headers: { - "Accept": "application/json" - } - }; - - return this.http.fetch(url_, options_).then((_response: Response) => { - return this.processAudit(_response); - }); - } - - protected processAudit(response: Response): Promise { - const status = response.status; - let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); }; - if (status === 200) { - return response.text().then((_responseText) => { - let result200: any = null; - result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as AuthzAuditDto[]; - return result200; - }); - } else if (status === 403) { - return response.text().then((_responseText) => { - let result403: any = null; - result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails; - return throwException("Forbidden", status, _responseText, _headers, result403); - }); - } else if (status !== 200 && status !== 204) { - return response.text().then((_responseText) => { - return throwException("An unexpected server error occurred.", status, _responseText, _headers); - }); - } - return Promise.resolve(null as any); - } - /** * @return OK */ @@ -1388,6 +1350,10 @@ export class ApiClient { result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefViewDto; return result200; }); + } else if (status === 404) { + return response.text().then((_responseText) => { + return throwException("Not Found", status, _responseText, _headers); + }); } else if (status !== 200 && status !== 204) { return response.text().then((_responseText) => { return throwException("An unexpected server error occurred.", status, _responseText, _headers); @@ -2237,11 +2203,6 @@ export interface ReferentieResponse { referentie?: string | undefined; } -export interface RegistratieRequest { - diplomaHerkomst?: string | undefined; - documents?: DocumentRefDto[] | undefined; -} - export interface RegistrationDto { bigNummer?: string | undefined; naam?: string | undefined; diff --git a/libs/shared/src/infrastructure/role.spec.ts b/libs/shared/src/infrastructure/role.spec.ts new file mode 100644 index 0000000..7b5cb9d --- /dev/null +++ b/libs/shared/src/infrastructure/role.spec.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { currentRole, ROLES } from './role'; + +const setUrl = (search: string) => history.pushState({}, '', search || '/'); + +// isDevMode() reads the `ngDevMode` global the Angular CLI defines away in a +// production build. There is no ambient type for it in app code, so this is +// accessed through an untyped bag rather than a `declare const`. +const globals = globalThis as Record; +const originalNgDevMode = globals['ngDevMode']; +const setDevMode = (on: boolean) => { + globals['ngDevMode'] = on; +}; + +describe('currentRole (dev mechanism)', () => { + beforeEach(() => { + sessionStorage.clear(); + setUrl('/'); + setDevMode(true); + }); + afterEach(() => { + globals['ngDevMode'] = originalNgDevMode; + }); + + it('lists the three roles', () => { + expect(ROLES).toEqual(['drafter', 'approver', 'admin']); + }); + + it('reads a valid ?role= from the URL and persists it for the tab', () => { + setUrl('?role=admin'); + expect(currentRole()).toBe('admin'); + setUrl('/'); // navigation drops the query param — value stays sticky + expect(currentRole()).toBe('admin'); + }); + + it('falls back to drafter when nothing is set or the value is invalid', () => { + expect(currentRole()).toBe('drafter'); + setUrl('?role=nonsense'); + expect(currentRole()).toBe('drafter'); + }); + + // BIO-012: the three hand-written `fetch` adapters call this function directly, + // bypassing `roleInterceptor`'s own isDevMode()-gated registration — so the gate + // has to hold here, not just at the interceptor, or `?role=` keeps working in a + // production build through that side door. + describe('outside isDevMode() (production build)', () => { + beforeEach(() => setDevMode(false)); + + it('ignores a ?role= in the URL and returns the default', () => { + setUrl('?role=admin'); + expect(currentRole()).toBe('drafter'); + }); + + it('never touches sessionStorage', () => { + setUrl('?role=admin'); + currentRole(); + expect(sessionStorage.getItem('dev-role')).toBeNull(); + }); + + it('ignores a role already sitting in sessionStorage from a prior dev session', () => { + sessionStorage.setItem('dev-role', 'admin'); + expect(currentRole()).toBe('drafter'); + }); + }); +}); diff --git a/libs/shared/src/infrastructure/role.ts b/libs/shared/src/infrastructure/role.ts index 7931cae..6dd421c 100644 --- a/libs/shared/src/infrastructure/role.ts +++ b/libs/shared/src/infrastructure/role.ts @@ -1,3 +1,4 @@ +import { isDevMode } from '@angular/core'; import { Role } from '@shared/domain/role'; /** @@ -14,13 +15,22 @@ import { Role } from '@shared/domain/role'; * don't carry it), which would silently revert an admin to drafter mid-session and * 403 the admin endpoints. So a `?role=` seen in the URL is remembered for the tab; * later requests use the remembered value. Set `?role=drafter` (or a fresh tab) to - * reset. Dev-only — the interceptor itself is only wired under `isDevMode()`. + * reset. + * + * **Gated here, not only at the interceptor (BIO-012):** the `roleInterceptor` that + * consumes this for `HttpClient` traffic is only registered under `isDevMode()` + * (`app.config.ts`), but `brief`'s three hand-written `fetch` adapters call this + * function directly, bypassing that interceptor entirely. Reading `?role=` and + * writing it to `sessionStorage` is therefore gated in the function itself — outside + * `isDevMode()` the query param is never read, `sessionStorage` is never touched, and + * the fixed default (`drafter`, the least-privileged role) is returned every time. */ const STORAGE_KEY = 'dev-role'; export const ROLES: readonly Role[] = ['drafter', 'approver', 'admin']; const isRole = (v: string | null): v is Role => !!v && ROLES.includes(v as Role); export function currentRole(): Role { + if (!isDevMode()) return 'drafter'; const fromUrl = new URLSearchParams(window.location.search).get('role'); if (isRole(fromUrl)) { sessionStorage.setItem(STORAGE_KEY, fromUrl); diff --git a/libs/shared/src/infrastructure/subject.interceptor.ts b/libs/shared/src/infrastructure/subject.interceptor.ts index 9890495..a25e002 100644 --- a/libs/shared/src/infrastructure/subject.interceptor.ts +++ b/libs/shared/src/infrastructure/subject.interceptor.ts @@ -12,7 +12,7 @@ import { currentSubject } from './subject'; * middleware resolves a `CallerIdentity` for every request, not just some endpoints. * * **BSN source — a deliberate compromise, read before changing:** the "obvious" - * source would be the authenticated `Session.bsn` held by each app's own + * source would be the authenticated `Principal.bsn` held by each app's own * `SessionStore`, but `libs/shared` may not depend on an app-local `auth` context * (the import-direction rule), and the one sanctioned cross-context seam — * `SessionPort` (`@shared/application/session.port`) — deliberately exposes only diff --git a/libs/shared/src/infrastructure/subject.spec.ts b/libs/shared/src/infrastructure/subject.spec.ts new file mode 100644 index 0000000..254ab21 --- /dev/null +++ b/libs/shared/src/infrastructure/subject.spec.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { currentSubject } from './subject'; + +const setUrl = (search: string) => history.pushState({}, '', search || '/'); + +// isDevMode() reads the `ngDevMode` global the Angular CLI defines away in a +// production build. There is no ambient type for it in app code, so this is +// accessed through an untyped bag rather than a `declare const`. +const globals = globalThis as Record; +const originalNgDevMode = globals['ngDevMode']; +const setDevMode = (on: boolean) => { + globals['ngDevMode'] = on; +}; + +describe('currentSubject (dev mechanism)', () => { + beforeEach(() => { + sessionStorage.clear(); + setUrl('/'); + setDevMode(true); + }); + afterEach(() => { + globals['ngDevMode'] = originalNgDevMode; + }); + + it('reads a ?subject= from the URL and persists it for the tab', () => { + setUrl('?subject=111222333'); + expect(currentSubject()).toBe('111222333'); + setUrl('/'); // navigation drops the query param — value stays sticky + expect(currentSubject()).toBe('111222333'); + }); + + it('returns undefined when nothing has ever been set', () => { + expect(currentSubject()).toBeUndefined(); + }); + + // BIO-012: a BSN is art. 9 GDPR special-category data. Prior to this fix this + // function wrote it into sessionStorage on any navigation, in any build. + describe('outside isDevMode() (production build)', () => { + beforeEach(() => setDevMode(false)); + + it('ignores a ?subject= (a BSN) in the URL and returns undefined', () => { + setUrl('?subject=111222333'); + expect(currentSubject()).toBeUndefined(); + }); + + it('never writes the BSN into sessionStorage', () => { + setUrl('?subject=111222333'); + currentSubject(); + expect(sessionStorage.getItem('dev-subject')).toBeNull(); + }); + + it('ignores a subject already sitting in sessionStorage from a prior dev session', () => { + sessionStorage.setItem('dev-subject', '111222333'); + expect(currentSubject()).toBeUndefined(); + }); + }); +}); diff --git a/libs/shared/src/infrastructure/subject.ts b/libs/shared/src/infrastructure/subject.ts index 5556d87..0d261aa 100644 --- a/libs/shared/src/infrastructure/subject.ts +++ b/libs/shared/src/infrastructure/subject.ts @@ -1,7 +1,9 @@ +import { isDevMode } from '@angular/core'; + /** * Dev-only role stand-in's sibling (the reading MECHANISM for `X-Subject`; see * `role.ts`'s own doc comment for the twin `X-Role` mechanism this mirrors). This - * POC has no real DigiD identity — `Session.bsn` lives only in each app's own + * POC has no real DigiD identity — `Principal.bsn` lives only in each app's own * in-memory `SessionStore` and is deliberately never persisted (see that store's G1 * comment) — so `subject.interceptor.ts` can't reach it without a layering * violation (`libs/shared` may not depend on an app-local `auth` context). Instead a @@ -13,6 +15,13 @@ * hand-written `fetch`, which bypasses every `HttpInterceptorFn` — the same reason * that adapter already sets `X-Role` explicitly via `currentRole()`). * + * **Gated here, not only at the interceptor (BIO-012):** `subjectInterceptor` is only + * registered under `isDevMode()`, but `letter-preview.adapter.ts` calls this function + * directly and bypasses that interceptor. The value read here is a **BSN** — a GDPR + * special-category identifier — so outside `isDevMode()` the query param is never + * read and `sessionStorage` is never written; `undefined` is returned unconditionally, + * exactly as if no `?subject=` had ever been seen. + * * `undefined` (not a default BSN) when nothing has ever set `?subject=`: unlike * `currentRole()` (a closed enum with a sensible default), there is no "default * subject" to fall back to here — omitting the header entirely lets the backend's @@ -21,6 +30,7 @@ const STORAGE_KEY = 'dev-subject'; export function currentSubject(): string | undefined { + if (!isDevMode()) return undefined; const fromUrl = new URLSearchParams(window.location.search).get('subject'); if (fromUrl) { sessionStorage.setItem(STORAGE_KEY, fromUrl); diff --git a/libs/shared/src/infrastructure/upload.adapter.spec.ts b/libs/shared/src/infrastructure/upload.adapter.spec.ts new file mode 100644 index 0000000..ddd33ec --- /dev/null +++ b/libs/shared/src/infrastructure/upload.adapter.spec.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { uploadOutcome } from './upload.adapter'; + +/** Matches the un-exported UPLOAD_FAILED fallback text in upload.adapter.ts. */ +const UPLOAD_FAILED = 'Uploaden is niet gelukt. Probeer het opnieuw.'; + +describe('uploadOutcome', () => { + it('resolves a 2xx response with a valid JSON body to the document id', () => { + const outcome = uploadOutcome(200, JSON.stringify({ documentId: 'doc-1' })); + expect(outcome).toEqual({ ok: true, value: { documentId: 'doc-1' } }); + }); + + it('falls back to the generic error when a 2xx body is not valid JSON', () => { + const outcome = uploadOutcome(201, 'not json'); + expect(outcome).toEqual({ ok: false, error: UPLOAD_FAILED }); + }); + + it('maps a non-2xx ProblemDetails body to its detail', () => { + const outcome = uploadOutcome( + 409, + JSON.stringify({ detail: 'Document is al aan een aanvraag gekoppeld.', status: 409 }), + ); + expect(outcome).toEqual({ ok: false, error: 'Document is al aan een aanvraag gekoppeld.' }); + }); + + it('falls back to the generic error for a non-2xx body without a ProblemDetails detail', () => { + const outcome = uploadOutcome(500, 'Internal Server Error'); + expect(outcome).toEqual({ ok: false, error: UPLOAD_FAILED }); + }); + + it('treats status 200-299 as success and everything else as failure', () => { + expect(uploadOutcome(299, JSON.stringify({ documentId: 'd' })).ok).toBe(true); + expect(uploadOutcome(300, JSON.stringify({ detail: 'x' })).ok).toBe(false); + }); +}); diff --git a/libs/shared/src/upload/upload.adapter.ts b/libs/shared/src/infrastructure/upload.adapter.ts similarity index 89% rename from libs/shared/src/upload/upload.adapter.ts rename to libs/shared/src/infrastructure/upload.adapter.ts index a0d1620..38bc22a 100644 --- a/libs/shared/src/upload/upload.adapter.ts +++ b/libs/shared/src/infrastructure/upload.adapter.ts @@ -8,7 +8,8 @@ import { problemDetail } from '@shared/infrastructure/api-error'; import { currentScenario } from '@shared/infrastructure/scenario'; import { currentSubject } from '@shared/infrastructure/subject'; import { environment } from '@shared/environments/environment'; -import { DocumentCategory } from './upload.machine'; +import { DocumentCategory } from '@shared/domain/upload.machine'; +import { Result, err, ok } from '@shared/kernel/fp'; /** Answer-derived query params that affect which categories the server presents. */ export interface CategoryParams { @@ -128,15 +129,8 @@ export class UploadAdapter { if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100)); }); xhr.addEventListener('load', () => { - if (xhr.status >= 200 && xhr.status < 300) { - try { - resolve({ documentId: JSON.parse(xhr.responseText).documentId }); - } catch { - reject(genericError()); - } - } else { - reject(parseError(xhr.responseText)); - } + const outcome = uploadOutcome(xhr.status, xhr.responseText); + outcome.ok ? resolve(outcome.value) : reject(outcome.error); }); xhr.addEventListener('error', () => reject(genericError())); xhr.addEventListener('abort', () => @@ -160,6 +154,24 @@ export class UploadAdapter { const UPLOAD_FAILED = $localize`:@@upload.failed:Uploaden is niet gelukt. Probeer het opnieuw.`; const genericError = (): string => UPLOAD_FAILED; +/** + * Pure interpretation of one finished XHR `load` event: 2xx-vs-not, `JSON.parse` + * of the body with a fallback to a generic error, and (on a non-2xx status) + * ProblemDetails mapping via `parseError`. No DOM and no XHR — the listener that + * calls this only reads `xhr.status`/`xhr.responseText` and dispatches the result. + */ +export function uploadOutcome( + status: number, + responseText: string, +): Result { + if (status < 200 || status >= 300) return err(parseError(responseText)); + try { + return ok({ documentId: JSON.parse(responseText).documentId }); + } catch { + return err(genericError()); + } +} + /** * Demo-only (dev): the real XHR POST finishes instantly for metadata, so progress * and failure can't otherwise be shown. Drives the progress bar over ~2.5s, then diff --git a/libs/shared/src/testing/value-object.ts b/libs/shared/src/testing/value-object.ts deleted file mode 100644 index 08245e0..0000000 --- a/libs/shared/src/testing/value-object.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Result } from '@shared/kernel/fp'; - -/** - * Unwrap a `Result` produced by a REAL `parse*` value-object parser, throwing - * if it isn't `ok`. This is the only sanctioned way for a spec to obtain a - * branded value-object type — it closes off the `'garbage' as Postcode` cast - * route, since the only door to the branded type is the parser itself. - */ -export function unwrapOk(result: Result): T { - if (!result.ok) { - throw new Error(`unwrapOk: expected ok, got error: ${JSON.stringify(result.error)}`); - } - return result.value; -} diff --git a/libs/shared/src/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts b/libs/shared/src/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts index 41fab35..59d2bc4 100644 --- a/libs/shared/src/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts +++ b/libs/shared/src/ui/upload/delivery-channel-toggle/delivery-channel-toggle.component.ts @@ -1,5 +1,5 @@ import { Component, input, output } from '@angular/core'; -import type { DeliveryChannel } from '@shared/upload/upload.machine'; +import type { DeliveryChannel } from '@shared/domain/upload.machine'; /** Atom: choose how a document is delivered — uploaded digitally or sent by post. Thin wrapper over the Utrecht/RHC radio CSS. Pure UI: emits the chosen channel. */ diff --git a/libs/shared/src/ui/upload/document-category/document-category.component.ts b/libs/shared/src/ui/upload/document-category/document-category.component.ts index 33193bb..6540530 100644 --- a/libs/shared/src/ui/upload/document-category/document-category.component.ts +++ b/libs/shared/src/ui/upload/document-category/document-category.component.ts @@ -1,5 +1,5 @@ import { Component, computed, input, output } from '@angular/core'; -import type { DeliveryChannel, DocumentCategory, Upload } from '@shared/upload/upload.machine'; +import type { DeliveryChannel, DocumentCategory, Upload } from '@shared/domain/upload.machine'; import { DeliveryChannelToggleComponent } from '../delivery-channel-toggle/delivery-channel-toggle.component'; import { FileInputComponent } from '../file-input/file-input.component'; import { SingleUploadComponent } from '../single-upload/single-upload.component'; diff --git a/libs/shared/src/ui/upload/document-category/document-category.stories.ts b/libs/shared/src/ui/upload/document-category/document-category.stories.ts index a82275f..1bc2aed 100644 --- a/libs/shared/src/ui/upload/document-category/document-category.stories.ts +++ b/libs/shared/src/ui/upload/document-category/document-category.stories.ts @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/angular'; -import type { DocumentCategory, Upload } from '@shared/upload/upload.machine'; +import type { DocumentCategory, Upload } from '@shared/domain/upload.machine'; import { DocumentCategoryComponent } from './document-category.component'; const meta: Meta = { diff --git a/libs/shared/src/ui/upload/document-chip/document-chip.component.ts b/libs/shared/src/ui/upload/document-chip/document-chip.component.ts index e3a10b8..b5053e3 100644 --- a/libs/shared/src/ui/upload/document-chip/document-chip.component.ts +++ b/libs/shared/src/ui/upload/document-chip/document-chip.component.ts @@ -1,5 +1,5 @@ import { Component, computed, input } from '@angular/core'; -import type { UploadStatus } from '@shared/upload/upload.machine'; +import type { UploadStatus } from '@shared/domain/upload.machine'; import { UploadStatusIconComponent } from '../upload-status-icon/upload-status-icon.component'; const STATUS_LABELS: Record = { diff --git a/libs/shared/src/ui/upload/document-chip/document-chip.stories.ts b/libs/shared/src/ui/upload/document-chip/document-chip.stories.ts index ac1ff05..4747651 100644 --- a/libs/shared/src/ui/upload/document-chip/document-chip.stories.ts +++ b/libs/shared/src/ui/upload/document-chip/document-chip.stories.ts @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/angular'; -import type { UploadStatus } from '@shared/upload/upload.machine'; +import type { UploadStatus } from '@shared/domain/upload.machine'; import { DocumentChipComponent } from './document-chip.component'; const meta: Meta = { diff --git a/libs/shared/src/ui/upload/document-upload/document-upload.component.ts b/libs/shared/src/ui/upload/document-upload/document-upload.component.ts index 9aee010..eb024f1 100644 --- a/libs/shared/src/ui/upload/document-upload/document-upload.component.ts +++ b/libs/shared/src/ui/upload/document-upload/document-upload.component.ts @@ -1,5 +1,5 @@ import { Component, input, output } from '@angular/core'; -import type { DeliveryChannel, UploadState } from '@shared/upload/upload.machine'; +import type { DeliveryChannel, UploadState } from '@shared/domain/upload.machine'; import { AlertComponent } from '@shared/ui/alert/alert.component'; import { DocumentCategoryComponent } from '../document-category/document-category.component'; diff --git a/libs/shared/src/ui/upload/document-upload/document-upload.stories.ts b/libs/shared/src/ui/upload/document-upload/document-upload.stories.ts index 578ca75..af6fff8 100644 --- a/libs/shared/src/ui/upload/document-upload/document-upload.stories.ts +++ b/libs/shared/src/ui/upload/document-upload/document-upload.stories.ts @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/angular'; -import type { UploadState } from '@shared/upload/upload.machine'; +import type { UploadState } from '@shared/domain/upload.machine'; import { DocumentUploadComponent } from './document-upload.component'; const meta: Meta = { diff --git a/libs/shared/src/ui/upload/single-upload/single-upload.component.ts b/libs/shared/src/ui/upload/single-upload/single-upload.component.ts index 25454bd..6fdda98 100644 --- a/libs/shared/src/ui/upload/single-upload/single-upload.component.ts +++ b/libs/shared/src/ui/upload/single-upload/single-upload.component.ts @@ -1,5 +1,5 @@ import { Component, computed, input, output } from '@angular/core'; -import type { Upload } from '@shared/upload/upload.machine'; +import type { Upload } from '@shared/domain/upload.machine'; import { DocumentChipComponent } from '../document-chip/document-chip.component'; import { UploadProgressBarComponent } from '../upload-progress-bar/upload-progress-bar.component'; diff --git a/libs/shared/src/ui/upload/single-upload/single-upload.stories.ts b/libs/shared/src/ui/upload/single-upload/single-upload.stories.ts index 5d4ee64..f612abe 100644 --- a/libs/shared/src/ui/upload/single-upload/single-upload.stories.ts +++ b/libs/shared/src/ui/upload/single-upload/single-upload.stories.ts @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/angular'; -import type { Upload } from '@shared/upload/upload.machine'; +import type { Upload } from '@shared/domain/upload.machine'; import { SingleUploadComponent } from './single-upload.component'; const meta: Meta = { diff --git a/libs/shared/src/ui/upload/upload-status-icon/upload-status-icon.component.ts b/libs/shared/src/ui/upload/upload-status-icon/upload-status-icon.component.ts index b5ac079..a2cdf4a 100644 --- a/libs/shared/src/ui/upload/upload-status-icon/upload-status-icon.component.ts +++ b/libs/shared/src/ui/upload/upload-status-icon/upload-status-icon.component.ts @@ -1,5 +1,5 @@ import { Component, computed, input } from '@angular/core'; -import type { UploadStatus } from '@shared/upload/upload.machine'; +import type { UploadStatus } from '@shared/domain/upload.machine'; interface Glyph { char: string; diff --git a/package.json b/package.json index 41c60b2..3265575 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "format": "prettier --write .", "start": "ng serve ssp", "start:behandelportal": "ng serve behandelportal", - "gen:api": "cd backend && dotnet tool restore && dotnet build src/BigRegister.Api -v q && dotnet swagger tofile --output swagger.json src/BigRegister.Api/bin/Debug/net10.0/BigRegister.Api.dll v1 && cd .. && nswag run nswag.json", + "gen:api": "cd backend && dotnet tool restore && dotnet build src/BigRegister.Api -v q && ASPNETCORE_ENVIRONMENT=Development dotnet swagger tofile --output swagger.json src/BigRegister.Api/bin/Debug/net10.0/BigRegister.Api.dll v1 && cd .. && nswag run nswag.json", "build": "ng build ssp && ng build behandelportal", "watch": "ng build ssp --watch --configuration development", "test": "ng test ssp && ng test behandelportal && ng test shared && ng test beheer", diff --git a/scripts/check-tokens.sh b/scripts/check-tokens.sh index 17ae022..3e86c97 100755 --- a/scripts/check-tokens.sh +++ b/scripts/check-tokens.sh @@ -18,3 +18,17 @@ if [ -n "$hits" ]; then exit 1 fi echo 'OK: no hardcoded colours in components' + +# ADR-C-008 guard: every `// CIBG-GAP EXTENSION:` marker (ADR-0003 §Consequences) +# must have a row in the register, so the table cannot drift from the code +# again without CI catching it. +gap_register='libs/shared/docs/cibg-gaps.mdx' +markers=$(grep -rl 'CIBG-GAP EXTENSION' apps libs --include='*.component.ts' | xargs -n1 dirname | xargs -n1 basename | sort -u) +rows=$(grep -oP '^\| `\K[^`]+' "$gap_register" | sort -u) +missing=$(comm -23 <(echo "$markers") <(echo "$rows")) +if [ -n "$missing" ]; then + echo "$missing" + echo "FAIL: CIBG-GAP EXTENSION marker(s) with no row in $gap_register" + exit 1 +fi +echo 'OK: every CIBG-GAP EXTENSION marker has a cibg-gaps.mdx row' diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index 63ecd0c..d80e2ac 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -9,6 +9,14 @@ set -euo pipefail cd "$(dirname "$0")/.." +# Steps chain with `;`, NOT `&&`. Under `set -e`, bash exempts every command of an +# AND-OR list except the last, so in `gen && git diff --exit-code` a CRASH in `gen` +# is silently swallowed — the diff never runs and the script sails on. That is not +# hypothetical: it hid a real `gen:api` crash (RB-09), which .github/workflows/ci.yml +# would have caught because it runs each step as its own `- run:`. With `;` errexit +# fires on the first failure. The one `( cd backend && ... )` below is safe as-is: +# a subshell propagates its own non-zero status, so errexit sees it. + step() { printf '\n\033[1;36m▶ %s\033[0m\n' "$1"; } step "lint"; npm run lint @@ -18,16 +26,17 @@ step "format:check (prettier)"; npm run format:check step "check:tokens"; npm run check:tokens step "check:seam"; npm run check:seam step "test (vitest + coverage)"; npm run test:coverage -step "build --localize (nl+en)"; npx ng build ssp --localize && npx ng build behandelportal --localize +step "build --localize (nl+en)"; npx ng build ssp --localize; npx ng build behandelportal --localize step "npm audit (shipped deps)"; npm audit --omit=dev step "backend format + tests"; ( cd backend && dotnet format BigRegister.slnx --verify-no-changes && dotnet test BigRegister.slnx --filter "Category!=Integration" ) -step "showcase snippets drift"; npm run gen:snippets && git diff --exit-code apps/ssp/src/app/showcase/snippets.generated.ts -step "behaviour spec drift"; npm run gen:behaviour-spec && git diff --exit-code libs/shared/docs/behaviour-spec.mdx -step "api-client drift"; npm run gen:api && git diff --exit-code libs/shared/src/infrastructure/api-client.ts backend/swagger.json +step "backend dependency audit"; ./scripts/dotnet-audit.sh +step "showcase snippets drift"; npm run gen:snippets; git diff --exit-code apps/ssp/src/app/showcase/snippets.generated.ts +step "behaviour spec drift"; npm run gen:behaviour-spec; git diff --exit-code libs/shared/docs/behaviour-spec.mdx +step "api-client drift"; npm run gen:api; git diff --exit-code libs/shared/src/infrastructure/api-client.ts backend/swagger.json if [[ "${1:-}" == "--full" ]]; then - step "storybook build + axe (ssp)"; npm run build-storybook && npm run test-storybook:ci - step "storybook build + axe (behandelportal)"; npm run build-storybook:behandelportal && npm run test-storybook:ci:behandelportal + step "storybook build + axe (ssp)"; npm run build-storybook; npm run test-storybook:ci + step "storybook build + axe (behandelportal)"; npm run build-storybook:behandelportal; npm run test-storybook:ci:behandelportal fi printf '\n\033[1;32m✔ local CI passed\033[0m\n' diff --git a/scripts/dotnet-audit.sh b/scripts/dotnet-audit.sh new file mode 100755 index 0000000..7774c93 --- /dev/null +++ b/scripts/dotnet-audit.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Fail if any NuGet package (direct or transitive) has a known advisory — the .NET half of +# `npm audit --omit=dev`, which only ever covered the frontend (RB-14/BIO-016). +# +# `dotnet list package --vulnerable` is a REPORTING command: it prints the advisory table and +# still exits 0. Verified against a deliberately vulnerable project — System.Net.Http 4.3.0, +# GHSA-7jgj-8wvc-jh57, severity High, exit code 0. So `- run: dotnet list package --vulnerable` +# on its own is a gate that enforces nothing, which is worse than no gate: it reads like +# coverage in the workflow file. Matching its output is what makes it block. +# +# Shared by .github/workflows/ci.yml and scripts/ci-local.sh so the two cannot drift. +set -euo pipefail +cd "$(dirname "$0")/.." + +report=$(dotnet list backend/BigRegister.slnx package --vulnerable --include-transitive) +echo "$report" + +# The exact sentence `dotnet list` prints per project when it finds something; the clean case +# prints "has no vulnerable packages given the current sources" instead. +if grep -q "has the following vulnerable packages" <<<"$report"; then + echo + echo "✖ Vulnerable NuGet packages found (table above)." >&2 + echo " Transitive hits can be pinned with a direct PackageReference to a patched version." >&2 + exit 1 +fi