Merge refactor/adr-c-006-shared-route-guards — RB-01..RB-33 + 4 ADR-fixes
CI / changes (push) Successful in 12s
CI / lint (push) Successful in 2m45s
CI / frontend (push) Failing after 11m9s
CI / backend (push) Successful in 2m22s
CI / e2e (push) Successful in 3m25s
CI / semgrep (push) Successful in 1m11s
CI / api-client-drift (push) Successful in 1m55s
CI / storybook-a11y (push) Failing after 15m10s

Closes the CD refactor backlog (docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md).
All 33 code tickets and the four gated ADR-fixes (ADR-C-001, ADR-C-003,
ADR-C-007, ADR-C-009) are merged, one commit per ticket, across six CD
batches plus the ADR-fix batch. npm run ci is green after every merge in
the arc, each verified independently.

Highlights: RB-01/02 fixed a BSN leak in the persisted audit trail and an
unauthorized document-content endpoint. RB-09/13 landed Session -> Principal
per ADR-0002. RB-12 added a route-table authz gate as a CI safety net.
RB-19 reordered the backend's 940-line Program.cs into reads-then-writes,
verified as a pure move by comparing every (route, gate, handler) triple
before and after. RB-24..30 moved libs/shared/upload into its proper
layers and made every layer testable. RB-31 found and fixed a real
ADR-0006 violation: two tests asserted a wizard state the real reducer
cannot produce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-28 13:48:10 +02:00
co-authored by Claude Opus 5
221 changed files with 13803 additions and 1379 deletions
+2 -2
View File
@@ -100,9 +100,9 @@ module.exports = function buildConfig(contextAllowed, appName, tsConfigFileName)
{ {
name: 'apiclient-infrastructure-only', name: 'apiclient-infrastructure-only',
comment: 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', severity: 'error',
from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' }, from: { pathNot: '/infrastructure/' },
to: { to: {
path: '^libs/shared/src/infrastructure/api-client\\.ts$', path: '^libs/shared/src/infrastructure/api-client\\.ts$',
dependencyTypesNot: ['type-only'], dependencyTypesNot: ['type-only'],
+5
View File
@@ -202,6 +202,11 @@ jobs:
# run manually against backend/openzaak/ (see its README), never in CI. # run manually against backend/openzaak/ (see its README), never in CI.
- run: dotnet test backend/BigRegister.slnx --filter "Category!=Integration" - run: dotnet test backend/BigRegister.slnx --filter "Category!=Integration"
if: needs.changes.outputs.backend == 'true' 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: e2e:
needs: changes needs: changes
+4
View File
@@ -58,3 +58,7 @@ backend/openzaak/seeded.env
# WP-55: render-prod-secrets.sh's output — the real client secret, never committed # WP-55: render-prod-secrets.sh's output — the real client secret, never committed
backend/openzaak/setup_configuration/data.prod.yaml 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/
+9
View File
@@ -4,6 +4,11 @@ storybook-static*/
coverage/ coverage/
.angular/ .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 # Lockfile
package-lock.json package-lock.json
@@ -21,3 +26,7 @@ plop-templates/
# Backend is formatted by `dotnet format`, not prettier # Backend is formatted by `dotnet format`, not prettier
backend/ 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/
+29 -6
View File
@@ -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 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) 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, 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 the design system does the visuals. (Where CIBG lacks a class — e.g. `skeleton`,
small hand-rolled surface built from the token bridge; see ADR-0003.) `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 ### 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). for instant feedback, server re-validates as authority — e.g. scholing threshold).
FE keeps only **format** validation, never as authority. FE keeps only **format** validation, never as authority.
DTO lives in `contracts/`; a hand-written `parse*`/`toDomain` in `infrastructure/` The generated client
validates the untrusted shape and maps DTO → domain. Wiring a real .NET backend (`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 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 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 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-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 business controls (profession↔diploma map, thresholds, policy-question text) live as typed
C# in `backend/.../Stamdata/`, validated at build by `StamdataValidationTests` (a bad edit 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 fails CI, never prod) — never runtime-editable. Operational configuration is the deliberate
(operational per-org config in SQLite). UI copy is `$localize`. See ADR-0004. 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 ### 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`, - **Naming:** shared/reusable UI is **English** (language-agnostic: `button`,
`wizard-shell`); domain contexts are **Dutch** (`registratie`, `herregistratie`, `wizard-shell`); domain contexts are **Dutch** (`registratie`, `herregistratie`,
`*.machine.ts`). Pick the language by which side of the seam the code is on. `*.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 - **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 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 (`` $localize`:@@context.key:Tekst` ``). Source locale is `nl`; a second locale is a
+4 -3
View File
@@ -106,7 +106,7 @@
"**/*.spec.ts", "**/*.spec.ts",
"**/*.stories.ts", "**/*.stories.ts",
"**/contracts/**", "**/contracts/**",
"libs/shared/src/infrastructure/api-client.ts", "**/infrastructure/api-client.ts",
"apps/ssp/src/main.ts", "apps/ssp/src/main.ts",
"**/*.testing.ts", "**/*.testing.ts",
"**/*.d.ts" "**/*.d.ts"
@@ -235,7 +235,7 @@
"**/*.spec.ts", "**/*.spec.ts",
"**/*.stories.ts", "**/*.stories.ts",
"**/contracts/**", "**/contracts/**",
"libs/shared/src/infrastructure/api-client.ts", "**/infrastructure/api-client.ts",
"apps/behandelportal/src/main.ts", "apps/behandelportal/src/main.ts",
"**/*.testing.ts", "**/*.testing.ts",
"**/*.d.ts" "**/*.d.ts"
@@ -293,7 +293,7 @@
"**/*.spec.ts", "**/*.spec.ts",
"**/*.stories.ts", "**/*.stories.ts",
"**/contracts/**", "**/contracts/**",
"src/infrastructure/api-client.ts", "**/infrastructure/api-client.ts",
"src/test-entry.ts", "src/test-entry.ts",
"**/*.testing.ts", "**/*.testing.ts",
"**/*.d.ts" "**/*.d.ts"
@@ -332,6 +332,7 @@
"**/*.spec.ts", "**/*.spec.ts",
"**/*.stories.ts", "**/*.stories.ts",
"**/contracts/**", "**/contracts/**",
"**/infrastructure/api-client.ts",
"src/test-entry.ts", "src/test-entry.ts",
"**/*.testing.ts", "**/*.testing.ts",
"**/*.d.ts" "**/*.d.ts"
@@ -1,58 +1,50 @@
import { Injectable, computed, effect, inject, signal } from '@angular/core'; import { Injectable, computed, effect, inject, signal } from '@angular/core';
import { Result } from '@shared/kernel/fp'; import { Principal, parseStoredPrincipal } from '../domain/principal';
import { Session } from '../domain/session'; import { MedewerkerAdapter } from '../infrastructure/medewerker.adapter';
import { DigidAdapter } from '../infrastructure/digid.adapter';
const STORAGE_KEY = 'session-v1'; const STORAGE_KEY = 'session-v1';
/** Restore a persisted session (best-effort; corrupt entry → logged out). /** Restore a persisted principal (best-effort; corrupt entry → logged out).
G2: validate the shape before trusting it. G1: the BSN is never persisted The shape validation (G2 — there is no BSN here, so no G1 to enforce) lives in
(see the effect below), so a restored session carries an empty one — it is `parseStoredPrincipal` (`../domain/principal`) — pure, spec'd, and testable
unused after login; only `naam` is shown in the chrome. */ without stubbing `localStorage`; this just supplies the raw value. */
function restore(): Session | null { function restore(): Principal | null {
try { return parseStoredPrincipal(localStorage.getItem(STORAGE_KEY));
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<Session>;
return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null;
} catch {
return null;
}
} }
/** /**
* Holds the current session for the whole app. Because it is providedIn:'root' * Holds the current medewerker principal for the whole backoffice app. One
* there is exactly one instance — every component that injects it sees the same * `providedIn: 'root'` instance, so logging in is instantly visible everywhere
* session signal, so logging in is instantly visible everywhere (the guard, the * (the guard, the header). Persisted to localStorage — a refresh or the
* header, etc.). The session is mirrored to localStorage so a refresh, a deep-link, * cross-bundle language switch (nl at `/` ⇄ en at `/en/`) keeps you logged in —
* or the full-page navigation the language switch performs (nl at `/` ⇄ en at `/en/`, * which is safe to do verbatim here: a medewerker principal carries no BSN or
* separate bundles) keeps you logged in. ponytail: localStorage, not sessionStorage — * other national identifier, unlike the SSP's `SessionStore`, whose equivalent
* sessionStorage's per-tab clearing dropped the login on the cross-bundle language * comment explains why *that* app strips a field before writing. A real
* switch. Trade-off: the demo session now survives tab close; a real portal keeps auth * deployment keeps auth in an httpOnly cookie/token, not web storage, regardless.
* in an httpOnly cookie/token, not web storage.
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class SessionStore { export class SessionStore {
private digid = inject(DigidAdapter); private medewerker = inject(MedewerkerAdapter);
private _session = signal<Session | null>(restore()); private _session = signal<Principal | null>(restore());
readonly session = this._session.asReadonly(); readonly session = this._session.asReadonly();
readonly isAuthenticated = computed(() => this._session() !== null); readonly isAuthenticated = computed(() => this._session() !== null);
constructor() { constructor() {
effect(() => { effect(() => {
const s = this._session(); const p = this._session();
// G1: persist only `naam` — never write the BSN (national ID) to storage. if (p) localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam }));
else localStorage.removeItem(STORAGE_KEY); else localStorage.removeItem(STORAGE_KEY);
}); });
} }
/** Effectful command: authenticate, then store the session on success. */ /** Effectful command: authenticate via the SSO stand-in, then store the
async login(bsn: string): Promise<Result<string, Session>> { resulting principal. No credential to pass in, and nothing that can fail
const r = await this.digid.authenticate(bsn); today — see `MedewerkerAdapter`. */
if (r.ok) this._session.set(r.value); async login(): Promise<Principal> {
return r; const p = await this.medewerker.authenticate();
this._session.set(p);
return p;
} }
logout() { logout() {
+7 -31
View File
@@ -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 * The route guards live in `libs/shared` (ADR-C-006) — they are actor-agnostic, reading
* redirect. Used by the admin pages (`/brief/huisstijl`, `/beheer/stamdata`). * 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` * ADR-0002 §3's "auth stays duplicated" still holds for what it actually scopes —
* is still loading — it would deny an entitled admin and bounce them. We await * `Principal`, the login flow, `SessionStore`. A guard is neither.
* `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 { export { authGuard, capabilityGuard } from '@shared/application/auth.guard';
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']);
};
}
@@ -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([]);
});
});
@@ -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<Principal>;
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;
}
}
@@ -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);
});
});
@@ -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;
}
@@ -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<Result<string, Session>> {
const r = parseBsn(bsn);
return r.ok ? ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r;
}
}
@@ -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<Principal> {
return {
kind: 'medewerker',
medewerkerId: MEDEWERKER_ID,
naam: 'H. (Hassan) Bakker',
rollen: parseRollen(currentRollen()),
};
}
}
@@ -1,50 +1,27 @@
import { Component, output } from '@angular/core'; 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'; 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({ @Component({
selector: 'app-login-form', selector: 'app-login-form',
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent], imports: [ButtonComponent],
template: ` template: `
<form (ngSubmit)="submitted.emit(bsn)" class="form-horizontal"> <div class="form-horizontal">
<div class="form-header"> <p i18n="@@login.ssoExplainer">
<div class="form-action"> U meldt zich aan via de SSO van uw organisatie — er is geen wachtwoord nodig.
<span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span> </p>
</div> <app-button type="button" variant="primary" (click)="submitted.emit()" i18n="@@login.submit">
</div> Inloggen met SSO
</app-button>
<app-form-field </div>
i18n-label="@@login.bsnLabel"
label="BSN"
fieldId="bsn"
required
i18n-description="@@login.bsnDescription"
description="9-cijferig BSN, elfproef-geldig (demo: 123456782)"
>
<app-text-input
inputId="bsn"
hasDescription
[(ngModel)]="bsn"
name="bsn"
placeholder="123456782"
/>
</app-form-field>
<app-form-field i18n-label="@@login.wachtwoordLabel" label="Wachtwoord" fieldId="pw" required>
<app-text-input inputId="pw" type="password" [(ngModel)]="password" name="pw" />
</app-form-field>
<app-button type="submit" variant="primary" i18n="@@login.submit"
>Inloggen met DigiD</app-button
>
</form>
`, `,
}) })
export class LoginFormComponent { export class LoginFormComponent {
bsn = ''; submitted = output<void>();
password = '';
submitted = output<string>();
} }
@@ -1,36 +1,35 @@
import { Component, inject, signal } from '@angular/core'; import { Component, inject } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component'; 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 { LoginFormComponent } from '@auth/ui/login-form/login-form.component';
import { SessionStore } from '@auth/application/session.store'; 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({ @Component({
selector: 'app-login-page', selector: 'app-login-page',
imports: [PageShellComponent, AlertComponent, LoginFormComponent], imports: [PageShellComponent, LoginFormComponent],
template: ` template: `
<app-page-shell <app-page-shell
i18n-heading="@@login.heading" i18n-heading="@@login.heading"
heading="Inloggen" heading="Inloggen bij het behandelportal"
width="narrow" width="narrow"
i18n-intro="@@login.intro" i18n-intro="@@login.intro"
intro="Log in op uw persoonlijke BIG-register omgeving." intro="Voor medewerkers die aanvragen beoordelen."
> >
@if (error()) { <app-login-form (submitted)="login()" />
<app-alert type="error">{{ error() }}</app-alert>
}
<app-login-form (submitted)="login($event)" />
</app-page-shell> </app-page-shell>
`, `,
}) })
export class LoginPage { export class LoginPage {
private store = inject(SessionStore); private store = inject(SessionStore);
private router = inject(Router); private router = inject(Router);
error = signal('');
async login(bsn: string) { async login() {
const r = await this.store.login(bsn); await this.store.login();
if (r.ok) this.router.navigate(['/dashboard']); this.router.navigate(['/dashboard']);
else this.error.set(r.error);
} }
} }
@@ -1,12 +1,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { expectTag } from '@shared/testing/expect-tag'; import { expectTag } from '@shared/testing/expect-tag';
import { BesluitState, reduce, initial } from './besluit.machine'; import { reduce, initial } from './besluit.machine';
import { givenBesluit } from './besluit.testing';
const editingWith = (besluit: string, toelichting = ''): BesluitState => ({
tag: 'Editing',
draft: { besluit, toelichting },
errors: {},
});
describe('besluit reduce', () => { describe('besluit reduce', () => {
it('SetField updates the draft while editing', () => { 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', () => { 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(); expect(expectTag(s, 'Editing').errors.besluit).toBeTruthy();
}); });
it('Submit Afwijzen without a toelichting stays Editing and reports a field error', () => { 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(); expect(expectTag(s, 'Editing').errors.toelichting).toBeTruthy();
}); });
it('Submit Goedkeuren with no toelichting moves to Submitting (optional there)', () => { 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({ expect(expectTag(s, 'Submitting').data).toEqual({
besluit: 'Goedkeuren', besluit: 'Goedkeuren',
toelichting: undefined, toelichting: undefined,
@@ -33,7 +34,11 @@ describe('besluit reduce', () => {
}); });
it('Submit Afwijzen with a toelichting moves to Submitting with the trimmed value', () => { 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({ expect(expectTag(s, 'Submitting').data).toEqual({
besluit: 'Afwijzen', besluit: 'Afwijzen',
toelichting: 'niet erkend', toelichting: 'niet erkend',
@@ -41,24 +46,36 @@ describe('besluit reduce', () => {
}); });
it('SubmitConfirmed maps Submitting to Submitted', () => { 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'); expect(reduce(submitting, { tag: 'SubmitConfirmed' }).tag).toBe('Submitted');
}); });
it('SubmitFailed maps Submitting to Failed with the error', () => { 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' }); const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
expect(failed).toMatchObject({ tag: 'Failed', error: 'boom' }); expect(failed).toMatchObject({ tag: 'Failed', error: 'boom' });
}); });
it('Retry re-submits a failure', () => { 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' }); const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
expect(reduce(failed, { tag: 'Retry' }).tag).toBe('Submitting'); expect(reduce(failed, { tag: 'Retry' }).tag).toBe('Submitting');
}); });
it('Reset returns to the initial editing state', () => { 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); expect(reduce(submitting, { tag: 'Reset' })).toEqual(initial);
}); });
}); });
@@ -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);
+15 -43
View File
@@ -26,65 +26,33 @@
<context context-type="linenumber">27</context> <context context-type="linenumber">27</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="form.verplichteVelden" datatype="html"> <trans-unit id="login.ssoExplainer" datatype="html">
<source>* verplichte velden</source> <source>U meldt zich aan via de SSO van uw organisatie — er is geen wachtwoord nodig.</source>
<target datatype="html">* required fields</target> <target datatype="html">You sign in through your organization's SSO — no password is needed.</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context> <context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
<context context-type="linenumber">15,18</context> <context context-type="linenumber">17,19</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/registratie/ui/change-request-form/change-request-form.component.ts</context>
<context context-type="linenumber">44,46</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/layout/wizard-shell/wizard-shell.component.ts</context>
<context context-type="linenumber">90,92</context>
</context-group>
</trans-unit>
<trans-unit id="login.bsnLabel" datatype="html">
<source>BSN</source>
<target datatype="html">BSN</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
<context context-type="linenumber">22,23</context>
</context-group>
</trans-unit>
<trans-unit id="login.bsnDescription" datatype="html">
<source>9-cijferig BSN, elfproef-geldig (demo: 123456782)</source>
<target datatype="html">9-digit BSN, valid eleven-test checksum (demo: 123456782)</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
<context context-type="linenumber">25,28</context>
</context-group>
</trans-unit>
<trans-unit id="login.wachtwoordLabel" datatype="html">
<source>Wachtwoord</source>
<target datatype="html">Password</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
<context context-type="linenumber">36,37</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="login.submit" datatype="html"> <trans-unit id="login.submit" datatype="html">
<source>Inloggen met DigiD</source> <source>Inloggen met SSO</source>
<target datatype="html">Log in with DigiD</target> <target datatype="html">Log in with SSO</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context> <context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
<context context-type="linenumber">41,43</context> <context context-type="linenumber">20,21</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="login.heading" datatype="html"> <trans-unit id="login.heading" datatype="html">
<source>Inloggen</source> <source>Inloggen bij het behandelportal</source>
<target datatype="html">Log in</target> <target datatype="html">Log in to the treatment portal</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/auth/ui/login.page.ts</context> <context context-type="sourcefile">src/app/auth/ui/login.page.ts</context>
<context context-type="linenumber">14,16</context> <context context-type="linenumber">14,16</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="login.intro" datatype="html"> <trans-unit id="login.intro" datatype="html">
<source>Log in op uw persoonlijke BIG-register omgeving.</source> <source>Voor medewerkers die aanvragen beoordelen.</source>
<target datatype="html">Log in to your personal BIG register environment.</target> <target datatype="html">For staff who assess applications.</target>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/auth/ui/login.page.ts</context> <context context-type="sourcefile">src/app/auth/ui/login.page.ts</context>
<context context-type="linenumber">17,19</context> <context context-type="linenumber">17,19</context>
@@ -3878,6 +3846,10 @@
<source>De functievlaggen konden niet worden geladen.</source> <source>De functievlaggen konden niet worden geladen.</source>
<target datatype="html">The feature flags could not be loaded.</target> <target datatype="html">The feature flags could not be loaded.</target>
</trans-unit> </trans-unit>
<trans-unit id="flags.set.failed" datatype="html">
<source>De functievlag kon niet worden opgeslagen.</source>
<target datatype="html">The feature flag could not be saved.</target>
</trans-unit>
<trans-unit id="flags.retry" datatype="html"> <trans-unit id="flags.retry" datatype="html">
<source>Opnieuw proberen</source> <source>Opnieuw proberen</source>
<target datatype="html">Try again</target> <target datatype="html">Try again</target>
@@ -1,55 +1,50 @@
import { Injectable, computed, effect, inject, signal } from '@angular/core'; import { Injectable, computed, effect, inject, signal } from '@angular/core';
import { Result } from '@shared/kernel/fp'; import { Result } from '@shared/kernel/fp';
import { Session } from '../domain/session'; import { Principal, parseStoredPrincipal } from '../domain/principal';
import { DigidAdapter } from '../infrastructure/digid.adapter'; import { DigidAdapter } from '../infrastructure/digid.adapter';
const STORAGE_KEY = 'session-v1'; const STORAGE_KEY = 'session-v1';
/** Restore a persisted session (best-effort; corrupt entry → logged out). /** Restore a persisted principal (best-effort; corrupt entry → logged out).
G2: validate the shape before trusting it. G1: the BSN is never persisted The parse + shape validation (G1/G2) lives in `parseStoredPrincipal`
(see the effect below), so a restored session carries an empty one — it is (`../domain/principal`) — pure, spec'd, and testable without stubbing
unused after login; only `naam` is shown in the chrome. */ `localStorage`; this just supplies the raw value. */
function restore(): Session | null { function restore(): Principal | null {
try { return parseStoredPrincipal(localStorage.getItem(STORAGE_KEY));
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<Session>;
return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null;
} catch {
return null;
}
} }
/** /**
* Holds the current session for the whole app. Because it is providedIn:'root' * Holds the current zorgverlener principal for the whole SSP. One
* there is exactly one instance — every component that injects it sees the same * `providedIn: 'root'` instance, so logging in is instantly visible everywhere
* session signal, so logging in is instantly visible everywhere (the guard, the * (the guard, the header). Persisted to localStorage — a refresh or the
* header, etc.). The session is mirrored to localStorage so a refresh, a deep-link, * cross-bundle language switch (nl at `/` ⇄ en at `/en/`) keeps you logged in —
* or the full-page navigation the language switch performs (nl at `/` ⇄ en at `/en/`, * but never the BSN itself (G1 in the `effect` below): this principal carries a
* separate bundles) keeps you logged in. ponytail: localStorage, not sessionStorage — * citizen's national identifier, which the behandelportal's equivalent store does
* sessionStorage's per-tab clearing dropped the login on the cross-bundle language * not have to guard against, because its `medewerker` principal has no BSN.
* switch. Trade-off: the demo session now survives tab close; a real portal keeps auth * ponytail: localStorage, not sessionStorage — sessionStorage's per-tab clearing
* in an httpOnly cookie/token, not web storage. * 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' }) @Injectable({ providedIn: 'root' })
export class SessionStore { export class SessionStore {
private digid = inject(DigidAdapter); private digid = inject(DigidAdapter);
private _session = signal<Session | null>(restore()); private _session = signal<Principal | null>(restore());
readonly session = this._session.asReadonly(); readonly session = this._session.asReadonly();
readonly isAuthenticated = computed(() => this._session() !== null); readonly isAuthenticated = computed(() => this._session() !== null);
constructor() { constructor() {
effect(() => { effect(() => {
const s = this._session(); const p = this._session();
// G1: persist only `naam` — never write the BSN (national ID) to storage. // 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); else localStorage.removeItem(STORAGE_KEY);
}); });
} }
/** Effectful command: authenticate, then store the session on success. */ /** Effectful command: authenticate, then store the principal on success. */
async login(bsn: string): Promise<Result<string, Session>> { async login(bsn: string): Promise<Result<string, Principal>> {
const r = await this.digid.authenticate(bsn); const r = await this.digid.authenticate(bsn);
if (r.ok) this._session.set(r.value); if (r.ok) this._session.set(r.value);
return r; return r;
-62
View File
@@ -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<void>;
};
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 = <T>(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<Promise<unknown>>(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<Promise<unknown>>(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<Promise<unknown>>(guard())).resolves.toEqual({ tree: ['/login'] });
expect(readySpy).not.toHaveBeenCalled();
});
});
+7 -31
View File
@@ -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 * The route guards live in `libs/shared` (ADR-C-006) — they are actor-agnostic, reading
* redirect. Used by the admin pages (`/brief/huisstijl`, `/beheer/stamdata`). * 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` * ADR-0002 §3's "auth stays duplicated" still holds for what it actually scopes —
* is still loading — it would deny an entitled admin and bounce them. We await * `Principal`, the login flow, `SessionStore`. A guard is neither.
* `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 { export { authGuard, capabilityGuard } from '@shared/application/auth.guard';
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']);
};
}
@@ -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' });
});
});
+39
View File
@@ -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<Principal>;
return typeof parsed?.naam === 'string'
? { kind: 'zorgverlener', bsn: '', naam: parsed.naam }
: null;
} catch {
return 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);
});
});
-9
View File
@@ -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;
}
@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Result, ok } from '@shared/kernel/fp'; import { Result, ok } from '@shared/kernel/fp';
import { parseBsn } from '@shared/kernel/bsn'; import { parseBsn } from '@shared/kernel/bsn';
import { Session } from '../domain/session'; import { Principal } from '../domain/principal';
/** Infrastructure: talks to the (mock) DigiD identity provider. */ /** Infrastructure: talks to the (mock) DigiD identity provider. */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -9,8 +9,8 @@ export class DigidAdapter {
// ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity. // 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 // 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. // for a real OIDC redirect flow when there's an IdP.
async authenticate(bsn: string): Promise<Result<string, Session>> { async authenticate(bsn: string): Promise<Result<string, Principal>> {
const r = parseBsn(bsn); 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;
} }
} }
@@ -1,9 +1,15 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { describe, it, expect, vi, afterEach } from 'vitest'; import { describe, it, expect, vi, afterEach } from 'vitest';
import { Result } from '@shared/kernel/fp'; 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 { Brief, BriefDecisions, CaseContext, LetterBlock } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template'; 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 { LetterPreviewAdapter, PREVIEW_FAILED } from '@brief/infrastructure/letter-preview.adapter';
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter'; import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
import { BriefStore } from './brief.store'; import { BriefStore } from './brief.store';
@@ -48,8 +54,26 @@ const caseContext: CaseContext = {
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext }; const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext };
function setup(adapter: Partial<BriefAdapter>): BriefStore { /** A recording fake of BLOB_PRESENTER (RB-28/TE-006) — records every call instead of
TestBed.configureTestingModule({ providers: [{ provide: BriefAdapter, useValue: adapter }] }); 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<BriefAdapter>, blobPresenter?: BlobPresenter): BriefStore {
TestBed.configureTestingModule({
providers: [
{ provide: BriefAdapter, useValue: adapter },
...(blobPresenter ? [{ provide: BLOB_PRESENTER, useValue: blobPresenter }] : []),
],
});
return TestBed.inject(BriefStore); return TestBed.inject(BriefStore);
} }
@@ -60,7 +84,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } }, brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
}; };
const store = setup({ const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }), load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
Promise.resolve({ ok: true, value: view }),
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }), save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise<Result<string, BriefView>> => approve: (): Promise<Result<string, BriefView>> =>
Promise.resolve({ ok: true, value: approved }), 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' } }, brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
}; };
const store = setup({ const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }), load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
Promise.resolve({ ok: true, value: view }),
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }), save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise<Result<string, BriefView>> => approve: (): Promise<Result<string, BriefView>> =>
Promise.resolve({ ok: true, value: approved }), 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 () => { it('goes Busy then Failed on a failing transition, surfacing the error', async () => {
const store = setup({ const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }), load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
Promise.resolve({ ok: true, value: view }),
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }), save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise<Result<string, BriefView>> => approve: (): Promise<Result<string, BriefView>> =>
Promise.resolve({ ok: false, error: 'niet toegestaan' }), 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 () => { it('a subsequent successful transition clears a prior Failed state', async () => {
let approveResult: Result<string, BriefView> = { ok: false, error: 'eerste poging mislukt' }; let approveResult: Result<string, BriefView> = { ok: false, error: 'eerste poging mislukt' };
const store = setup({ const store = setup({
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }), load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
Promise.resolve({ ok: true, value: view }),
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }), save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
approve: (): Promise<Result<string, BriefView>> => Promise.resolve(approveResult), approve: (): Promise<Result<string, BriefView>> => Promise.resolve(approveResult),
}); });
@@ -156,8 +184,10 @@ function loadedBrief(store: BriefStore): Brief {
} }
async function loadedStore(over: Partial<BriefAdapter> = {}): Promise<BriefStore> { async function loadedStore(over: Partial<BriefAdapter> = {}): Promise<BriefStore> {
const ok = (v: BriefView): Promise<Result<string, BriefView>> => // Untyped return (inferred as the narrow `{ ok: true; value }` literal) so this one
Promise.resolve({ ok: true, value: v }); // 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 }); const store = setup({ load: () => ok(filledView), save: () => ok(filledView), ...over });
await store.load(); await store.load();
return store; return store;
@@ -255,8 +285,7 @@ describe('BriefStore rejection diff', () => {
...filledBrief, ...filledBrief,
status: { tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'nee' }, status: { tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'nee' },
}; };
const ok = (v: BriefView): Promise<Result<string, BriefView>> => const ok = (v: BriefView) => Promise.resolve({ ok: true, value: v } as const);
Promise.resolve({ ok: true, value: v });
const store = setup({ const store = setup({
load: () => ok({ ...filledView, brief: submitted }), load: () => ok({ ...filledView, brief: submitted }),
save: () => ok(filledView), save: () => ok(filledView),
@@ -277,41 +306,46 @@ describe('BriefStore rejection diff', () => {
}); });
describe('BriefStore.previewLetter', () => { 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()); afterEach(() => vi.restoreAllMocks());
it('opens the composed letter in a new tab on success', async () => { it('opens the composed letter via BLOB_PRESENTER on success (RB-28)', async () => {
const store = setup({ const { presenter, opened } = fakeBlobPresenter();
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }), const store = setup(
}); {
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
Promise.resolve({ ok: true, value: view }),
},
presenter,
);
await store.load(); await store.load();
const blob = new Blob(['<html></html>'], { type: 'text/html' }); const blob = new Blob(['<html></html>'], { 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({ vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
ok: true, ok: true,
value: blob, value: blob,
}); });
await store.previewLetter(); await store.previewLetter();
expect(open).toHaveBeenCalledWith('blob:mock', '_blank'); expect(opened).toEqual([blob]);
expect(store.lastError()).toBeNull(); expect(store.lastError()).toBeNull();
}); });
it('surfaces the error without opening a tab on failure', async () => { it('surfaces the error without opening a tab on failure', async () => {
const store = setup({ const { presenter, opened } = fakeBlobPresenter();
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }), const store = setup(
}); {
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
Promise.resolve({ ok: true, value: view }),
},
presenter,
);
await store.load(); await store.load();
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({ vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
ok: false, ok: false,
error: PREVIEW_FAILED, error: PREVIEW_FAILED,
}); });
await store.previewLetter(); await store.previewLetter();
expect(open).not.toHaveBeenCalled(); expect(opened).toHaveLength(0);
expect(store.lastError()).toBe(PREVIEW_FAILED); expect(store.lastError()).toBe(PREVIEW_FAILED);
}); });
}); });
@@ -377,3 +411,42 @@ describe('BriefStore.flushPending (CanDeactivate guard / beforeunload)', () => {
expect(save).not.toHaveBeenCalled(); 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<BriefLoadFailure, BriefView> = { ok: false, error: { tag: 'notFound' } };
const resetOk: Result<string, BriefView> = { 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 });
});
});
@@ -16,11 +16,12 @@ import {
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine'; import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
import { BlockDiffKind, changedBlocks, diffBlocks } from '@brief/domain/brief-diff'; import { BlockDiffKind, changedBlocks, diffBlocks } from '@brief/domain/brief-diff';
import { OrgTemplate } from '@brief/domain/org-template'; 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 { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.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 { 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 * 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 adapter = inject(BriefAdapter);
private previewAdapter = inject(LetterPreviewAdapter); private previewAdapter = inject(LetterPreviewAdapter);
private revealAdapter = inject(RevealBigNummerAdapter); private revealAdapter = inject(RevealBigNummerAdapter);
private blobPresenter = inject(BLOB_PRESENTER);
private store = createStore<BriefState, BriefMsg>(initial, reduce); private store = createStore<BriefState, BriefMsg>(initial, reduce);
readonly model = this.store.model; readonly model = this.store.model;
@@ -119,13 +121,40 @@ export class BriefStore implements PendingSave {
return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics()); 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() { async load() {
const r = await this.adapter.load(); const r = await this.adapter.load();
if (r.ok) { if (r.ok) {
this.orgTemplate.set(r.value.orgTemplate); this.applyLoadedView(r.value);
this.caseContext.set(r.value.caseContext); } else if (r.error.tag === 'notFound' && !this.hasRecoveredFromMissingBrief) {
this.history.clear(); this.hasRecoveredFromMissingBrief = true;
this.store.dispatch({ tag: 'BriefLoaded', ...r.value }); 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 { } else {
this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error }); this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
} }
@@ -217,8 +246,8 @@ export class BriefStore implements PendingSave {
send = () => this.transition(() => this.adapter.send()); send = () => this.transition(() => this.adapter.send());
/** Explicit action, never a live re-render (PRD §8): opens the server-composed /** 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 letter in a new tab via `BLOB_PRESENTER.open` — see its doc comment for why the
the tab outlives this call; not worth a teardown hook for a POC. */ object URL is never revoked. */
async previewLetter() { async previewLetter() {
this.actionState.set({ tag: 'Busy' }); this.actionState.set({ tag: 'Busy' });
const r = await this.previewAdapter.preview(); const r = await this.previewAdapter.preview();
@@ -227,15 +256,18 @@ export class BriefStore implements PendingSave {
return; return;
} }
this.actionState.set({ tag: 'Idle' }); 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 /** 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 + 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 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() { async revealBigNummer() {
const r = await this.revealAdapter.reveal(); const r = await this.revealAdapter.reveal(true);
if (!r.ok) { if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error }); this.actionState.set({ tag: 'Failed', error: r.error });
return; return;
@@ -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<UploadAdapter['categoriesResource']> {
const fake = { status: () => 'idle' as const, value: () => undefined };
return fake as unknown as ReturnType<UploadAdapter['categoriesResource']>;
}
function setup(
adapter: Partial<OrgTemplateAdapter>,
blobPresenter: BlobPresenter,
): OrgTemplateStore {
const uploadAdapter: Partial<UploadAdapter> = {
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(['<html></html>'], { type: 'text/html' });
const store = setup(
{
list: (): Promise<Result<string, SubOrgSummary[]>> => Promise.resolve(ok(subOrgs)),
load: (): Promise<Result<string, OrgTemplateAdminView>> => Promise.resolve(ok(view)),
proefbrief: (): Promise<Result<string, Blob>> => 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<Result<string, SubOrgSummary[]>> => Promise.resolve(ok(subOrgs)),
load: (): Promise<Result<string, OrgTemplateAdminView>> => Promise.resolve(ok(view)),
proefbrief: (): Promise<Result<string, Blob>> =>
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');
});
});
@@ -3,9 +3,9 @@ import { createStore } from '@shared/application/store';
import { ActionState, SaveState } from '@shared/application/action-state'; import { ActionState, SaveState } from '@shared/application/action-state';
import { createDebouncedSave } from '@shared/application/debounced-save'; import { createDebouncedSave } from '@shared/application/debounced-save';
import { machineRemoteData } from '@shared/application/machine-remote-data'; import { machineRemoteData } from '@shared/application/machine-remote-data';
import { UploadAdapter } from '@shared/upload/upload.adapter'; import { UploadAdapter, uploadContentUrl } from '@shared/infrastructure/upload.adapter';
import { UploadShellService } from '@shared/upload/upload-shell.service'; import { UploadShellService } from '@shared/application/upload-shell.service';
import { UploadMsg, initialUpload, rejectReason } from '@shared/upload/upload.machine'; import { UploadMsg, initialUpload, rejectReason } from '@shared/domain/upload.machine';
import { import {
MARGIN_MAX_MM, MARGIN_MAX_MM,
MARGIN_MIN_MM, MARGIN_MIN_MM,
@@ -20,6 +20,7 @@ import {
} from '@brief/domain/org-template.machine'; } from '@brief/domain/org-template.machine';
import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter'; import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves'; import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
type LoadedState = Extract<OrgTemplateState, { tag: 'loaded' }>; type LoadedState = Extract<OrgTemplateState, { tag: 'loaded' }>;
@@ -38,6 +39,7 @@ export class OrgTemplateStore implements PendingSave {
private adapter = inject(OrgTemplateAdapter); private adapter = inject(OrgTemplateAdapter);
private uploadAdapter = inject(UploadAdapter); private uploadAdapter = inject(UploadAdapter);
private shell = inject(UploadShellService); private shell = inject(UploadShellService);
private blobPresenter = inject(BLOB_PRESENTER);
private store = createStore<OrgTemplateState, OrgTemplateMsg>(initial, reduce); private store = createStore<OrgTemplateState, OrgTemplateMsg>(initial, reduce);
readonly model = this.store.model; readonly model = this.store.model;
@@ -72,6 +74,9 @@ export class OrgTemplateStore implements PendingSave {
return id ? this.uploadAdapter.contentUrl(id) : null; 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; /** Client-side mirror of the server rules (`OrgTemplateRules`) for instant feedback;
the server re-validates and stays the authority — publish is gated on this. */ the server re-validates and stays the authority — publish is gated on this. */
readonly draftValid = computed(() => { readonly draftValid = computed(() => {
@@ -214,7 +219,7 @@ export class OrgTemplateStore implements PendingSave {
return; return;
} }
this.actionState.set({ tag: 'Idle' }); 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) --- // --- logo upload (reuses the shared upload transport; single `org-logo` file) ---
@@ -3,6 +3,7 @@ import { Besluit, Brief, BriefDecisions, BriefStatus, LibraryPassage } from './b
import { RichTextBlock } from '@shared/kernel/rich-text'; import { RichTextBlock } from '@shared/kernel/rich-text';
import { PlaceholderDef } from './placeholders'; import { PlaceholderDef } from './placeholders';
import { BriefState, reduce } from './brief.machine'; import { BriefState, reduce } from './brief.machine';
import { givenBrief } from './brief.testing';
const placeholders: PlaceholderDef[] = [ const placeholders: PlaceholderDef[] = [
{ key: 'naam', label: 'Naam', autoResolvable: true }, { key: 'naam', label: 'Naam', autoResolvable: true },
@@ -64,15 +65,15 @@ const decisions: BriefDecisions = {
canRevealBigNummer: true, canRevealBigNummer: true,
}; };
const loaded = ( // Replays a real `BriefLoaded` message through the real `reduce` (ADR-0006 §2)
status: BriefStatus = { tag: 'draft' }, // instead of hand-assembling the 'loaded' state directly.
sections?: Brief['sections'], const loaded = (status: BriefStatus = { tag: 'draft' }, sections?: Brief['sections']): BriefState =>
): BriefState => ({ givenBrief({
tag: 'loaded', tag: 'BriefLoaded',
brief: briefWith(status, sections), brief: briefWith(status, sections),
availablePassages: lib, availablePassages: lib,
decisions, decisions,
}); });
const sectionBlocks = (s: BriefState, key: string) => const sectionBlocks = (s: BriefState, key: string) =>
s.tag === 'loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : []; 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', () => { it('BesluitSelected deep-copies content — later library mutation does not leak in', () => {
const passage = libPassage('intro', 'kern'); // shared → offered for any besluit const passage = libPassage('intro', 'kern'); // shared → offered for any besluit
const st: BriefState = { const st = givenBrief({
tag: 'loaded', tag: 'BriefLoaded',
brief: briefWith({ tag: 'draft' }), brief: briefWith({ tag: 'draft' }),
availablePassages: [passage], availablePassages: [passage],
decisions, decisions,
}; });
const s = reduce(st, besluit('positief')); const s = reduce(st, besluit('positief'));
// Mutate the source passage object after composition. // Mutate the source passage object after composition.
(passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED'; (passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED';
@@ -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);
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
import { expectTag } from '@shared/testing/expect-tag'; import { expectTag } from '@shared/testing/expect-tag';
import { OrgTemplate, OrgTemplateAdminView } from './org-template'; import { OrgTemplate, OrgTemplateAdminView } from './org-template';
import { OrgTemplateState, reduce } from './org-template.machine'; import { OrgTemplateState, reduce } from './org-template.machine';
import { DocumentCategory } from '@shared/upload/upload.machine'; import { DocumentCategory } from '@shared/domain/upload.machine';
const template: OrgTemplate = { const template: OrgTemplate = {
subOrgId: 'cibg-registers', subOrgId: 'cibg-registers',
@@ -1,6 +1,6 @@
import { assertNever } from '@shared/kernel/fp'; import { assertNever } from '@shared/kernel/fp';
import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from './org-template'; 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) — * The admin org-template editor as one Elm-style machine (WP-26, PRD Brief v2 §5) —
@@ -1,6 +1,7 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp'; import { Result, ok, err } from '@shared/kernel/fp';
import { runSubmit } from '@shared/application/submit'; import { runSubmit } from '@shared/application/submit';
import { problemDetail } from '@shared/infrastructure/api-error';
import { import {
ApiClient, ApiClient,
BriefDecisionsDto, 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 * 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); * uses FLAT unions (a `type`/`tag` string + nullable fields, the repo convention);
* the `parse*` boundary narrows them into the domain's proper discriminated unions * the `parse*` boundary narrows them into the domain's proper discriminated unions
* and rejects malformed shapes. Mutations go through `runSubmit` (ProblemDetails → * and rejects malformed shapes. Every mutation folds through `runSubmit`
* error string), then parse the returned brief. * (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 { export interface BriefView {
@@ -45,16 +51,39 @@ export interface BriefView {
readonly caseContext: CaseContext; 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_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.`; 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' }) @Injectable({ providedIn: 'root' })
export class BriefAdapter { export class BriefAdapter {
private client = inject(ApiClient); private client = inject(ApiClient);
async load(): Promise<Result<string, BriefView>> { async load(): Promise<Result<BriefLoadFailure, BriefView>> {
const r = await runSubmit(() => this.client.briefGET(), BRIEF_LOAD_FAILED); try {
return r.ok ? parseBriefView(r.value) : r; 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<Result<string, BriefView>> { async save(sections: readonly LetterSection[]): Promise<Result<string, BriefView>> {
@@ -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<string, unknown>;
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<string, string>;
expect(headers['X-Role']).toBeDefined();
expect(headers['X-Subject']).toBe('111222333');
});
});
@@ -1,4 +1,4 @@
import { Injectable } from '@angular/core'; import { Injectable, isDevMode } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp'; import { Result, ok, err } from '@shared/kernel/fp';
import { currentRole } from '@shared/infrastructure/role'; import { currentRole } from '@shared/infrastructure/role';
import { currentSubject } from '@shared/infrastructure/subject'; 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 * 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 * `roleInterceptor` AND `subjectInterceptor`, so both `X-Role` and `X-Subject` are set
* here explicitly (WP-74 — without `X-Subject` this always previewed * 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 * `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 * `Vary: Origin`, and its content changes at the SAME URL as the letter moves
@@ -43,7 +46,9 @@ export class LetterPreviewAdapter {
const subject = currentSubject(); const subject = currentSubject();
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/preview`, { res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/preview`, {
cache: 'no-store', cache: 'no-store',
headers: { 'X-Role': currentRole(), ...(subject ? { 'X-Subject': subject } : {}) }, headers: isDevMode()
? { 'X-Role': currentRole(), ...(subject ? { 'X-Subject': subject } : {}) }
: {},
}); });
} catch { } catch {
return err(PREVIEW_FAILED); return err(PREVIEW_FAILED);
@@ -53,7 +58,9 @@ export class LetterPreviewAdapter {
} }
} }
async function errorMessage(res: Response): Promise<string> { /** 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<string> {
try { try {
return problemDetail(await res.json(), PREVIEW_FAILED); return problemDetail(await res.json(), PREVIEW_FAILED);
} catch { } catch {
@@ -1,6 +1,10 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { OrgTemplateAdminViewDto, OrgTemplateDto } from '@shared/infrastructure/api-client'; 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 = { const draft: OrgTemplateDto = {
subOrgId: 'cibg-registers', subOrgId: 'cibg-registers',
@@ -54,3 +58,25 @@ describe('parseOrgTemplateAdminView', () => {
expect(r.ok).toBe(false); 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);
});
});
@@ -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 { 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 { currentRole } from '@shared/infrastructure/role';
import { problemDetail } from '@shared/infrastructure/api-error'; import { problemDetail } from '@shared/infrastructure/api-error';
import { environment } from '@shared/environments/environment'; 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`); * rollback go through the generated client (X-Role added by `roleInterceptor`);
* `parse*` narrows the untrusted wire shape. The proefbrief is `text/html` and * `parse*` narrows the untrusted wire shape. The proefbrief is `text/html` and
* `ExcludeFromDescription`'d — a hand-written fetch, same seam as `letter-preview.adapter`. * `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 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' }) @Injectable({ providedIn: 'root' })
export class OrgTemplateAdapter { export class OrgTemplateAdapter {
private client = inject(ApiClient); private client = inject(ApiClient);
async list(): Promise<Result<string, SubOrgSummary[]>> { async list(): Promise<Result<string, SubOrgSummary[]>> {
const r = await runSubmit(() => this.client.orgTemplates(), FAILED); const r = await runResult(() => this.client.orgTemplates(), FAILED);
if (!r.ok) return r; if (!r.ok) return r;
const out: SubOrgSummary[] = []; const out: SubOrgSummary[] = [];
for (const s of r.value ?? []) { for (const s of r.value ?? []) {
@@ -48,7 +51,7 @@ export class OrgTemplateAdapter {
} }
async load(subOrgId: string): Promise<Result<string, OrgTemplateAdminView>> { async load(subOrgId: string): Promise<Result<string, OrgTemplateAdminView>> {
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; return r.ok ? parseAdminView(r.value) : r;
} }
@@ -76,22 +79,26 @@ export class OrgTemplateAdapter {
try { try {
res = await fetch( res = await fetch(
`${environment.apiBaseUrl}/api/v1/admin/org-template/${encodeURIComponent(subOrgId)}/preview`, `${environment.apiBaseUrl}/api/v1/admin/org-template/${encodeURIComponent(subOrgId)}/preview`,
{ headers: { 'X-Role': currentRole() } }, { headers: isDevMode() ? { 'X-Role': currentRole() } : {} },
); );
} catch { } catch {
return err(PROEFBRIEF_FAILED); return err(PROEFBRIEF_FAILED);
} }
if (!res.ok) { if (!res.ok) return err(await proefbriefErrorMessage(res));
try {
return err(problemDetail(await res.json(), PROEFBRIEF_FAILED));
} catch {
return err(PROEFBRIEF_FAILED);
}
}
return ok(await res.blob()); 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<string> {
try {
return problemDetail(await res.json(), PROEFBRIEF_FAILED);
} catch {
return PROEFBRIEF_FAILED;
}
}
// --- parse: wire → domain, validating at the boundary --- // --- parse: wire → domain, validating at the boundary ---
function parseSubOrg(dto: SubOrgSummaryDto): Result<string, SubOrgSummary> { function parseSubOrg(dto: SubOrgSummaryDto): Result<string, SubOrgSummary> {
@@ -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<string, unknown>;
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<string, string>;
expect(headersWithoutStepUp['X-Step-Up']).toBeUndefined();
await new RevealBigNummerAdapter().reveal(true);
const headersWithStepUp = fetchSpy.mock.calls[1][1].headers as Record<string, string>;
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<string, string>;
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<string, string>;
expect(devHeaders['X-Role']).toBeDefined();
});
});
@@ -1,47 +1,62 @@
import { Injectable } from '@angular/core'; import { Injectable, isDevMode } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp'; import { Result, ok, err } from '@shared/kernel/fp';
import { currentRole } from '@shared/infrastructure/role'; import { currentRole } from '@shared/infrastructure/role';
import { problemDetail } from '@shared/infrastructure/api-error'; import { problemDetail } from '@shared/infrastructure/api-error';
import { environment } from '@shared/environments/environment'; 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; * 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 * 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 * step-up is stubbed as the `X-Step-Up` header, sent only when the caller passes
* user's confirm gesture, so a plain call (or a role without the capability) 403s. * `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; * 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 * `.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' }) @Injectable({ providedIn: 'root' })
export class RevealBigNummerAdapter { export class RevealBigNummerAdapter {
async reveal(): Promise<Result<string, string>> { async reveal(stepUp: boolean): Promise<Result<string, string>> {
let res: Response; let res: Response;
try { try {
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/reveal-bignummer`, { res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/reveal-bignummer`, {
method: 'POST', method: 'POST',
headers: { 'X-Role': currentRole(), 'X-Step-Up': 'true' }, headers: {
...(isDevMode() ? { 'X-Role': currentRole() } : {}),
...(stepUp ? { 'X-Step-Up': 'true' } : {}),
},
}); });
} catch { } catch {
return err(REVEAL_FAILED); return err(REVEAL_FAILED);
} }
if (!res.ok) return err(await errorMessage(res)); if (!res.ok) return err(await errorMessage(res));
const body: unknown = await res.json().catch(() => null); return parseRevealed(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);
} }
} }
/** 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<string, 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);
}
async function errorMessage(res: Response): Promise<string> { async function errorMessage(res: Response): Promise<string> {
try { try {
return problemDetail(await res.json(), REVEAL_FAILED); return problemDetail(await res.json(), REVEAL_FAILED);
@@ -5,7 +5,7 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component'; import { AlertComponent } from '@shared/ui/alert/alert.component';
import { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component'; import { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component';
import { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.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 { Brief } from '@brief/domain/brief';
import { import {
MARGIN_MAX_MM, MARGIN_MAX_MM,
@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from '@storybook/angular'; import type { Meta, StoryObj } from '@storybook/angular';
import { OrgTemplateEditorComponent } from './org-template-editor.component'; import { OrgTemplateEditorComponent } from './org-template-editor.component';
import { OrgTemplate, OrgTemplateVersion, SubOrgSummary } from '@brief/domain/org-template'; 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 = { const draft: OrgTemplate = {
subOrgId: 'cibg-registers', subOrgId: 'cibg-registers',
@@ -4,7 +4,6 @@ import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/button/button.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/async/async.component';
import { AccessStore } from '@shared/application/access.store'; import { AccessStore } from '@shared/application/access.store';
import { UploadAdapter } from '@shared/upload/upload.adapter';
import { OrgTemplateStore } from '@brief/application/org-template.store'; import { OrgTemplateStore } from '@brief/application/org-template.store';
import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component'; 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 { export class OrgTemplatePage {
protected store = inject(OrgTemplateStore); protected store = inject(OrgTemplateStore);
protected access = inject(AccessStore); protected access = inject(AccessStore);
private uploadAdapter = inject(UploadAdapter);
protected canEdit = computed(() => this.access.can('orgtemplate:edit')); 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 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.`; protected intro = $localize`:@@orgTemplate.page.intro:Beheer per organisatieonderdeel het uiterlijk van de brief: logo, afzender, ondertekening, voettekst en marges.`;
@@ -7,7 +7,7 @@ import {
reduceUpload, reduceUpload,
requiredCategoriesSatisfied, requiredCategoriesSatisfied,
deliveryRefs, deliveryRefs,
} from '@shared/upload/upload.machine'; } from '@shared/domain/upload.machine';
/** What the user is typing (raw, possibly invalid). */ /** What the user is typing (raw, possibly invalid). */
export interface Draft { export interface Draft {
@@ -2,7 +2,6 @@ import { describe, it, expect } from 'vitest';
import { ok, err } from '@shared/kernel/fp'; import { ok, err } from '@shared/kernel/fp';
import { expectTag } from '@shared/testing/expect-tag'; import { expectTag } from '@shared/testing/expect-tag';
import { import {
Answers,
initial, initial,
STEPS, STEPS,
lageUren, lageUren,
@@ -15,14 +14,7 @@ import {
reduce, reduce,
IntakeState, IntakeState,
} from './intake.machine'; } from './intake.machine';
import { givenIntake } from './intake.testing';
const answering = (answers: Answers, cursor = 0, scholingThreshold = 1000): IntakeState => ({
tag: 'Answering',
answers,
cursor,
errors: {},
scholingThreshold,
});
describe('STEPS (fixed) and inline questions', () => { describe('STEPS (fixed) and inline questions', () => {
it('always has the same three steps', () => { 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', () => { it('reveals the buitenland detail questions inline only when worked abroad', () => {
// No new step; instead these fields become required within the buitenland step. // No new step; instead these fields become required within the buitenland step.
expect(next(answering({ buitenlandGewerkt: 'ja' })).tag).toBe('Answering'); // land/uren missing -> blocked const abroad = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' });
expect( expect(next(abroad).tag).toBe('Answering'); // land/uren missing -> blocked
expectTag(next(answering({ buitenlandGewerkt: 'ja' })), 'Answering').errors.land, expect(expectTag(next(abroad), 'Answering').errors.land).toBeTruthy();
).toBeTruthy(); const domestic = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' });
expect(next(answering({ buitenlandGewerkt: 'nee' })).tag).toBe('Answering'); // valid, advances (cursor moves) expect(next(domestic).tag).toBe('Answering'); // valid, advances (cursor moves)
expect(expectTag(next(answering({ buitenlandGewerkt: 'nee' })), 'Answering').cursor).toBe(1); expect(expectTag(next(domestic), 'Answering').cursor).toBe(1);
}); });
it('reveals the scholing question only when NL-hours are below the threshold', () => { 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' }, 1000)).toBe(false);
expect(lageUren({ uren: '1500' }, 2000)).toBe(true); expect(lageUren({ uren: '1500' }, 2000)).toBe(true);
// And the threshold from state flows through submit: // And the threshold from state flows through submit:
const lowThreshold = submit( const lowThresholdState = givenIntake(
answering({ buitenlandGewerkt: 'nee', uren: '1500', punten: '200' }, 0, 2000), { 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(lowThreshold.tag).toBe('Answering'); // scholing now required (1500 < 2000), unanswered → blocked
expect(expectTag(lowThreshold, 'Answering').errors.scholingGevolgd).toBeTruthy(); expect(expectTag(lowThreshold, 'Answering').errors.scholingGevolgd).toBeTruthy();
}); });
@@ -65,18 +61,21 @@ describe('navigation', () => {
}); });
it('Next advances once the step is valid', () => { 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(s.cursor).toBe(1);
expect(currentStep(s)).toBe('werk'); expect(currentStep(s)).toBe('werk');
}); });
it('editing an answer leaves the cursor fixed (steps never collapse)', () => { 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( const edited = expectTag(
reduce(answering({ buitenlandGewerkt: 'ja' }, 1), { reduce(atWerk, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }),
tag: 'SetAnswer',
key: 'buitenlandGewerkt',
value: 'nee',
}),
'Answering', 'Answering',
); );
expect(edited.cursor).toBe(1); // cursor untouched; only inline questions change 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', () => { 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); expect(expectTag(gaNaarStap(s, 0), 'Answering').cursor).toBe(0);
}); });
it('gaNaarStap ignores a same/forward jump and jumps outside Answering', () => { 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, 1)).toBe(s); // same step -> no-op
expect(gaNaarStap(s, 2)).toBe(s); // forward -> 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 expect(gaNaarStap(submitting, 0)).toBe(submitting); // not Answering -> no-op
}); });
}); });
describe('submit', () => { describe('submit', () => {
// High hours: no scholing question, so no punten is asked or collected. // 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', () => { it('reaches Submitting ONLY with valid answers', () => {
// Bad punten only blocks when scholing was followed (otherwise punten is ignored). // Bad punten only blocks when scholing was followed (otherwise punten is ignored).
expect( const badPunten = givenIntake(
submit( { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: 'x' }), { tag: 'SetAnswer', key: 'uren', value: '500' },
).tag, { tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
).toBe('Answering'); { tag: 'SetAnswer', key: 'punten', value: 'x' },
const good = expectTag(submit(answering(complete)), 'Submitting'); );
expect(submit(badPunten).tag).toBe('Answering');
const good = expectTag(submit(highUren), 'Submitting');
expect(good.data.uren).toBe(4160); expect(good.data.uren).toBe(4160);
expect(good.data.punten).toBeUndefined(); // not collected without scholing expect(good.data.punten).toBeUndefined(); // not collected without scholing
}); });
it('punten is required only when aanvullende scholing was gevolgd', () => { it('punten is required only when aanvullende scholing was gevolgd', () => {
// scholing = ja but punten missing -> blocked on punten. // scholing = ja but punten missing -> blocked on punten.
const missing = expectTag( const scholingJaNoPunten = givenIntake(
submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja' })), { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
'Answering', { tag: 'SetAnswer', key: 'uren', value: '500' },
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
); );
const missing = expectTag(submit(scholingJaNoPunten), 'Answering');
expect(missing.errors.punten).toBeTruthy(); expect(missing.errors.punten).toBeTruthy();
// scholing = nee -> punten not required, submits without it. // scholing = nee -> punten not required, submits without it.
expect( const scholingNee = givenIntake(
submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'nee' })).tag, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
).toBe('Submitting'); { 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', () => { it('low hours requires the scholing answer before submit', () => {
const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500' })); const lowUrenNoScholing = givenIntake(
expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
const withScholing = expectTag( { tag: 'SetAnswer', key: 'uren', value: '500' },
submit(
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: '200' }),
),
'Submitting',
); );
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.aanvullendeScholing).toBe(true);
expect(withScholing.data.punten).toBe(200); expect(withScholing.data.punten).toBe(200);
}); });
@@ -145,38 +173,36 @@ describe('submit', () => {
it('does not require punten for a hidden question (WP-69 §6)', () => { 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 // 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. // threshold — the template hides the question, so punten must not be required either.
const good = expectTag( const staleScholingNoPunten = givenIntake(
submit(answering({ buitenlandGewerkt: 'nee', uren: '1500', scholingGevolgd: 'ja' })), { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
'Submitting', { tag: 'SetAnswer', key: 'uren', value: '1500' },
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
); );
const good = expectTag(submit(staleScholingNoPunten), 'Submitting');
expect(good.data.aanvullendeScholing).toBeUndefined(); expect(good.data.aanvullendeScholing).toBeUndefined();
}); });
it('drops punten when raising uren hides the question (WP-69 §6)', () => { 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. // Same stale answer, but this time punten was also filled in while uren was low.
const good = expectTag( const staleScholingWithPunten = givenIntake(
submit( { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
answering({ { tag: 'SetAnswer', key: 'uren', value: '1500' },
buitenlandGewerkt: 'nee', { tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
uren: '1500', { tag: 'SetAnswer', key: 'punten', value: '150' },
scholingGevolgd: 'ja',
punten: '150',
}),
),
'Submitting',
); );
const good = expectTag(submit(staleScholingWithPunten), 'Submitting');
// ValidIntake stays honest: neither the stale 'ja' nor its punten leak through. // ValidIntake stays honest: neither the stale 'ja' nor its punten leak through.
expect(good.data.aanvullendeScholing).toBeUndefined(); expect(good.data.aanvullendeScholing).toBeUndefined();
expect(good.data.punten).toBeUndefined(); expect(good.data.punten).toBeUndefined();
}); });
it('resolve maps Submitting to Submitted on a successful submit', () => { 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'); expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
}); });
it('resolve maps Submitting to Failed on a failed submit', () => { 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'); expect(resolve(submitting, err('boom')).tag).toBe('Failed');
}); });
}); });
@@ -23,9 +23,8 @@ import {
} from '@herregistratie/domain/herregistratie.machine'; } from '@herregistratie/domain/herregistratie.machine';
import { createDraftSync } from '@registratie/application/draft-sync'; import { createDraftSync } from '@registratie/application/draft-sync';
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component'; import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
import { createUploadController } from '@shared/upload/upload-controller'; import { createUploadController } from '@shared/application/upload-controller';
import { UploadAdapter } from '@shared/upload/upload.adapter'; import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.machine';
import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine';
/** Organism: multi-step herregistratie wizard. ALL state lives in one signal /** Organism: multi-step herregistratie wizard. ALL state lives in one signal
driven by the pure `reduce` function (see herregistratie.machine.ts) via an 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 { export class HerregistratieWizardComponent {
private profile = inject(BigProfileStore); private profile = inject(BigProfileStore);
private uploadAdapter = inject(UploadAdapter);
private store = createStore<WizardState, WizardMsg>(initial, reduce); private store = createStore<WizardState, WizardMsg>(initial, reduce);
/** Preview/download link for a completed upload; dev-simulation `demo-*` ids have /** Preview/download link for a completed upload; delegates to the upload
no stored bytes, so they get no link. */ 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 => 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. */ /** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input<WizardState>(initial); seed = input<WizardState>(initial);
@@ -4,7 +4,7 @@ import { provideHttpClient } from '@angular/common/http';
import { provideApiClient } from '@shared/infrastructure/api-client.provider'; import { provideApiClient } from '@shared/infrastructure/api-client.provider';
import { HerregistratieWizardComponent } from './herregistratie-wizard.component'; import { HerregistratieWizardComponent } from './herregistratie-wizard.component';
import { WizardState } from '@herregistratie/domain/herregistratie.machine'; 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'; import { Uren } from '@registratie/domain/value-objects/uren';
const validData = { uren: 4160 as Uren, jaren: 5, punten: 200, documents: [] }; const validData = { uren: 4160 as Uren, jaren: 5, punten: 200, documents: [] };
@@ -1,5 +1,6 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { SUBMIT_FAILED } from '@shared/application/submit';
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter'; import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
import { AdminCasesStore } from './admin-cases.store'; import { AdminCasesStore } from './admin-cases.store';
@@ -43,7 +44,10 @@ describe('AdminCasesStore', () => {
expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['b']); 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 deleteAny = vi.fn().mockRejectedValue(new Error('boom'));
const store = setup({ listAll: () => Promise.resolve([summary('a')]), deleteAny }); const store = setup({ listAll: () => Promise.resolve([summary('a')]), deleteAny });
await store.load(); await store.load();
@@ -51,5 +55,24 @@ describe('AdminCasesStore', () => {
await store.delete('a'); await store.delete('a');
const s = store.cases(); const s = store.cases();
expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['a']); // reappears 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();
}); });
}); });
@@ -1,5 +1,6 @@
import { Injectable, inject, signal } from '@angular/core'; import { Injectable, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data'; import { RemoteData } from '@shared/application/remote-data';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
import { Aanvraag } from '@registratie/domain/aanvraag'; import { Aanvraag } from '@registratie/domain/aanvraag';
import { import {
ApplicationsAdapter, ApplicationsAdapter,
@@ -12,8 +13,9 @@ type Err = Error | undefined;
* Admin view of ALL cases across owners (WP-36; `cases:manage`) the back-office * 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 * counterpart of the user-facing `ApplicationsStore`. Same shape: one root singleton
* owns the list as a writable RemoteData signal, delete removes the row synchronously * 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, * (optimistic), goes through `runSubmit`, and rolls back plus surfaces `lastError` on
* submitted or not the server enforces the capability). * failure (RB-20). Admin delete removes any case (any owner, submitted or not the
* server enforces the capability).
*/ */
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AdminCasesStore { export class AdminCasesStore {
@@ -22,6 +24,11 @@ export class AdminCasesStore {
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' }); private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
readonly cases = this.state.asReadonly(); 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<string | null>(null);
readonly lastError = this.error.asReadonly();
/** Fetch + parse at the trust boundary, then publish as RemoteData. Keeps the /** 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). */ last-good value on a resync (only shows Loading on the first load). */
async load() { async load() {
@@ -42,16 +49,18 @@ export class AdminCasesStore {
void this.load(); 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) { async delete(id: string) {
const before = this.state(); const before = this.state();
if (before.tag === 'Success') { if (before.tag === 'Success') {
this.state.set({ tag: 'Success', value: before.value.filter((a) => a.id !== id) }); this.state.set({ tag: 'Success', value: before.value.filter((a) => a.id !== id) });
} }
try { this.error.set(null);
await this.adapter.deleteAny(id); const r = await runSubmit(() => this.adapter.deleteAny(id), SUBMIT_FAILED);
} catch { if (!r.ok) {
this.state.set(before); // roll back: the row reappears this.state.set(before); // roll back: the row reappears
this.error.set(r.error);
} }
} }
} }
@@ -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<ApplicationsAdapter>): 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();
});
});
@@ -1,5 +1,6 @@
import { Injectable, inject, signal } from '@angular/core'; import { Injectable, inject, signal } from '@angular/core';
import { RemoteData } from '@shared/application/remote-data'; import { RemoteData } from '@shared/application/remote-data';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
import { Aanvraag } from '@registratie/domain/aanvraag'; import { Aanvraag } from '@registratie/domain/aanvraag';
import { import {
ApplicationsAdapter, ApplicationsAdapter,
@@ -15,7 +16,8 @@ type Err = Error | undefined;
* the row SYNCHRONOUSLY, so the block disappears deterministically no dependence on * the row SYNCHRONOUSLY, so the block disappears deterministically no dependence on
* change-detection timing, HTTP caching, or a resource `reload()`. `reload()` re-fetches * change-detection timing, HTTP caching, or a resource `reload()`. `reload()` re-fetches
* so a page revisit reflects auto-approval (Concept In behandeling Goedgekeurd is * 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' }) @Injectable({ providedIn: 'root' })
export class ApplicationsStore { export class ApplicationsStore {
@@ -24,6 +26,11 @@ export class ApplicationsStore {
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' }); private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
readonly applications = this.state.asReadonly(); 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<string | null>(null);
readonly lastError = this.error.asReadonly();
constructor() { constructor() {
void this.load(); void this.load();
} }
@@ -50,16 +57,19 @@ export class ApplicationsStore {
} }
/** Cancel a Concept: drop it now (synchronous, guaranteed), then confirm the DELETE. /** 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) { async cancel(id: string) {
const before = this.state(); const before = this.state();
if (before.tag === 'Success') { if (before.tag === 'Success') {
this.state.set({ tag: 'Success', value: before.value.filter((a) => a.id !== id) }); this.state.set({ tag: 'Success', value: before.value.filter((a) => a.id !== id) });
} }
try { this.error.set(null);
await this.adapter.cancel(id); const r = await runSubmit(() => this.adapter.cancel(id), SUBMIT_FAILED);
} catch { if (!r.ok) {
this.state.set(before); // roll back: the block reappears this.state.set(before); // roll back: the block reappears
this.error.set(r.error);
} }
} }
} }
@@ -8,10 +8,8 @@ import type {
SubmitApplicationResponse, SubmitApplicationResponse,
} from '@shared/infrastructure/api-client'; } from '@shared/infrastructure/api-client';
import { AanvraagType } from '@registratie/domain/aanvraag'; import { AanvraagType } from '@registratie/domain/aanvraag';
import { import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
ApplicationsAdapter, import { findConcept, loadConcept } from './find-concept';
parseApplications,
} from '@registratie/infrastructure/applications.adapter';
/** What a wizard persists per step: the opaque machine snapshot + progress + docs. */ /** What a wizard persists per step: the opaque machine snapshot + progress + docs. */
export interface DraftSnapshot { export interface DraftSnapshot {
@@ -70,7 +68,7 @@ export function createDraftSync(deps: DraftSyncDeps) {
// server's guard (409) — recover by adopting the existing Concept instead of // server's guard (409) — recover by adopting the existing Concept instead of
// erroring. Only recover when one actually exists; otherwise surface the failure. // erroring. Only recover when one actually exists; otherwise surface the failure.
.catch(async (e) => { .catch(async (e) => {
const existing = await findConcept(); const existing = await findConcept(adapter, deps.type);
if (existing) return existing; if (existing) return existing;
throw e; 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. // (submitted/gone) id is treated as fresh so it can't reopen as an editable draft.
const load = (linked: string): Promise<void> => { const load = (linked: string): Promise<void> => {
id = linked; id = linked;
return adapter return loadConcept(adapter, linked).then((result) => {
.detail(linked) if (result.tag === 'not-concept') {
.then((dto) => {
if (dto.status && dto.status.tag !== 'Concept') {
id = undefined;
applyResume(null);
return;
}
applyResume(dto.draft ?? null);
})
.catch(() => {
id = undefined; id = undefined;
applyResume(null); // unknown/deleted id → start fresh applyResume(null);
}); return;
}; }
applyResume(result.draft);
// Find the user's existing Concept of this type (at most one), if any. });
const findConcept = async (): Promise<string | undefined> => {
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;
}
}; };
return { return {
@@ -189,7 +169,7 @@ export function createDraftSync(deps: DraftSyncDeps) {
await load(linked); await load(linked);
return; return;
} }
const existing = await findConcept(); const existing = await findConcept(adapter, deps.type);
if (existing) { if (existing) {
await load(existing); await load(existing);
// Stamp the id into the URL so a reload resumes the same Concept. // Stamp the id into the URL so a reload resumes the same Concept.
@@ -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>): 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' });
});
});
@@ -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<string | undefined> {
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<LoadedConcept> {
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' };
}
}
@@ -1,9 +1,8 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { ok, err } from '@shared/kernel/fp'; 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 { expectTag } from '@shared/testing/expect-tag';
import { import {
Draft,
RegistratieState, RegistratieState,
STEPS, STEPS,
initial, initial,
@@ -21,28 +20,50 @@ import {
resolve, resolve,
reduce, reduce,
} from './registratie-wizard.machine'; } from './registratie-wizard.machine';
import { givenRegistratieWizard } from './registratie-wizard.testing';
const invullen = (draft: Partial<Draft>, cursor = 0): RegistratieState => ({ /**
tag: 'Invullen', * Every fixture below is built by replaying real `RegistratieMsg`s through the
draft: { antwoorden: {}, ...draft }, * real `reduce` (ADR-0006 §2) never a hand-assembled `RegistratieState`
cursor, * literal. Each helper reaches a named point in the wizard one transition at a
errors: {}, * time, so a spec can only assert on a state the reducer can actually produce.
upload: initialUpload, */
}); const toAdresValid = (): RegistratieState =>
givenRegistratieWizard(
{
tag: 'PrefillAdres',
straat: 'Lange Voorhout 9',
postcode: '2514 EA',
woonplaats: 'Den Haag',
},
{ tag: 'SetCorrespondentie', value: 'post' },
);
const validAdres = { const toBeroepStep = (): RegistratieState => reduce(toAdresValid(), { tag: 'Next' }); // cursor 0 -> 1, no diploma yet
straat: 'Lange Voorhout 9',
postcode: '2514 EA', const toBeroepStepWithDiploma = (): RegistratieState =>
woonplaats: 'Den Haag', reduce(toBeroepStep(), { tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] });
correspondentie: 'post' as const,
adresHerkomst: 'brp' as const, const toControleStep = (): RegistratieState => reduce(toBeroepStepWithDiploma(), { tag: 'Next' }); // cursor 1 -> 2
};
const validDraft: Partial<Draft> = { const toIndienen = (): RegistratieState => reduce(toControleStep(), { tag: 'Submit' });
...validAdres,
diplomaId: 'd1', // A complete, valid draft assembled WITHOUT ever advancing the cursor. Setting a
beroep: 'Arts', // field or choosing a diploma is never gated by cursor position, so this is a
diplomaHerkomst: 'duo', // 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)', () => { describe('STEPS (fixed)', () => {
it('always has the same three steps', () => { it('always has the same three steps', () => {
@@ -60,46 +81,55 @@ describe('navigation', () => {
}); });
it('Next advances once the adres step is valid', () => { 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(s.cursor).toBe(1);
expect(currentStep(s)).toBe('beroep'); expect(currentStep(s)).toBe('beroep');
}); });
it('requires a valid e-mail only when the channel is email', () => { 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(); expect(bad.errors.email).toBeTruthy();
const good = expectTag( 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', 'Invullen',
); );
expect(good.cursor).toBe(1); expect(good.cursor).toBe(1);
}); });
it('beroep step requires a chosen diploma', () => { 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.cursor).toBe(1);
expect(noDiploma.errors.diploma).toBeTruthy(); expect(noDiploma.errors.diploma).toBeTruthy();
const withDiploma = expectTag(next(invullen(validDraft, 1)), 'Invullen'); const withDiploma = expectTag(next(toBeroepStepWithDiploma()), 'Invullen');
expect(withDiploma.cursor).toBe(2); expect(withDiploma.cursor).toBe(2);
}); });
it('Back never goes below the first step and preserves the draft', () => { it('Back never goes below the first step and preserves the draft', () => {
expect(back(initial)).toBe(initial); 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.cursor).toBe(1);
expect(s.draft.beroep).toBe('Arts'); expect(s.draft.beroep).toBe('Arts');
}); });
it('GaNaarStap only jumps backwards', () => { it('GaNaarStap only jumps backwards', () => {
expect(expectTag(gaNaarStap(invullen(validDraft, 2), 0), 'Invullen').cursor).toBe(0); expect(expectTag(gaNaarStap(toControleStep(), 0), 'Invullen').cursor).toBe(0);
expect(expectTag(gaNaarStap(invullen(validDraft, 1), 2), 'Invullen').cursor).toBe(1); // forward jump rejected expect(expectTag(gaNaarStap(toBeroepStepWithDiploma(), 2), 'Invullen').cursor).toBe(1); // forward jump rejected
}); });
}); });
describe('adres origin (BRP vs handmatig)', () => { describe('adres origin (BRP vs handmatig)', () => {
it('prefillAdres flags origin brp', () => { it('prefillAdres flags origin brp', () => {
const s = expectTag( const s = expectTag(
prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag'), prefillAdres(initial, 'Lange Voorhout 9', '2514 EA', 'Den Haag'),
'Invullen', 'Invullen',
); );
expect(s.draft.adresHerkomst).toBe('brp'); 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', () => { 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'); const edited = expectTag(setField(prefilled, 'woonplaats', 'Rotterdam'), 'Invullen');
expect(edited.draft.adresHerkomst).toBe('handmatig'); expect(edited.draft.adresHerkomst).toBe('handmatig');
}); });
it('typing an address with no BRP prefill yields 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'); expect(s.draft.adresHerkomst).toBe('handmatig');
}); });
it('editing the e-mail field does not change the address origin', () => { 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'); const edited = expectTag(setField(prefilled, 'email', 'a@b.nl'), 'Invullen');
expect(edited.draft.adresHerkomst).toBe('brp'); expect(edited.draft.adresHerkomst).toBe('brp');
}); });
it('a manually entered address still submits (only manual diploma is gated)', () => { it('a manually entered address still submits (only manual diploma is gated)', () => {
const s = submit( const manualAdres = givenRegistratieWizard(
invullen({ { tag: 'SetField', key: 'straat', value: 'Kerkstraat 1' },
straat: 'Kerkstraat 1', { tag: 'SetField', key: 'postcode', value: '1234 AB' },
postcode: '1234 AB', { tag: 'SetField', key: 'woonplaats', value: 'Utrecht' },
woonplaats: 'Utrecht', { tag: 'SetCorrespondentie', value: 'post' },
correspondentie: 'post', { tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] },
adresHerkomst: 'handmatig',
diplomaId: 'd1',
beroep: 'Arts',
diplomaHerkomst: 'duo',
}),
); );
const indienen = expectTag(s, 'Indienen'); const indienen = expectTag(submit(manualAdres), 'Indienen');
expect(indienen.data.adresHerkomst).toBe('handmatig'); expect(indienen.data.adresHerkomst).toBe('handmatig');
}); });
}); });
describe('kiesDiploma', () => { describe('kiesDiploma', () => {
it('derives the beroep from the chosen diploma and flags origin duo', () => { 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.diplomaId).toBe('d9');
expect(s.draft.beroep).toBe('Verpleegkundige'); expect(s.draft.beroep).toBe('Verpleegkundige');
expect(s.draft.diplomaHerkomst).toBe('duo'); expect(s.draft.diplomaHerkomst).toBe('duo');
@@ -152,7 +177,7 @@ describe('kiesDiploma', () => {
describe('policy questions (geldigheidsvragen)', () => { describe('policy questions (geldigheidsvragen)', () => {
it('a diploma with questions blocks Next until they are answered', () => { 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'); const blocked = expectTag(next(s), 'Invullen');
expect(blocked.cursor).toBe(1); expect(blocked.cursor).toBe(1);
expect(blocked.errors.antwoorden?.['nl-taalvaardigheid']).toBeTruthy(); 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', () => { 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, 'nl-taalvaardigheid', 'ja');
s = setAntwoord(s, 'stale', 'x'); // not in vraagIds s = setAntwoord(s, 'stale', 'x'); // not in vraagIds
const done = expectTag(submit(s), 'Indienen'); const done = expectTag(submit(s), 'Indienen');
@@ -173,14 +203,16 @@ describe('manual diploma fallback', () => {
const maxIds = ['nl-taalvaardigheid', 'diploma-erkend', 'toelichting']; const maxIds = ['nl-taalvaardigheid', 'diploma-erkend', 'toelichting'];
it('KiesHandmatig flags handmatig with the maximal question set and no beroep yet', () => { 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.diplomaHerkomst).toBe('handmatig');
expect(s.draft.beroep).toBeUndefined(); expect(s.draft.beroep).toBeUndefined();
expect(s.draft.vraagIds).toEqual(maxIds); expect(s.draft.vraagIds).toEqual(maxIds);
}); });
it('requires a declared beroep + all maximal questions before submit', () => { 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 expect(submit(s).tag).toBe('Invullen'); // no beroep declared
s = declareerBeroep(s, 'Fysiotherapeut'); s = declareerBeroep(s, 'Fysiotherapeut');
expect(submit(s).tag).toBe('Invullen'); // questions unanswered expect(submit(s).tag).toBe('Invullen'); // questions unanswered
@@ -193,11 +225,11 @@ describe('manual diploma fallback', () => {
describe('submit', () => { describe('submit', () => {
it('stays in Invullen when the draft is incomplete (no diploma)', () => { 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', () => { 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.beroep).toBe('Arts');
expect(good.data.adres.postcode).toBe('2514 EA'); expect(good.data.adres.postcode).toBe('2514 EA');
expect(good.data.adresHerkomst).toBe('brp'); expect(good.data.adresHerkomst).toBe('brp');
@@ -205,43 +237,18 @@ describe('submit', () => {
it('resolve maps Indienen to Ingediend with the referentie', () => { it('resolve maps Indienen to Ingediend with the referentie', () => {
const ingediend = expectTag( const ingediend = expectTag(
resolve(submit(invullen(validDraft)), ok('BIG-2026-001')), resolve(submit(toFullDraftAtCursor0()), ok('BIG-2026-001')),
'Ingediend', 'Ingediend',
); );
expect(ingediend.referentie).toBe('BIG-2026-001'); expect(ingediend.referentie).toBe('BIG-2026-001');
}); });
it('resolve maps Indienen to Mislukt on a failed submit', () => { 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)', () => { 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', () => { it('adres and correspondentie set, Next advances from adres to beroep', () => {
// Given the initial wizard. // Given the initial wizard.
// When the adres is prefilled, correspondentie chosen, and Next dispatched... // 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', () => { it('SubmitFailed moves Indienen to Mislukt', () => {
const s = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), { const s = reduce(reduce(toFullDraftAtCursor0(), { tag: 'Submit' }), {
tag: 'SubmitFailed', tag: 'SubmitFailed',
error: 'boom', error: 'boom',
}); });
@@ -287,7 +294,7 @@ describe('reduce (message-driven happy path)', () => {
}); });
it('Retry returns Mislukt to Indienen with the same data', () => { 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', tag: 'SubmitFailed',
error: 'boom', error: 'boom',
}); });
@@ -310,7 +317,7 @@ describe('inline document upload (beroep step)', () => {
it('routes Upload messages through the upload reducer', () => { it('routes Upload messages through the upload reducer', () => {
const s = expectTag( const s = expectTag(
reduce(invullen(validDraft), { reduce(toFullDraftAtCursor0(), {
tag: 'Upload', tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [cat] }, 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', () => { it('blocks the beroep step until a required category is satisfied', () => {
let s = reduce(invullen(validDraft, 1), { let s = reduce(toBeroepStepWithDiploma(), {
tag: 'Upload', tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [cat] }, msg: { type: 'CategoriesLoaded', categories: [cat] },
}); });
@@ -339,7 +346,7 @@ describe('inline document upload (beroep step)', () => {
}); });
it('includes delivery refs in the submitted data', () => { it('includes delivery refs in the submitted data', () => {
let s = reduce(invullen(validDraft), { let s = reduce(toFullDraftAtCursor0(), {
tag: 'Upload', tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [cat] }, msg: { type: 'CategoriesLoaded', categories: [cat] },
}); });
@@ -9,7 +9,7 @@ import {
reduceUpload, reduceUpload,
requiredCategoriesSatisfied, requiredCategoriesSatisfied,
deliveryRefs, deliveryRefs,
} from '@shared/upload/upload.machine'; } from '@shared/domain/upload.machine';
/** /**
* A FIXED 3-step registration wizard. The steps never change in number (always * A FIXED 3-step registration wizard. The steps never change in number (always
@@ -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);
@@ -42,6 +42,9 @@ import { AdminCasesStore } from '@registratie/application/admin-cases.store';
} @else if (!canManage()) { } @else if (!canManage()) {
<app-alert type="error">{{ deniedText }}</app-alert> <app-alert type="error">{{ deniedText }}</app-alert>
} @else { } @else {
@if (store.lastError(); as err) {
<app-alert type="error">{{ err }}</app-alert>
}
<app-async [data]="store.cases()"> <app-async [data]="store.cases()">
<ng-template appAsyncError> <ng-template appAsyncError>
<app-alert type="error">{{ failedText }}</app-alert> <app-alert type="error">{{ failedText }}</app-alert>
@@ -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." intro="Welkom in uw persoonlijke omgeving van het BIG-register. Hier ziet u uw registratie en regelt u uw zaken."
> >
<div class="app-stack"> <div class="app-stack">
@if (cancelError(); as err) {
<app-alert type="error">{{ err }}</app-alert>
}
@if (aanvragen().length) { @if (aanvragen().length) {
<section> <section>
@for (a of concepten(); track a.id) { @for (a of concepten(); track a.id) {
@@ -260,6 +263,8 @@ export class DashboardPage {
protected cancelAanvraag(a: Aanvraag) { protected cancelAanvraag(a: Aanvraag) {
void this.apps.cancel(a.id); 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). */ /** Server-computed eligibility (rendered, not recomputed). */
private readonly eligible = computed(() => { private readonly eligible = computed(() => {
@@ -37,9 +37,8 @@ import {
} from '@registratie/domain/registratie-wizard.machine'; } from '@registratie/domain/registratie-wizard.machine';
import { createDraftSync } from '@registratie/application/draft-sync'; import { createDraftSync } from '@registratie/application/draft-sync';
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component'; import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
import { createUploadController } from '@shared/upload/upload-controller'; import { createUploadController } from '@shared/application/upload-controller';
import { UploadAdapter } from '@shared/upload/upload.adapter'; import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.machine';
import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine';
const KANALEN = [ const KANALEN = [
{ value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` }, { value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` },
@@ -368,13 +367,13 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
}) })
export class RegistratieWizardComponent { export class RegistratieWizardComponent {
private lookup = inject(RegistratieLookupStore); private lookup = inject(RegistratieLookupStore);
private uploadAdapter = inject(UploadAdapter);
private store = createStore<RegistratieState, RegistratieMsg>(initial, reduce); private store = createStore<RegistratieState, RegistratieMsg>(initial, reduce);
/** Preview/download link for a completed upload; the dev-simulation `demo-*` ids /** Preview/download link for a completed upload; delegates to the upload
have no stored bytes, so they get no link. */ 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 => 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. */ /** Optional seed so Storybook / tests can mount any state directly. */
seed = input<RegistratieState>(initial); seed = input<RegistratieState>(initial);
@@ -8,7 +8,7 @@ import {
RegistratieState, RegistratieState,
ValidRegistratie, ValidRegistratie,
} from '@registratie/domain/registratie-wizard.machine'; } 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'; import { Postcode } from '@registratie/domain/value-objects/postcode';
const adres: Partial<Draft> = { const adres: Partial<Draft> = {
@@ -1,7 +1,7 @@
import { Component, Injector, computed, inject, isDevMode, signal } from '@angular/core'; import { Component, Injector, computed, inject, isDevMode, signal } from '@angular/core';
import { JsonPipe } from '@angular/common'; import { JsonPipe } from '@angular/common';
import { SessionStore } from '@auth/application/session.store'; 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 { BigProfileStore } from '@registratie/application/big-profile.store';
import { map } from '@shared/application/remote-data'; import { map } from '@shared/application/remote-data';
import { Role } from '@shared/domain/role'; import { Role } from '@shared/domain/role';
@@ -172,6 +172,6 @@ export class DebugStateComponent {
} }
} }
function maskSession(s: Session | null): Session | null { function maskSession(p: Principal | null): Principal | null {
return s ? { ...s, bsn: maskBsn(s.bsn) } : null; return p ? { ...p, bsn: maskBsn(p.bsn) } : null;
} }
@@ -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);
});
});
+4
View File
@@ -3738,6 +3738,10 @@
<source>De functievlaggen konden niet worden geladen.</source> <source>De functievlaggen konden niet worden geladen.</source>
<target datatype="html">The feature flags could not be loaded.</target> <target datatype="html">The feature flags could not be loaded.</target>
</trans-unit> </trans-unit>
<trans-unit id="flags.set.failed" datatype="html">
<source>De functievlag kon niet worden opgeslagen.</source>
<target datatype="html">The feature flag could not be saved.</target>
</trans-unit>
<trans-unit id="flags.retry" datatype="html"> <trans-unit id="flags.retry" datatype="html">
<source>Opnieuw proberen</source> <source>Opnieuw proberen</source>
<target datatype="html">Try again</target> <target datatype="html">Try again</target>
@@ -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 // 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. // stay on the client). ponytail: a real submit would carry the full application.
public sealed record RegistratieRequest(string DiplomaHerkomst, IReadOnlyList<DocumentRefDto>? Documents = null);
public sealed record ChangeRequestRequest(string Telefoon); public sealed record ChangeRequestRequest(string Telefoon);
@@ -70,8 +70,12 @@ public static class Mappers
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), SubmittedAtOf(a)); 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). /// 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) => 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( public static ApplicationDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new(
a.Id, a.Type, a.ToStatusDto(now), DraftOf(a), a.DocumentIds, a.Id, a.Type, a.ToStatusDto(now), DraftOf(a), a.DocumentIds,
@@ -53,7 +53,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
modelBuilder.Entity<BriefEntity>(e => modelBuilder.Entity<BriefEntity>(e =>
{ {
e.HasKey(b => b.BriefId); 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<IReadOnlyList<PlaceholderDefDto>>()); e.Property(b => b.Placeholders).HasConversion(Json<IReadOnlyList<PlaceholderDefDto>>());
e.Property(b => b.Sections).HasConversion(Json<List<LetterSectionDto>>()); e.Property(b => b.Sections).HasConversion(Json<List<LetterSectionDto>>());
e.Property(b => b.Status).HasConversion(Json<BriefStatusDto>()); e.Property(b => b.Status).HasConversion(Json<BriefStatusDto>());
+15 -18
View File
@@ -47,17 +47,15 @@ public static class BriefStore
private static readonly object _gate = new(); 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) lock (_gate)
{ {
using var db = Db.Create(); using var db = Db.Create();
var existing = db.Briefs.FirstOrDefault(e => e.Owner == owner); return 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;
} }
} }
@@ -70,10 +68,10 @@ public static class BriefStore
using var db = Db.Create(); using var db = Db.Create();
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner); var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null); if (e is null) return (Outcome.Conflict, null);
if (!isDrafter) return (Outcome.Forbidden, null); var outcome = BriefRules.CanSave(e.Status, isDrafter);
if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null); if (outcome != Outcome.Ok) return (outcome, null);
e.Sections = sections.ToList(); e.Sections = sections.ToList();
if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft"); e.Status = BriefRules.StatusAfterSave(e.Status);
db.SaveChanges(); db.SaveChanges();
return (Outcome.Ok, e); return (Outcome.Ok, e);
} }
@@ -86,8 +84,8 @@ public static class BriefStore
using var db = Db.Create(); using var db = Db.Create();
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner); var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null); if (e is null) return (Outcome.Conflict, null);
if (!isDrafter) return (Outcome.Forbidden, null); var outcome = BriefRules.CanSubmit(e.Status, isDrafter, BriefRules.RequiredFilled(e.Sections));
if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null); if (outcome != Outcome.Ok) return (outcome, null);
e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at); e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at);
db.SaveChanges(); db.SaveChanges();
return (Outcome.Ok, e); return (Outcome.Ok, e);
@@ -109,7 +107,8 @@ public static class BriefStore
using var db = Db.Create(); using var db = Db.Create();
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner); var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null); 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); e.Status = new BriefStatusDto("sent", SentAt: at);
// Pin the org-template version the letter was sent with (WP-23): from here on // 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. // 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 // 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 // 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 // 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<BriefStatusDto> next) private static (Outcome, BriefEntity?) Review(string owner, Principal principal, BriefAction action, Func<BriefStatusDto> next)
{ {
lock (_gate) lock (_gate)
@@ -160,15 +159,13 @@ public static class BriefStore
using var db = Db.Create(); using var db = Db.Create();
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner); var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
if (e is null) return (Outcome.Conflict, null); if (e is null) return (Outcome.Conflict, null);
if (!Authz.CanActOn(action, principal, e.DrafterId)) return (Outcome.Forbidden, null); var outcome = BriefRules.CanDecide(action, e.Status, principal, e.DrafterId);
if (e.Status.Tag != "submitted") return (Outcome.Conflict, null); if (outcome != Outcome.Ok) return (outcome, null);
e.Status = next(); e.Status = next();
db.SaveChanges(); db.SaveChanges();
return (Outcome.Ok, e); return (Outcome.Ok, e);
} }
} }
private static bool RequiredFilled(BriefEntity e) => e.Sections.All(s => !s.Required || s.Blocks.Count > 0);
} }
/// <summary>Seeded template (sections + placeholder fields) and passage library.</summary> /// <summary>Seeded template (sections + placeholder fields) and passage library.</summary>
@@ -1,3 +1,5 @@
using BigRegister.Domain.People;
namespace BigRegister.Api.Data; namespace BigRegister.Api.Data;
/// <summary> /// <summary>
@@ -58,7 +60,7 @@ public static class DocumentStore
db.Documents.Add(doc); db.Documents.Add(doc);
db.SaveChanges(); db.SaveChanges();
} }
Audit("upload", doc.DocumentId, categoryId, owner); Audit("upload", doc.DocumentId, categoryId, Pii.MaskTail(owner, 3));
return doc; return doc;
} }
@@ -73,13 +75,13 @@ public static class DocumentStore
/// Status for the poll-on-return pattern: a known localId is "complete" (it /// Status for the poll-on-return pattern: a known localId is "complete" (it
/// arrived), an unknown one is still in flight / never started. /// arrived), an unknown one is still in flight / never started.
public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds) public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds, string owner)
{ {
var set = localIds.ToHashSet(); var set = localIds.ToHashSet();
lock (_gate) lock (_gate)
{ {
using var db = Db.Create(); 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.Documents.Remove(d);
db.SaveChanges(); db.SaveChanges();
} }
Audit("delete-user", documentId, categoryId, owner); Audit("delete-user", documentId, categoryId, Pii.MaskTail(owner, 3));
return DeleteResult.Ok; return DeleteResult.Ok;
} }
@@ -178,6 +180,12 @@ public static class DocumentStore
return true; return true;
} }
/// <summary>Append one metadata-only audit row. <paramref name="actor"/> must arrive
/// **already redacted** (RB-04/BIO-005) — the two citizen call sites pass
/// <see cref="Pii.MaskTail"/> of the owner BSN, `delete-admin` passes the literal
/// `"admin"`. The unmasked BSN lives only in <see cref="StoredDocument.Owner"/>, which is
/// the authorization key and stays untouched. Masking here instead would have to guess
/// which actors are BSNs and which are role names.</summary>
public static void Audit(string action, string documentId, string categoryId, string actor) public static void Audit(string action, string documentId, string categoryId, string actor)
{ {
lock (_gate) lock (_gate)
@@ -4,9 +4,14 @@ namespace BigRegister.Domain.Authorization;
/// Resolves the acting <see cref="CallerIdentity"/> for a request (WP-53) — one of the two actor /// Resolves the acting <see cref="CallerIdentity"/> 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 /// kinds (WP-62, ADR-0002 §3): a zorgverlener (real DigiD claims in production) or a medewerker
/// (real employee SSO/eHerkenning claims in production). <see cref="StubIdentityProvider"/> is /// (real employee SSO/eHerkenning claims in production). <see cref="StubIdentityProvider"/> is
/// the only implementation today. /// the only implementation today, and is registered only in Development (<c>Program.cs</c>,
/// RB-09/BIO-002).
/// </summary> /// </summary>
public interface IIdentityProvider public interface IIdentityProvider
{ {
CallerIdentity Resolve(HttpContext ctx); /// <summary>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 (<c>Program.cs</c>)
/// turns a null into a 401 instead of a silent identity substitution.</summary>
CallerIdentity? Resolve(HttpContext ctx);
} }
@@ -13,6 +13,11 @@ namespace BigRegister.Domain.Authorization;
/// A real system builds this from verified DigiD claims (zorgverlener) / employee SSO claims /// A real system builds this from verified DigiD claims (zorgverlener) / employee SSO claims
/// (medewerker); every consumer of <see cref="CallerIdentity"/> carries over unchanged once that /// (medewerker); every consumer of <see cref="CallerIdentity"/> carries over unchanged once that
/// swap happens. /// swap happens.
///
/// Registered only in Development (<c>Program.cs</c>, 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
/// <see cref="IIdentityProvider.Resolve"/>, this stub never has "no identity" to report.
/// </summary> /// </summary>
public sealed class StubIdentityProvider : IIdentityProvider public sealed class StubIdentityProvider : IIdentityProvider
{ {
@@ -0,0 +1,63 @@
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using BigRegister.Domain.Authorization;
namespace BigRegister.Domain.Letters;
/// <summary>
/// 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 <see cref="BriefStore"/>'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 <see cref="BriefStore.Outcome"/> — 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.
/// </summary>
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<LetterSectionDto> 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 <see cref="Authz.CanActOn"/>), 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;
}
}
@@ -56,7 +56,7 @@ public static class LetterHtml
{ {
sb.Append("<section><h3>").Append(Enc(section.Title)).Append("</h3>"); sb.Append("<section><h3>").Append(Enc(section.Title)).Append("</h3>");
foreach (var block in section.Blocks) foreach (var block in section.Blocks)
RenderParagraphs(sb, block.Content.Paragraphs, defs); RenderParagraphs(sb, block.Content.Paragraphs, defs, at);
sb.Append("</section>"); sb.Append("</section>");
} }
sb.Append("</div>"); sb.Append("</div>");
@@ -89,7 +89,8 @@ public static class LetterHtml
private const string RecipientPlaceholder = "Adres van de geadresseerde\n(wordt ingevuld bij verzending)"; private const string RecipientPlaceholder = "Adres van de geadresseerde\n(wordt ingevuld bij verzending)";
private static void RenderParagraphs( private static void RenderParagraphs(
StringBuilder sb, IReadOnlyList<ParagraphDto> paragraphs, IReadOnlyDictionary<string, PlaceholderDefDto> defs) StringBuilder sb, IReadOnlyList<ParagraphDto> paragraphs, IReadOnlyDictionary<string, PlaceholderDefDto> defs,
string at)
{ {
string? openList = null; string? openList = null;
foreach (var para in paragraphs) foreach (var para in paragraphs)
@@ -101,14 +102,14 @@ public static class LetterHtml
openList = para.List; openList = para.List;
} }
sb.Append(openList is null ? "<p>" : "<li>"); sb.Append(openList is null ? "<p>" : "<li>");
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 ? "</p>" : "</li>"); sb.Append(openList is null ? "</p>" : "</li>");
} }
if (openList is not null) sb.Append(openList == "bullet" ? "</ul>" : "</ol>"); if (openList is not null) sb.Append(openList == "bullet" ? "</ul>" : "</ol>");
} }
private static void RenderNode( private static void RenderNode(
StringBuilder sb, RichTextNodeDto node, IReadOnlyDictionary<string, PlaceholderDefDto> defs) StringBuilder sb, RichTextNodeDto node, IReadOnlyDictionary<string, PlaceholderDefDto> defs, string at)
{ {
switch (node.Type) switch (node.Type)
{ {
@@ -122,7 +123,7 @@ public static class LetterHtml
var key = node.Key ?? ""; var key = node.Key ?? "";
var def = defs.GetValueOrDefault(key); var def = defs.GetValueOrDefault(key);
var label = def?.Label ?? 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; break;
} }
} }
@@ -131,11 +132,11 @@ public static class LetterHtml
// single demo applicant (SeedData.Registration — no per-brief resolved value is // 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 // 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. // 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, "naam_zorgverlener" => SeedData.Registration.Naam,
"big_nummer" => SeedData.Registration.BigNummer, "big_nummer" => SeedData.Registration.BigNummer,
"datum" => FormatDatumNl(DateTimeOffset.UtcNow.ToString("o")), "datum" => FormatDatumNl(at),
_ => label, _ => label,
}; };
@@ -0,0 +1,18 @@
namespace BigRegister.Domain.People;
/// <summary>
/// One redaction rule for identifiers that must not leave the server in full (BSN,
/// BIG-nummer). Lives in <c>Domain/</c> because three layers need it — the DTO mappers
/// (<c>Contracts/Mappers.cs</c>), the audit writes (<c>Data/DocumentStore.cs</c>) and the
/// endpoints themselves — and a second hand-rolled copy is exactly how one of them drifts
/// into leaking. Mirrors the FE <c>maskTail</c> (<c>libs/shared/src/ui/debug-state/mask.ts</c>)
/// so wire redaction and the dev panel agree on what a masked value looks like.
/// </summary>
public static class Pii
{
/// Keep the last <paramref name="keep"/> 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..];
}
@@ -9,12 +9,6 @@ namespace BigRegister.Domain.Submissions;
/// </summary> /// </summary>
public static class SubmissionRules 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. // RULE: an application reporting zero worked hours is rejected.
public static string? RejectZeroUren(int uren) => public static string? RejectZeroUren(int uren) =>
uren == 0 ? "Aanvraag afgewezen: geen gewerkte uren geregistreerd." : null; uren == 0 ? "Aanvraag afgewezen: geen gewerkte uren geregistreerd." : null;
+274 -128
View File
@@ -12,6 +12,7 @@ using BigRegister.Domain.Documents;
using BigRegister.Domain.Features; using BigRegister.Domain.Features;
using BigRegister.Domain.Intake; using BigRegister.Domain.Intake;
using BigRegister.Domain.Letters; using BigRegister.Domain.Letters;
using BigRegister.Domain.People;
using BigRegister.Domain.Registrations; using BigRegister.Domain.Registrations;
using BigRegister.Domain.Submissions; using BigRegister.Domain.Submissions;
using BigRegister.Api.Zgw; 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/ // 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 // X-Subject for a zorgverlener, X-Medewerker/X-Rollen for a medewerker); a real
// DigiD/employee-SSO provider swaps in without touching a consumer. // DigiD/employee-SSO provider swaps in without touching a consumer.
builder.Services.AddSingleton<IIdentityProvider, StubIdentityProvider>(); //
// 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<IIdentityProvider, StubIdentityProvider>();
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 // 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 // (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 // 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 // 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<IIdentityProvider>(); var identityProvider = app.Services.GetRequiredService<IIdentityProvider>();
app.Use(async (ctx, next) => 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); await next(ctx);
}); });
app.UseSwagger(); // RB-15/BIO-015: the OpenAPI document + its UI are a genuine attack-surface reduction to
app.UseSwaggerUI(); // 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); app.UseCors(SpaCors);
// Liveness/readiness for orchestrators (k8s probes, load balancers). No data, no PII. // 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, () => api.MapGet("/stamdata", (HttpContext ctx) => StamdataAdmin(ctx, () =>
Results.Ok(StamdataCatalog.All.Select(t => Results.Ok(StamdataCatalog.All.Select(t =>
new StamdataTableSummaryDto(t.Id, t.Label, t.Columns.Select(ToColumnDto).ToList(), t.Temporal)).ToList()))) new StamdataTableSummaryDto(t.Id, t.Label, t.Columns.Select(ToColumnDto).ToList(), t.Temporal)).ToList())))
.Gate("StamdataAdmin")
.WithName("stamdataTables") .WithName("stamdataTables")
.Produces<List<StamdataTableSummaryDto>>() .Produces<List<StamdataTableSummaryDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden); .ProducesProblem(StatusCodes.Status403Forbidden);
@@ -174,21 +212,29 @@ api.MapGet("/stamdata/{table}", (string table, string? peildatum, HttpContext ct
{ {
var t = StamdataCatalog.Find(table); var t = StamdataCatalog.Find(table);
if (t is null) return Results.NotFound(); 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)); return Results.Ok(new StamdataTableDto(t.Id, t.Label, t.Columns.Select(ToColumnDto).ToList(), t.Temporal, rows));
})) }))
.Gate("StamdataAdmin")
.WithName("stamdataTable") .WithName("stamdataTable")
.Produces<StamdataTableDto>() .Produces<StamdataTableDto>()
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
// --- POST: submits. The server is the authority; it re-validates and decides. --- // --- 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<ReferentieResponse>()
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) => api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) =>
Submit(ctx, "telefoonwijziging", SubmissionRules.RejectPhoneChange(req.Telefoon))) Submit(ctx, "telefoonwijziging", SubmissionRules.RejectPhoneChange(req.Telefoon)))
.Produces<ReferentieResponse>() .Produces<ReferentieResponse>()
@@ -196,10 +242,47 @@ api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) =>
// --- Document upload --- // --- Document upload ---
// --- reads ---
// Server-owned category config per wizard. The FE renders these; it never hardcodes. // Server-owned category config per wizard. The FE renders these; it never hardcodes.
api.MapGet("/uploads/categories", (string wizardId, string? diplomaHerkomst, string? taalvaardigheid) => api.MapGet("/uploads/categories", (string wizardId, string? diplomaHerkomst, string? taalvaardigheid) =>
new UploadCategoriesDto(DocumentRules.CategoriesFor(wizardId, diplomaHerkomst, taalvaardigheid).Select(c => c.ToDto()).ToList())); 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 // 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 // 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). // 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(); .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. // User delete: owner-scoped; 409 once linked to a finalised submission.
api.MapDelete("/uploads/{documentId}", (string documentId, HttpContext ctx) => api.MapDelete("/uploads/{documentId}", (string documentId, HttpContext ctx) =>
DocumentStore.DeleteOwned(documentId, ctx.Zorgverlener().Bsn) switch DocumentStore.DeleteOwned(documentId, ctx.Zorgverlener().Bsn) switch
@@ -263,17 +323,21 @@ api.MapDelete("/uploads/{documentId}", (string documentId, HttpContext ctx) =>
.ProducesProblem(StatusCodes.Status409Conflict) .ProducesProblem(StatusCodes.Status409Conflict)
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
// Admin delete (seam): a real system requires an admin role; here an X-Admin header // Admin delete: bypasses ownership, unlinks, and flags the submission for review. Gated
// stands in. Bypasses ownership, unlinks, and flags the submission for review. // by the same CasesAdmin wrapper (cases:manage) the other admin-cases endpoints use
api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx) => // (RB-08/BIO-003) — it used to be gated by a standalone X-Admin header, outside Authz and
!IsAdmin(ctx) ? Results.StatusCode(StatusCodes.Status403Forbidden) // unaudited; CasesAdmin gives it the missing AuthzAuditStore row for free (RB-07).
: DocumentStore.AdminDelete(documentId, "admin") ? Results.NoContent() : Results.NotFound()) 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.Status204NoContent)
.Produces(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
// --- Applications (aanvragen): the system of record the dashboard reads. --- // --- Applications (aanvragen): the system of record the dashboard reads. ---
// --- reads ---
// WP-53: routed through IZaakSource (like /admin/cases already was) rather than calling // 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 // 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 // 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<ApplicationDetailDto>() .Produces<ApplicationDetailDto>()
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
// --- writes ---
api.MapPost("/applications", (CreateApplicationRequest req, HttpContext ctx) => api.MapPost("/applications", (CreateApplicationRequest req, HttpContext ctx) =>
{ {
// Feature flag (WP-47): self-service registration can be closed by an admin. // 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); .Produces(StatusCodes.Status404NotFound);
// --- Admin cases (WP-36): cross-owner list + admin delete, gated by `cases:manage`. --- // --- Admin cases (WP-36): cross-owner list + admin delete, gated by `cases:manage`. ---
// --- reads ---
api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ctx, () => api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ctx, () =>
Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow)))) Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow))))
.Gate("CasesAdmin")
.Produces<List<ApplicationSummaryDto>>() .Produces<List<ApplicationSummaryDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden); .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<List<AuthzAuditDto>>()
.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. --- // --- Werkvoorraad (WP-64): the behandelportal's queue of aanvragen needing treatment. ---
// Cross-owner like /admin/cases, but gated by the medewerker capability (`CanBeoordelen`, // 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 — // 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) Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow)
.Where(c => c.Status.Tag is "Ingediend" or "InBehandeling") .Where(c => c.Status.Tag is "Ingediend" or "InBehandeling")
.ToList()))) .ToList())))
.Gate("Beoordelen")
.Produces<List<ApplicationSummaryDto>>() .Produces<List<ApplicationSummaryDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden); .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(); if (c is null || c.Status.Tag == "Concept") return Results.NotFound();
var docs = DocumentStore.ByIds(c.DocumentIds) var docs = DocumentStore.ByIds(c.DocumentIds)
.Select(d => new BeoordelingDocumentDto(d.DocumentId, d.CategoryId, d.FileName)).ToList(); .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 // 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. // unrecognised tag degrades to "cannot decide" instead of a 500.
var canBesluiten = Enum.TryParse<AanvraagStatusTag>(c.Status.Tag, out var tag) && BeoordelingRules.CanDecide(tag); var canBesluiten = Enum.TryParse<AanvraagStatusTag>(c.Status.Tag, out var tag) && BeoordelingRules.CanDecide(tag);
var decisions = new BeoordelingDecisionsDto(canBesluiten); var decisions = new BeoordelingDecisionsDto(canBesluiten);
return Results.Ok(new BeoordelingViewDto(masked, docs, decisions)); return Results.Ok(new BeoordelingViewDto(masked, docs, decisions));
})) }))
.Gate("Beoordelen")
.Produces<BeoordelingViewDto>() .Produces<BeoordelingViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound); .Produces(StatusCodes.Status404NotFound);
@@ -497,6 +597,10 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H
statusCode: StatusCodes.Status409Conflict); statusCode: StatusCodes.Status409Conflict);
app.Logger.LogInformation("aanvraag besluit id={Id} besluit={Besluit}", a.Id, besluit); 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 // 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 // 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))); return Results.Ok(new RecordBesluitResponse(updated!.ToStatusDto(now)));
})) }))
.Gate("Beoordelen")
.Produces<RecordBesluitResponse>() .Produces<RecordBesluitResponse>()
.ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status403Forbidden)
@@ -549,27 +654,6 @@ api.MapPost("/zgw/notificaties", (HttpContext ctx, NotificatieDto body) =>
// /uploads and /brief/reveal-bignummer. // /uploads and /brief/reveal-bignummer.
.ExcludeFromDescription(); .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<List<AuthzAuditDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden);
// PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks (NOT // 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). // 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 // 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())) Results.Ok(FeatureFlagStore.All().Select(f => new FeatureFlagDto(f.Key, f.Description, f.Enabled)).ToList()))
.Produces<List<FeatureFlagDto>>(); .Produces<List<FeatureFlagDto>>();
api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpContext ctx) => FlagsAdmin(ctx, () => api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpContext ctx) =>
FeatureFlagStore.Set(key, req.Enabled) ? Results.NoContent() : Results.NotFound())) FlagsAdmin(ctx, $"feature-flags/{key}={req.Enabled}", () =>
FeatureFlagStore.Set(key, req.Enabled) ? Results.NoContent() : Results.NotFound()))
.Gate("FlagsAdmin")
.Produces(StatusCodes.Status204NoContent) .Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status403Forbidden); .ProducesProblem(StatusCodes.Status403Forbidden);
// --- Brief (letter composition). One demo brief per owner; the server owns the // --- Brief (letter composition). One demo brief per owner; the server owns the
// status machine + authorization (Authz, PRD-0002 phase P1). Principal is a // 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= // dev-only stand-in via X-Role (mirrors the FE ?role= toggle) — no real
// toggle) — no real identities in this POC. --- // identities in this POC. ---
// --- reads ---
api.MapGet("/brief", (HttpContext ctx) => api.MapGet("/brief", (HttpContext ctx) =>
{ {
var e = BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn); // RB-23/CQ-007: a read that used to allocate a row on first call. The owner's first
return ToView(ctx, e); // 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<BriefViewDto>(); .Produces<BriefViewDto>()
.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) => 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 isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter;
var r = BriefStore.Submit(ctx.Zorgverlener().Bsn, isDrafter, Now()); 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."); return BriefResult(ctx, r, "Alleen de opsteller mag indienen.");
}) })
.WithName("briefSubmit") // distinct name so the generated client method isn't `submit2` .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) => api.MapPost("/brief/approve", (HttpContext ctx) =>
{ {
var r = BriefStore.Approve(ctx.Zorgverlener().Bsn, Authz.ResolvePrincipal(ctx), Now()); 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."); return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn.");
}) })
.Produces<BriefViewDto>() .Produces<BriefViewDto>()
@@ -641,7 +755,7 @@ api.MapPost("/brief/approve", (HttpContext ctx) =>
api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) => api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) =>
{ {
var r = BriefStore.Reject(ctx.Zorgverlener().Bsn, Authz.ResolvePrincipal(ctx), req.Comments, Now()); 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."); return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn.");
}) })
.Produces<BriefViewDto>() .Produces<BriefViewDto>()
@@ -654,7 +768,7 @@ api.MapPost("/brief/send", (HttpContext ctx) =>
// port); the backend only guards the approved→sent transition (not role-gated // port); the backend only guards the approved→sent transition (not role-gated
// today — see Authz.CanActOn(Send, …), a mechanical dispatch step). // today — see Authz.CanActOn(Send, …), a mechanical dispatch step).
var r = BriefStore.Send(ctx.Zorgverlener().Bsn, Now()); 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."); return BriefResult(ctx, r, "Versturen kan niet in deze status.");
}) })
.Produces<BriefViewDto>() .Produces<BriefViewDto>()
@@ -671,7 +785,10 @@ api.MapPost("/brief/reveal-bignummer", (HttpContext ctx) =>
var canReveal = Authz.CanRevealBigNummer(principal); var canReveal = Authz.CanRevealBigNummer(principal);
var steppedUp = ctx.Request.Headers["X-Step-Up"] == "true"; var steppedUp = ctx.Request.Headers["X-Step-Up"] == "true";
var allowed = canReveal && steppedUp; 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) if (!allowed)
return Results.Problem( return Results.Problem(
detail: canReveal detail: canReveal
@@ -684,31 +801,6 @@ api.MapPost("/brief/reveal-bignummer", (HttpContext ctx) =>
// OpenAPI doc, same seam as /brief/preview and uploads. // OpenAPI doc, same seam as /brief/preview and uploads.
.ExcludeFromDescription(); .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) => api.MapPost("/brief/reset", (HttpContext ctx) =>
{ {
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only. // 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 // as drafter/approver); the same Authz check gates every endpoint and feeds the
// `orgtemplate:edit` capability on /me, so emit and enforce cannot drift. --- // `orgtemplate:edit` capability on /me, so emit and enforce cannot drift. ---
// --- reads ---
api.MapGet("/admin/org-templates", (HttpContext ctx) => OrgAdmin(ctx, () => api.MapGet("/admin/org-templates", (HttpContext ctx) => OrgAdmin(ctx, () =>
Results.Ok(OrgTemplateStore.List()))) Results.Ok(OrgTemplateStore.List())))
.Gate("OrgAdmin")
.WithName("orgTemplates") .WithName("orgTemplates")
.Produces<List<SubOrgSummaryDto>>() .Produces<List<SubOrgSummaryDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden); .ProducesProblem(StatusCodes.Status403Forbidden);
api.MapGet("/admin/org-template/{subOrgId}", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () => api.MapGet("/admin/org-template/{subOrgId}", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
OrgTemplateStore.AdminView(subOrgId) is { } view ? Results.Ok(view) : Results.NotFound())) OrgTemplateStore.AdminView(subOrgId) is { } view ? Results.Ok(view) : Results.NotFound()))
.Gate("OrgAdmin")
.WithName("orgTemplateGET") .WithName("orgTemplateGET")
.Produces<OrgTemplateAdminViewDto>() .Produces<OrgTemplateAdminViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound); .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, () => api.MapPut("/admin/org-template/{subOrgId}", (string subOrgId, SaveOrgTemplateRequest req, HttpContext ctx) => OrgAdmin(ctx, () =>
{ {
var reject = OrgTemplateRules.RejectDraft(req.Draft); var reject = OrgTemplateRules.RejectDraft(req.Draft);
if (reject is not null) return Results.Problem(detail: reject, statusCode: StatusCodes.Status400BadRequest); 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(); return OrgTemplateStore.SaveDraft(subOrgId, req.Draft) is { } view ? Results.Ok(view) : Results.NotFound();
})) }))
.Gate("OrgAdmin")
.WithName("orgTemplatePUT") .WithName("orgTemplatePUT")
.Produces<OrgTemplateAdminViewDto>() .Produces<OrgTemplateAdminViewDto>()
.ProducesProblem(StatusCodes.Status400BadRequest) .ProducesProblem(StatusCodes.Status400BadRequest)
@@ -756,6 +867,7 @@ api.MapPost("/admin/org-template/{subOrgId}/publish", (string subOrgId, HttpCont
subOrgId, r.Version, r.AffectedUnsentBriefs); subOrgId, r.Version, r.AffectedUnsentBriefs);
return r is not null ? Results.Ok(r) : Results.NotFound(); return r is not null ? Results.Ok(r) : Results.NotFound();
})) }))
.Gate("OrgAdmin")
.WithName("orgTemplatePublish") .WithName("orgTemplatePublish")
.Produces<PublishOrgTemplateResponse>() .Produces<PublishOrgTemplateResponse>()
.ProducesProblem(StatusCodes.Status403Forbidden) .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, () => 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())) OrgTemplateStore.Rollback(subOrgId, version) is { } view ? Results.Ok(view) : Results.NotFound()))
.Gate("OrgAdmin")
.WithName("orgTemplateRollback") .WithName("orgTemplateRollback")
.Produces<OrgTemplateAdminViewDto>() .Produces<OrgTemplateAdminViewDto>()
.ProducesProblem(StatusCodes.Status403Forbidden) .ProducesProblem(StatusCodes.Status403Forbidden)
@@ -770,17 +883,20 @@ api.MapPost("/admin/org-template/{subOrgId}/rollback/{version:int}", (string sub
app.Run(); 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 // One gate for every org-template endpoint — the enforce twin of the
// `orgtemplate:edit` capability RoleCapabilities emits (single Authz source). A denial // `orgtemplate:edit` capability RoleCapabilities emits (single Authz source).
// is audited (PRD-0002 §8); the allow path is left un-logged (the endpoints log their //
// own effect, e.g. publish). // 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<IResult> action) IResult OrgAdmin(HttpContext ctx, Func<IResult> action)
{ {
var principal = Authz.ResolvePrincipal(ctx); var principal = Authz.ResolvePrincipal(ctx);
if (Authz.CanManageOrgTemplates(principal)) return action(); var ok = Authz.CanManageOrgTemplates(principal);
AuditAuthz(ctx, "orgtemplate:edit", "org-templates", false, principal); AuditAuthz(ctx, "orgtemplate:edit", "org-templates", ok, principal);
if (ok) return action();
return Results.Problem(detail: "Alleen een beheerder mag organisatiesjablonen beheren.", return Results.Problem(detail: "Alleen een beheerder mag organisatiesjablonen beheren.",
statusCode: StatusCodes.Status403Forbidden); statusCode: StatusCodes.Status403Forbidden);
} }
@@ -790,8 +906,9 @@ IResult OrgAdmin(HttpContext ctx, Func<IResult> action)
IResult StamdataAdmin(HttpContext ctx, Func<IResult> action) IResult StamdataAdmin(HttpContext ctx, Func<IResult> action)
{ {
var principal = Authz.ResolvePrincipal(ctx); var principal = Authz.ResolvePrincipal(ctx);
if (Authz.CanEditStamdata(principal)) return action(); var ok = Authz.CanEditStamdata(principal);
AuditAuthz(ctx, "stamdata:edit", "stamdata", false, principal); AuditAuthz(ctx, "stamdata:edit", "stamdata", ok, principal);
if (ok) return action();
return Results.Problem(detail: "Alleen een beheerder mag stamdata onderhouden.", return Results.Problem(detail: "Alleen een beheerder mag stamdata onderhouden.",
statusCode: StatusCodes.Status403Forbidden); statusCode: StatusCodes.Status403Forbidden);
} }
@@ -801,8 +918,9 @@ IResult StamdataAdmin(HttpContext ctx, Func<IResult> action)
IResult CasesAdmin(HttpContext ctx, Func<IResult> action) IResult CasesAdmin(HttpContext ctx, Func<IResult> action)
{ {
var principal = Authz.ResolvePrincipal(ctx); var principal = Authz.ResolvePrincipal(ctx);
if (Authz.CanManageCases(principal)) return action(); var ok = Authz.CanManageCases(principal);
AuditAuthz(ctx, "cases:manage", "cases", false, principal); AuditAuthz(ctx, "cases:manage", "cases", ok, principal);
if (ok) return action();
return Results.Problem(detail: "Alleen een beheerder mag aanvragen beheren.", return Results.Problem(detail: "Alleen een beheerder mag aanvragen beheren.",
statusCode: StatusCodes.Status403Forbidden); statusCode: StatusCodes.Status403Forbidden);
} }
@@ -813,18 +931,23 @@ IResult CasesAdmin(HttpContext ctx, Func<IResult> action)
// zorgverlener with X-Role=admin still gets denied. `resource` feeds the denial's audit row. // zorgverlener with X-Role=admin still gets denied. `resource` feeds the denial's audit row.
IResult Beoordelen(HttpContext ctx, string resource, Func<IResult> action) IResult Beoordelen(HttpContext ctx, string resource, Func<IResult> action)
{ {
if (Authz.CanBeoordelen(ctx.Caller())) return action(); var ok = Authz.CanBeoordelen(ctx.Caller());
AuditAuthz(ctx, "aanvraag:beoordelen", resource, false, Authz.ResolvePrincipal(ctx)); AuditAuthz(ctx, "aanvraag:beoordelen", resource, ok, Authz.ResolvePrincipal(ctx));
if (ok) return action();
return Results.Problem(detail: "Alleen een behandelaar mag aanvragen beoordelen.", return Results.Problem(detail: "Alleen een behandelaar mag aanvragen beoordelen.",
statusCode: StatusCodes.Status403Forbidden); statusCode: StatusCodes.Status403Forbidden);
} }
// One gate for the feature-flag toggle — the enforce twin of `flags:manage` (WP-47). // One gate for the feature-flag toggle — the enforce twin of `flags:manage` (WP-47). Takes a
IResult FlagsAdmin(HttpContext ctx, Func<IResult> action) // 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<IResult> action)
{ {
var principal = Authz.ResolvePrincipal(ctx); var principal = Authz.ResolvePrincipal(ctx);
if (Authz.CanManageFeatureFlags(principal)) return action(); var ok = Authz.CanManageFeatureFlags(principal);
AuditAuthz(ctx, "flags:manage", "feature-flags", false, principal); AuditAuthz(ctx, "flags:manage", resource, ok, principal);
if (ok) return action();
return Results.Problem(detail: "Alleen een beheerder mag functievlaggen beheren.", return Results.Problem(detail: "Alleen een beheerder mag functievlaggen beheren.",
statusCode: StatusCodes.Status403Forbidden); 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); 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"); static string Now() => DateTimeOffset.UtcNow.ToString("o");
BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new( 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. // 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 // The BIG-nummer ships MASKED by default (PRD-0002 §5c, field-level PII); the reveal
// endpoint returns the full value, gated + audited. // 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 // Emit (decision flags, via ToView) and enforce (Forbidden/Conflict below) both run
// through Authz — see BriefStore.Review and Authz.CanActOn — so they cannot drift. // 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), _ => 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) => // RB-07/BIO-007: every brief transition already funnelled through here for its log line,
app.Logger.LogInformation("brief {Action} outcome={Outcome} status={Status}", // so the audit row goes here too — a fifth transition cannot be added that logs but leaves
action, r.outcome, r.entity?.Status.Tag ?? "-"); // 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, // Audit + outcome for a submit, with NO personal data: only kind, outcome,
// generated reference and the caller's correlation id (the observability seam — a // generated reference and the caller's correlation id (the observability seam — a
// real system ships this to structured logging / an audit store). A repeated // real system ships this to structured logging / an audit store). A repeated
// Idempotency-Key short-circuits to the first call's result — see IdempotencyStore // 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<DocumentRefDto>? documents = null) IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<DocumentRefDto>? documents = null)
{ {
var cid = ctx.Items.TryGetValue("CorrelationId", out var v) ? (string)v! : "none"; 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) var idemKey = ctx.Request.Headers.TryGetValue("Idempotency-Key", out var k) && !string.IsNullOrEmpty(k)
? k.ToString() ? $"{ctx.Caller().SubjectId}:{k}"
: null; : null;
if (idemKey is not null && IdempotencyStore.TryGet(idemKey, out var cached)) if (idemKey is not null && IdempotencyStore.TryGet(idemKey, out var cached))
@@ -936,5 +1062,25 @@ IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<Docum
return result; return result;
} }
// RB-12/BIO-016: a machine-checkable "this endpoint passes through one of the five admin
// authz wrappers" signal, attached at mapping time. It has to be attached here — reflecting
// over the compiled lambda at test time cannot see which local function a closure calls, but
// endpoint metadata set when the route is mapped is exactly what EndpointDataSource exposes
// to a test host. RouteInventoryTests.cs cross-checks every mapped route against either this
// marker or an explicit, named allow-list — see that file for the actual safety net.
// Public, not internal: RouteInventoryTests.cs (a separate assembly, no InternalsVisibleTo
// wired up for one marker type) reads this metadata directly off EndpointDataSource.
public sealed record AuthzGateMetadata(string Wrapper);
public static class AuthzGateEndpointExtensions
{
public static TBuilder Gate<TBuilder>(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. // Exposed so the integration tests can spin up the app with WebApplicationFactory.
public partial class Program { } public partial class Program { }
@@ -19,14 +19,26 @@ public static class Professions
/// <summary>Every mapping in the data-file, typed.</summary> /// <summary>Every mapping in the data-file, typed.</summary>
public static readonly IReadOnlyList<ProfessionMapping> Mappings = StamdataFile.Load<ProfessionMapping>("professions"); public static readonly IReadOnlyList<ProfessionMapping> Mappings = StamdataFile.Load<ProfessionMapping>("professions");
/// <summary>The mappings valid today, as a program→beroep lookup. Consumers that don't /// <summary>The mappings valid on <paramref name="on"/>, as a program→beroep lookup.
/// yet reason about a peildatum (e.g. <c>DiplomaRules.ProfessionFor</c>) use this — it ///
/// preserves the pre-valid-time behaviour exactly while the file's rows are all current.</summary> /// Takes the peildatum as an argument rather than reading the clock. It used to be a
public static readonly IReadOnlyDictionary<string, string> ByProgram = /// <c>static readonly</c> field filtered on <c>DateTime.Today</c>, which evaluated once at
Mappings.Where(m => StamdataFile.ActiveOn(m.GeldigVan, m.GeldigTot, DateOnly.FromDateTime(DateTime.Today))) /// type-load: a long-running process kept yesterday's answer across midnight, and a mapping
/// whose <c>geldigVan</c> fell after startup never appeared at all. It also made both
/// branches of <see cref="StamdataFile.ActiveOn"/> permanently unreachable from here, which
/// is why this table's validity window was never exercised by a test.</summary>
public static IReadOnlyDictionary<string, string> ByProgramOn(DateOnly on) =>
Mappings.Where(m => StamdataFile.ActiveOn(m.GeldigVan, m.GeldigTot, on))
.ToDictionary(m => m.Program, m => m.Beroep, StringComparer.OrdinalIgnoreCase); .ToDictionary(m => m.Program, m => m.Beroep, StringComparer.OrdinalIgnoreCase);
/// <summary>Distinct professions, in declaration order — the list a user may declare /// <summary>The mappings valid today. Consumers that don't yet reason about a peildatum
/// for a manual (unlisted) diploma.</summary> /// (e.g. <c>DiplomaRules.ProfessionFor</c>) use this — same behaviour as before, but
/// evaluated per call so the date is current.</summary>
public static IReadOnlyDictionary<string, string> ByProgram => ByProgramOn(Today());
/// <summary>Distinct professions valid today, in declaration order — the list a user may
/// declare for a manual (unlisted) diploma.</summary>
public static IReadOnlyList<string> All() => ByProgram.Values.Distinct().ToList(); public static IReadOnlyList<string> All() => ByProgram.Values.Distinct().ToList();
private static DateOnly Today() => DateOnly.FromDateTime(DateTime.Today);
} }
@@ -24,7 +24,7 @@ internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens)
{ {
using var res = await SendWithRetryAsync(() => new HttpRequestMessage(HttpMethod.Get, url), caller); using var res = await SendWithRetryAsync(() => new HttpRequestMessage(HttpMethod.Get, url), caller);
return (await res.Content.ReadFromJsonAsync<T>()) return (await res.Content.ReadFromJsonAsync<T>())
?? throw new InvalidOperationException($"ZGW GET {url} returned null body."); ?? throw new InvalidOperationException($"ZGW GET {Redact(url)} returned null body.");
} }
public async Task<T> PostAsync<T>(string url, object body, CallerIdentity? caller = null) public async Task<T> PostAsync<T>(string url, object body, CallerIdentity? caller = null)
@@ -32,7 +32,7 @@ internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens)
using var res = await SendWithRetryAsync( using var res = await SendWithRetryAsync(
() => new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(body) }, caller); () => new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(body) }, caller);
return (await res.Content.ReadFromJsonAsync<T>()) return (await res.Content.ReadFromJsonAsync<T>())
?? throw new InvalidOperationException($"ZGW POST {url} returned null body."); ?? throw new InvalidOperationException($"ZGW POST {Redact(url)} returned null body.");
} }
/// <summary> /// <summary>
@@ -42,7 +42,7 @@ internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens)
/// partial commit on the two non-idempotent ZGW POSTs (<c>/statussen</c>, <c>/rollen</c>) and /// partial commit on the two non-idempotent ZGW POSTs (<c>/statussen</c>, <c>/rollen</c>) and
/// retrying risks a duplicate write — the create-zaak/document POSTs are additionally /// retrying risks a duplicate write — the create-zaak/document POSTs are additionally
/// protected by OpenZaak's own uniqueness constraint on (bronorganisatie, identificatie). /// 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
/// <c>Program.cs</c>'s submit endpoint catches and records as a flagged divergence rather /// <c>Program.cs</c>'s submit endpoint catches and records as a flagged divergence rather
/// than letting it diverge silently (see openzaak-integration.md's "Write resilience" section). /// than letting it diverge silently (see openzaak-integration.md's "Write resilience" section).
/// </summary> /// </summary>
@@ -73,15 +73,26 @@ internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens)
continue; continue;
} }
var body = await res.Content.ReadAsStringAsync(); // RB-05/BIO-009: path only — no query string, no response-body snippet. The
var snippet = body.Length > 500 ? body[..500] : body; // BSN-filtered zaken list puts a BSN in the query, and OpenZaak echoes the request in
var message = $"ZGW {req.Method} {req.RequestUri} failed: {(int)res.StatusCode} {snippet}"; // 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; var status = res.StatusCode;
res.Dispose(); res.Dispose();
throw new HttpRequestException(message, null, status); throw new HttpRequestException(message, null, status);
} }
} }
/// <summary>The path without its query string — ZGW filters travel as query parameters and
/// one of them is a BSN (<c>rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn</c>), so no
/// ZGW url may be interpolated into a message that is logged or persisted (RB-05).</summary>
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 private static bool IsTransient(HttpStatusCode status) => status is
HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests or HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests or
HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout; HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout;
+88 -124
View File
@@ -194,6 +194,16 @@
} }
} }
}, },
"400": {
"description": "Bad Request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"403": { "403": {
"description": "Forbidden", "description": "Forbidden",
"content": { "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": { "/api/v1/change-requests": {
"post": { "post": {
"tags": [ "tags": [
@@ -439,7 +410,14 @@
"description": "No Content" "description": "No Content"
}, },
"403": { "403": {
"description": "Forbidden" "description": "Forbidden",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}, },
"404": { "404": {
"description": "Not Found" "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": { "/api/v1/werkvoorraad": {
"get": { "get": {
"tags": [ "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": { "/api/v1/me": {
"get": { "get": {
"tags": [ "tags": [
@@ -1022,6 +1000,9 @@
} }
} }
} }
},
"404": {
"description": "Not Found"
} }
} }
}, },
@@ -2468,23 +2449,6 @@
}, },
"additionalProperties": false "additionalProperties": false
}, },
"RegistratieRequest": {
"type": "object",
"properties": {
"diplomaHerkomst": {
"type": "string",
"nullable": true
},
"documents": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DocumentRefDto"
},
"nullable": true
}
},
"additionalProperties": false
},
"RegistrationDto": { "RegistrationDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1,6 +1,7 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using BigRegister.Api.Contracts; using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests; namespace BigRegister.Tests;
@@ -34,7 +35,10 @@ public class AdminCasesTests(TestWebApplicationFactory factory) : IClassFixture<
list.EnsureSuccessStatusCode(); list.EnsureSuccessStatusCode();
var cases = (await list.Content.ReadFromJsonAsync<List<ApplicationSummaryDto>>())!; var cases = (await list.Content.ReadFromJsonAsync<List<ApplicationSummaryDto>>())!;
var mine = cases.Single(x => x.Id == a.Id); 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 finally
{ {
@@ -1,8 +1,10 @@
using System.Net; using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using BigRegister.Api.Contracts; using BigRegister.Api.Contracts;
using BigRegister.Api.Data; using BigRegister.Api.Data;
using BigRegister.Domain.Features;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests; namespace BigRegister.Tests;
@@ -26,6 +28,20 @@ public class AuthzAuditTests(TestWebApplicationFactory factory) : IClassFixture<
return (await res.Content.ReadFromJsonAsync<List<AuthzAuditDto>>())!; return (await res.Content.ReadFromJsonAsync<List<AuthzAuditDto>>())!;
} }
private async Task<string> 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<UploadResponse>())!.DocumentId;
}
[Fact] [Fact]
public async Task A_denied_admin_action_is_recorded() 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"); 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
/// <c>BriefEndpointTests.Submit_succeeds_when_required_sections_filled</c>, 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] [Fact]
public void The_audit_schema_carries_no_pii() public void The_audit_schema_carries_no_pii()
{ {
@@ -2,6 +2,7 @@ using System.Net;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using BigRegister.Api.Contracts; using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests; namespace BigRegister.Tests;
@@ -150,6 +151,12 @@ public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture
var view = (await detail.Content.ReadFromJsonAsync<BeoordelingViewDto>())!; var view = (await detail.Content.ReadFromJsonAsync<BeoordelingViewDto>())!;
Assert.Equal("Goedgekeurd", view.Aanvraag.Status.Tag); Assert.Equal("Goedgekeurd", view.Aanvraag.Status.Tag);
Assert.False(view.Decisions.CanBesluiten); // terminal — no further decision allowed 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 finally
{ {
@@ -28,10 +28,14 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
return new SaveBriefRequest(sections); return new SaveBriefRequest(sections);
} }
private async Task<BriefDto> 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<BriefDto> SeedBrief()
{ {
BriefStore.Reset(); BriefStore.Reset();
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"); var res = await _client.PostAsync("/api/v1/brief/reset", null);
var view = await res.Content.ReadFromJsonAsync<BriefViewDto>();
Assert.NotNull(view); Assert.NotNull(view);
return view.Brief; return view.Brief;
} }
@@ -44,10 +48,26 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
return req; return req;
} }
// --- RB-23/CQ-007: GET /brief is a pure query — it must not create a row. ---
[Fact] [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("draft", brief.Status.Tag);
Assert.Equal(new[] { "aanhef", "kern", "slot" }, brief.Sections.Select(s => s.SectionKey)); Assert.Equal(new[] { "aanhef", "kern", "slot" }, brief.Sections.Select(s => s.SectionKey));
// aanhef + slot are locked, predefined and prefilled; only kern is editable + empty. // aanhef + slot are locked, predefined and prefilled; only kern is editable + empty.
@@ -63,7 +83,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact] [Fact]
public async Task Get_offers_only_global_and_arts_scoped_besluit_tagged_passages() public async Task Get_offers_only_global_and_arts_scoped_besluit_tagged_passages()
{ {
await Get(); await SeedBrief();
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"); var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
Assert.NotNull(view); Assert.NotNull(view);
// global passages + the arts-scoped one; no other-beroep passages leak in. // global passages + the arts-scoped one; no other-beroep passages leak in.
@@ -78,7 +98,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact] [Fact]
public async Task Get_joins_the_case_context_with_the_BIG_nummer_masked() public async Task Get_joins_the_case_context_with_the_BIG_nummer_masked()
{ {
await Get(); await SeedBrief();
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"); var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
Assert.NotNull(view); Assert.NotNull(view);
// Case context is joined onto the screen DTO for the behandel scherm header. // Case context is joined onto the screen DTO for the behandel scherm header.
@@ -128,7 +148,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact] [Fact]
public async Task Save_is_drafter_only() public async Task Save_is_drafter_only()
{ {
var brief = await Get(); var brief = await SeedBrief();
var save = FilledFrom(brief); var save = FilledFrom(brief);
var approver = Post("/api/v1/brief", role: "approver"); var approver = Post("/api/v1/brief", role: "approver");
@@ -142,7 +162,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact] [Fact]
public async Task Submit_blocks_on_empty_required_section() public async Task Submit_blocks_on_empty_required_section()
{ {
await Get(); await SeedBrief();
// Nothing filled yet → required sections empty → 409. // Nothing filled yet → required sections empty → 409.
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/submit"))).StatusCode); Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/submit"))).StatusCode);
} }
@@ -150,7 +170,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact] [Fact]
public async Task Submit_succeeds_when_required_sections_filled() public async Task Submit_succeeds_when_required_sections_filled()
{ {
await Get(); await SeedBrief();
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"); var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
Assert.NotNull(view); Assert.NotNull(view);
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(view.Brief)); 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<BriefViewDto>(); var submitted = await res.Content.ReadFromJsonAsync<BriefViewDto>();
Assert.NotNull(submitted); Assert.NotNull(submitted);
Assert.Equal("submitted", submitted.Brief.Status.Tag); 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] [Fact]
public async Task Drafter_cannot_approve_own_letter_but_a_different_reviewer_can() 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.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit")); await _client.SendAsync(Post("/api/v1/brief/submit"));
@@ -182,7 +207,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact] [Fact]
public async Task Reject_returns_comments() public async Task Reject_returns_comments()
{ {
var brief = await Get(); var brief = await SeedBrief();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit")); await _client.SendAsync(Post("/api/v1/brief/submit"));
@@ -197,7 +222,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact] [Fact]
public async Task Editing_a_rejected_letter_reopens_it_to_draft() 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.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit")); await _client.SendAsync(Post("/api/v1/brief/submit"));
await _client.SendAsync( await _client.SendAsync(
@@ -213,7 +238,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact] [Fact]
public async Task Send_only_from_approved() 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.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit")); await _client.SendAsync(Post("/api/v1/brief/submit"));
@@ -231,7 +256,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact] [Fact]
public async Task Decisions_on_the_view_mirror_the_acting_principal_and_live_status() 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<BriefViewDto>("/api/v1/brief"); var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
Assert.NotNull(view); Assert.NotNull(view);
Assert.True(view.Decisions.CanEdit); // default (no X-Role) = drafter, draft status Assert.True(view.Decisions.CanEdit); // default (no X-Role) = drafter, draft status
@@ -264,7 +289,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
[Fact] [Fact]
public async Task Reset_recreates_a_fresh_draft_with_locked_prefilled_sections() 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. // Advance out of draft so the reset back to draft is observable.
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief)); await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit")); await _client.SendAsync(Post("/api/v1/brief/submit"));
@@ -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<ParagraphDto>())))
.ToList());
[Fact]
public void No_required_sections_means_nothing_to_fill() =>
Assert.True(BriefRules.RequiredFilled(Array.Empty<LetterSectionDto>()));
[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));
}
@@ -0,0 +1,47 @@
using BigRegister.Stamdata;
namespace BigRegister.Tests.Domain;
/// <summary>
/// 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.
/// </summary>
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);
}
}
@@ -4,14 +4,6 @@ namespace BigRegister.Tests.Domain;
public class SubmissionRuleTests 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] [Fact]
public void Zero_hours_is_rejected() => public void Zero_hours_is_rejected() =>
Assert.NotNull(SubmissionRules.RejectZeroUren(0)); Assert.NotNull(SubmissionRules.RejectZeroUren(0));
@@ -69,26 +69,6 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<Te
Assert.Equal(1000, dto.ScholingThreshold); Assert.Equal(1000, dto.ScholingThreshold);
} }
[Fact]
public async Task Registration_with_duo_diploma_succeeds()
{
var res = await _client.PostAsJsonAsync("/api/v1/registrations", new RegistratieRequest("duo"));
res.EnsureSuccessStatusCode();
var body = await res.Content.ReadFromJsonAsync<ReferentieResponse>();
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] [Fact]
public async Task Change_request_with_valid_phone_succeeds() public async Task Change_request_with_valid_phone_succeeds()
{ {
@@ -101,11 +81,16 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<Te
} }
[Fact] [Fact]
public async Task Change_request_with_bad_phone_is_rejected() public async Task Change_request_with_bad_phone_is_rejected_with_problem_details()
{ {
var res = await _client.PostAsJsonAsync("/api/v1/change-requests", var res = await _client.PostAsJsonAsync("/api/v1/change-requests",
new { telefoon = "nope" }); new { telefoon = "nope" });
Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode); Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode);
// The Submit helper's rejection shape — was asserted through POST /registrations until
// RB-06 deleted it; /change-requests is the other endpoint on the same helper.
var contentType = res.Content.Headers.ContentType;
Assert.NotNull(contentType);
Assert.Contains("application/problem+json", contentType.ToString());
} }
[Fact] [Fact]
@@ -215,8 +200,12 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<Te
public async Task User_delete_blocked_with_409_once_linked_to_submission() public async Task User_delete_blocked_with_409_once_linked_to_submission()
{ {
var doc = await Upload(Guid.NewGuid().ToString()); var doc = await Upload(Guid.NewGuid().ToString());
var submit = await _client.PostAsJsonAsync("/api/v1/registrations", // Through the real submit path (RB-06 deleted POST /registrations, which was the only
new RegistratieRequest("duo", new[] { new DocumentRefDto("diploma", "digital", doc.DocumentId) })); // other caller of DocumentStore.Link and had no ownership guard on it).
var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" });
var aanvraag = (await created.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
var submit = await _client.PostAsJsonAsync($"/api/v1/applications/{aanvraag.Id}/submit",
new { diplomaHerkomst = "duo", documents = new[] { new DocumentRefDto("diploma", "digital", doc.DocumentId) } });
submit.EnsureSuccessStatusCode(); submit.EnsureSuccessStatusCode();
Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode); Assert.Equal(HttpStatusCode.Conflict, (await _client.DeleteAsync($"/api/v1/uploads/{doc.DocumentId}")).StatusCode);
} }
@@ -224,11 +213,13 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<Te
[Fact] [Fact]
public async Task Admin_delete_requires_admin_role() public async Task Admin_delete_requires_admin_role()
{ {
// RB-08: routed through CasesAdmin (cases:manage), like the other admin-cases
// endpoints, not the standalone X-Admin header this used to accept.
var doc = await Upload(Guid.NewGuid().ToString()); var doc = await Upload(Guid.NewGuid().ToString());
Assert.Equal(HttpStatusCode.Forbidden, (await _client.DeleteAsync($"/api/v1/admin/uploads/{doc.DocumentId}")).StatusCode); Assert.Equal(HttpStatusCode.Forbidden, (await _client.DeleteAsync($"/api/v1/admin/uploads/{doc.DocumentId}")).StatusCode);
var req = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/admin/uploads/{doc.DocumentId}"); var req = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/admin/uploads/{doc.DocumentId}");
req.Headers.Add("X-Admin", "true"); req.Headers.Add("X-Role", "admin");
Assert.Equal(HttpStatusCode.NoContent, (await _client.SendAsync(req)).StatusCode); Assert.Equal(HttpStatusCode.NoContent, (await _client.SendAsync(req)).StatusCode);
} }
@@ -45,6 +45,31 @@ public class IdempotencyTests(TestWebApplicationFactory factory) : IClassFixture
Assert.NotEqual(firstBody!.Referentie, secondBody!.Referentie); Assert.NotEqual(firstBody!.Referentie, secondBody!.Referentie);
} }
// RB-18/BIO-018: IdempotencyStore used to key on the raw client-supplied header alone, so
// caller B replaying caller A's Idempotency-Key got caller A's cached reference back —
// a cross-caller leak of a value caller B never submitted. The store now keys on
// "{SubjectId}:{idemKey}", so the same header value from two different callers is two
// independent submissions.
[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<ReferentieResponse>();
var callerBRequest = ChangeRequestWithKey(sharedKey);
callerBRequest.Headers.Add("X-Subject", "999888777");
var callerB = await _client.SendAsync(callerBRequest);
callerB.EnsureSuccessStatusCode();
var callerBBody = await callerB.Content.ReadFromJsonAsync<ReferentieResponse>();
Assert.NotEqual(callerABody!.Referentie, callerBBody!.Referentie);
}
[Fact] [Fact]
public async Task A_rejected_submission_replays_the_same_rejection_not_a_retry() public async Task A_rejected_submission_replays_the_same_rejection_not_a_retry()
{ {
@@ -80,6 +80,44 @@ public class LetterHtmlTests
private static readonly string GoldenPath = Path.Combine(AppContext.BaseDirectory, "LetterHtml.golden.html"); 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<LetterBlockDto>
{
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, "<dt>Datum</dt><dd>([^<]+)</dd>").Groups[1].Value;
private static string ExtractBodyDatumParagraph(string html)
{
var bodyStart = html.IndexOf("<div class=\"letter__body\">", StringComparison.Ordinal);
var bodyEnd = html.IndexOf("<div class=\"letter__signature\">", StringComparison.Ordinal);
var body = html[bodyStart..bodyEnd];
return Regex.Match(body, "<p>([^<]+)</p>").Groups[1].Value;
}
[Fact] [Fact]
public void Render_matches_the_golden_file() public void Render_matches_the_golden_file()
{ {
@@ -88,6 +126,29 @@ public class LetterHtmlTests
Assert.Equal(golden, html); 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] [Fact]
public void Every_letter_prefixed_class_exists_in_letter_css() public void Every_letter_prefixed_class_exists_in_letter_css()
{ {
@@ -58,8 +58,8 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
public async Task Publish_increments_the_version() public async Task Publish_increments_the_version()
{ {
ResetStores(); ResetStores();
// One unsent brief for this sub-org (GetOrCreate on first read). // One unsent brief for this sub-org (RB-23: GET no longer seeds — create explicitly).
await _client.GetAsync("/api/v1/brief"); 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")); var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
@@ -74,7 +74,7 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
public async Task Publish_appends_to_the_version_history() public async Task Publish_appends_to_the_version_history()
{ {
ResetStores(); 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")); var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
@@ -87,8 +87,8 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
public async Task Publish_counts_the_unsent_briefs_it_affects() public async Task Publish_counts_the_unsent_briefs_it_affects()
{ {
ResetStores(); ResetStores();
// One unsent brief for this sub-org (GetOrCreate on first read). // One unsent brief for this sub-org (RB-23: GET no longer seeds — create explicitly).
await _client.GetAsync("/api/v1/brief"); 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")); var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
res.EnsureSuccessStatusCode(); res.EnsureSuccessStatusCode();
@@ -154,7 +154,8 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
private async Task WalkBriefToSentThenRepublish() private async Task WalkBriefToSentThenRepublish()
{ {
ResetStores(); ResetStores();
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief; var resetRes = await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: create explicitly
var brief = (await resetRes.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief;
var filled = brief.Sections var filled = brief.Sections
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required, .Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required,
s.Required && s.Blocks.Count == 0 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() public async Task Admin_cannot_slip_into_the_brief_review_flow()
{ {
ResetStores(); ResetStores();
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief; var resetRes = await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: create explicitly
var brief = (await resetRes.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief;
var filled = brief.Sections var filled = brief.Sections
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required, .Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required,
s.Required && s.Blocks.Count == 0 s.Required && s.Blocks.Count == 0
@@ -41,7 +41,7 @@ public class PreviewEndpointTests(TestWebApplicationFactory factory) : IClassFix
public async Task Preview_of_an_unsent_brief_renders_live_with_a_watermark() public async Task Preview_of_an_unsent_brief_renders_live_with_a_watermark()
{ {
ResetStores(); 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"); var res = await _client.GetAsync("/api/v1/brief/preview");
res.EnsureSuccessStatusCode(); 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() public async Task Preview_of_a_sent_brief_serves_the_archive_unchanged_after_a_republish()
{ {
ResetStores(); ResetStores();
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief; var resetRes = await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: create explicitly
var brief = (await resetRes.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief;
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(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/submit"));
await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/approve", role: "approver")); await _client.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/approve", role: "approver"));
@@ -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 <see cref="AuthzGateMetadata"/> marker (<c>.Gate("XAdmin")</c>, added at the
/// call site in Program.cs) naming one of the five admin authz wrappers, or
/// - it is named, with a reason, in <see cref="AllowList"/> 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<TestWebApplicationFactory>
{
// 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<Program>()`, 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<string> KnownWrappers =
["OrgAdmin", "StamdataAdmin", "CasesAdmin", "Beoordelen", "FlagsAdmin"];
private static IEnumerable<RouteEndpoint> RealRoutes(EndpointDataSource source) =>
source.Endpoints.OfType<RouteEndpoint>()
// 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<HttpMethodMetadata>() 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<EndpointDataSource>();
var allowed = AllowList.ToDictionary(e => Key(e.Method, e.Pattern));
var seenAllowListKeys = new HashSet<string>();
var unaccounted = new List<string>();
foreach (var route in RealRoutes(source))
{
var pattern = route.RoutePattern.RawText!;
foreach (var method in route.Metadata.GetMetadata<HttpMethodMetadata>()!.HttpMethods)
{
var key = Key(method, pattern);
var gated = route.Metadata.GetMetadata<AuthzGateMetadata>() 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<EndpointDataSource>();
var unknown = RealRoutes(source)
.Select(r => r.Metadata.GetMetadata<AuthzGateMetadata>())
.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));
}
}
@@ -59,6 +59,16 @@ public class StamdataEndpointTests(TestWebApplicationFactory factory) : IClassFi
Assert.Empty(table.Rows); 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] [Fact]
public async Task Unknown_table_is_404() public async Task Unknown_table_is_404()
{ {
@@ -1,6 +1,8 @@
using BigRegister.Api.Data; using BigRegister.Api.Data;
using BigRegister.Domain.Authorization; using BigRegister.Domain.Authorization;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests; namespace BigRegister.Tests;
@@ -93,4 +95,32 @@ public class StubIdentityProviderTests
var caller = Resolve(role: "admin", medewerker: "m.jansen"); var caller = Resolve(role: "admin", medewerker: "m.jansen");
Assert.Equal(PrincipalRole.Admin, caller.Role); 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<Program>()
.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<Exception>(() => factory.CreateClient());
}
} }
@@ -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<TestWebApplicationFactory>
{
[Fact]
public async Task Swagger_document_is_served_in_development()
{
// The default test environment (WebApplicationFactory<T> 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<Program>()` — 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<IIdentityProvider, StubIdentityProvider>()));
var res = await staging.CreateClient().GetAsync("/swagger/v1/swagger.json");
Assert.Equal(HttpStatusCode.NotFound, res.StatusCode);
}
}
@@ -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<TestWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
private const string OtherCitizen = "999999990";
private async Task<string> 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<UploadResponse>())!.DocumentId;
}
private Task<HttpResponseMessage> 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<UploadStatusDto>())!;
var item = Assert.Single(status.Results);
Assert.Equal("unknown", item.Status);
Assert.Null(item.DocumentId);
}
}
@@ -38,7 +38,8 @@ public class WerkvoorraadTests(TestWebApplicationFactory factory) : IClassFixtur
var queue = (await res.Content.ReadFromJsonAsync<List<ApplicationSummaryDto>>())!; var queue = (await res.Content.ReadFromJsonAsync<List<ApplicationSummaryDto>>())!;
var mine = queue.Single(x => x.Id == a.Id); var mine = queue.Single(x => x.Id == a.Id);
Assert.Equal("InBehandeling", mine.Status.Tag); 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 finally
{ {
@@ -111,6 +111,32 @@ public class ZgwDivergenceTests
Assert.Null(stored.ZgwError); 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) private static HttpRequestMessage AdminRequest(HttpMethod method, string path)
{ {
var req = new HttpRequestMessage(method, path); var req = new HttpRequestMessage(method, path);

Some files were not shown because too many files have changed in this diff Show More