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
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:
@@ -100,9 +100,9 @@ module.exports = function buildConfig(contextAllowed, appName, tsConfigFileName)
|
||||
{
|
||||
name: 'apiclient-infrastructure-only',
|
||||
comment:
|
||||
'The generated ApiClient is a value only inside infrastructure/ (+ shared/upload); elsewhere type-only.',
|
||||
'The generated ApiClient is a value only inside infrastructure/; elsewhere type-only.',
|
||||
severity: 'error',
|
||||
from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' },
|
||||
from: { pathNot: '/infrastructure/' },
|
||||
to: {
|
||||
path: '^libs/shared/src/infrastructure/api-client\\.ts$',
|
||||
dependencyTypesNot: ['type-only'],
|
||||
|
||||
@@ -202,6 +202,11 @@ jobs:
|
||||
# run manually against backend/openzaak/ (see its README), never in CI.
|
||||
- run: dotnet test backend/BigRegister.slnx --filter "Category!=Integration"
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
# RB-14/BIO-016: `npm audit --omit=dev` covers only the frontend; the .NET dependency
|
||||
# tree was entirely unscanned. The script — not a bare `dotnet list` — is the gate,
|
||||
# because `dotnet list package --vulnerable` exits 0 even on a High advisory.
|
||||
- run: ./scripts/dotnet-audit.sh
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
|
||||
e2e:
|
||||
needs: changes
|
||||
|
||||
@@ -58,3 +58,7 @@ backend/openzaak/seeded.env
|
||||
|
||||
# WP-55: render-prod-secrets.sh's output — the real client secret, never committed
|
||||
backend/openzaak/setup_configuration/data.prod.yaml
|
||||
|
||||
# Agent git worktrees (Claude Code `isolation: "worktree"`) — full checkouts of
|
||||
# this repo nested inside it; never commit one.
|
||||
.claude/worktrees/
|
||||
|
||||
@@ -4,6 +4,11 @@ storybook-static*/
|
||||
coverage/
|
||||
.angular/
|
||||
|
||||
# Agent git worktrees — full checkouts of this repo nested inside it, so an
|
||||
# unignored `prettier --check .` walks into every one of them (and reports the
|
||||
# vendored CIBG files that the top-level ignore already excludes).
|
||||
.claude/worktrees/
|
||||
|
||||
# Lockfile
|
||||
package-lock.json
|
||||
|
||||
@@ -21,3 +26,7 @@ plop-templates/
|
||||
|
||||
# Backend is formatted by `dotnet format`, not prettier
|
||||
backend/
|
||||
|
||||
# Agent prompts — their exact wording is the input, reflowing markdown edits the prompt
|
||||
docs/project/refactor-backlog-setup/agents/
|
||||
docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/
|
||||
|
||||
@@ -124,8 +124,10 @@ than hardcoding one app's content — the two apps' primary nav genuinely differ
|
||||
should be **composition of existing blocks** — adding building blocks is the
|
||||
exception, not the default. Atoms are thin wrappers over CIBG Huisstijl (Bootstrap 5.2)
|
||||
CSS classes (`btn`, `form-control`, `card`, …); we own only a small typed `input()` API,
|
||||
the design system does the visuals. (Where CIBG lacks a class — e.g. `alert` — the atom is a
|
||||
small hand-rolled surface built from the token bridge; see ADR-0003.)
|
||||
the design system does the visuals. (Where CIBG lacks a class — e.g. `skeleton`,
|
||||
`spinner` — the atom is a small hand-rolled surface built from the token bridge and carries a
|
||||
`// CIBG-GAP EXTENSION:` marker; see ADR-0003. `alert` is **not** such a case: it wraps the
|
||||
vendored `.feedback feedback-*` classes.)
|
||||
|
||||
### 3. State: make illegal states unrepresentable
|
||||
|
||||
@@ -175,8 +177,14 @@ herregistratie eligibility) or _config value_ (server sends threshold, FE applie
|
||||
for instant feedback, server re-validates as authority — e.g. scholing threshold).
|
||||
FE keeps only **format** validation, never as authority.
|
||||
|
||||
DTO lives in `contracts/`; a hand-written `parse*`/`toDomain` in `infrastructure/`
|
||||
validates the untrusted shape and maps DTO → domain. Wiring a real .NET backend
|
||||
The generated client
|
||||
(`libs/shared/src/infrastructure/api-client.ts`, `npm run gen:api`, drift-checked in CI) **is**
|
||||
the wire contract — consume its types directly, as 19 of the 20 adapters do. A hand-written
|
||||
`contracts/*.dto.ts` is the exception, only where codegen does not reach the endpoint or types
|
||||
it too loosely (the four survivors are all the latter — the generator emits every property as
|
||||
optional and flattens unions); such a file must still import nothing. Either way a hand-written
|
||||
`parse*`/`toDomain` in `infrastructure/` validates the untrusted shape and maps DTO → domain —
|
||||
**a generated type is a compile-time claim about the wire, not a runtime guarantee.** Wiring a real .NET backend
|
||||
touches only `infrastructure/` + `contracts/` (see ARCHITECTURE §6). Server-owned
|
||||
rules live **only** on the server, with no FE mirror to drift from it — the FE may
|
||||
mirror a server-supplied _value_ (a threshold, a bound) for instant feedback, but
|
||||
@@ -185,8 +193,12 @@ never reimplements the _algorithm_.
|
||||
**Business-tunable reference data ("stamdata") is config-as-code, not a DB.** Tables the
|
||||
business controls (profession↔diploma map, thresholds, policy-question text) live as typed
|
||||
C# in `backend/.../Stamdata/`, validated at build by `StamdataValidationTests` (a bad edit
|
||||
fails CI, never prod) — never runtime-editable. Org-templates are the deliberate exception
|
||||
(operational per-org config in SQLite). UI copy is `$localize`. See ADR-0004.
|
||||
fails CI, never prod) — never runtime-editable. Operational configuration is the deliberate
|
||||
exception, and ADR-0004 states it as a four-part test rather than a list: the catalog lives in
|
||||
code, an unknown key fails closed, the value is operational rather than a shared business rule,
|
||||
and writes are admin-capability-gated **and** audited. Two surfaces pass it today —
|
||||
`OrgTemplateStore` (per-org letterhead) and `FeatureFlagStore` (rollout switches), both in
|
||||
SQLite. A third surface must pass the same test, not argue by analogy. UI copy is `$localize`. See ADR-0004.
|
||||
|
||||
### 5. Testing
|
||||
|
||||
@@ -214,6 +226,17 @@ regardless of which atomic layer it is (a context organism doesn't get its own
|
||||
- **Naming:** shared/reusable UI is **English** (language-agnostic: `button`,
|
||||
`wizard-shell`); domain contexts are **Dutch** (`registratie`, `herregistratie`,
|
||||
`*.machine.ts`). Pick the language by which side of the seam the code is on.
|
||||
- **English prose uses Simplified Technical English (STE).** This covers documentation,
|
||||
code comments, commit messages, ADRs, and the backlog notes. One idea per sentence;
|
||||
20 words or fewer in a procedure, 25 in a description. Active voice, present tense.
|
||||
One word for one meaning — pick a term and repeat it, do not vary it for style. Keep
|
||||
articles ("the test fails"). Three nouns together at most. No idioms and no humour.
|
||||
Six sentences per paragraph at most. Write a procedure as numbered steps, one action
|
||||
per step.
|
||||
**STE governs form, not content.** Split a long sentence; never drop a caveat, a
|
||||
measurement, or a precise term to make it shorter.
|
||||
**STE does not apply to** Dutch identifiers, `$localize` copy, quoted output, or
|
||||
existing documents you are not already editing.
|
||||
- **User-facing copy = `$localize`.** Every user-visible string is wrapped in Angular's
|
||||
first-party `$localize` (no third-party i18n lib), with a stable custom id
|
||||
(`` $localize`:@@context.key:Tekst` ``). Source locale is `nl`; a second locale is a
|
||||
|
||||
+4
-3
@@ -106,7 +106,7 @@
|
||||
"**/*.spec.ts",
|
||||
"**/*.stories.ts",
|
||||
"**/contracts/**",
|
||||
"libs/shared/src/infrastructure/api-client.ts",
|
||||
"**/infrastructure/api-client.ts",
|
||||
"apps/ssp/src/main.ts",
|
||||
"**/*.testing.ts",
|
||||
"**/*.d.ts"
|
||||
@@ -235,7 +235,7 @@
|
||||
"**/*.spec.ts",
|
||||
"**/*.stories.ts",
|
||||
"**/contracts/**",
|
||||
"libs/shared/src/infrastructure/api-client.ts",
|
||||
"**/infrastructure/api-client.ts",
|
||||
"apps/behandelportal/src/main.ts",
|
||||
"**/*.testing.ts",
|
||||
"**/*.d.ts"
|
||||
@@ -293,7 +293,7 @@
|
||||
"**/*.spec.ts",
|
||||
"**/*.stories.ts",
|
||||
"**/contracts/**",
|
||||
"src/infrastructure/api-client.ts",
|
||||
"**/infrastructure/api-client.ts",
|
||||
"src/test-entry.ts",
|
||||
"**/*.testing.ts",
|
||||
"**/*.d.ts"
|
||||
@@ -332,6 +332,7 @@
|
||||
"**/*.spec.ts",
|
||||
"**/*.stories.ts",
|
||||
"**/contracts/**",
|
||||
"**/infrastructure/api-client.ts",
|
||||
"src/test-entry.ts",
|
||||
"**/*.testing.ts",
|
||||
"**/*.d.ts"
|
||||
|
||||
@@ -1,58 +1,50 @@
|
||||
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { Session } from '../domain/session';
|
||||
import { DigidAdapter } from '../infrastructure/digid.adapter';
|
||||
import { Principal, parseStoredPrincipal } from '../domain/principal';
|
||||
import { MedewerkerAdapter } from '../infrastructure/medewerker.adapter';
|
||||
|
||||
const STORAGE_KEY = 'session-v1';
|
||||
|
||||
/** Restore a persisted session (best-effort; corrupt entry → logged out).
|
||||
G2: validate the shape before trusting it. G1: the BSN is never persisted
|
||||
(see the effect below), so a restored session carries an empty one — it is
|
||||
unused after login; only `naam` is shown in the chrome. */
|
||||
function restore(): Session | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<Session>;
|
||||
return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
/** Restore a persisted principal (best-effort; corrupt entry → logged out).
|
||||
The shape validation (G2 — there is no BSN here, so no G1 to enforce) lives in
|
||||
`parseStoredPrincipal` (`../domain/principal`) — pure, spec'd, and testable
|
||||
without stubbing `localStorage`; this just supplies the raw value. */
|
||||
function restore(): Principal | null {
|
||||
return parseStoredPrincipal(localStorage.getItem(STORAGE_KEY));
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the current session for the whole app. Because it is providedIn:'root'
|
||||
* there is exactly one instance — every component that injects it sees the same
|
||||
* session signal, so logging in is instantly visible everywhere (the guard, the
|
||||
* header, etc.). The session is mirrored to localStorage so a refresh, a deep-link,
|
||||
* or the full-page navigation the language switch performs (nl at `/` ⇄ en at `/en/`,
|
||||
* separate bundles) keeps you logged in. ponytail: localStorage, not sessionStorage —
|
||||
* sessionStorage's per-tab clearing dropped the login on the cross-bundle language
|
||||
* switch. Trade-off: the demo session now survives tab close; a real portal keeps auth
|
||||
* in an httpOnly cookie/token, not web storage.
|
||||
* Holds the current medewerker principal for the whole backoffice app. One
|
||||
* `providedIn: 'root'` instance, so logging in is instantly visible everywhere
|
||||
* (the guard, the header). Persisted to localStorage — a refresh or the
|
||||
* cross-bundle language switch (nl at `/` ⇄ en at `/en/`) keeps you logged in —
|
||||
* which is safe to do verbatim here: a medewerker principal carries no BSN or
|
||||
* other national identifier, unlike the SSP's `SessionStore`, whose equivalent
|
||||
* comment explains why *that* app strips a field before writing. A real
|
||||
* deployment keeps auth in an httpOnly cookie/token, not web storage, regardless.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SessionStore {
|
||||
private digid = inject(DigidAdapter);
|
||||
private _session = signal<Session | null>(restore());
|
||||
private medewerker = inject(MedewerkerAdapter);
|
||||
private _session = signal<Principal | null>(restore());
|
||||
|
||||
readonly session = this._session.asReadonly();
|
||||
readonly isAuthenticated = computed(() => this._session() !== null);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const s = this._session();
|
||||
// G1: persist only `naam` — never write the BSN (national ID) to storage.
|
||||
if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam }));
|
||||
const p = this._session();
|
||||
if (p) localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
|
||||
else localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
}
|
||||
|
||||
/** Effectful command: authenticate, then store the session on success. */
|
||||
async login(bsn: string): Promise<Result<string, Session>> {
|
||||
const r = await this.digid.authenticate(bsn);
|
||||
if (r.ok) this._session.set(r.value);
|
||||
return r;
|
||||
/** Effectful command: authenticate via the SSO stand-in, then store the
|
||||
resulting principal. No credential to pass in, and nothing that can fail
|
||||
today — see `MedewerkerAdapter`. */
|
||||
async login(): Promise<Principal> {
|
||||
const p = await this.medewerker.authenticate();
|
||||
this._session.set(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
logout() {
|
||||
|
||||
@@ -1,34 +1,10 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { Capability } from '@shared/domain/capability';
|
||||
import { SessionStore } from './application/session.store';
|
||||
|
||||
/** Route guard: only let authenticated users in; otherwise redirect to /login. */
|
||||
export const authGuard: CanActivateFn = () => {
|
||||
const store = inject(SessionStore);
|
||||
const router = inject(Router);
|
||||
return store.isAuthenticated() ? true : router.createUrlTree(['/login']);
|
||||
};
|
||||
|
||||
/**
|
||||
* Route guard factory (PRD-0002 §6): authenticated AND holding `capability`, else
|
||||
* redirect. Used by the admin pages (`/brief/huisstijl`, `/beheer/stamdata`).
|
||||
* The route guards live in `libs/shared` (ADR-C-006) — they are actor-agnostic, reading
|
||||
* only `SESSION_PORT` and `AccessStore`, so both apps share one copy and one spec.
|
||||
* Re-exported here so `app.routes.ts` keeps importing them from `@auth/auth.guard`:
|
||||
* routing asks the auth context for its guards, which is the right direction to read.
|
||||
*
|
||||
* **Async on purpose:** `can()` is deny-by-default, so it must not be read while `/me`
|
||||
* is still loading — it would deny an entitled admin and bounce them. We await
|
||||
* `AccessStore.whenReady()` (caps resolved) before deciding. An unauthenticated user
|
||||
* goes to `/login`; an authenticated-but-unentitled user goes to `/dashboard` (they're
|
||||
* logged in, just not allowed here — no re-login loop). The backend re-enforces
|
||||
* regardless (403); this guard is the UX pre-gate.
|
||||
* ADR-0002 §3's "auth stays duplicated" still holds for what it actually scopes —
|
||||
* `Principal`, the login flow, `SessionStore`. A guard is neither.
|
||||
*/
|
||||
export function capabilityGuard(capability: Capability): CanActivateFn {
|
||||
return async () => {
|
||||
const session = inject(SessionStore);
|
||||
const access = inject(AccessStore);
|
||||
const router = inject(Router);
|
||||
if (!session.isAuthenticated()) return router.createUrlTree(['/login']);
|
||||
await access.whenReady();
|
||||
return access.can(capability) ? true : router.createUrlTree(['/dashboard']);
|
||||
};
|
||||
}
|
||||
export { authGuard, capabilityGuard } from '@shared/application/auth.guard';
|
||||
|
||||
@@ -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 { FormsModule } from '@angular/forms';
|
||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
|
||||
/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */
|
||||
/**
|
||||
* Organism: employee-SSO-style mock login (ADR-C-004/RB-13). No real auth — and,
|
||||
* unlike the SSP's DigiD form, no credential to enter at all: a Behandelaar has no
|
||||
* BSN, and this app has no password of its own to check either way. There is
|
||||
* nothing to compose beyond one button, which is itself evidence for the ADR — the
|
||||
* two apps' login flows are meant to look this different.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-login-form',
|
||||
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent],
|
||||
imports: [ButtonComponent],
|
||||
template: `
|
||||
<form (ngSubmit)="submitted.emit(bsn)" class="form-horizontal">
|
||||
<div class="form-header">
|
||||
<div class="form-action">
|
||||
<span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span>
|
||||
<div class="form-horizontal">
|
||||
<p i18n="@@login.ssoExplainer">
|
||||
U meldt zich aan via de SSO van uw organisatie — er is geen wachtwoord nodig.
|
||||
</p>
|
||||
<app-button type="button" variant="primary" (click)="submitted.emit()" i18n="@@login.submit">
|
||||
Inloggen met SSO
|
||||
</app-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-form-field
|
||||
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 {
|
||||
bsn = '';
|
||||
password = '';
|
||||
submitted = output<string>();
|
||||
submitted = output<void>();
|
||||
}
|
||||
|
||||
@@ -1,36 +1,35 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { LoginFormComponent } from '@auth/ui/login-form/login-form.component';
|
||||
import { SessionStore } from '@auth/application/session.store';
|
||||
|
||||
/**
|
||||
* No error alert here — unlike the SSP's DigiD form, `SessionStore.login()` has
|
||||
* nothing to fail on (see `MedewerkerAdapter`). A real SSO integration is where
|
||||
* this page would grow one back.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-login-page',
|
||||
imports: [PageShellComponent, AlertComponent, LoginFormComponent],
|
||||
imports: [PageShellComponent, LoginFormComponent],
|
||||
template: `
|
||||
<app-page-shell
|
||||
i18n-heading="@@login.heading"
|
||||
heading="Inloggen"
|
||||
heading="Inloggen bij het behandelportal"
|
||||
width="narrow"
|
||||
i18n-intro="@@login.intro"
|
||||
intro="Log in op uw persoonlijke BIG-register omgeving."
|
||||
intro="Voor medewerkers die aanvragen beoordelen."
|
||||
>
|
||||
@if (error()) {
|
||||
<app-alert type="error">{{ error() }}</app-alert>
|
||||
}
|
||||
<app-login-form (submitted)="login($event)" />
|
||||
<app-login-form (submitted)="login()" />
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
export class LoginPage {
|
||||
private store = inject(SessionStore);
|
||||
private router = inject(Router);
|
||||
error = signal('');
|
||||
|
||||
async login(bsn: string) {
|
||||
const r = await this.store.login(bsn);
|
||||
if (r.ok) this.router.navigate(['/dashboard']);
|
||||
else this.error.set(r.error);
|
||||
async login() {
|
||||
await this.store.login();
|
||||
this.router.navigate(['/dashboard']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { expectTag } from '@shared/testing/expect-tag';
|
||||
import { BesluitState, reduce, initial } from './besluit.machine';
|
||||
|
||||
const editingWith = (besluit: string, toelichting = ''): BesluitState => ({
|
||||
tag: 'Editing',
|
||||
draft: { besluit, toelichting },
|
||||
errors: {},
|
||||
});
|
||||
import { reduce, initial } from './besluit.machine';
|
||||
import { givenBesluit } from './besluit.testing';
|
||||
|
||||
describe('besluit reduce', () => {
|
||||
it('SetField updates the draft while editing', () => {
|
||||
@@ -15,17 +10,23 @@ describe('besluit reduce', () => {
|
||||
});
|
||||
|
||||
it('Submit with no besluit chosen stays Editing and reports a field error', () => {
|
||||
const s = reduce(editingWith(''), { tag: 'Submit' });
|
||||
const s = reduce(initial, { tag: 'Submit' });
|
||||
expect(expectTag(s, 'Editing').errors.besluit).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Submit Afwijzen without a toelichting stays Editing and reports a field error', () => {
|
||||
const s = reduce(editingWith('Afwijzen'), { tag: 'Submit' });
|
||||
const editingAfwijzen = givenBesluit({ tag: 'SetField', key: 'besluit', value: 'Afwijzen' });
|
||||
const s = reduce(editingAfwijzen, { tag: 'Submit' });
|
||||
expect(expectTag(s, 'Editing').errors.toelichting).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Submit Goedkeuren with no toelichting moves to Submitting (optional there)', () => {
|
||||
const s = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
|
||||
const editingGoedkeuren = givenBesluit({
|
||||
tag: 'SetField',
|
||||
key: 'besluit',
|
||||
value: 'Goedkeuren',
|
||||
});
|
||||
const s = reduce(editingGoedkeuren, { tag: 'Submit' });
|
||||
expect(expectTag(s, 'Submitting').data).toEqual({
|
||||
besluit: 'Goedkeuren',
|
||||
toelichting: undefined,
|
||||
@@ -33,7 +34,11 @@ describe('besluit reduce', () => {
|
||||
});
|
||||
|
||||
it('Submit Afwijzen with a toelichting moves to Submitting with the trimmed value', () => {
|
||||
const s = reduce(editingWith('Afwijzen', ' niet erkend '), { tag: 'Submit' });
|
||||
const editingAfwijzenWithToelichting = givenBesluit(
|
||||
{ tag: 'SetField', key: 'besluit', value: 'Afwijzen' },
|
||||
{ tag: 'SetField', key: 'toelichting', value: ' niet erkend ' },
|
||||
);
|
||||
const s = reduce(editingAfwijzenWithToelichting, { tag: 'Submit' });
|
||||
expect(expectTag(s, 'Submitting').data).toEqual({
|
||||
besluit: 'Afwijzen',
|
||||
toelichting: 'niet erkend',
|
||||
@@ -41,24 +46,36 @@ describe('besluit reduce', () => {
|
||||
});
|
||||
|
||||
it('SubmitConfirmed maps Submitting to Submitted', () => {
|
||||
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
|
||||
const submitting = givenBesluit(
|
||||
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
|
||||
{ tag: 'Submit' },
|
||||
);
|
||||
expect(reduce(submitting, { tag: 'SubmitConfirmed' }).tag).toBe('Submitted');
|
||||
});
|
||||
|
||||
it('SubmitFailed maps Submitting to Failed with the error', () => {
|
||||
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
|
||||
const submitting = givenBesluit(
|
||||
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
|
||||
{ tag: 'Submit' },
|
||||
);
|
||||
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
|
||||
expect(failed).toMatchObject({ tag: 'Failed', error: 'boom' });
|
||||
});
|
||||
|
||||
it('Retry re-submits a failure', () => {
|
||||
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
|
||||
const submitting = givenBesluit(
|
||||
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
|
||||
{ tag: 'Submit' },
|
||||
);
|
||||
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
|
||||
expect(reduce(failed, { tag: 'Retry' }).tag).toBe('Submitting');
|
||||
});
|
||||
|
||||
it('Reset returns to the initial editing state', () => {
|
||||
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
|
||||
const submitting = givenBesluit(
|
||||
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
|
||||
{ tag: 'Submit' },
|
||||
);
|
||||
expect(reduce(submitting, { tag: 'Reset' })).toEqual(initial);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
@@ -26,65 +26,33 @@
|
||||
<context context-type="linenumber">27</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="form.verplichteVelden" datatype="html">
|
||||
<source>* verplichte velden</source>
|
||||
<target datatype="html">* required fields</target>
|
||||
<trans-unit id="login.ssoExplainer" datatype="html">
|
||||
<source>U meldt zich aan via de SSO van uw organisatie — er is geen wachtwoord nodig.</source>
|
||||
<target datatype="html">You sign in through your organization's SSO — no password is needed.</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
|
||||
<context context-type="linenumber">15,18</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 context-type="linenumber">17,19</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="login.submit" datatype="html">
|
||||
<source>Inloggen met DigiD</source>
|
||||
<target datatype="html">Log in with DigiD</target>
|
||||
<source>Inloggen met SSO</source>
|
||||
<target datatype="html">Log in with SSO</target>
|
||||
<context-group purpose="location">
|
||||
<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>
|
||||
</trans-unit>
|
||||
<trans-unit id="login.heading" datatype="html">
|
||||
<source>Inloggen</source>
|
||||
<target datatype="html">Log in</target>
|
||||
<source>Inloggen bij het behandelportal</source>
|
||||
<target datatype="html">Log in to the treatment portal</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/auth/ui/login.page.ts</context>
|
||||
<context context-type="linenumber">14,16</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="login.intro" datatype="html">
|
||||
<source>Log in op uw persoonlijke BIG-register omgeving.</source>
|
||||
<target datatype="html">Log in to your personal BIG register environment.</target>
|
||||
<source>Voor medewerkers die aanvragen beoordelen.</source>
|
||||
<target datatype="html">For staff who assess applications.</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/auth/ui/login.page.ts</context>
|
||||
<context context-type="linenumber">17,19</context>
|
||||
@@ -3878,6 +3846,10 @@
|
||||
<source>De functievlaggen konden niet worden geladen.</source>
|
||||
<target datatype="html">The feature flags could not be loaded.</target>
|
||||
</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">
|
||||
<source>Opnieuw proberen</source>
|
||||
<target datatype="html">Try again</target>
|
||||
|
||||
@@ -1,55 +1,50 @@
|
||||
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { Session } from '../domain/session';
|
||||
import { Principal, parseStoredPrincipal } from '../domain/principal';
|
||||
import { DigidAdapter } from '../infrastructure/digid.adapter';
|
||||
|
||||
const STORAGE_KEY = 'session-v1';
|
||||
|
||||
/** Restore a persisted session (best-effort; corrupt entry → logged out).
|
||||
G2: validate the shape before trusting it. G1: the BSN is never persisted
|
||||
(see the effect below), so a restored session carries an empty one — it is
|
||||
unused after login; only `naam` is shown in the chrome. */
|
||||
function restore(): Session | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<Session>;
|
||||
return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
/** Restore a persisted principal (best-effort; corrupt entry → logged out).
|
||||
The parse + shape validation (G1/G2) lives in `parseStoredPrincipal`
|
||||
(`../domain/principal`) — pure, spec'd, and testable without stubbing
|
||||
`localStorage`; this just supplies the raw value. */
|
||||
function restore(): Principal | null {
|
||||
return parseStoredPrincipal(localStorage.getItem(STORAGE_KEY));
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the current session for the whole app. Because it is providedIn:'root'
|
||||
* there is exactly one instance — every component that injects it sees the same
|
||||
* session signal, so logging in is instantly visible everywhere (the guard, the
|
||||
* header, etc.). The session is mirrored to localStorage so a refresh, a deep-link,
|
||||
* or the full-page navigation the language switch performs (nl at `/` ⇄ en at `/en/`,
|
||||
* separate bundles) keeps you logged in. ponytail: localStorage, not sessionStorage —
|
||||
* sessionStorage's per-tab clearing dropped the login on the cross-bundle language
|
||||
* switch. Trade-off: the demo session now survives tab close; a real portal keeps auth
|
||||
* in an httpOnly cookie/token, not web storage.
|
||||
* Holds the current zorgverlener principal for the whole SSP. One
|
||||
* `providedIn: 'root'` instance, so logging in is instantly visible everywhere
|
||||
* (the guard, the header). Persisted to localStorage — a refresh or the
|
||||
* cross-bundle language switch (nl at `/` ⇄ en at `/en/`) keeps you logged in —
|
||||
* but never the BSN itself (G1 in the `effect` below): this principal carries a
|
||||
* citizen's national identifier, which the behandelportal's equivalent store does
|
||||
* not have to guard against, because its `medewerker` principal has no BSN.
|
||||
* ponytail: localStorage, not sessionStorage — sessionStorage's per-tab clearing
|
||||
* dropped the login on the cross-bundle language switch. Trade-off: the demo
|
||||
* session now survives tab close; a real portal keeps auth in an httpOnly
|
||||
* cookie/token, not web storage.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SessionStore {
|
||||
private digid = inject(DigidAdapter);
|
||||
private _session = signal<Session | null>(restore());
|
||||
private _session = signal<Principal | null>(restore());
|
||||
|
||||
readonly session = this._session.asReadonly();
|
||||
readonly isAuthenticated = computed(() => this._session() !== null);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const s = this._session();
|
||||
const p = this._session();
|
||||
// G1: persist only `naam` — never write the BSN (national ID) to storage.
|
||||
if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam }));
|
||||
if (p) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: p.naam }));
|
||||
else localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
}
|
||||
|
||||
/** Effectful command: authenticate, then store the session on success. */
|
||||
async login(bsn: string): Promise<Result<string, Session>> {
|
||||
/** Effectful command: authenticate, then store the principal on success. */
|
||||
async login(bsn: string): Promise<Result<string, Principal>> {
|
||||
const r = await this.digid.authenticate(bsn);
|
||||
if (r.ok) this._session.set(r.value);
|
||||
return r;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,34 +1,10 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { Capability } from '@shared/domain/capability';
|
||||
import { SessionStore } from './application/session.store';
|
||||
|
||||
/** Route guard: only let authenticated users in; otherwise redirect to /login. */
|
||||
export const authGuard: CanActivateFn = () => {
|
||||
const store = inject(SessionStore);
|
||||
const router = inject(Router);
|
||||
return store.isAuthenticated() ? true : router.createUrlTree(['/login']);
|
||||
};
|
||||
|
||||
/**
|
||||
* Route guard factory (PRD-0002 §6): authenticated AND holding `capability`, else
|
||||
* redirect. Used by the admin pages (`/brief/huisstijl`, `/beheer/stamdata`).
|
||||
* The route guards live in `libs/shared` (ADR-C-006) — they are actor-agnostic, reading
|
||||
* only `SESSION_PORT` and `AccessStore`, so both apps share one copy and one spec.
|
||||
* Re-exported here so `app.routes.ts` keeps importing them from `@auth/auth.guard`:
|
||||
* routing asks the auth context for its guards, which is the right direction to read.
|
||||
*
|
||||
* **Async on purpose:** `can()` is deny-by-default, so it must not be read while `/me`
|
||||
* is still loading — it would deny an entitled admin and bounce them. We await
|
||||
* `AccessStore.whenReady()` (caps resolved) before deciding. An unauthenticated user
|
||||
* goes to `/login`; an authenticated-but-unentitled user goes to `/dashboard` (they're
|
||||
* logged in, just not allowed here — no re-login loop). The backend re-enforces
|
||||
* regardless (403); this guard is the UX pre-gate.
|
||||
* ADR-0002 §3's "auth stays duplicated" still holds for what it actually scopes —
|
||||
* `Principal`, the login flow, `SessionStore`. A guard is neither.
|
||||
*/
|
||||
export function capabilityGuard(capability: Capability): CanActivateFn {
|
||||
return async () => {
|
||||
const session = inject(SessionStore);
|
||||
const access = inject(AccessStore);
|
||||
const router = inject(Router);
|
||||
if (!session.isAuthenticated()) return router.createUrlTree(['/login']);
|
||||
await access.whenReady();
|
||||
return access.can(capability) ? true : router.createUrlTree(['/dashboard']);
|
||||
};
|
||||
}
|
||||
export { authGuard, capabilityGuard } from '@shared/application/auth.guard';
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 { Result, ok } from '@shared/kernel/fp';
|
||||
import { parseBsn } from '@shared/kernel/bsn';
|
||||
import { Session } from '../domain/session';
|
||||
import { Principal } from '../domain/principal';
|
||||
|
||||
/** Infrastructure: talks to the (mock) DigiD identity provider. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -9,8 +9,8 @@ export class DigidAdapter {
|
||||
// ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity.
|
||||
// Real BSN validation (parseBsn, WP-40) is the trust boundary; swap the fixed identity
|
||||
// for a real OIDC redirect flow when there's an IdP.
|
||||
async authenticate(bsn: string): Promise<Result<string, Session>> {
|
||||
async authenticate(bsn: string): Promise<Result<string, Principal>> {
|
||||
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 { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { BLOB_PRESENTER, BlobPresenter } from '@shared/application/blob-presenter';
|
||||
import { Brief, BriefDecisions, CaseContext, LetterBlock } from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
||||
import {
|
||||
BRIEF_LOAD_FAILED,
|
||||
BriefAdapter,
|
||||
BriefLoadFailure,
|
||||
BriefView,
|
||||
} from '@brief/infrastructure/brief.adapter';
|
||||
import { LetterPreviewAdapter, PREVIEW_FAILED } from '@brief/infrastructure/letter-preview.adapter';
|
||||
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
|
||||
import { BriefStore } from './brief.store';
|
||||
@@ -48,8 +54,26 @@ const caseContext: CaseContext = {
|
||||
|
||||
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext };
|
||||
|
||||
function setup(adapter: Partial<BriefAdapter>): BriefStore {
|
||||
TestBed.configureTestingModule({ providers: [{ provide: BriefAdapter, useValue: adapter }] });
|
||||
/** A recording fake of BLOB_PRESENTER (RB-28/TE-006) — records every call instead of
|
||||
touching the DOM, so a spec can assert a command's success path directly. */
|
||||
function fakeBlobPresenter() {
|
||||
const opened: Blob[] = [];
|
||||
const presenter: BlobPresenter = {
|
||||
open: (blob) => opened.push(blob),
|
||||
download: () => {
|
||||
throw new Error('not used by BriefStore');
|
||||
},
|
||||
};
|
||||
return { presenter, opened };
|
||||
}
|
||||
|
||||
function setup(adapter: Partial<BriefAdapter>, blobPresenter?: BlobPresenter): BriefStore {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: BriefAdapter, useValue: adapter },
|
||||
...(blobPresenter ? [{ provide: BLOB_PRESENTER, useValue: blobPresenter }] : []),
|
||||
],
|
||||
});
|
||||
return TestBed.inject(BriefStore);
|
||||
}
|
||||
|
||||
@@ -60,7 +84,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
|
||||
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
|
||||
};
|
||||
const store = setup({
|
||||
load: (): Promise<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 }),
|
||||
approve: (): Promise<Result<string, BriefView>> =>
|
||||
Promise.resolve({ ok: true, value: approved }),
|
||||
@@ -79,7 +104,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
|
||||
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
|
||||
};
|
||||
const store = setup({
|
||||
load: (): Promise<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 }),
|
||||
approve: (): Promise<Result<string, BriefView>> =>
|
||||
Promise.resolve({ ok: true, value: approved }),
|
||||
@@ -93,7 +119,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
|
||||
|
||||
it('goes Busy then Failed on a failing transition, surfacing the error', async () => {
|
||||
const store = setup({
|
||||
load: (): Promise<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 }),
|
||||
approve: (): Promise<Result<string, BriefView>> =>
|
||||
Promise.resolve({ ok: false, error: 'niet toegestaan' }),
|
||||
@@ -108,7 +135,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
|
||||
it('a subsequent successful transition clears a prior Failed state', async () => {
|
||||
let approveResult: Result<string, BriefView> = { ok: false, error: 'eerste poging mislukt' };
|
||||
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 }),
|
||||
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> {
|
||||
const ok = (v: BriefView): Promise<Result<string, BriefView>> =>
|
||||
Promise.resolve({ ok: true, value: v });
|
||||
// Untyped return (inferred as the narrow `{ ok: true; value }` literal) so this one
|
||||
// helper satisfies both `load` (error channel `BriefLoadFailure`) and `save` (error
|
||||
// channel `string`) — it only ever produces the `ok: true` branch.
|
||||
const ok = (v: BriefView) => Promise.resolve({ ok: true, value: v } as const);
|
||||
const store = setup({ load: () => ok(filledView), save: () => ok(filledView), ...over });
|
||||
await store.load();
|
||||
return store;
|
||||
@@ -255,8 +285,7 @@ describe('BriefStore rejection diff', () => {
|
||||
...filledBrief,
|
||||
status: { tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'nee' },
|
||||
};
|
||||
const ok = (v: BriefView): Promise<Result<string, BriefView>> =>
|
||||
Promise.resolve({ ok: true, value: v });
|
||||
const ok = (v: BriefView) => Promise.resolve({ ok: true, value: v } as const);
|
||||
const store = setup({
|
||||
load: () => ok({ ...filledView, brief: submitted }),
|
||||
save: () => ok(filledView),
|
||||
@@ -277,41 +306,46 @@ describe('BriefStore rejection diff', () => {
|
||||
});
|
||||
|
||||
describe('BriefStore.previewLetter', () => {
|
||||
// vi.spyOn reuses an existing spy (and its call history) if one is already on
|
||||
// the property — window.open/URL.createObjectURL must be restored between tests.
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('opens the composed letter in a new tab on success', async () => {
|
||||
const store = setup({
|
||||
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||
});
|
||||
it('opens the composed letter via BLOB_PRESENTER on success (RB-28)', async () => {
|
||||
const { presenter, opened } = fakeBlobPresenter();
|
||||
const store = setup(
|
||||
{
|
||||
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
|
||||
Promise.resolve({ ok: true, value: view }),
|
||||
},
|
||||
presenter,
|
||||
);
|
||||
await store.load();
|
||||
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({
|
||||
ok: true,
|
||||
value: blob,
|
||||
});
|
||||
|
||||
await store.previewLetter();
|
||||
expect(open).toHaveBeenCalledWith('blob:mock', '_blank');
|
||||
expect(opened).toEqual([blob]);
|
||||
expect(store.lastError()).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces the error without opening a tab on failure', async () => {
|
||||
const store = setup({
|
||||
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||
});
|
||||
const { presenter, opened } = fakeBlobPresenter();
|
||||
const store = setup(
|
||||
{
|
||||
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
|
||||
Promise.resolve({ ok: true, value: view }),
|
||||
},
|
||||
presenter,
|
||||
);
|
||||
await store.load();
|
||||
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
|
||||
ok: false,
|
||||
error: PREVIEW_FAILED,
|
||||
});
|
||||
|
||||
await store.previewLetter();
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(opened).toHaveLength(0);
|
||||
expect(store.lastError()).toBe(PREVIEW_FAILED);
|
||||
});
|
||||
});
|
||||
@@ -377,3 +411,42 @@ describe('BriefStore.flushPending (CanDeactivate guard / beforeunload)', () => {
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// --- RB-22 (CQ-007 expand half): a 404 from GET /brief tolerates by calling the
|
||||
// existing reset() command, exactly once. Today's backend never 404s (RB-23 adds
|
||||
// that); this fake adapter is what exercises the branch until then. ---
|
||||
|
||||
describe('BriefStore.load — 404 tolerance (RB-22)', () => {
|
||||
const notFound: Result<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 { BlockDiffKind, changedBlocks, diffBlocks } from '@brief/domain/brief-diff';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
||||
import { BRIEF_LOAD_FAILED, BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
||||
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
|
||||
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
|
||||
import { uploadContentUrl } from '@shared/upload/upload.adapter';
|
||||
import { uploadContentUrl } from '@shared/infrastructure/upload.adapter';
|
||||
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
|
||||
import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
|
||||
|
||||
/**
|
||||
* Root singleton for the letter: the Elm store (Model + dispatch), the derived
|
||||
@@ -35,6 +36,7 @@ export class BriefStore implements PendingSave {
|
||||
private adapter = inject(BriefAdapter);
|
||||
private previewAdapter = inject(LetterPreviewAdapter);
|
||||
private revealAdapter = inject(RevealBigNummerAdapter);
|
||||
private blobPresenter = inject(BLOB_PRESENTER);
|
||||
private store = createStore<BriefState, BriefMsg>(initial, reduce);
|
||||
|
||||
readonly model = this.store.model;
|
||||
@@ -119,13 +121,40 @@ export class BriefStore implements PendingSave {
|
||||
return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics());
|
||||
});
|
||||
|
||||
/** True once a 404-triggered recovery has been attempted (RB-22, CQ-007's expand
|
||||
half — see `recoverFromMissingBrief`). This is the structural once-only bound:
|
||||
a repeated 404 falls straight to the `error` branch below and can never reach
|
||||
`adapter.reset()` a second time, regardless of how many times `load()` runs. */
|
||||
private hasRecoveredFromMissingBrief = false;
|
||||
|
||||
async load() {
|
||||
const r = await this.adapter.load();
|
||||
if (r.ok) {
|
||||
this.orgTemplate.set(r.value.orgTemplate);
|
||||
this.caseContext.set(r.value.caseContext);
|
||||
this.applyLoadedView(r.value);
|
||||
} else if (r.error.tag === 'notFound' && !this.hasRecoveredFromMissingBrief) {
|
||||
this.hasRecoveredFromMissingBrief = true;
|
||||
await this.recoverFromMissingBrief();
|
||||
} else {
|
||||
const reason = r.error.tag === 'notFound' ? BRIEF_LOAD_FAILED : r.error.reason;
|
||||
this.store.dispatch({ tag: 'BriefLoadFailed', reason });
|
||||
}
|
||||
}
|
||||
|
||||
private applyLoadedView(view: BriefView) {
|
||||
this.orgTemplate.set(view.orgTemplate);
|
||||
this.caseContext.set(view.caseContext);
|
||||
this.history.clear();
|
||||
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
||||
this.store.dispatch({ tag: 'BriefLoaded', ...view });
|
||||
}
|
||||
|
||||
/** `GET /brief` 404'd — no brief exists yet for this owner. Recover by calling the
|
||||
existing `reset()` command directly (the same POST `resetDemo()` uses) and
|
||||
applying whatever it returns; this NEVER calls `load()` again, so a second 404
|
||||
(e.g. `reset()` itself failing) cannot loop back into this method. */
|
||||
private async recoverFromMissingBrief() {
|
||||
const r = await this.adapter.reset();
|
||||
if (r.ok) {
|
||||
this.applyLoadedView(r.value);
|
||||
} else {
|
||||
this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
|
||||
}
|
||||
@@ -217,8 +246,8 @@ export class BriefStore implements PendingSave {
|
||||
send = () => this.transition(() => this.adapter.send());
|
||||
|
||||
/** Explicit action, never a live re-render (PRD §8): opens the server-composed
|
||||
letter in a new tab. ponytail: the blob URL is never revoked — it's cheap and
|
||||
the tab outlives this call; not worth a teardown hook for a POC. */
|
||||
letter in a new tab via `BLOB_PRESENTER.open` — see its doc comment for why the
|
||||
object URL is never revoked. */
|
||||
async previewLetter() {
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
const r = await this.previewAdapter.preview();
|
||||
@@ -227,15 +256,18 @@ export class BriefStore implements PendingSave {
|
||||
return;
|
||||
}
|
||||
this.actionState.set({ tag: 'Idle' });
|
||||
window.open(URL.createObjectURL(r.value), '_blank');
|
||||
this.blobPresenter.open(r.value);
|
||||
}
|
||||
|
||||
/** Reveal the masked case BIG-nummer (PRD-0002 §5c). Server re-checks the capability
|
||||
+ step-up and audits the attempt; on success we swap the masked value in the
|
||||
already-loaded caseContext (a field update, not a reload). The step-up gesture
|
||||
itself is the UI's concern — this command just runs the audited server call. */
|
||||
itself is the UI's concern (`behandel-scherm.component.ts`'s `onReveal()` confirm)
|
||||
— this command is only reachable once that gesture has happened, so it is the one
|
||||
that tells the adapter to send `X-Step-Up` (BIO-006a: the adapter itself no longer
|
||||
hardcodes the header). */
|
||||
async revealBigNummer() {
|
||||
const r = await this.revealAdapter.reveal();
|
||||
const r = await this.revealAdapter.reveal(true);
|
||||
if (!r.ok) {
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
return;
|
||||
|
||||
@@ -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 { createDebouncedSave } from '@shared/application/debounced-save';
|
||||
import { machineRemoteData } from '@shared/application/machine-remote-data';
|
||||
import { UploadAdapter } from '@shared/upload/upload.adapter';
|
||||
import { UploadShellService } from '@shared/upload/upload-shell.service';
|
||||
import { UploadMsg, initialUpload, rejectReason } from '@shared/upload/upload.machine';
|
||||
import { UploadAdapter, uploadContentUrl } from '@shared/infrastructure/upload.adapter';
|
||||
import { UploadShellService } from '@shared/application/upload-shell.service';
|
||||
import { UploadMsg, initialUpload, rejectReason } from '@shared/domain/upload.machine';
|
||||
import {
|
||||
MARGIN_MAX_MM,
|
||||
MARGIN_MIN_MM,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@brief/domain/org-template.machine';
|
||||
import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
|
||||
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
|
||||
import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
|
||||
|
||||
type LoadedState = Extract<OrgTemplateState, { tag: 'loaded' }>;
|
||||
|
||||
@@ -38,6 +39,7 @@ export class OrgTemplateStore implements PendingSave {
|
||||
private adapter = inject(OrgTemplateAdapter);
|
||||
private uploadAdapter = inject(UploadAdapter);
|
||||
private shell = inject(UploadShellService);
|
||||
private blobPresenter = inject(BLOB_PRESENTER);
|
||||
private store = createStore<OrgTemplateState, OrgTemplateMsg>(initial, reduce);
|
||||
|
||||
readonly model = this.store.model;
|
||||
@@ -72,6 +74,9 @@ export class OrgTemplateStore implements PendingSave {
|
||||
return id ? this.uploadAdapter.contentUrl(id) : null;
|
||||
});
|
||||
|
||||
/** Preview/download link for any completed upload in the editor's document list. */
|
||||
readonly previewUrlFor = (documentId: string): string | undefined => uploadContentUrl(documentId);
|
||||
|
||||
/** Client-side mirror of the server rules (`OrgTemplateRules`) for instant feedback;
|
||||
the server re-validates and stays the authority — publish is gated on this. */
|
||||
readonly draftValid = computed(() => {
|
||||
@@ -214,7 +219,7 @@ export class OrgTemplateStore implements PendingSave {
|
||||
return;
|
||||
}
|
||||
this.actionState.set({ tag: 'Idle' });
|
||||
window.open(URL.createObjectURL(r.value), '_blank');
|
||||
this.blobPresenter.open(r.value);
|
||||
}
|
||||
|
||||
// --- logo upload (reuses the shared upload transport; single `org-logo` file) ---
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Besluit, Brief, BriefDecisions, BriefStatus, LibraryPassage } from './b
|
||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||
import { PlaceholderDef } from './placeholders';
|
||||
import { BriefState, reduce } from './brief.machine';
|
||||
import { givenBrief } from './brief.testing';
|
||||
|
||||
const placeholders: PlaceholderDef[] = [
|
||||
{ key: 'naam', label: 'Naam', autoResolvable: true },
|
||||
@@ -64,11 +65,11 @@ const decisions: BriefDecisions = {
|
||||
canRevealBigNummer: true,
|
||||
};
|
||||
|
||||
const loaded = (
|
||||
status: BriefStatus = { tag: 'draft' },
|
||||
sections?: Brief['sections'],
|
||||
): BriefState => ({
|
||||
tag: 'loaded',
|
||||
// Replays a real `BriefLoaded` message through the real `reduce` (ADR-0006 §2)
|
||||
// instead of hand-assembling the 'loaded' state directly.
|
||||
const loaded = (status: BriefStatus = { tag: 'draft' }, sections?: Brief['sections']): BriefState =>
|
||||
givenBrief({
|
||||
tag: 'BriefLoaded',
|
||||
brief: briefWith(status, sections),
|
||||
availablePassages: lib,
|
||||
decisions,
|
||||
@@ -128,12 +129,12 @@ describe('brief.machine reduce', () => {
|
||||
|
||||
it('BesluitSelected deep-copies content — later library mutation does not leak in', () => {
|
||||
const passage = libPassage('intro', 'kern'); // shared → offered for any besluit
|
||||
const st: BriefState = {
|
||||
tag: 'loaded',
|
||||
const st = givenBrief({
|
||||
tag: 'BriefLoaded',
|
||||
brief: briefWith({ tag: 'draft' }),
|
||||
availablePassages: [passage],
|
||||
decisions,
|
||||
};
|
||||
});
|
||||
const s = reduce(st, besluit('positief'));
|
||||
// Mutate the source passage object after composition.
|
||||
(passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED';
|
||||
|
||||
@@ -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 { OrgTemplate, OrgTemplateAdminView } from './org-template';
|
||||
import { OrgTemplateState, reduce } from './org-template.machine';
|
||||
import { DocumentCategory } from '@shared/upload/upload.machine';
|
||||
import { DocumentCategory } from '@shared/domain/upload.machine';
|
||||
|
||||
const template: OrgTemplate = {
|
||||
subOrgId: 'cibg-registers',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { assertNever } from '@shared/kernel/fp';
|
||||
import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from './org-template';
|
||||
import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/upload/upload.machine';
|
||||
import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/domain/upload.machine';
|
||||
|
||||
/**
|
||||
* The admin org-template editor as one Elm-style machine (WP-26, PRD Brief v2 §5) —
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { runSubmit } from '@shared/application/submit';
|
||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||
import {
|
||||
ApiClient,
|
||||
BriefDecisionsDto,
|
||||
@@ -33,8 +34,13 @@ import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/ric
|
||||
* The only place brief HTTP lives (ADR-0001 anti-corruption boundary). The wire
|
||||
* uses FLAT unions (a `type`/`tag` string + nullable fields, the repo convention);
|
||||
* the `parse*` boundary narrows them into the domain's proper discriminated unions
|
||||
* and rejects malformed shapes. Mutations go through `runSubmit` (ProblemDetails →
|
||||
* error string), then parse the returned brief.
|
||||
* and rejects malformed shapes. Every mutation folds through `runSubmit`
|
||||
* (ProblemDetails → error string, plus the Idempotency-Key mint), then parses the
|
||||
* returned brief. `load` (the only read) does its own try/catch instead of the
|
||||
* shared `runResult` fold, because it needs one extra bit `runResult` throws away:
|
||||
* whether the failure was an HTTP 404 (see `BriefLoadFailure` — RB-22, CQ-007's
|
||||
* expand half). Today's backend never 404s `GET /brief` (RB-23 adds that), so the
|
||||
* `notFound` branch is unreached until RB-23 ships; this adapter is ready in advance.
|
||||
*/
|
||||
|
||||
export interface BriefView {
|
||||
@@ -45,16 +51,39 @@ export interface BriefView {
|
||||
readonly caseContext: CaseContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why `load()` did not return a brief. `notFound` is a bare HTTP 404 — kept
|
||||
* distinct from every other failure so `BriefStore.load()` can tolerate it (call
|
||||
* `reset()` instead of showing an error banner) without conflating it with a real
|
||||
* failure. See the class docstring above.
|
||||
*/
|
||||
export type BriefLoadFailure =
|
||||
{ readonly tag: 'notFound' } | { readonly tag: 'error'; readonly reason: string };
|
||||
|
||||
export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`;
|
||||
export const BRIEF_ACTION_FAILED = $localize`:@@brief.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;
|
||||
|
||||
/** True when the thrown value carries an HTTP 404 status — matches both the
|
||||
generic `SwaggerException` (today's shape, since `GET /brief` declares no 404
|
||||
response yet) and a parsed `ProblemDetails` (RFC 7807 `status`, the shape once
|
||||
RB-23 gives the endpoint a documented 404 response). */
|
||||
function isHttpNotFound(e: unknown): boolean {
|
||||
return !!e && typeof e === 'object' && (e as { status?: unknown }).status === 404;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BriefAdapter {
|
||||
private client = inject(ApiClient);
|
||||
|
||||
async load(): Promise<Result<string, BriefView>> {
|
||||
const r = await runSubmit(() => this.client.briefGET(), BRIEF_LOAD_FAILED);
|
||||
return r.ok ? parseBriefView(r.value) : r;
|
||||
async load(): Promise<Result<BriefLoadFailure, BriefView>> {
|
||||
try {
|
||||
const dto = await this.client.briefGET();
|
||||
const parsed = parseBriefView(dto);
|
||||
return parsed.ok ? ok(parsed.value) : err({ tag: 'error', reason: parsed.error });
|
||||
} catch (e) {
|
||||
if (isHttpNotFound(e)) return err({ tag: 'notFound' });
|
||||
return err({ tag: 'error', reason: problemDetail(e, BRIEF_LOAD_FAILED) });
|
||||
}
|
||||
}
|
||||
|
||||
async save(sections: readonly LetterSection[]): Promise<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 { currentRole } from '@shared/infrastructure/role';
|
||||
import { currentSubject } from '@shared/infrastructure/subject';
|
||||
@@ -15,7 +15,10 @@ export const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning
|
||||
* hand-written fetch, not the `ApiClient`. That also means it bypasses `HttpClient`'s
|
||||
* `roleInterceptor` AND `subjectInterceptor`, so both `X-Role` and `X-Subject` are set
|
||||
* here explicitly (WP-74 — without `X-Subject` this always previewed
|
||||
* `DocumentStore.DemoOwner`'s letter regardless of who was actually logged in).
|
||||
* `DocumentStore.DemoOwner`'s letter regardless of who was actually logged in). Both are
|
||||
* dev-only identity stand-ins (`role.ts`/`subject.ts`) and are sent only under
|
||||
* `isDevMode()`, mirroring how the interceptors themselves are only registered in dev
|
||||
* (`app.config.ts`) — a production build sends neither header from this call (BIO-012).
|
||||
*
|
||||
* `cache: 'no-store'` (WP-74): the endpoint has no `Cache-Control`, only a CORS-driven
|
||||
* `Vary: Origin`, and its content changes at the SAME URL as the letter moves
|
||||
@@ -43,7 +46,9 @@ export class LetterPreviewAdapter {
|
||||
const subject = currentSubject();
|
||||
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/preview`, {
|
||||
cache: 'no-store',
|
||||
headers: { 'X-Role': currentRole(), ...(subject ? { 'X-Subject': subject } : {}) },
|
||||
headers: isDevMode()
|
||||
? { 'X-Role': currentRole(), ...(subject ? { 'X-Subject': subject } : {}) }
|
||||
: {},
|
||||
});
|
||||
} catch {
|
||||
return err(PREVIEW_FAILED);
|
||||
@@ -53,7 +58,9 @@ export class LetterPreviewAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async function errorMessage(res: Response): Promise<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 {
|
||||
return problemDetail(await res.json(), PREVIEW_FAILED);
|
||||
} catch {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { OrgTemplateAdminViewDto, OrgTemplateDto } from '@shared/infrastructure/api-client';
|
||||
import { parseOrgTemplateAdminView } from './org-template.adapter';
|
||||
import {
|
||||
parseOrgTemplateAdminView,
|
||||
proefbriefErrorMessage,
|
||||
PROEFBRIEF_FAILED,
|
||||
} from './org-template.adapter';
|
||||
|
||||
const draft: OrgTemplateDto = {
|
||||
subOrgId: 'cibg-registers',
|
||||
@@ -54,3 +58,25 @@ describe('parseOrgTemplateAdminView', () => {
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Minimal Response stand-in — proefbriefErrorMessage only calls `.json()`. Avoids
|
||||
// stubbing globalThis.fetch to reach this trust boundary (TE-002).
|
||||
const fakeResponse = (body: unknown): Response =>
|
||||
({ json: () => Promise.resolve(body) }) as unknown as Response;
|
||||
|
||||
describe('proefbriefErrorMessage (TE-002 trust boundary)', () => {
|
||||
it('surfaces the ProblemDetails detail when present', async () => {
|
||||
expect(
|
||||
await proefbriefErrorMessage(fakeResponse({ detail: 'Niet gevonden.', status: 404 })),
|
||||
).toBe('Niet gevonden.');
|
||||
});
|
||||
|
||||
it('falls back to PROEFBRIEF_FAILED when the body has no detail', async () => {
|
||||
expect(await proefbriefErrorMessage(fakeResponse({ status: 500 }))).toBe(PROEFBRIEF_FAILED);
|
||||
});
|
||||
|
||||
it('falls back to PROEFBRIEF_FAILED when the body is not JSON', async () => {
|
||||
const res = { json: () => Promise.reject(new Error('not json')) } as unknown as Response;
|
||||
expect(await proefbriefErrorMessage(res)).toBe(PROEFBRIEF_FAILED);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Injectable, inject, isDevMode } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { runSubmit } from '@shared/application/submit';
|
||||
import { runResult, runSubmit } from '@shared/application/submit';
|
||||
import { currentRole } from '@shared/infrastructure/role';
|
||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||
import { environment } from '@shared/environments/environment';
|
||||
@@ -26,17 +26,20 @@ import { parseOrgTemplate } from '@brief/infrastructure/brief.adapter';
|
||||
* rollback go through the generated client (X-Role added by `roleInterceptor`);
|
||||
* `parse*` narrows the untrusted wire shape. The proefbrief is `text/html` and
|
||||
* `ExcludeFromDescription`'d — a hand-written fetch, same seam as `letter-preview.adapter`.
|
||||
* `X-Role` there is a dev-only identity stand-in (`role.ts`) and is sent only under
|
||||
* `isDevMode()`, mirroring `roleInterceptor`'s own dev-only registration — a production
|
||||
* build never sends it from this hand-written call either (BIO-012).
|
||||
*/
|
||||
|
||||
const FAILED = $localize`:@@orgTemplate.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;
|
||||
const PROEFBRIEF_FAILED = $localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`;
|
||||
export const PROEFBRIEF_FAILED = $localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class OrgTemplateAdapter {
|
||||
private client = inject(ApiClient);
|
||||
|
||||
async list(): Promise<Result<string, SubOrgSummary[]>> {
|
||||
const r = await runSubmit(() => this.client.orgTemplates(), FAILED);
|
||||
const r = await runResult(() => this.client.orgTemplates(), FAILED);
|
||||
if (!r.ok) return r;
|
||||
const out: SubOrgSummary[] = [];
|
||||
for (const s of r.value ?? []) {
|
||||
@@ -48,7 +51,7 @@ export class OrgTemplateAdapter {
|
||||
}
|
||||
|
||||
async load(subOrgId: string): Promise<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;
|
||||
}
|
||||
|
||||
@@ -76,22 +79,26 @@ export class OrgTemplateAdapter {
|
||||
try {
|
||||
res = await fetch(
|
||||
`${environment.apiBaseUrl}/api/v1/admin/org-template/${encodeURIComponent(subOrgId)}/preview`,
|
||||
{ headers: { 'X-Role': currentRole() } },
|
||||
{ headers: isDevMode() ? { 'X-Role': currentRole() } : {} },
|
||||
);
|
||||
} catch {
|
||||
return err(PROEFBRIEF_FAILED);
|
||||
}
|
||||
if (!res.ok) {
|
||||
try {
|
||||
return err(problemDetail(await res.json(), PROEFBRIEF_FAILED));
|
||||
} catch {
|
||||
return err(PROEFBRIEF_FAILED);
|
||||
}
|
||||
}
|
||||
if (!res.ok) return err(await proefbriefErrorMessage(res));
|
||||
return ok(await res.blob());
|
||||
}
|
||||
}
|
||||
|
||||
/** Trust boundary (TE-002): maps a non-OK proefbrief response to a message. Exported
|
||||
so a spec can call it directly instead of stubbing `globalThis.fetch`. */
|
||||
export async function proefbriefErrorMessage(res: Response): Promise<string> {
|
||||
try {
|
||||
return problemDetail(await res.json(), PROEFBRIEF_FAILED);
|
||||
} catch {
|
||||
return PROEFBRIEF_FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
// --- parse: wire → domain, validating at the boundary ---
|
||||
|
||||
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,36 +1,52 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, isDevMode } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { currentRole } from '@shared/infrastructure/role';
|
||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||
import { environment } from '@shared/environments/environment';
|
||||
|
||||
const REVEAL_FAILED = $localize`:@@brief.reveal.failed:Het BIG-nummer kon niet worden getoond.`;
|
||||
/** Exported so specs can assert against the same message id instead of retyping the
|
||||
Dutch sentence (matches `letter-preview.adapter.ts`'s `PREVIEW_FAILED`). */
|
||||
export const REVEAL_FAILED = $localize`:@@brief.reveal.failed:Het BIG-nummer kon niet worden getoond.`;
|
||||
|
||||
/**
|
||||
* Field-level PII reveal (PRD-0002 §5c). The case screen ships the BIG-nummer masked;
|
||||
* this unmasks it, gated server-side by the reveal capability AND a step-up. The
|
||||
* step-up is stubbed as the `X-Step-Up` header — the caller sends it only after the
|
||||
* user's confirm gesture, so a plain call (or a role without the capability) 403s.
|
||||
* step-up is stubbed as the `X-Step-Up` header, sent only when the caller passes
|
||||
* `stepUp: true` — `BriefStore.revealBigNummer()` is the only caller and it is only
|
||||
* ever reachable after `behandel-scherm.component.ts`'s `onReveal()` confirm gesture,
|
||||
* so the header now reflects that gesture instead of being a constant baked into this
|
||||
* adapter (BIO-006a — a call that skips confirmation sends no step-up at all).
|
||||
*
|
||||
* Hand-written fetch (not the `ApiClient`) because the call needs a per-request header;
|
||||
* `.ExcludeFromDescription()` on the endpoint keeps the generated client JSON-only, the
|
||||
* same seam as `/brief/preview` and uploads — which also means `X-Role` is set here.
|
||||
* same seam as `/brief/preview` and uploads. `X-Role` is a dev-only identity stand-in
|
||||
* (see `role.ts`) and is therefore only sent under `isDevMode()`, mirroring the
|
||||
* `roleInterceptor` registration in `app.config.ts` — a production build never sends it
|
||||
* from this hand-written call either (BIO-012).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RevealBigNummerAdapter {
|
||||
async reveal(): Promise<Result<string, string>> {
|
||||
async reveal(stepUp: boolean): Promise<Result<string, string>> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/reveal-bignummer`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Role': currentRole(), 'X-Step-Up': 'true' },
|
||||
headers: {
|
||||
...(isDevMode() ? { 'X-Role': currentRole() } : {}),
|
||||
...(stepUp ? { 'X-Step-Up': 'true' } : {}),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return err(REVEAL_FAILED);
|
||||
}
|
||||
if (!res.ok) return err(await errorMessage(res));
|
||||
const body: unknown = await res.json().catch(() => null);
|
||||
// Trust boundary: validate the shape before handing back a plain string.
|
||||
return parseRevealed(await res.json().catch(() => null));
|
||||
}
|
||||
}
|
||||
|
||||
/** Trust boundary: validate the untrusted response shape before handing back a plain
|
||||
string (TE-002) — exported so a spec can call it without stubbing `globalThis.fetch`. */
|
||||
export function parseRevealed(body: unknown): Result<string, string> {
|
||||
if (
|
||||
typeof body === 'object' &&
|
||||
body !== null &&
|
||||
@@ -40,7 +56,6 @@ export class RevealBigNummerAdapter {
|
||||
}
|
||||
return err(REVEAL_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
async function errorMessage(res: Response): Promise<string> {
|
||||
try {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component';
|
||||
import { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.component';
|
||||
import { UploadState } from '@shared/upload/upload.machine';
|
||||
import { UploadState } from '@shared/domain/upload.machine';
|
||||
import { Brief } from '@brief/domain/brief';
|
||||
import {
|
||||
MARGIN_MAX_MM,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { OrgTemplateEditorComponent } from './org-template-editor.component';
|
||||
import { OrgTemplate, OrgTemplateVersion, SubOrgSummary } from '@brief/domain/org-template';
|
||||
import { UploadState, initialUpload } from '@shared/upload/upload.machine';
|
||||
import { UploadState, initialUpload } from '@shared/domain/upload.machine';
|
||||
|
||||
const draft: OrgTemplate = {
|
||||
subOrgId: 'cibg-registers',
|
||||
|
||||
@@ -4,7 +4,6 @@ import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { ASYNC } from '@shared/ui/async/async.component';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { UploadAdapter } from '@shared/upload/upload.adapter';
|
||||
import { OrgTemplateStore } from '@brief/application/org-template.store';
|
||||
import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component';
|
||||
|
||||
@@ -86,10 +85,9 @@ import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-te
|
||||
export class OrgTemplatePage {
|
||||
protected store = inject(OrgTemplateStore);
|
||||
protected access = inject(AccessStore);
|
||||
private uploadAdapter = inject(UploadAdapter);
|
||||
|
||||
protected canEdit = computed(() => this.access.can('orgtemplate:edit'));
|
||||
protected previewUrlFor = (documentId: string) => this.uploadAdapter.contentUrl(documentId);
|
||||
protected previewUrlFor = this.store.previewUrlFor;
|
||||
|
||||
protected heading = $localize`:@@orgTemplate.page.heading:Huisstijl beheren`;
|
||||
protected intro = $localize`:@@orgTemplate.page.intro:Beheer per organisatieonderdeel het uiterlijk van de brief: logo, afzender, ondertekening, voettekst en marges.`;
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
reduceUpload,
|
||||
requiredCategoriesSatisfied,
|
||||
deliveryRefs,
|
||||
} from '@shared/upload/upload.machine';
|
||||
} from '@shared/domain/upload.machine';
|
||||
|
||||
/** What the user is typing (raw, possibly invalid). */
|
||||
export interface Draft {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, it, expect } from 'vitest';
|
||||
import { ok, err } from '@shared/kernel/fp';
|
||||
import { expectTag } from '@shared/testing/expect-tag';
|
||||
import {
|
||||
Answers,
|
||||
initial,
|
||||
STEPS,
|
||||
lageUren,
|
||||
@@ -15,14 +14,7 @@ import {
|
||||
reduce,
|
||||
IntakeState,
|
||||
} from './intake.machine';
|
||||
|
||||
const answering = (answers: Answers, cursor = 0, scholingThreshold = 1000): IntakeState => ({
|
||||
tag: 'Answering',
|
||||
answers,
|
||||
cursor,
|
||||
errors: {},
|
||||
scholingThreshold,
|
||||
});
|
||||
import { givenIntake } from './intake.testing';
|
||||
|
||||
describe('STEPS (fixed) and inline questions', () => {
|
||||
it('always has the same three steps', () => {
|
||||
@@ -31,12 +23,12 @@ describe('STEPS (fixed) and inline questions', () => {
|
||||
|
||||
it('reveals the buitenland detail questions inline only when worked abroad', () => {
|
||||
// No new step; instead these fields become required within the buitenland step.
|
||||
expect(next(answering({ buitenlandGewerkt: 'ja' })).tag).toBe('Answering'); // land/uren missing -> blocked
|
||||
expect(
|
||||
expectTag(next(answering({ buitenlandGewerkt: 'ja' })), 'Answering').errors.land,
|
||||
).toBeTruthy();
|
||||
expect(next(answering({ buitenlandGewerkt: 'nee' })).tag).toBe('Answering'); // valid, advances (cursor moves)
|
||||
expect(expectTag(next(answering({ buitenlandGewerkt: 'nee' })), 'Answering').cursor).toBe(1);
|
||||
const abroad = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' });
|
||||
expect(next(abroad).tag).toBe('Answering'); // land/uren missing -> blocked
|
||||
expect(expectTag(next(abroad), 'Answering').errors.land).toBeTruthy();
|
||||
const domestic = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' });
|
||||
expect(next(domestic).tag).toBe('Answering'); // valid, advances (cursor moves)
|
||||
expect(expectTag(next(domestic), 'Answering').cursor).toBe(1);
|
||||
});
|
||||
|
||||
it('reveals the scholing question only when NL-hours are below the threshold', () => {
|
||||
@@ -49,9 +41,13 @@ describe('STEPS (fixed) and inline questions', () => {
|
||||
expect(lageUren({ uren: '1500' }, 1000)).toBe(false);
|
||||
expect(lageUren({ uren: '1500' }, 2000)).toBe(true);
|
||||
// And the threshold from state flows through submit:
|
||||
const lowThreshold = submit(
|
||||
answering({ buitenlandGewerkt: 'nee', uren: '1500', punten: '200' }, 0, 2000),
|
||||
const lowThresholdState = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '1500' },
|
||||
{ tag: 'SetAnswer', key: 'punten', value: '200' },
|
||||
{ tag: 'SetPolicy', scholingThreshold: 2000 },
|
||||
);
|
||||
const lowThreshold = submit(lowThresholdState);
|
||||
expect(lowThreshold.tag).toBe('Answering'); // scholing now required (1500 < 2000), unanswered → blocked
|
||||
expect(expectTag(lowThreshold, 'Answering').errors.scholingGevolgd).toBeTruthy();
|
||||
});
|
||||
@@ -65,18 +61,21 @@ describe('navigation', () => {
|
||||
});
|
||||
|
||||
it('Next advances once the step is valid', () => {
|
||||
const s = expectTag(next(answering({ buitenlandGewerkt: 'nee' })), 'Answering');
|
||||
const domestic = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' });
|
||||
const s = expectTag(next(domestic), 'Answering');
|
||||
expect(s.cursor).toBe(1);
|
||||
expect(currentStep(s)).toBe('werk');
|
||||
});
|
||||
|
||||
it('editing an answer leaves the cursor fixed (steps never collapse)', () => {
|
||||
const atWerk = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' },
|
||||
{ tag: 'SetAnswer', key: 'land', value: 'België' },
|
||||
{ tag: 'SetAnswer', key: 'buitenlandseUren', value: '300' },
|
||||
{ tag: 'Next' }, // buitenland step valid -> cursor 0 -> 1
|
||||
);
|
||||
const edited = expectTag(
|
||||
reduce(answering({ buitenlandGewerkt: 'ja' }, 1), {
|
||||
tag: 'SetAnswer',
|
||||
key: 'buitenlandGewerkt',
|
||||
value: 'nee',
|
||||
}),
|
||||
reduce(atWerk, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }),
|
||||
'Answering',
|
||||
);
|
||||
expect(edited.cursor).toBe(1); // cursor untouched; only inline questions change
|
||||
@@ -87,57 +86,86 @@ describe('navigation', () => {
|
||||
});
|
||||
|
||||
it('gaNaarStap jumps back to an earlier step, clearing errors', () => {
|
||||
const s = answering({ buitenlandGewerkt: 'nee' }, 2);
|
||||
const s = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'Next' }, // cursor 0 -> 1
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '4160' },
|
||||
{ tag: 'Next' }, // cursor 1 -> 2
|
||||
);
|
||||
expect(expectTag(gaNaarStap(s, 0), 'Answering').cursor).toBe(0);
|
||||
});
|
||||
|
||||
it('gaNaarStap ignores a same/forward jump and jumps outside Answering', () => {
|
||||
const s = answering({ buitenlandGewerkt: 'nee' }, 1);
|
||||
const s = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'Next' }, // cursor 0 -> 1
|
||||
);
|
||||
expect(gaNaarStap(s, 1)).toBe(s); // same step -> no-op
|
||||
expect(gaNaarStap(s, 2)).toBe(s); // forward -> no-op
|
||||
const submitting = submit(answering({ buitenlandGewerkt: 'nee', uren: '4160' }, 2));
|
||||
const atReview = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'Next' }, // cursor 0 -> 1
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '4160' },
|
||||
{ tag: 'Next' }, // cursor 1 -> 2
|
||||
);
|
||||
const submitting = submit(atReview);
|
||||
expect(gaNaarStap(submitting, 0)).toBe(submitting); // not Answering -> no-op
|
||||
});
|
||||
});
|
||||
|
||||
describe('submit', () => {
|
||||
// High hours: no scholing question, so no punten is asked or collected.
|
||||
const complete: Answers = { buitenlandGewerkt: 'nee', uren: '4160' };
|
||||
const highUren = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '4160' },
|
||||
);
|
||||
|
||||
it('reaches Submitting ONLY with valid answers', () => {
|
||||
// Bad punten only blocks when scholing was followed (otherwise punten is ignored).
|
||||
expect(
|
||||
submit(
|
||||
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: 'x' }),
|
||||
).tag,
|
||||
).toBe('Answering');
|
||||
const good = expectTag(submit(answering(complete)), 'Submitting');
|
||||
const badPunten = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '500' },
|
||||
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
|
||||
{ tag: 'SetAnswer', key: 'punten', value: 'x' },
|
||||
);
|
||||
expect(submit(badPunten).tag).toBe('Answering');
|
||||
const good = expectTag(submit(highUren), 'Submitting');
|
||||
expect(good.data.uren).toBe(4160);
|
||||
expect(good.data.punten).toBeUndefined(); // not collected without scholing
|
||||
});
|
||||
|
||||
it('punten is required only when aanvullende scholing was gevolgd', () => {
|
||||
// scholing = ja but punten missing -> blocked on punten.
|
||||
const missing = expectTag(
|
||||
submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja' })),
|
||||
'Answering',
|
||||
const scholingJaNoPunten = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '500' },
|
||||
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
|
||||
);
|
||||
const missing = expectTag(submit(scholingJaNoPunten), 'Answering');
|
||||
expect(missing.errors.punten).toBeTruthy();
|
||||
// scholing = nee -> punten not required, submits without it.
|
||||
expect(
|
||||
submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'nee' })).tag,
|
||||
).toBe('Submitting');
|
||||
const scholingNee = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '500' },
|
||||
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'nee' },
|
||||
);
|
||||
expect(submit(scholingNee).tag).toBe('Submitting');
|
||||
});
|
||||
|
||||
it('low hours requires the scholing answer before submit', () => {
|
||||
const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500' }));
|
||||
expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered
|
||||
const withScholing = expectTag(
|
||||
submit(
|
||||
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: '200' }),
|
||||
),
|
||||
'Submitting',
|
||||
const lowUrenNoScholing = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '500' },
|
||||
);
|
||||
const noScholing = submit(lowUrenNoScholing);
|
||||
expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered
|
||||
const lowUrenWithScholing = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '500' },
|
||||
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
|
||||
{ tag: 'SetAnswer', key: 'punten', value: '200' },
|
||||
);
|
||||
const withScholing = expectTag(submit(lowUrenWithScholing), 'Submitting');
|
||||
expect(withScholing.data.aanvullendeScholing).toBe(true);
|
||||
expect(withScholing.data.punten).toBe(200);
|
||||
});
|
||||
@@ -145,38 +173,36 @@ describe('submit', () => {
|
||||
it('does not require punten for a hidden question (WP-69 §6)', () => {
|
||||
// scholingGevolgd is a stale 'ja' from when uren was low, but uren is now above
|
||||
// threshold — the template hides the question, so punten must not be required either.
|
||||
const good = expectTag(
|
||||
submit(answering({ buitenlandGewerkt: 'nee', uren: '1500', scholingGevolgd: 'ja' })),
|
||||
'Submitting',
|
||||
const staleScholingNoPunten = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '1500' },
|
||||
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
|
||||
);
|
||||
const good = expectTag(submit(staleScholingNoPunten), 'Submitting');
|
||||
expect(good.data.aanvullendeScholing).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops punten when raising uren hides the question (WP-69 §6)', () => {
|
||||
// Same stale answer, but this time punten was also filled in while uren was low.
|
||||
const good = expectTag(
|
||||
submit(
|
||||
answering({
|
||||
buitenlandGewerkt: 'nee',
|
||||
uren: '1500',
|
||||
scholingGevolgd: 'ja',
|
||||
punten: '150',
|
||||
}),
|
||||
),
|
||||
'Submitting',
|
||||
const staleScholingWithPunten = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '1500' },
|
||||
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
|
||||
{ tag: 'SetAnswer', key: 'punten', value: '150' },
|
||||
);
|
||||
const good = expectTag(submit(staleScholingWithPunten), 'Submitting');
|
||||
// ValidIntake stays honest: neither the stale 'ja' nor its punten leak through.
|
||||
expect(good.data.aanvullendeScholing).toBeUndefined();
|
||||
expect(good.data.punten).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolve maps Submitting to Submitted on a successful submit', () => {
|
||||
const submitting = submit(answering(complete));
|
||||
const submitting = submit(highUren);
|
||||
expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
|
||||
});
|
||||
|
||||
it('resolve maps Submitting to Failed on a failed submit', () => {
|
||||
const submitting = submit(answering(complete));
|
||||
const submitting = submit(highUren);
|
||||
expect(resolve(submitting, err('boom')).tag).toBe('Failed');
|
||||
});
|
||||
});
|
||||
|
||||
+6
-7
@@ -23,9 +23,8 @@ import {
|
||||
} from '@herregistratie/domain/herregistratie.machine';
|
||||
import { createDraftSync } from '@registratie/application/draft-sync';
|
||||
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
|
||||
import { createUploadController } from '@shared/upload/upload-controller';
|
||||
import { UploadAdapter } from '@shared/upload/upload.adapter';
|
||||
import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine';
|
||||
import { createUploadController } from '@shared/application/upload-controller';
|
||||
import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.machine';
|
||||
|
||||
/** Organism: multi-step herregistratie wizard. ALL state lives in one signal
|
||||
driven by the pure `reduce` function (see herregistratie.machine.ts) via an
|
||||
@@ -149,13 +148,13 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.
|
||||
})
|
||||
export class HerregistratieWizardComponent {
|
||||
private profile = inject(BigProfileStore);
|
||||
private uploadAdapter = inject(UploadAdapter);
|
||||
private store = createStore<WizardState, WizardMsg>(initial, reduce);
|
||||
|
||||
/** Preview/download link for a completed upload; dev-simulation `demo-*` ids have
|
||||
no stored bytes, so they get no link. */
|
||||
/** Preview/download link for a completed upload; delegates to the upload
|
||||
controller (application layer), which knows the dev-simulation `demo-*` ids
|
||||
have no stored bytes and returns no link for them. */
|
||||
protected previewUrlFor = (documentId: string): string | undefined =>
|
||||
documentId.startsWith('demo-') ? undefined : this.uploadAdapter.contentUrl(documentId);
|
||||
this.uploadCtl.previewUrlFor(documentId);
|
||||
|
||||
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
||||
seed = input<WizardState>(initial);
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
|
||||
import { HerregistratieWizardComponent } from './herregistratie-wizard.component';
|
||||
import { WizardState } from '@herregistratie/domain/herregistratie.machine';
|
||||
import { initialUpload } from '@shared/upload/upload.machine';
|
||||
import { initialUpload } from '@shared/domain/upload.machine';
|
||||
import { Uren } from '@registratie/domain/value-objects/uren';
|
||||
|
||||
const validData = { uren: 4160 as Uren, jaren: 5, punten: 200, documents: [] };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { SUBMIT_FAILED } from '@shared/application/submit';
|
||||
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
|
||||
import { AdminCasesStore } from './admin-cases.store';
|
||||
|
||||
@@ -43,7 +44,10 @@ describe('AdminCasesStore', () => {
|
||||
expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('rolls back the removal when the delete fails', async () => {
|
||||
// RB-20: a failed delete must not be silent — the row rolls back AND the store
|
||||
// surfaces the error the page renders. Before RB-20 this only rolled back
|
||||
// (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever.
|
||||
it('rolls back the removal and surfaces the error when the delete fails', async () => {
|
||||
const deleteAny = vi.fn().mockRejectedValue(new Error('boom'));
|
||||
const store = setup({ listAll: () => Promise.resolve([summary('a')]), deleteAny });
|
||||
await store.load();
|
||||
@@ -51,5 +55,24 @@ describe('AdminCasesStore', () => {
|
||||
await store.delete('a');
|
||||
const s = store.cases();
|
||||
expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['a']); // reappears
|
||||
expect(store.lastError()).toBe(SUBMIT_FAILED);
|
||||
});
|
||||
|
||||
it('clears a stale error on the next delete attempt', async () => {
|
||||
const deleteAny = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const store = setup({
|
||||
listAll: () => Promise.resolve([summary('a'), summary('b')]),
|
||||
deleteAny,
|
||||
});
|
||||
await store.load();
|
||||
|
||||
await store.delete('a');
|
||||
expect(store.lastError()).toBe(SUBMIT_FAILED);
|
||||
|
||||
await store.delete('b');
|
||||
expect(store.lastError()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { RemoteData } from '@shared/application/remote-data';
|
||||
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
|
||||
import { Aanvraag } from '@registratie/domain/aanvraag';
|
||||
import {
|
||||
ApplicationsAdapter,
|
||||
@@ -12,8 +13,9 @@ type Err = Error | undefined;
|
||||
* Admin view of ALL cases across owners (WP-36; `cases:manage`) — the back-office
|
||||
* counterpart of the user-facing `ApplicationsStore`. Same shape: one root singleton
|
||||
* owns the list as a writable RemoteData signal, delete removes the row synchronously
|
||||
* (optimistic) and rolls back on error. Admin delete removes any case (any owner,
|
||||
* submitted or not — the server enforces the capability).
|
||||
* (optimistic), goes through `runSubmit`, and rolls back plus surfaces `lastError` on
|
||||
* failure (RB-20). Admin delete removes any case (any owner, submitted or not — the
|
||||
* server enforces the capability).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminCasesStore {
|
||||
@@ -22,6 +24,11 @@ export class AdminCasesStore {
|
||||
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
|
||||
readonly cases = this.state.asReadonly();
|
||||
|
||||
/** Set on a failed delete (RB-20): the optimistic removal already rolled back by
|
||||
then, this is only the message for the alert the page renders above the list. */
|
||||
private error = signal<string | null>(null);
|
||||
readonly lastError = this.error.asReadonly();
|
||||
|
||||
/** Fetch + parse at the trust boundary, then publish as RemoteData. Keeps the
|
||||
last-good value on a resync (only shows Loading on the first load). */
|
||||
async load() {
|
||||
@@ -42,16 +49,18 @@ export class AdminCasesStore {
|
||||
void this.load();
|
||||
}
|
||||
|
||||
/** Delete a case: drop it now (synchronous), then confirm the DELETE; roll back on error. */
|
||||
/** Delete a case: drop it now (synchronous), then confirm the DELETE; roll back on error
|
||||
AND surface it (RB-20) — a silent reappearance leaves the admin guessing why. */
|
||||
async delete(id: string) {
|
||||
const before = this.state();
|
||||
if (before.tag === 'Success') {
|
||||
this.state.set({ tag: 'Success', value: before.value.filter((a) => a.id !== id) });
|
||||
}
|
||||
try {
|
||||
await this.adapter.deleteAny(id);
|
||||
} catch {
|
||||
this.error.set(null);
|
||||
const r = await runSubmit(() => this.adapter.deleteAny(id), SUBMIT_FAILED);
|
||||
if (!r.ok) {
|
||||
this.state.set(before); // roll back: the row reappears
|
||||
this.error.set(r.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { RemoteData } from '@shared/application/remote-data';
|
||||
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
|
||||
import { Aanvraag } from '@registratie/domain/aanvraag';
|
||||
import {
|
||||
ApplicationsAdapter,
|
||||
@@ -15,7 +16,8 @@ type Err = Error | undefined;
|
||||
* the row SYNCHRONOUSLY, so the block disappears deterministically — no dependence on
|
||||
* change-detection timing, HTTP caching, or a resource `reload()`. `reload()` re-fetches
|
||||
* so a page revisit reflects auto-approval (Concept → In behandeling → Goedgekeurd is
|
||||
* computed server-side on read).
|
||||
* computed server-side on read). Cancel goes through `runSubmit` and rolls back plus
|
||||
* surfaces `lastError` on failure (RB-20).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApplicationsStore {
|
||||
@@ -24,6 +26,11 @@ export class ApplicationsStore {
|
||||
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
|
||||
readonly applications = this.state.asReadonly();
|
||||
|
||||
/** Set on a failed cancel (RB-20): the optimistic removal already rolled back by
|
||||
then, this is only the message for the alert the page renders above the list. */
|
||||
private error = signal<string | null>(null);
|
||||
readonly lastError = this.error.asReadonly();
|
||||
|
||||
constructor() {
|
||||
void this.load();
|
||||
}
|
||||
@@ -50,16 +57,19 @@ export class ApplicationsStore {
|
||||
}
|
||||
|
||||
/** Cancel a Concept: drop it now (synchronous, guaranteed), then confirm the DELETE.
|
||||
No resync — the delete succeeded, so the optimistic removal is authoritative. */
|
||||
No resync — the delete succeeded, so the optimistic removal is authoritative. On
|
||||
failure, roll back AND surface the error (RB-20) — a silent reappearance leaves the
|
||||
user guessing why the block came back. */
|
||||
async cancel(id: string) {
|
||||
const before = this.state();
|
||||
if (before.tag === 'Success') {
|
||||
this.state.set({ tag: 'Success', value: before.value.filter((a) => a.id !== id) });
|
||||
}
|
||||
try {
|
||||
await this.adapter.cancel(id);
|
||||
} catch {
|
||||
this.error.set(null);
|
||||
const r = await runSubmit(() => this.adapter.cancel(id), SUBMIT_FAILED);
|
||||
if (!r.ok) {
|
||||
this.state.set(before); // roll back: the block reappears
|
||||
this.error.set(r.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,8 @@ import type {
|
||||
SubmitApplicationResponse,
|
||||
} from '@shared/infrastructure/api-client';
|
||||
import { AanvraagType } from '@registratie/domain/aanvraag';
|
||||
import {
|
||||
ApplicationsAdapter,
|
||||
parseApplications,
|
||||
} from '@registratie/infrastructure/applications.adapter';
|
||||
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
|
||||
import { findConcept, loadConcept } from './find-concept';
|
||||
|
||||
/** What a wizard persists per step: the opaque machine snapshot + progress + docs. */
|
||||
export interface DraftSnapshot {
|
||||
@@ -70,7 +68,7 @@ export function createDraftSync(deps: DraftSyncDeps) {
|
||||
// server's guard (409) — recover by adopting the existing Concept instead of
|
||||
// erroring. Only recover when one actually exists; otherwise surface the failure.
|
||||
.catch(async (e) => {
|
||||
const existing = await findConcept();
|
||||
const existing = await findConcept(adapter, deps.type);
|
||||
if (existing) return existing;
|
||||
throw e;
|
||||
})
|
||||
@@ -140,34 +138,16 @@ export function createDraftSync(deps: DraftSyncDeps) {
|
||||
// (submitted/gone) id is treated as fresh so it can't reopen as an editable draft.
|
||||
const load = (linked: string): Promise<void> => {
|
||||
id = linked;
|
||||
return adapter
|
||||
.detail(linked)
|
||||
.then((dto) => {
|
||||
if (dto.status && dto.status.tag !== 'Concept') {
|
||||
return loadConcept(adapter, linked).then((result) => {
|
||||
if (result.tag === 'not-concept') {
|
||||
id = undefined;
|
||||
applyResume(null);
|
||||
return;
|
||||
}
|
||||
applyResume(dto.draft ?? null);
|
||||
})
|
||||
.catch(() => {
|
||||
id = undefined;
|
||||
applyResume(null); // unknown/deleted id → start fresh
|
||||
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 {
|
||||
/** True while a debounced draft write is still pending (PendingSave). */
|
||||
hasPendingSave,
|
||||
@@ -189,7 +169,7 @@ export function createDraftSync(deps: DraftSyncDeps) {
|
||||
await load(linked);
|
||||
return;
|
||||
}
|
||||
const existing = await findConcept();
|
||||
const existing = await findConcept(adapter, deps.type);
|
||||
if (existing) {
|
||||
await load(existing);
|
||||
// Stamp the id into the URL so a reload resumes the same Concept.
|
||||
|
||||
@@ -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 { ok, err } from '@shared/kernel/fp';
|
||||
import { initialUpload } from '@shared/upload/upload.machine';
|
||||
import { given } from '@shared/testing/machine';
|
||||
import { expectTag } from '@shared/testing/expect-tag';
|
||||
import {
|
||||
Draft,
|
||||
RegistratieState,
|
||||
STEPS,
|
||||
initial,
|
||||
@@ -21,28 +20,50 @@ import {
|
||||
resolve,
|
||||
reduce,
|
||||
} from './registratie-wizard.machine';
|
||||
import { givenRegistratieWizard } from './registratie-wizard.testing';
|
||||
|
||||
const invullen = (draft: Partial<Draft>, cursor = 0): RegistratieState => ({
|
||||
tag: 'Invullen',
|
||||
draft: { antwoorden: {}, ...draft },
|
||||
cursor,
|
||||
errors: {},
|
||||
upload: initialUpload,
|
||||
});
|
||||
|
||||
const validAdres = {
|
||||
/**
|
||||
* Every fixture below is built by replaying real `RegistratieMsg`s through the
|
||||
* real `reduce` (ADR-0006 §2) — never a hand-assembled `RegistratieState`
|
||||
* literal. Each helper reaches a named point in the wizard one transition at a
|
||||
* time, so a spec can only assert on a state the reducer can actually produce.
|
||||
*/
|
||||
const toAdresValid = (): RegistratieState =>
|
||||
givenRegistratieWizard(
|
||||
{
|
||||
tag: 'PrefillAdres',
|
||||
straat: 'Lange Voorhout 9',
|
||||
postcode: '2514 EA',
|
||||
woonplaats: 'Den Haag',
|
||||
correspondentie: 'post' as const,
|
||||
adresHerkomst: 'brp' as const,
|
||||
};
|
||||
const validDraft: Partial<Draft> = {
|
||||
...validAdres,
|
||||
diplomaId: 'd1',
|
||||
beroep: 'Arts',
|
||||
diplomaHerkomst: 'duo',
|
||||
};
|
||||
},
|
||||
{ tag: 'SetCorrespondentie', value: 'post' },
|
||||
);
|
||||
|
||||
const toBeroepStep = (): RegistratieState => reduce(toAdresValid(), { tag: 'Next' }); // cursor 0 -> 1, no diploma yet
|
||||
|
||||
const toBeroepStepWithDiploma = (): RegistratieState =>
|
||||
reduce(toBeroepStep(), { tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] });
|
||||
|
||||
const toControleStep = (): RegistratieState => reduce(toBeroepStepWithDiploma(), { tag: 'Next' }); // cursor 1 -> 2
|
||||
|
||||
const toIndienen = (): RegistratieState => reduce(toControleStep(), { tag: 'Submit' });
|
||||
|
||||
// A complete, valid draft assembled WITHOUT ever advancing the cursor. Setting a
|
||||
// field or choosing a diploma is never gated by cursor position, so this is a
|
||||
// real, reachable 'Invullen' state at cursor 0 — matching what `submit()`
|
||||
// (which validates the whole draft regardless of cursor) is exercised against
|
||||
// in the tests below.
|
||||
const toFullDraftAtCursor0 = (): RegistratieState =>
|
||||
givenRegistratieWizard(
|
||||
{
|
||||
tag: 'PrefillAdres',
|
||||
straat: 'Lange Voorhout 9',
|
||||
postcode: '2514 EA',
|
||||
woonplaats: 'Den Haag',
|
||||
},
|
||||
{ tag: 'SetCorrespondentie', value: 'post' },
|
||||
{ tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] },
|
||||
);
|
||||
|
||||
describe('STEPS (fixed)', () => {
|
||||
it('always has the same three steps', () => {
|
||||
@@ -60,46 +81,55 @@ describe('navigation', () => {
|
||||
});
|
||||
|
||||
it('Next advances once the adres step is valid', () => {
|
||||
const s = expectTag(next(invullen(validAdres)), 'Invullen');
|
||||
const s = expectTag(next(toAdresValid()), 'Invullen');
|
||||
expect(s.cursor).toBe(1);
|
||||
expect(currentStep(s)).toBe('beroep');
|
||||
});
|
||||
|
||||
it('requires a valid e-mail only when the channel is email', () => {
|
||||
const bad = expectTag(next(invullen({ ...validAdres, correspondentie: 'email' })), 'Invullen');
|
||||
const withEmailChannel = givenRegistratieWizard(
|
||||
{
|
||||
tag: 'PrefillAdres',
|
||||
straat: 'Lange Voorhout 9',
|
||||
postcode: '2514 EA',
|
||||
woonplaats: 'Den Haag',
|
||||
},
|
||||
{ tag: 'SetCorrespondentie', value: 'email' },
|
||||
);
|
||||
const bad = expectTag(next(withEmailChannel), 'Invullen');
|
||||
expect(bad.errors.email).toBeTruthy();
|
||||
const good = expectTag(
|
||||
next(invullen({ ...validAdres, correspondentie: 'email', email: 'a@b.nl' })),
|
||||
next(given(reduce, withEmailChannel)({ tag: 'SetField', key: 'email', value: 'a@b.nl' })),
|
||||
'Invullen',
|
||||
);
|
||||
expect(good.cursor).toBe(1);
|
||||
});
|
||||
|
||||
it('beroep step requires a chosen diploma', () => {
|
||||
const noDiploma = expectTag(next(invullen(validAdres, 1)), 'Invullen');
|
||||
const noDiploma = expectTag(next(toBeroepStep()), 'Invullen');
|
||||
expect(noDiploma.cursor).toBe(1);
|
||||
expect(noDiploma.errors.diploma).toBeTruthy();
|
||||
const withDiploma = expectTag(next(invullen(validDraft, 1)), 'Invullen');
|
||||
const withDiploma = expectTag(next(toBeroepStepWithDiploma()), 'Invullen');
|
||||
expect(withDiploma.cursor).toBe(2);
|
||||
});
|
||||
|
||||
it('Back never goes below the first step and preserves the draft', () => {
|
||||
expect(back(initial)).toBe(initial);
|
||||
const s = expectTag(back(invullen(validDraft, 2)), 'Invullen');
|
||||
const s = expectTag(back(toControleStep()), 'Invullen');
|
||||
expect(s.cursor).toBe(1);
|
||||
expect(s.draft.beroep).toBe('Arts');
|
||||
});
|
||||
|
||||
it('GaNaarStap only jumps backwards', () => {
|
||||
expect(expectTag(gaNaarStap(invullen(validDraft, 2), 0), 'Invullen').cursor).toBe(0);
|
||||
expect(expectTag(gaNaarStap(invullen(validDraft, 1), 2), 'Invullen').cursor).toBe(1); // forward jump rejected
|
||||
expect(expectTag(gaNaarStap(toControleStep(), 0), 'Invullen').cursor).toBe(0);
|
||||
expect(expectTag(gaNaarStap(toBeroepStepWithDiploma(), 2), 'Invullen').cursor).toBe(1); // forward jump rejected
|
||||
});
|
||||
});
|
||||
|
||||
describe('adres origin (BRP vs handmatig)', () => {
|
||||
it('prefillAdres flags origin brp', () => {
|
||||
const s = expectTag(
|
||||
prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag'),
|
||||
prefillAdres(initial, 'Lange Voorhout 9', '2514 EA', 'Den Haag'),
|
||||
'Invullen',
|
||||
);
|
||||
expect(s.draft.adresHerkomst).toBe('brp');
|
||||
@@ -107,43 +137,38 @@ describe('adres origin (BRP vs handmatig)', () => {
|
||||
});
|
||||
|
||||
it('editing a prefilled address field flips origin to handmatig', () => {
|
||||
const prefilled = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag');
|
||||
const prefilled = prefillAdres(initial, 'Lange Voorhout 9', '2514 EA', 'Den Haag');
|
||||
const edited = expectTag(setField(prefilled, 'woonplaats', 'Rotterdam'), 'Invullen');
|
||||
expect(edited.draft.adresHerkomst).toBe('handmatig');
|
||||
});
|
||||
|
||||
it('typing an address with no BRP prefill yields handmatig', () => {
|
||||
const s = expectTag(setField(invullen({}), 'straat', 'Kerkstraat 1'), 'Invullen');
|
||||
const s = expectTag(setField(initial, 'straat', 'Kerkstraat 1'), 'Invullen');
|
||||
expect(s.draft.adresHerkomst).toBe('handmatig');
|
||||
});
|
||||
|
||||
it('editing the e-mail field does not change the address origin', () => {
|
||||
const prefilled = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag');
|
||||
const prefilled = prefillAdres(initial, 'Lange Voorhout 9', '2514 EA', 'Den Haag');
|
||||
const edited = expectTag(setField(prefilled, 'email', 'a@b.nl'), 'Invullen');
|
||||
expect(edited.draft.adresHerkomst).toBe('brp');
|
||||
});
|
||||
|
||||
it('a manually entered address still submits (only manual diploma is gated)', () => {
|
||||
const s = submit(
|
||||
invullen({
|
||||
straat: 'Kerkstraat 1',
|
||||
postcode: '1234 AB',
|
||||
woonplaats: 'Utrecht',
|
||||
correspondentie: 'post',
|
||||
adresHerkomst: 'handmatig',
|
||||
diplomaId: 'd1',
|
||||
beroep: 'Arts',
|
||||
diplomaHerkomst: 'duo',
|
||||
}),
|
||||
const manualAdres = givenRegistratieWizard(
|
||||
{ tag: 'SetField', key: 'straat', value: 'Kerkstraat 1' },
|
||||
{ tag: 'SetField', key: 'postcode', value: '1234 AB' },
|
||||
{ tag: 'SetField', key: 'woonplaats', value: 'Utrecht' },
|
||||
{ tag: 'SetCorrespondentie', value: 'post' },
|
||||
{ tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] },
|
||||
);
|
||||
const indienen = expectTag(s, 'Indienen');
|
||||
const indienen = expectTag(submit(manualAdres), 'Indienen');
|
||||
expect(indienen.data.adresHerkomst).toBe('handmatig');
|
||||
});
|
||||
});
|
||||
|
||||
describe('kiesDiploma', () => {
|
||||
it('derives the beroep from the chosen diploma and flags origin duo', () => {
|
||||
const s = expectTag(kiesDiploma(invullen({}), 'd9', 'Verpleegkundige', []), 'Invullen');
|
||||
const s = expectTag(kiesDiploma(initial, 'd9', 'Verpleegkundige', []), 'Invullen');
|
||||
expect(s.draft.diplomaId).toBe('d9');
|
||||
expect(s.draft.beroep).toBe('Verpleegkundige');
|
||||
expect(s.draft.diplomaHerkomst).toBe('duo');
|
||||
@@ -152,7 +177,7 @@ describe('kiesDiploma', () => {
|
||||
|
||||
describe('policy questions (geldigheidsvragen)', () => {
|
||||
it('a diploma with questions blocks Next until they are answered', () => {
|
||||
let s = kiesDiploma(invullen(validAdres, 1), 'd2', 'Arts', ['nl-taalvaardigheid']);
|
||||
let s = kiesDiploma(toBeroepStep(), 'd2', 'Arts', ['nl-taalvaardigheid']);
|
||||
const blocked = expectTag(next(s), 'Invullen');
|
||||
expect(blocked.cursor).toBe(1);
|
||||
expect(blocked.errors.antwoorden?.['nl-taalvaardigheid']).toBeTruthy();
|
||||
@@ -161,7 +186,12 @@ describe('policy questions (geldigheidsvragen)', () => {
|
||||
});
|
||||
|
||||
it('validateAll keeps only the answers to the questions that applied', () => {
|
||||
let s = kiesDiploma(invullen(validAdres, 2), 'd2', 'Arts', ['nl-taalvaardigheid']);
|
||||
// DRIFT (see rb-31.md): the old literal put the wizard at cursor 2 before any
|
||||
// diploma was chosen. That combination cannot occur in the real reducer —
|
||||
// advancing past 'beroep' (cursor 1 -> 2) requires a diploma to already be
|
||||
// set. Replayed here at cursor 1 instead; submit() validates the whole draft
|
||||
// regardless of cursor, so the assertion below is unaffected.
|
||||
let s = kiesDiploma(toBeroepStep(), 'd2', 'Arts', ['nl-taalvaardigheid']);
|
||||
s = setAntwoord(s, 'nl-taalvaardigheid', 'ja');
|
||||
s = setAntwoord(s, 'stale', 'x'); // not in vraagIds
|
||||
const done = expectTag(submit(s), 'Indienen');
|
||||
@@ -173,14 +203,16 @@ describe('manual diploma fallback', () => {
|
||||
const maxIds = ['nl-taalvaardigheid', 'diploma-erkend', 'toelichting'];
|
||||
|
||||
it('KiesHandmatig flags handmatig with the maximal question set and no beroep yet', () => {
|
||||
const s = expectTag(kiesHandmatig(invullen(validAdres, 1), maxIds), 'Invullen');
|
||||
const s = expectTag(kiesHandmatig(toBeroepStep(), maxIds), 'Invullen');
|
||||
expect(s.draft.diplomaHerkomst).toBe('handmatig');
|
||||
expect(s.draft.beroep).toBeUndefined();
|
||||
expect(s.draft.vraagIds).toEqual(maxIds);
|
||||
});
|
||||
|
||||
it('requires a declared beroep + all maximal questions before submit', () => {
|
||||
let s = kiesHandmatig(invullen(validAdres, 2), maxIds);
|
||||
// DRIFT (see rb-31.md): same unreachable cursor-2-before-diploma combination
|
||||
// as above. Replayed at cursor 1; submit() is cursor-agnostic.
|
||||
let s = kiesHandmatig(toBeroepStep(), maxIds);
|
||||
expect(submit(s).tag).toBe('Invullen'); // no beroep declared
|
||||
s = declareerBeroep(s, 'Fysiotherapeut');
|
||||
expect(submit(s).tag).toBe('Invullen'); // questions unanswered
|
||||
@@ -193,11 +225,11 @@ describe('manual diploma fallback', () => {
|
||||
|
||||
describe('submit', () => {
|
||||
it('stays in Invullen when the draft is incomplete (no diploma)', () => {
|
||||
expect(submit(invullen(validAdres)).tag).toBe('Invullen');
|
||||
expect(submit(toAdresValid()).tag).toBe('Invullen');
|
||||
});
|
||||
|
||||
it('reaches Indienen with a complete, valid draft, carrying its data', () => {
|
||||
const good = expectTag(submit(invullen(validDraft)), 'Indienen');
|
||||
const good = expectTag(submit(toFullDraftAtCursor0()), 'Indienen');
|
||||
expect(good.data.beroep).toBe('Arts');
|
||||
expect(good.data.adres.postcode).toBe('2514 EA');
|
||||
expect(good.data.adresHerkomst).toBe('brp');
|
||||
@@ -205,43 +237,18 @@ describe('submit', () => {
|
||||
|
||||
it('resolve maps Indienen to Ingediend with the referentie', () => {
|
||||
const ingediend = expectTag(
|
||||
resolve(submit(invullen(validDraft)), ok('BIG-2026-001')),
|
||||
resolve(submit(toFullDraftAtCursor0()), ok('BIG-2026-001')),
|
||||
'Ingediend',
|
||||
);
|
||||
expect(ingediend.referentie).toBe('BIG-2026-001');
|
||||
});
|
||||
|
||||
it('resolve maps Indienen to Mislukt on a failed submit', () => {
|
||||
expect(resolve(submit(invullen(validDraft)), err('boom')).tag).toBe('Mislukt');
|
||||
expect(resolve(submit(toFullDraftAtCursor0()), err('boom')).tag).toBe('Mislukt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reduce (message-driven happy path)', () => {
|
||||
// Each helper replays real messages through the real reducer up to the named
|
||||
// point — no hand-assembled state literal — so each test below Givens its own
|
||||
// starting point independently, one transition at a time.
|
||||
const toBeroepStep = (): RegistratieState => {
|
||||
let s: RegistratieState = initial;
|
||||
s = reduce(s, {
|
||||
tag: 'PrefillAdres',
|
||||
straat: 'Lange Voorhout 9',
|
||||
postcode: '2514 EA',
|
||||
woonplaats: 'Den Haag',
|
||||
});
|
||||
s = reduce(s, { tag: 'SetCorrespondentie', value: 'post' });
|
||||
return reduce(s, { tag: 'Next' });
|
||||
};
|
||||
const toControleStep = (): RegistratieState => {
|
||||
const s = reduce(toBeroepStep(), {
|
||||
tag: 'KiesDiploma',
|
||||
diplomaId: 'd1',
|
||||
beroep: 'Arts',
|
||||
vraagIds: [],
|
||||
});
|
||||
return reduce(s, { tag: 'Next' });
|
||||
};
|
||||
const toIndienen = (): RegistratieState => reduce(toControleStep(), { tag: 'Submit' });
|
||||
|
||||
it('adres and correspondentie set, Next advances from adres to beroep', () => {
|
||||
// Given the initial wizard.
|
||||
// When the adres is prefilled, correspondentie chosen, and Next dispatched...
|
||||
@@ -279,7 +286,7 @@ describe('reduce (message-driven happy path)', () => {
|
||||
});
|
||||
|
||||
it('SubmitFailed moves Indienen to Mislukt', () => {
|
||||
const s = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), {
|
||||
const s = reduce(reduce(toFullDraftAtCursor0(), { tag: 'Submit' }), {
|
||||
tag: 'SubmitFailed',
|
||||
error: 'boom',
|
||||
});
|
||||
@@ -287,7 +294,7 @@ describe('reduce (message-driven happy path)', () => {
|
||||
});
|
||||
|
||||
it('Retry returns Mislukt to Indienen with the same data', () => {
|
||||
const mislukt = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), {
|
||||
const mislukt = reduce(reduce(toFullDraftAtCursor0(), { tag: 'Submit' }), {
|
||||
tag: 'SubmitFailed',
|
||||
error: 'boom',
|
||||
});
|
||||
@@ -310,7 +317,7 @@ describe('inline document upload (beroep step)', () => {
|
||||
|
||||
it('routes Upload messages through the upload reducer', () => {
|
||||
const s = expectTag(
|
||||
reduce(invullen(validDraft), {
|
||||
reduce(toFullDraftAtCursor0(), {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'CategoriesLoaded', categories: [cat] },
|
||||
}),
|
||||
@@ -320,7 +327,7 @@ describe('inline document upload (beroep step)', () => {
|
||||
});
|
||||
|
||||
it('blocks the beroep step until a required category is satisfied', () => {
|
||||
let s = reduce(invullen(validDraft, 1), {
|
||||
let s = reduce(toBeroepStepWithDiploma(), {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'CategoriesLoaded', categories: [cat] },
|
||||
});
|
||||
@@ -339,7 +346,7 @@ describe('inline document upload (beroep step)', () => {
|
||||
});
|
||||
|
||||
it('includes delivery refs in the submitted data', () => {
|
||||
let s = reduce(invullen(validDraft), {
|
||||
let s = reduce(toFullDraftAtCursor0(), {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'CategoriesLoaded', categories: [cat] },
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
reduceUpload,
|
||||
requiredCategoriesSatisfied,
|
||||
deliveryRefs,
|
||||
} from '@shared/upload/upload.machine';
|
||||
} from '@shared/domain/upload.machine';
|
||||
|
||||
/**
|
||||
* A FIXED 3-step registration wizard. The steps never change in number (always
|
||||
|
||||
@@ -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()) {
|
||||
<app-alert type="error">{{ deniedText }}</app-alert>
|
||||
} @else {
|
||||
@if (store.lastError(); as err) {
|
||||
<app-alert type="error">{{ err }}</app-alert>
|
||||
}
|
||||
<app-async [data]="store.cases()">
|
||||
<ng-template appAsyncError>
|
||||
<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."
|
||||
>
|
||||
<div class="app-stack">
|
||||
@if (cancelError(); as err) {
|
||||
<app-alert type="error">{{ err }}</app-alert>
|
||||
}
|
||||
@if (aanvragen().length) {
|
||||
<section>
|
||||
@for (a of concepten(); track a.id) {
|
||||
@@ -260,6 +263,8 @@ export class DashboardPage {
|
||||
protected cancelAanvraag(a: Aanvraag) {
|
||||
void this.apps.cancel(a.id);
|
||||
}
|
||||
/** RB-20: the message from a failed cancel, rendered above the list. */
|
||||
protected cancelError = computed(() => this.apps.lastError());
|
||||
|
||||
/** Server-computed eligibility (rendered, not recomputed). */
|
||||
private readonly eligible = computed(() => {
|
||||
|
||||
@@ -37,9 +37,8 @@ import {
|
||||
} from '@registratie/domain/registratie-wizard.machine';
|
||||
import { createDraftSync } from '@registratie/application/draft-sync';
|
||||
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
|
||||
import { createUploadController } from '@shared/upload/upload-controller';
|
||||
import { UploadAdapter } from '@shared/upload/upload.adapter';
|
||||
import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine';
|
||||
import { createUploadController } from '@shared/application/upload-controller';
|
||||
import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.machine';
|
||||
|
||||
const KANALEN = [
|
||||
{ value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` },
|
||||
@@ -368,13 +367,13 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
|
||||
})
|
||||
export class RegistratieWizardComponent {
|
||||
private lookup = inject(RegistratieLookupStore);
|
||||
private uploadAdapter = inject(UploadAdapter);
|
||||
private store = createStore<RegistratieState, RegistratieMsg>(initial, reduce);
|
||||
|
||||
/** Preview/download link for a completed upload; the dev-simulation `demo-*` ids
|
||||
have no stored bytes, so they get no link. */
|
||||
/** Preview/download link for a completed upload; delegates to the upload
|
||||
controller (application layer), which knows the dev-simulation `demo-*` ids
|
||||
have no stored bytes and returns no link for them. */
|
||||
protected previewUrlFor = (documentId: string): string | undefined =>
|
||||
documentId.startsWith('demo-') ? undefined : this.uploadAdapter.contentUrl(documentId);
|
||||
this.uploadCtl.previewUrlFor(documentId);
|
||||
|
||||
/** Optional seed so Storybook / tests can mount any state directly. */
|
||||
seed = input<RegistratieState>(initial);
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
RegistratieState,
|
||||
ValidRegistratie,
|
||||
} from '@registratie/domain/registratie-wizard.machine';
|
||||
import { initialUpload } from '@shared/upload/upload.machine';
|
||||
import { initialUpload } from '@shared/domain/upload.machine';
|
||||
import { Postcode } from '@registratie/domain/value-objects/postcode';
|
||||
|
||||
const adres: Partial<Draft> = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, Injector, computed, inject, isDevMode, signal } from '@angular/core';
|
||||
import { JsonPipe } from '@angular/common';
|
||||
import { SessionStore } from '@auth/application/session.store';
|
||||
import { Session } from '@auth/domain/session';
|
||||
import { Principal } from '@auth/domain/principal';
|
||||
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||
import { map } from '@shared/application/remote-data';
|
||||
import { Role } from '@shared/domain/role';
|
||||
@@ -172,6 +172,6 @@ export class DebugStateComponent {
|
||||
}
|
||||
}
|
||||
|
||||
function maskSession(s: Session | null): Session | null {
|
||||
return s ? { ...s, bsn: maskBsn(s.bsn) } : null;
|
||||
function maskSession(p: Principal | null): Principal | null {
|
||||
return p ? { ...p, bsn: maskBsn(p.bsn) } : null;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -3738,6 +3738,10 @@
|
||||
<source>De functievlaggen konden niet worden geladen.</source>
|
||||
<target datatype="html">The feature flags could not be loaded.</target>
|
||||
</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">
|
||||
<source>Opnieuw proberen</source>
|
||||
<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
|
||||
// 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);
|
||||
|
||||
|
||||
@@ -70,8 +70,12 @@ public static class Mappers
|
||||
a.CreatedAt.ToString("o"), a.UpdatedAt.ToString("o"), SubmittedAtOf(a));
|
||||
|
||||
/// Admin summary — same shape plus the owner (WP-36; the user-facing list leaves Owner null).
|
||||
/// The owner is a BSN, and both consumers of this mapper are cross-owner lists read by
|
||||
/// someone who is not the subject (`/admin/cases`, `/werkvoorraad`), so it goes out masked
|
||||
/// (RB-03/BIO-003). Masking here rather than at each endpoint means a third cross-owner
|
||||
/// list cannot be added that forgets to.
|
||||
public static ApplicationSummaryDto ToAdminSummaryDto(this Aanvraag a, DateTimeOffset now) =>
|
||||
a.ToSummaryDto(now) with { Owner = a.Owner };
|
||||
a.ToSummaryDto(now) with { Owner = Pii.MaskTail(a.Owner, 3) };
|
||||
|
||||
public static ApplicationDetailDto ToDetailDto(this Aanvraag a, DateTimeOffset now) => new(
|
||||
a.Id, a.Type, a.ToStatusDto(now), DraftOf(a), a.DocumentIds,
|
||||
|
||||
@@ -53,7 +53,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
modelBuilder.Entity<BriefEntity>(e =>
|
||||
{
|
||||
e.HasKey(b => b.BriefId);
|
||||
e.HasIndex(b => b.Owner).IsUnique(); // one demo brief per owner (GetOrCreate's invariant)
|
||||
e.HasIndex(b => b.Owner).IsUnique(); // one demo brief per owner (ResetAndCreate's invariant)
|
||||
e.Property(b => b.Placeholders).HasConversion(Json<IReadOnlyList<PlaceholderDefDto>>());
|
||||
e.Property(b => b.Sections).HasConversion(Json<List<LetterSectionDto>>());
|
||||
e.Property(b => b.Status).HasConversion(Json<BriefStatusDto>());
|
||||
|
||||
@@ -47,17 +47,15 @@ public static class BriefStore
|
||||
|
||||
private static readonly object _gate = new();
|
||||
|
||||
public static BriefEntity GetOrCreate(string owner)
|
||||
/// Pure query (RB-23/CQ-007): no write. `GET /brief` 404s when this returns null —
|
||||
/// the owner's first-ever draft is created only through the explicit `ResetAndCreate`
|
||||
/// command (`POST /brief/reset`), never as a side effect of a read.
|
||||
public static BriefEntity? Get(string owner)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
var existing = db.Briefs.FirstOrDefault(e => e.Owner == owner);
|
||||
if (existing is not null) return existing;
|
||||
var created = BriefSeed.NewBrief(owner);
|
||||
db.Briefs.Add(created);
|
||||
db.SaveChanges();
|
||||
return created;
|
||||
return db.Briefs.FirstOrDefault(e => e.Owner == owner);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,10 +68,10 @@ public static class BriefStore
|
||||
using var db = Db.Create();
|
||||
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
|
||||
if (e is null) return (Outcome.Conflict, null);
|
||||
if (!isDrafter) return (Outcome.Forbidden, null);
|
||||
if (e.Status.Tag is not ("draft" or "rejected")) return (Outcome.Conflict, null);
|
||||
var outcome = BriefRules.CanSave(e.Status, isDrafter);
|
||||
if (outcome != Outcome.Ok) return (outcome, null);
|
||||
e.Sections = sections.ToList();
|
||||
if (e.Status.Tag == "rejected") e.Status = new BriefStatusDto("draft");
|
||||
e.Status = BriefRules.StatusAfterSave(e.Status);
|
||||
db.SaveChanges();
|
||||
return (Outcome.Ok, e);
|
||||
}
|
||||
@@ -86,8 +84,8 @@ public static class BriefStore
|
||||
using var db = Db.Create();
|
||||
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
|
||||
if (e is null) return (Outcome.Conflict, null);
|
||||
if (!isDrafter) return (Outcome.Forbidden, null);
|
||||
if (e.Status.Tag != "draft" || !RequiredFilled(e)) return (Outcome.Conflict, null);
|
||||
var outcome = BriefRules.CanSubmit(e.Status, isDrafter, BriefRules.RequiredFilled(e.Sections));
|
||||
if (outcome != Outcome.Ok) return (outcome, null);
|
||||
e.Status = new BriefStatusDto("submitted", SubmittedBy: e.DrafterId, SubmittedAt: at);
|
||||
db.SaveChanges();
|
||||
return (Outcome.Ok, e);
|
||||
@@ -109,7 +107,8 @@ public static class BriefStore
|
||||
using var db = Db.Create();
|
||||
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
|
||||
if (e is null) return (Outcome.Conflict, null);
|
||||
if (e.Status.Tag != "approved") return (Outcome.Conflict, null);
|
||||
var outcome = BriefRules.CanSend(e.Status);
|
||||
if (outcome != Outcome.Ok) return (outcome, null);
|
||||
e.Status = new BriefStatusDto("sent", SentAt: at);
|
||||
// Pin the org-template version the letter was sent with (WP-23): from here on
|
||||
// its appearance is frozen — republishing the template touches unsent briefs only.
|
||||
@@ -152,7 +151,7 @@ public static class BriefStore
|
||||
// from the drafter (a drafter cannot approve their own letter). The SoD check is
|
||||
// Authz.CanActOn — the SAME check the screen DTO's decision flags use — checked
|
||||
// BEFORE the status guard so Forbidden vs Conflict ordering matches the old
|
||||
// inline check exactly.
|
||||
// inline check exactly (BriefRules.CanDecide preserves that order).
|
||||
private static (Outcome, BriefEntity?) Review(string owner, Principal principal, BriefAction action, Func<BriefStatusDto> next)
|
||||
{
|
||||
lock (_gate)
|
||||
@@ -160,15 +159,13 @@ public static class BriefStore
|
||||
using var db = Db.Create();
|
||||
var e = db.Briefs.FirstOrDefault(e => e.Owner == owner);
|
||||
if (e is null) return (Outcome.Conflict, null);
|
||||
if (!Authz.CanActOn(action, principal, e.DrafterId)) return (Outcome.Forbidden, null);
|
||||
if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
|
||||
var outcome = BriefRules.CanDecide(action, e.Status, principal, e.DrafterId);
|
||||
if (outcome != Outcome.Ok) return (outcome, null);
|
||||
e.Status = next();
|
||||
db.SaveChanges();
|
||||
return (Outcome.Ok, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool RequiredFilled(BriefEntity e) => e.Sections.All(s => !s.Required || s.Blocks.Count > 0);
|
||||
}
|
||||
|
||||
/// <summary>Seeded template (sections + placeholder fields) and passage library.</summary>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using BigRegister.Domain.People;
|
||||
|
||||
namespace BigRegister.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
@@ -58,7 +60,7 @@ public static class DocumentStore
|
||||
db.Documents.Add(doc);
|
||||
db.SaveChanges();
|
||||
}
|
||||
Audit("upload", doc.DocumentId, categoryId, owner);
|
||||
Audit("upload", doc.DocumentId, categoryId, Pii.MaskTail(owner, 3));
|
||||
return doc;
|
||||
}
|
||||
|
||||
@@ -73,13 +75,13 @@ public static class DocumentStore
|
||||
|
||||
/// Status for the poll-on-return pattern: a known localId is "complete" (it
|
||||
/// arrived), an unknown one is still in flight / never started.
|
||||
public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds)
|
||||
public static IReadOnlyList<StoredDocument> ByLocalIds(IEnumerable<string> localIds, string owner)
|
||||
{
|
||||
var set = localIds.ToHashSet();
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
return db.Documents.Where(d => set.Contains(d.LocalId)).ToList();
|
||||
return db.Documents.Where(d => set.Contains(d.LocalId) && d.Owner == owner).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +158,7 @@ public static class DocumentStore
|
||||
db.Documents.Remove(d);
|
||||
db.SaveChanges();
|
||||
}
|
||||
Audit("delete-user", documentId, categoryId, owner);
|
||||
Audit("delete-user", documentId, categoryId, Pii.MaskTail(owner, 3));
|
||||
return DeleteResult.Ok;
|
||||
}
|
||||
|
||||
@@ -178,6 +180,12 @@ public static class DocumentStore
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
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
|
||||
/// 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
|
||||
/// the only implementation today.
|
||||
/// the only implementation today, and is registered only in Development (<c>Program.cs</c>,
|
||||
/// RB-09/BIO-002).
|
||||
/// </summary>
|
||||
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
|
||||
/// (medewerker); every consumer of <see cref="CallerIdentity"/> carries over unchanged once that
|
||||
/// 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>
|
||||
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>");
|
||||
foreach (var block in section.Blocks)
|
||||
RenderParagraphs(sb, block.Content.Paragraphs, defs);
|
||||
RenderParagraphs(sb, block.Content.Paragraphs, defs, at);
|
||||
sb.Append("</section>");
|
||||
}
|
||||
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 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;
|
||||
foreach (var para in paragraphs)
|
||||
@@ -101,14 +102,14 @@ public static class LetterHtml
|
||||
openList = para.List;
|
||||
}
|
||||
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>");
|
||||
}
|
||||
if (openList is not null) sb.Append(openList == "bullet" ? "</ul>" : "</ol>");
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -122,7 +123,7 @@ public static class LetterHtml
|
||||
var key = node.Key ?? "";
|
||||
var def = defs.GetValueOrDefault(key);
|
||||
var label = def?.Label ?? key;
|
||||
sb.Append(def is { AutoResolvable: true } ? Enc(ResolveAuto(key, label)) : Enc($"[NOG IN TE VULLEN: {label}]"));
|
||||
sb.Append(def is { AutoResolvable: true } ? Enc(ResolveAuto(key, label, at)) : Enc($"[NOG IN TE VULLEN: {label}]"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -131,11 +132,11 @@ public static class LetterHtml
|
||||
// single demo applicant (SeedData.Registration — no per-brief resolved value is
|
||||
// ever stored, see the class doc above). Falls back to the label itself for any
|
||||
// other auto-resolvable key, mirroring the FE canvas' own `sampleFor` fallback.
|
||||
private static string ResolveAuto(string key, string label) => key switch
|
||||
private static string ResolveAuto(string key, string label, string at) => key switch
|
||||
{
|
||||
"naam_zorgverlener" => SeedData.Registration.Naam,
|
||||
"big_nummer" => SeedData.Registration.BigNummer,
|
||||
"datum" => FormatDatumNl(DateTimeOffset.UtcNow.ToString("o")),
|
||||
"datum" => FormatDatumNl(at),
|
||||
_ => label,
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
public static class SubmissionRules
|
||||
{
|
||||
// RULE: a manually entered diploma cannot be auto-verified.
|
||||
public static string? RejectRegistratie(string diplomaHerkomst) =>
|
||||
diplomaHerkomst == "handmatig"
|
||||
? "Een handmatig ingevoerd diploma kan niet automatisch worden geverifieerd. Uw aanvraag is doorgestuurd voor handmatige beoordeling."
|
||||
: null;
|
||||
|
||||
// RULE: an application reporting zero worked hours is rejected.
|
||||
public static string? RejectZeroUren(int uren) =>
|
||||
uren == 0 ? "Aanvraag afgewezen: geen gewerkte uren geregistreerd." : null;
|
||||
|
||||
@@ -12,6 +12,7 @@ using BigRegister.Domain.Documents;
|
||||
using BigRegister.Domain.Features;
|
||||
using BigRegister.Domain.Intake;
|
||||
using BigRegister.Domain.Letters;
|
||||
using BigRegister.Domain.People;
|
||||
using BigRegister.Domain.Registrations;
|
||||
using BigRegister.Domain.Submissions;
|
||||
using BigRegister.Api.Zgw;
|
||||
@@ -50,7 +51,22 @@ Db.ConnectionString = builder.Configuration.GetConnectionString("AppDb") ?? Db.C
|
||||
// every store call site that used to hardcode DocumentStore.DemoOwner. Stub today (X-Role/
|
||||
// X-Subject for a zorgverlener, X-Medewerker/X-Rollen for a medewerker); a real
|
||||
// DigiD/employee-SSO provider swaps in without touching a consumer.
|
||||
//
|
||||
// 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
|
||||
// (OpenZaak) can replace the local SQLite store behind the same DTO contract — the FE never
|
||||
@@ -110,16 +126,37 @@ app.Use(async (ctx, next) =>
|
||||
|
||||
// WP-53: resolve the acting citizen once per request, right after correlation — everything
|
||||
// downstream (Authz.ResolvePrincipal, the endpoints below) reads it via ctx.Caller() instead of
|
||||
// re-deriving "who" itself.
|
||||
// re-deriving "who" itself. RB-09/BIO-002: a null resolution is "no identity", not "the seeded
|
||||
// citizen" — this is the one place that turns it into a response (401) rather than letting it
|
||||
// flow downstream as a silent identity substitution.
|
||||
var identityProvider = app.Services.GetRequiredService<IIdentityProvider>();
|
||||
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);
|
||||
});
|
||||
|
||||
// RB-15/BIO-015: the OpenAPI document + its UI are a genuine attack-surface reduction to
|
||||
// gate — they enumerate every route, request/response shape and (via SwaggerUI's "Try it
|
||||
// out") let a caller fire requests straight from the browser. Development-only, like the
|
||||
// dev-role/scenario-toggle hatches this POC already keeps out of production builds
|
||||
// (docker-compose.prod.yml runs Production; only docker-compose.yml's dev image runs
|
||||
// Development). `dotnet swagger tofile` (npm run gen:api) is unaffected: Swashbuckle's CLI
|
||||
// resolves ISwaggerProvider straight out of the DI container to build swagger.json — it
|
||||
// never sends an HTTP request through this pipeline, so it never touches this middleware at
|
||||
// all, gated or not. Verified empirically (see rb-15.md) rather than assumed, per RB-09's
|
||||
// note that this exact file has already broken that tool once.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
app.UseCors(SpaCors);
|
||||
|
||||
// Liveness/readiness for orchestrators (k8s probes, load balancers). No data, no PII.
|
||||
@@ -164,6 +201,7 @@ api.MapGet("/intake/policy", () => new IntakePolicyDto(IntakePolicy.ScholingThre
|
||||
api.MapGet("/stamdata", (HttpContext ctx) => StamdataAdmin(ctx, () =>
|
||||
Results.Ok(StamdataCatalog.All.Select(t =>
|
||||
new StamdataTableSummaryDto(t.Id, t.Label, t.Columns.Select(ToColumnDto).ToList(), t.Temporal)).ToList())))
|
||||
.Gate("StamdataAdmin")
|
||||
.WithName("stamdataTables")
|
||||
.Produces<List<StamdataTableSummaryDto>>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||
@@ -174,21 +212,29 @@ api.MapGet("/stamdata/{table}", (string table, string? peildatum, HttpContext ct
|
||||
{
|
||||
var t = StamdataCatalog.Find(table);
|
||||
if (t is null) return Results.NotFound();
|
||||
var rows = peildatum is { Length: > 0 } p ? t.RowsOn(DateOnly.Parse(p)) : t.Rows();
|
||||
DateOnly? peildatumWaarde = null;
|
||||
// RB-16/BIO-019: DateOnly.Parse threw FormatException on unparseable input, surfacing as
|
||||
// an unhandled 500 (and, in Development, an exception detail leaked to the caller) — an
|
||||
// admin-gated but still user-supplied string needs the same 400 path every other bad-input
|
||||
// check in this file uses, not a crash.
|
||||
if (peildatum is { Length: > 0 } p)
|
||||
{
|
||||
if (!DateOnly.TryParse(p, out var parsed))
|
||||
return Results.Problem(detail: $"Ongeldige peildatum '{p}'.", statusCode: StatusCodes.Status400BadRequest);
|
||||
peildatumWaarde = parsed;
|
||||
}
|
||||
var rows = peildatumWaarde is { } d ? t.RowsOn(d) : t.Rows();
|
||||
return Results.Ok(new StamdataTableDto(t.Id, t.Label, t.Columns.Select(ToColumnDto).ToList(), t.Temporal, rows));
|
||||
}))
|
||||
.Gate("StamdataAdmin")
|
||||
.WithName("stamdataTable")
|
||||
.Produces<StamdataTableDto>()
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// --- POST: submits. The server is the authority; it re-validates and decides. ---
|
||||
|
||||
api.MapPost("/registrations", (RegistratieRequest req, HttpContext ctx) =>
|
||||
Submit(ctx, "registratie", SubmissionRules.RejectRegistratie(req.DiplomaHerkomst), req.Documents))
|
||||
.Produces<ReferentieResponse>()
|
||||
.ProducesProblem(StatusCodes.Status422UnprocessableEntity);
|
||||
|
||||
api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) =>
|
||||
Submit(ctx, "telefoonwijziging", SubmissionRules.RejectPhoneChange(req.Telefoon)))
|
||||
.Produces<ReferentieResponse>()
|
||||
@@ -196,10 +242,47 @@ api.MapPost("/change-requests", (ChangeRequestRequest req, HttpContext ctx) =>
|
||||
|
||||
// --- Document upload ---
|
||||
|
||||
// --- reads ---
|
||||
|
||||
// Server-owned category config per wizard. The FE renders these; it never hardcodes.
|
||||
api.MapGet("/uploads/categories", (string wizardId, string? diplomaHerkomst, string? taalvaardigheid) =>
|
||||
new UploadCategoriesDto(DocumentRules.CategoriesFor(wizardId, diplomaHerkomst, taalvaardigheid).Select(c => c.ToDto()).ToList()));
|
||||
|
||||
// Serve stored bytes so a re-opened wizard can preview/download an upload. Inline
|
||||
// for pdf/image (browser renders it), attachment otherwise (download).
|
||||
// Scoped like DELETE on the same resource (RB-01/BIO-004): the owning citizen, or a
|
||||
// behandelaar reading an aanvraag's linked documents. A foreign id 404s rather than
|
||||
// 403s, so the endpoint never confirms that a document id exists.
|
||||
api.MapGet("/uploads/{documentId}/content", (string documentId, HttpContext ctx) =>
|
||||
{
|
||||
var doc = DocumentStore.Get(documentId);
|
||||
var allowed = ctx.Caller() switch
|
||||
{
|
||||
ZorgverlenerCaller z => doc?.Owner == z.Bsn,
|
||||
var caller => Authz.CanBeoordelen(caller),
|
||||
};
|
||||
if (doc is null || !allowed) return Results.NotFound();
|
||||
var inline = doc.ContentType == "application/pdf" || doc.ContentType.StartsWith("image/");
|
||||
return Results.File(doc.Content, doc.ContentType, fileDownloadName: inline ? null : doc.FileName);
|
||||
})
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// Poll-on-return: which of these client localIds have arrived at the BFF.
|
||||
api.MapGet("/uploads/status", (string? localIds, HttpContext ctx) =>
|
||||
{
|
||||
var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
// Owner-scoped (RB-01/BIO-004): someone else's localId reads back as "unknown", the
|
||||
// same answer an id that never existed gets.
|
||||
var found = DocumentStore.ByLocalIds(ids, ctx.Zorgverlener().Bsn).ToDictionary(d => d.LocalId);
|
||||
var results = ids.Select(id => found.TryGetValue(id, out var d)
|
||||
? new UploadStatusItemDto(id, "complete", d.DocumentId)
|
||||
: new UploadStatusItemDto(id, "unknown", null)).ToList();
|
||||
return new UploadStatusDto(results);
|
||||
});
|
||||
|
||||
// --- writes ---
|
||||
|
||||
// Multipart upload. Hand-written on the FE (XHR for progress), so it is excluded
|
||||
// from the OpenAPI doc to keep the NSwag-generated client JSON-only. Validates type
|
||||
// and size authoritatively; stores metadata only (no file bytes / PII held).
|
||||
@@ -226,29 +309,6 @@ api.MapPost("/uploads", async (HttpRequest request, HttpContext ctx, IDocumentSo
|
||||
})
|
||||
.ExcludeFromDescription();
|
||||
|
||||
// Serve stored bytes so a re-opened wizard can preview/download an upload. Inline
|
||||
// for pdf/image (browser renders it), attachment otherwise (download).
|
||||
api.MapGet("/uploads/{documentId}/content", (string documentId) =>
|
||||
{
|
||||
var doc = DocumentStore.Get(documentId);
|
||||
if (doc is null) return Results.NotFound();
|
||||
var inline = doc.ContentType == "application/pdf" || doc.ContentType.StartsWith("image/");
|
||||
return Results.File(doc.Content, doc.ContentType, fileDownloadName: inline ? null : doc.FileName);
|
||||
})
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// Poll-on-return: which of these client localIds have arrived at the BFF.
|
||||
api.MapGet("/uploads/status", (string? localIds) =>
|
||||
{
|
||||
var ids = (localIds ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var found = DocumentStore.ByLocalIds(ids).ToDictionary(d => d.LocalId);
|
||||
var results = ids.Select(id => found.TryGetValue(id, out var d)
|
||||
? new UploadStatusItemDto(id, "complete", d.DocumentId)
|
||||
: new UploadStatusItemDto(id, "unknown", null)).ToList();
|
||||
return new UploadStatusDto(results);
|
||||
});
|
||||
|
||||
// User delete: owner-scoped; 409 once linked to a finalised submission.
|
||||
api.MapDelete("/uploads/{documentId}", (string documentId, HttpContext ctx) =>
|
||||
DocumentStore.DeleteOwned(documentId, ctx.Zorgverlener().Bsn) switch
|
||||
@@ -263,17 +323,21 @@ api.MapDelete("/uploads/{documentId}", (string documentId, HttpContext ctx) =>
|
||||
.ProducesProblem(StatusCodes.Status409Conflict)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// Admin delete (seam): a real system requires an admin role; here an X-Admin header
|
||||
// stands in. Bypasses ownership, unlinks, and flags the submission for review.
|
||||
api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx) =>
|
||||
!IsAdmin(ctx) ? Results.StatusCode(StatusCodes.Status403Forbidden)
|
||||
: DocumentStore.AdminDelete(documentId, "admin") ? Results.NoContent() : Results.NotFound())
|
||||
// Admin delete: bypasses ownership, unlinks, and flags the submission for review. Gated
|
||||
// by the same CasesAdmin wrapper (cases:manage) the other admin-cases endpoints use
|
||||
// (RB-08/BIO-003) — it used to be gated by a standalone X-Admin header, outside Authz and
|
||||
// unaudited; CasesAdmin gives it the missing AuthzAuditStore row for free (RB-07).
|
||||
api.MapDelete("/admin/uploads/{documentId}", (string documentId, HttpContext ctx) => CasesAdmin(ctx, () =>
|
||||
DocumentStore.AdminDelete(documentId, "admin") ? Results.NoContent() : Results.NotFound()))
|
||||
.Gate("CasesAdmin")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status403Forbidden)
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// --- Applications (aanvragen): the system of record the dashboard reads. ---
|
||||
|
||||
// --- reads ---
|
||||
|
||||
// WP-53: routed through IZaakSource (like /admin/cases already was) rather than calling
|
||||
// ApplicationStore directly — under Zgw:Enabled=true a citizen's own dashboard list comes from
|
||||
// OpenZaak (BSN-filtered) too, closing the last "reads a static store directly" gap
|
||||
@@ -288,6 +352,8 @@ api.MapGet("/applications/{id}", (string id, HttpContext ctx) =>
|
||||
.Produces<ApplicationDetailDto>()
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// --- writes ---
|
||||
|
||||
api.MapPost("/applications", (CreateApplicationRequest req, HttpContext ctx) =>
|
||||
{
|
||||
// Feature flag (WP-47): self-service registration can be closed by an admin.
|
||||
@@ -422,11 +488,40 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// --- Admin cases (WP-36): cross-owner list + admin delete, gated by `cases:manage`. ---
|
||||
|
||||
// --- reads ---
|
||||
|
||||
api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ctx, () =>
|
||||
Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow))))
|
||||
.Gate("CasesAdmin")
|
||||
.Produces<List<ApplicationSummaryDto>>()
|
||||
.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. ---
|
||||
// Cross-owner like /admin/cases, but gated by the medewerker capability (`CanBeoordelen`,
|
||||
// WP-62) rather than the admin role, and pre-filtered to the two "still open" status tags —
|
||||
@@ -435,6 +530,7 @@ api.MapGet("/werkvoorraad", (HttpContext ctx, IZaakSource zaken) => Beoordelen(c
|
||||
Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow)
|
||||
.Where(c => c.Status.Tag is "Ingediend" or "InBehandeling")
|
||||
.ToList())))
|
||||
.Gate("Beoordelen")
|
||||
.Produces<List<ApplicationSummaryDto>>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||
|
||||
@@ -450,13 +546,17 @@ api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken)
|
||||
if (c is null || c.Status.Tag == "Concept") return Results.NotFound();
|
||||
var docs = DocumentStore.ByIds(c.DocumentIds)
|
||||
.Select(d => new BeoordelingDocumentDto(d.DocumentId, d.CategoryId, d.FileName)).ToList();
|
||||
var masked = c with { Owner = MaskTail(c.Owner!, 3) };
|
||||
// Belt and braces: ToAdminSummaryDto already masks the local source (RB-03) and
|
||||
// MaskTail is idempotent, but IZaakSource has a second implementation whose Owner
|
||||
// is mapped from OpenZaak, so this stays as the guarantee for this response.
|
||||
var masked = c with { Owner = Pii.MaskTail(c.Owner!, 3) };
|
||||
// WP-68 (F3): non-throwing — c.Status.Tag crosses the IZaakSource wire boundary, so an
|
||||
// unrecognised tag degrades to "cannot decide" instead of a 500.
|
||||
var canBesluiten = Enum.TryParse<AanvraagStatusTag>(c.Status.Tag, out var tag) && BeoordelingRules.CanDecide(tag);
|
||||
var decisions = new BeoordelingDecisionsDto(canBesluiten);
|
||||
return Results.Ok(new BeoordelingViewDto(masked, docs, decisions));
|
||||
}))
|
||||
.Gate("Beoordelen")
|
||||
.Produces<BeoordelingViewDto>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
@@ -497,6 +597,10 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
|
||||
app.Logger.LogInformation("aanvraag besluit id={Id} besluit={Besluit}", a.Id, besluit);
|
||||
// RB-07/BIO-007: the gate above records that a behandelaar was allowed to act; this
|
||||
// records what they decided. Without it /beheer/audit cannot answer "who rejected this
|
||||
// aanvraag", which is the question the trail exists for.
|
||||
AuditAuthz(ctx, "aanvraag:besluit", $"aanvraag/{a.Id}/{besluit}", true, Authz.ResolvePrincipal(ctx));
|
||||
|
||||
// WP-60: the local decision above already committed — a ZGW failure here is caught and
|
||||
// flagged rather than allowed to diverge silently, same handling as submit's create-zaak
|
||||
@@ -513,6 +617,7 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H
|
||||
|
||||
return Results.Ok(new RecordBesluitResponse(updated!.ToStatusDto(now)));
|
||||
}))
|
||||
.Gate("Beoordelen")
|
||||
.Produces<RecordBesluitResponse>()
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
@@ -549,27 +654,6 @@ api.MapPost("/zgw/notificaties", (HttpContext ctx, NotificatieDto body) =>
|
||||
// /uploads and /brief/reveal-bignummer.
|
||||
.ExcludeFromDescription();
|
||||
|
||||
// Admin delete removes ANY case (any owner, submitted or not) — unlike the user-facing
|
||||
// DELETE /applications/{id}. A missing id is a 404.
|
||||
api.MapDelete("/admin/cases/{id}", (string id, HttpContext ctx) => CasesAdmin(ctx, () =>
|
||||
{
|
||||
if (!ApplicationStore.DeleteAny(id)) return Results.NotFound();
|
||||
app.Logger.LogInformation("admin case delete id={Id}", id);
|
||||
return Results.NoContent();
|
||||
}))
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||
|
||||
// Queryable authz/PII-reveal audit trail (WP-41) — data-minimised, no PII. Admin-gated
|
||||
// via the existing CasesAdmin (cases:manage); a dedicated audit:read cap is a later refinement.
|
||||
api.MapGet("/admin/audit", (HttpContext ctx) => CasesAdmin(ctx, () =>
|
||||
Results.Ok(AuthzAuditStore.List()
|
||||
.Select(a => new AuthzAuditDto(a.At.ToString("o"), a.Action, a.Resource, a.Decision, a.Role, a.CorrelationId))
|
||||
.ToList())))
|
||||
.Produces<List<AuthzAuditDto>>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||
|
||||
// PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks (NOT
|
||||
// tied to a specific brief's live status — see BriefDecisionsDto for that).
|
||||
// WP-64: `aanvraag:beoordelen` is caller-kind-derived (CanBeoordelen), not role-derived like
|
||||
@@ -589,23 +673,53 @@ api.MapGet("/flags", () =>
|
||||
Results.Ok(FeatureFlagStore.All().Select(f => new FeatureFlagDto(f.Key, f.Description, f.Enabled)).ToList()))
|
||||
.Produces<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) =>
|
||||
FlagsAdmin(ctx, $"feature-flags/{key}={req.Enabled}", () =>
|
||||
FeatureFlagStore.Set(key, req.Enabled) ? Results.NoContent() : Results.NotFound()))
|
||||
.Gate("FlagsAdmin")
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status404NotFound)
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||
|
||||
// --- Brief (letter composition). One demo brief per owner; the server owns the
|
||||
// status machine + authorization (Authz, PRD-0002 phase P1). Principal is a
|
||||
// dev-only stand-in via X-Role (mirrors the X-Admin seam and the FE ?role=
|
||||
// toggle) — no real identities in this POC. ---
|
||||
// dev-only stand-in via X-Role (mirrors the FE ?role= toggle) — no real
|
||||
// identities in this POC. ---
|
||||
|
||||
// --- reads ---
|
||||
|
||||
api.MapGet("/brief", (HttpContext ctx) =>
|
||||
{
|
||||
var e = BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn);
|
||||
return ToView(ctx, e);
|
||||
// RB-23/CQ-007: a read that used to allocate a row on first call. The owner's first
|
||||
// draft now comes only from the explicit POST /brief/reset (BriefStore.ResetAndCreate)
|
||||
// — this GET is a pure query and 404s when there is nothing to read yet.
|
||||
var e = BriefStore.Get(ctx.Zorgverlener().Bsn);
|
||||
if (e is null) return Results.NotFound();
|
||||
return Results.Ok(ToView(ctx, e));
|
||||
})
|
||||
.Produces<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) =>
|
||||
{
|
||||
@@ -620,7 +734,7 @@ api.MapPost("/brief/submit", (HttpContext ctx) =>
|
||||
{
|
||||
var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter;
|
||||
var r = BriefStore.Submit(ctx.Zorgverlener().Bsn, isDrafter, Now());
|
||||
LogBrief("submit", r);
|
||||
LogBrief(ctx, "submit", r);
|
||||
return BriefResult(ctx, r, "Alleen de opsteller mag indienen.");
|
||||
})
|
||||
.WithName("briefSubmit") // distinct name so the generated client method isn't `submit2`
|
||||
@@ -631,7 +745,7 @@ api.MapPost("/brief/submit", (HttpContext ctx) =>
|
||||
api.MapPost("/brief/approve", (HttpContext ctx) =>
|
||||
{
|
||||
var r = BriefStore.Approve(ctx.Zorgverlener().Bsn, Authz.ResolvePrincipal(ctx), Now());
|
||||
LogBrief("approve", r);
|
||||
LogBrief(ctx, "approve", r);
|
||||
return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn.");
|
||||
})
|
||||
.Produces<BriefViewDto>()
|
||||
@@ -641,7 +755,7 @@ api.MapPost("/brief/approve", (HttpContext ctx) =>
|
||||
api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) =>
|
||||
{
|
||||
var r = BriefStore.Reject(ctx.Zorgverlener().Bsn, Authz.ResolvePrincipal(ctx), req.Comments, Now());
|
||||
LogBrief("reject", r);
|
||||
LogBrief(ctx, "reject", r);
|
||||
return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn.");
|
||||
})
|
||||
.Produces<BriefViewDto>()
|
||||
@@ -654,7 +768,7 @@ api.MapPost("/brief/send", (HttpContext ctx) =>
|
||||
// port); the backend only guards the approved→sent transition (not role-gated
|
||||
// today — see Authz.CanActOn(Send, …), a mechanical dispatch step).
|
||||
var r = BriefStore.Send(ctx.Zorgverlener().Bsn, Now());
|
||||
LogBrief("send", r);
|
||||
LogBrief(ctx, "send", r);
|
||||
return BriefResult(ctx, r, "Versturen kan niet in deze status.");
|
||||
})
|
||||
.Produces<BriefViewDto>()
|
||||
@@ -671,7 +785,10 @@ api.MapPost("/brief/reveal-bignummer", (HttpContext ctx) =>
|
||||
var canReveal = Authz.CanRevealBigNummer(principal);
|
||||
var steppedUp = ctx.Request.Headers["X-Step-Up"] == "true";
|
||||
var allowed = canReveal && steppedUp;
|
||||
AuditAuthz(ctx, "brief:reveal-bignummer", "brief/" + ctx.Zorgverlener().Bsn, allowed, principal);
|
||||
// RB-02/BIO-008: the resource ref is the brief, not the subject — a BSN concatenated
|
||||
// here lands in a persisted, admin-visible column the "no PII" guarantee covers. One
|
||||
// brief exists per owner, so the id added nothing the acting principal did not imply.
|
||||
AuditAuthz(ctx, "brief:reveal-bignummer", "brief", allowed, principal);
|
||||
if (!allowed)
|
||||
return Results.Problem(
|
||||
detail: canReveal
|
||||
@@ -684,31 +801,6 @@ api.MapPost("/brief/reveal-bignummer", (HttpContext ctx) =>
|
||||
// OpenAPI doc, same seam as /brief/preview and uploads.
|
||||
.ExcludeFromDescription();
|
||||
|
||||
// Server-rendered HTML preview (WP-25): "what you compose is what is sent" — the
|
||||
// same LetterHtml.Render a sent brief archived. Hand-written on the FE (fetch →
|
||||
// blob → new tab), so excluded from the OpenAPI doc, same seam as uploads. Sent
|
||||
// letters serve their frozen archive; anything else renders live with a watermark.
|
||||
api.MapGet("/brief/preview", (HttpContext ctx) =>
|
||||
{
|
||||
var e = BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn);
|
||||
if (e.Status.Tag == "sent" && e.ArchivedHtml is { } archived)
|
||||
return Results.Content(archived, "text/html");
|
||||
var template = OrgTemplateStore.TemplateForBrief(e.SubOrgId, null);
|
||||
return Results.Content(LetterHtml.Render(e, template, Now(), watermark: true), "text/html");
|
||||
})
|
||||
.ExcludeFromDescription();
|
||||
|
||||
// Proefbrief: the admin's unpublished draft template rendered over a fixture
|
||||
// brief, so the appearance can be checked before publishing touches real letters.
|
||||
api.MapGet("/admin/org-template/{subOrgId}/preview", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||
{
|
||||
var view = OrgTemplateStore.AdminView(subOrgId);
|
||||
if (view is null) return Results.NotFound();
|
||||
var fixture = BriefSeed.NewBrief("proefbrief");
|
||||
return Results.Content(LetterHtml.Render(fixture, view.Draft, Now(), watermark: true), "text/html");
|
||||
}))
|
||||
.ExcludeFromDescription();
|
||||
|
||||
api.MapPost("/brief/reset", (HttpContext ctx) =>
|
||||
{
|
||||
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only.
|
||||
@@ -723,25 +815,44 @@ api.MapPost("/brief/reset", (HttpContext ctx) =>
|
||||
// as drafter/approver); the same Authz check gates every endpoint and feeds the
|
||||
// `orgtemplate:edit` capability on /me, so emit and enforce cannot drift. ---
|
||||
|
||||
// --- reads ---
|
||||
|
||||
api.MapGet("/admin/org-templates", (HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||
Results.Ok(OrgTemplateStore.List())))
|
||||
.Gate("OrgAdmin")
|
||||
.WithName("orgTemplates")
|
||||
.Produces<List<SubOrgSummaryDto>>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||
|
||||
api.MapGet("/admin/org-template/{subOrgId}", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||
OrgTemplateStore.AdminView(subOrgId) is { } view ? Results.Ok(view) : Results.NotFound()))
|
||||
.Gate("OrgAdmin")
|
||||
.WithName("orgTemplateGET")
|
||||
.Produces<OrgTemplateAdminViewDto>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// Proefbrief: the admin's unpublished draft template rendered over a fixture
|
||||
// brief, so the appearance can be checked before publishing touches real letters.
|
||||
api.MapGet("/admin/org-template/{subOrgId}/preview", (string subOrgId, HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||
{
|
||||
var view = OrgTemplateStore.AdminView(subOrgId);
|
||||
if (view is null) return Results.NotFound();
|
||||
var fixture = BriefSeed.NewBrief("proefbrief");
|
||||
return Results.Content(LetterHtml.Render(fixture, view.Draft, Now(), watermark: true), "text/html");
|
||||
}))
|
||||
.Gate("OrgAdmin")
|
||||
.ExcludeFromDescription();
|
||||
|
||||
// --- writes ---
|
||||
|
||||
api.MapPut("/admin/org-template/{subOrgId}", (string subOrgId, SaveOrgTemplateRequest req, HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||
{
|
||||
var reject = OrgTemplateRules.RejectDraft(req.Draft);
|
||||
if (reject is not null) return Results.Problem(detail: reject, statusCode: StatusCodes.Status400BadRequest);
|
||||
return OrgTemplateStore.SaveDraft(subOrgId, req.Draft) is { } view ? Results.Ok(view) : Results.NotFound();
|
||||
}))
|
||||
.Gate("OrgAdmin")
|
||||
.WithName("orgTemplatePUT")
|
||||
.Produces<OrgTemplateAdminViewDto>()
|
||||
.ProducesProblem(StatusCodes.Status400BadRequest)
|
||||
@@ -756,6 +867,7 @@ api.MapPost("/admin/org-template/{subOrgId}/publish", (string subOrgId, HttpCont
|
||||
subOrgId, r.Version, r.AffectedUnsentBriefs);
|
||||
return r is not null ? Results.Ok(r) : Results.NotFound();
|
||||
}))
|
||||
.Gate("OrgAdmin")
|
||||
.WithName("orgTemplatePublish")
|
||||
.Produces<PublishOrgTemplateResponse>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
@@ -763,6 +875,7 @@ api.MapPost("/admin/org-template/{subOrgId}/publish", (string subOrgId, HttpCont
|
||||
|
||||
api.MapPost("/admin/org-template/{subOrgId}/rollback/{version:int}", (string subOrgId, int version, HttpContext ctx) => OrgAdmin(ctx, () =>
|
||||
OrgTemplateStore.Rollback(subOrgId, version) is { } view ? Results.Ok(view) : Results.NotFound()))
|
||||
.Gate("OrgAdmin")
|
||||
.WithName("orgTemplateRollback")
|
||||
.Produces<OrgTemplateAdminViewDto>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
@@ -770,17 +883,20 @@ api.MapPost("/admin/org-template/{subOrgId}/rollback/{version:int}", (string sub
|
||||
|
||||
app.Run();
|
||||
|
||||
static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true";
|
||||
|
||||
// One gate for every org-template endpoint — the enforce twin of the
|
||||
// `orgtemplate:edit` capability RoleCapabilities emits (single Authz source). A denial
|
||||
// is audited (PRD-0002 §8); the allow path is left un-logged (the endpoints log their
|
||||
// own effect, e.g. publish).
|
||||
// `orgtemplate:edit` capability RoleCapabilities emits (single Authz source).
|
||||
//
|
||||
// RB-07/BIO-007: every gate below audits the real decision, allow *and* deny. Auditing
|
||||
// only denials left /beheer/audit able to answer "who was turned away" but not "who
|
||||
// changed this", which for a register whose integrity is the product is the wrong half
|
||||
// (PRD-0002 §8 lists approvals alongside denials). The allow row is written by the gate,
|
||||
// not by the endpoint, so a new admin endpoint cannot be added that forgets it.
|
||||
IResult OrgAdmin(HttpContext ctx, Func<IResult> action)
|
||||
{
|
||||
var principal = Authz.ResolvePrincipal(ctx);
|
||||
if (Authz.CanManageOrgTemplates(principal)) return action();
|
||||
AuditAuthz(ctx, "orgtemplate:edit", "org-templates", false, principal);
|
||||
var ok = Authz.CanManageOrgTemplates(principal);
|
||||
AuditAuthz(ctx, "orgtemplate:edit", "org-templates", ok, principal);
|
||||
if (ok) return action();
|
||||
return Results.Problem(detail: "Alleen een beheerder mag organisatiesjablonen beheren.",
|
||||
statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
@@ -790,8 +906,9 @@ IResult OrgAdmin(HttpContext ctx, Func<IResult> action)
|
||||
IResult StamdataAdmin(HttpContext ctx, Func<IResult> action)
|
||||
{
|
||||
var principal = Authz.ResolvePrincipal(ctx);
|
||||
if (Authz.CanEditStamdata(principal)) return action();
|
||||
AuditAuthz(ctx, "stamdata:edit", "stamdata", false, principal);
|
||||
var ok = Authz.CanEditStamdata(principal);
|
||||
AuditAuthz(ctx, "stamdata:edit", "stamdata", ok, principal);
|
||||
if (ok) return action();
|
||||
return Results.Problem(detail: "Alleen een beheerder mag stamdata onderhouden.",
|
||||
statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
@@ -801,8 +918,9 @@ IResult StamdataAdmin(HttpContext ctx, Func<IResult> action)
|
||||
IResult CasesAdmin(HttpContext ctx, Func<IResult> action)
|
||||
{
|
||||
var principal = Authz.ResolvePrincipal(ctx);
|
||||
if (Authz.CanManageCases(principal)) return action();
|
||||
AuditAuthz(ctx, "cases:manage", "cases", false, principal);
|
||||
var ok = Authz.CanManageCases(principal);
|
||||
AuditAuthz(ctx, "cases:manage", "cases", ok, principal);
|
||||
if (ok) return action();
|
||||
return Results.Problem(detail: "Alleen een beheerder mag aanvragen beheren.",
|
||||
statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
@@ -813,18 +931,23 @@ IResult CasesAdmin(HttpContext ctx, Func<IResult> action)
|
||||
// zorgverlener with X-Role=admin still gets denied. `resource` feeds the denial's audit row.
|
||||
IResult Beoordelen(HttpContext ctx, string resource, Func<IResult> action)
|
||||
{
|
||||
if (Authz.CanBeoordelen(ctx.Caller())) return action();
|
||||
AuditAuthz(ctx, "aanvraag:beoordelen", resource, false, Authz.ResolvePrincipal(ctx));
|
||||
var ok = Authz.CanBeoordelen(ctx.Caller());
|
||||
AuditAuthz(ctx, "aanvraag:beoordelen", resource, ok, Authz.ResolvePrincipal(ctx));
|
||||
if (ok) return action();
|
||||
return Results.Problem(detail: "Alleen een behandelaar mag aanvragen beoordelen.",
|
||||
statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
// One gate for the feature-flag toggle — the enforce twin of `flags:manage` (WP-47).
|
||||
IResult FlagsAdmin(HttpContext ctx, Func<IResult> action)
|
||||
// One gate for the feature-flag toggle — the enforce twin of `flags:manage` (WP-47). Takes a
|
||||
// per-call `resource` like Beoordelen does, because the toggle endpoint writes no log line of
|
||||
// its own (BIO-007): a bare "feature-flags" row would say a flag changed without saying which,
|
||||
// and this is the surface CQ-004/ADR-C-009 hinge on.
|
||||
IResult FlagsAdmin(HttpContext ctx, string resource, Func<IResult> action)
|
||||
{
|
||||
var principal = Authz.ResolvePrincipal(ctx);
|
||||
if (Authz.CanManageFeatureFlags(principal)) return action();
|
||||
AuditAuthz(ctx, "flags:manage", "feature-flags", false, principal);
|
||||
var ok = Authz.CanManageFeatureFlags(principal);
|
||||
AuditAuthz(ctx, "flags:manage", resource, ok, principal);
|
||||
if (ok) return action();
|
||||
return Results.Problem(detail: "Alleen een beheerder mag functievlaggen beheren.",
|
||||
statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
@@ -856,12 +979,6 @@ void RecordZgwDivergence(HttpContext ctx, string id, string referentie, Exceptio
|
||||
AuthzAuditStore.Record("zgw:divergence", referentie, allowed: false, Authz.ResolvePrincipal(ctx).Role.ToString(), cid);
|
||||
}
|
||||
|
||||
// Keep the last `keep` characters, mask the rest — mirrors the FE maskTail
|
||||
// (src/app/shared/ui/debug-state/mask.ts) so wire redaction and the dev panel agree.
|
||||
static string MaskTail(string value, int keep) =>
|
||||
value.Length <= keep ? new string('*', value.Length)
|
||||
: new string('*', value.Length - keep) + value[^keep..];
|
||||
|
||||
static string Now() => DateTimeOffset.UtcNow.ToString("o");
|
||||
|
||||
BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new(
|
||||
@@ -875,7 +992,7 @@ BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new(
|
||||
// behandel scherm can show whom/what it concerns without brief/ importing registratie.
|
||||
// The BIG-nummer ships MASKED by default (PRD-0002 §5c, field-level PII); the reveal
|
||||
// endpoint returns the full value, gated + audited.
|
||||
new CaseContextDto(SeedData.Registration.Naam, MaskTail(SeedData.Registration.BigNummer, 3), e.Beroep, BriefSeed.AanvraagReferentie));
|
||||
new CaseContextDto(SeedData.Registration.Naam, Pii.MaskTail(SeedData.Registration.BigNummer, 3), e.Beroep, BriefSeed.AanvraagReferentie));
|
||||
|
||||
// Emit (decision flags, via ToView) and enforce (Forbidden/Conflict below) both run
|
||||
// through Authz — see BriefStore.Review and Authz.CanActOn — so they cannot drift.
|
||||
@@ -886,20 +1003,29 @@ IResult BriefResult(HttpContext ctx, (BriefStore.Outcome outcome, BriefEntity? e
|
||||
_ => Results.Problem(detail: "Ongeldige overgang voor de huidige status van de brief.", statusCode: StatusCodes.Status409Conflict),
|
||||
};
|
||||
|
||||
void LogBrief(string action, (BriefStore.Outcome outcome, BriefEntity? entity) r) =>
|
||||
// RB-07/BIO-007: every brief transition already funnelled through here for its log line,
|
||||
// so the audit row goes here too — a fifth transition cannot be added that logs but leaves
|
||||
// no trail. Resource is the bare "brief" (RB-02: never the owner's BSN); the decision is
|
||||
// the transition's own outcome, so a 403 or a 409 is as visible as a success.
|
||||
void LogBrief(HttpContext ctx, string action, (BriefStore.Outcome outcome, BriefEntity? entity) r)
|
||||
{
|
||||
app.Logger.LogInformation("brief {Action} outcome={Outcome} status={Status}",
|
||||
action, r.outcome, r.entity?.Status.Tag ?? "-");
|
||||
AuditAuthz(ctx, "brief:" + action, "brief", r.outcome == BriefStore.Outcome.Ok, Authz.ResolvePrincipal(ctx));
|
||||
}
|
||||
|
||||
// Audit + outcome for a submit, with NO personal data: only kind, outcome,
|
||||
// generated reference and the caller's correlation id (the observability seam — a
|
||||
// real system ships this to structured logging / an audit store). A repeated
|
||||
// Idempotency-Key short-circuits to the first call's result — see IdempotencyStore
|
||||
// — so a retried submit dedupes instead of minting a second reference.
|
||||
// — so a retried submit dedupes instead of minting a second reference. The key is
|
||||
// scoped to the caller (RB-18/BIO-018): two callers who happen to send the same
|
||||
// client-chosen header value do not share a cached result.
|
||||
IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<DocumentRefDto>? documents = null)
|
||||
{
|
||||
var cid = ctx.Items.TryGetValue("CorrelationId", out var v) ? (string)v! : "none";
|
||||
var idemKey = ctx.Request.Headers.TryGetValue("Idempotency-Key", out var k) && !string.IsNullOrEmpty(k)
|
||||
? k.ToString()
|
||||
? $"{ctx.Caller().SubjectId}:{k}"
|
||||
: null;
|
||||
|
||||
if (idemKey is not null && IdempotencyStore.TryGet(idemKey, out var cached))
|
||||
@@ -936,5 +1062,25 @@ IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<Docum
|
||||
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.
|
||||
public partial class Program { }
|
||||
|
||||
@@ -19,14 +19,26 @@ public static class Professions
|
||||
/// <summary>Every mapping in the data-file, typed.</summary>
|
||||
public static readonly IReadOnlyList<ProfessionMapping> Mappings = StamdataFile.Load<ProfessionMapping>("professions");
|
||||
|
||||
/// <summary>The mappings valid today, as a program→beroep lookup. Consumers that don't
|
||||
/// 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>
|
||||
public static readonly IReadOnlyDictionary<string, string> ByProgram =
|
||||
Mappings.Where(m => StamdataFile.ActiveOn(m.GeldigVan, m.GeldigTot, DateOnly.FromDateTime(DateTime.Today)))
|
||||
/// <summary>The mappings valid on <paramref name="on"/>, as a program→beroep lookup.
|
||||
///
|
||||
/// Takes the peildatum as an argument rather than reading the clock. It used to be a
|
||||
/// <c>static readonly</c> field filtered on <c>DateTime.Today</c>, which evaluated once at
|
||||
/// 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);
|
||||
|
||||
/// <summary>Distinct professions, in declaration order — the list a user may declare
|
||||
/// for a manual (unlisted) diploma.</summary>
|
||||
/// <summary>The mappings valid today. Consumers that don't yet reason about a peildatum
|
||||
/// (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();
|
||||
|
||||
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);
|
||||
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)
|
||||
@@ -32,7 +32,7 @@ internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens)
|
||||
using var res = await SendWithRetryAsync(
|
||||
() => new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(body) }, caller);
|
||||
return (await res.Content.ReadFromJsonAsync<T>())
|
||||
?? throw new InvalidOperationException($"ZGW POST {url} returned null body.");
|
||||
?? throw new InvalidOperationException($"ZGW POST {Redact(url)} returned null body.");
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// retrying risks a duplicate write — the create-zaak/document POSTs are additionally
|
||||
/// protected by OpenZaak's own uniqueness constraint on (bronorganisatie, identificatie).
|
||||
/// A non-transient (or exhausted) failure throws with the status + a body snippet, which
|
||||
/// A non-transient (or exhausted) failure throws with the status + the redacted path, which
|
||||
/// <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).
|
||||
/// </summary>
|
||||
@@ -73,15 +73,26 @@ internal sealed class ZgwHttpClient(HttpClient http, ZgwTokenProvider tokens)
|
||||
continue;
|
||||
}
|
||||
|
||||
var body = await res.Content.ReadAsStringAsync();
|
||||
var snippet = body.Length > 500 ? body[..500] : body;
|
||||
var message = $"ZGW {req.Method} {req.RequestUri} failed: {(int)res.StatusCode} {snippet}";
|
||||
// RB-05/BIO-009: path only — no query string, no response-body snippet. The
|
||||
// BSN-filtered zaken list puts a BSN in the query, and OpenZaak echoes the request in
|
||||
// its error bodies, so both used to reach a message Program.cs persists as a flagged
|
||||
// divergence and writes to the application log. Status + path routes the failure;
|
||||
// ZGW_DEBUG_HTTP=1 (ZgwDiagnosticHandler) is the deliberate opt-in for the rest.
|
||||
var message = $"ZGW {req.Method} {Redact(req.RequestUri)} failed: {(int)res.StatusCode} {res.ReasonPhrase}";
|
||||
var status = res.StatusCode;
|
||||
res.Dispose();
|
||||
throw new HttpRequestException(message, null, status);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests or
|
||||
HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout;
|
||||
|
||||
+88
-124
@@ -194,6 +194,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
@@ -210,45 +220,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/registrations": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RegistratieRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ReferentieResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Content",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/change-requests": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -439,7 +410,14 @@
|
||||
"description": "No Content"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
@@ -708,6 +686,73 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/audit": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AuthzAuditDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/cases/{id}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/werkvoorraad": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -854,73 +899,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/cases/{id}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/audit": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AuthzAuditDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/me": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -1022,6 +1000,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2468,23 +2449,6 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RegistratieRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"diplomaHerkomst": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"documents": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DocumentRefDto"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"RegistrationDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Api.Data;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
@@ -34,7 +35,10 @@ public class AdminCasesTests(TestWebApplicationFactory factory) : IClassFixture<
|
||||
list.EnsureSuccessStatusCode();
|
||||
var cases = (await list.Content.ReadFromJsonAsync<List<ApplicationSummaryDto>>())!;
|
||||
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
|
||||
{
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Api.Data;
|
||||
using BigRegister.Domain.Features;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
@@ -26,6 +28,20 @@ public class AuthzAuditTests(TestWebApplicationFactory factory) : IClassFixture<
|
||||
return (await res.Content.ReadFromJsonAsync<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]
|
||||
public async Task A_denied_admin_action_is_recorded()
|
||||
{
|
||||
@@ -43,6 +59,85 @@ public class AuthzAuditTests(TestWebApplicationFactory factory) : IClassFixture<
|
||||
Assert.Contains(await AuditLog(), e => e.Action == "brief:reveal-bignummer");
|
||||
}
|
||||
|
||||
/// RB-07/BIO-007: the trail used to record only denials, so `/beheer/audit` could answer
|
||||
/// "who was turned away" but not "who changed this" — for a register whose integrity is the
|
||||
/// product, the wrong half. Every gate now audits the real decision.
|
||||
[Fact]
|
||||
public async Task An_allowed_admin_action_is_recorded()
|
||||
{
|
||||
(await _client.SendAsync(Admin(HttpMethod.Get, "/api/v1/admin/cases"))).EnsureSuccessStatusCode();
|
||||
Assert.Contains(await AuditLog(), e => e.Action == "cases:manage" && e.Decision == "allow" && e.Role == "Admin");
|
||||
}
|
||||
|
||||
/// RB-08/BIO-003: the admin upload delete used to be gated by a standalone X-Admin
|
||||
/// header, outside Authz and writing no AuthzAuditStore row at all. Routing it through
|
||||
/// CasesAdmin (cases:manage) gives it the same allow-path row every other admin-cases
|
||||
/// endpoint gets, for free, per RB-07. `CasesAdmin` audits under a fixed "cases"
|
||||
/// resource shared with the other admin-cases endpoints, so this asserts a **count**
|
||||
/// increase — reading the store directly (not via `GET /admin/audit`, itself a
|
||||
/// `CasesAdmin` endpoint that would write its own row and confound the count) —
|
||||
/// rather than mere presence, which this class's other cases:manage calls would
|
||||
/// already satisfy even without the fix.
|
||||
[Fact]
|
||||
public async Task An_admin_upload_delete_is_recorded()
|
||||
{
|
||||
bool IsCasesManageAllow(AuthzAuditEntry e) =>
|
||||
e.Action == "cases:manage" && e.Decision == "allow" && e.Role == "Admin";
|
||||
|
||||
var documentId = await UploadAsOwner();
|
||||
var before = AuthzAuditStore.List().Count(IsCasesManageAllow);
|
||||
|
||||
(await _client.SendAsync(Admin(HttpMethod.Delete, $"/api/v1/admin/uploads/{documentId}")))
|
||||
.EnsureSuccessStatusCode();
|
||||
|
||||
Assert.Equal(before + 1, AuthzAuditStore.List().Count(IsCasesManageAllow));
|
||||
}
|
||||
|
||||
/// The flag toggle writes no log line of its own, so the audit row is the only record that
|
||||
/// it happened — a bare "feature-flags" resource would not say which flag.
|
||||
[Fact]
|
||||
public async Task A_feature_flag_toggle_records_which_flag_changed()
|
||||
{
|
||||
var toggle = Admin(HttpMethod.Put, $"/api/v1/admin/flags/{FeatureFlags.InschrijvingOpen}");
|
||||
toggle.Content = JsonContent.Create(new { enabled = false });
|
||||
(await _client.SendAsync(toggle)).EnsureSuccessStatusCode();
|
||||
|
||||
Assert.Contains(await AuditLog(), e =>
|
||||
e.Action == "flags:manage" && e.Decision == "allow" &&
|
||||
e.Resource == $"feature-flags/{FeatureFlags.InschrijvingOpen}=False");
|
||||
}
|
||||
|
||||
/// Every brief transition funnels through LogBrief, so all four are covered by the audit
|
||||
/// call living there. The allow side is asserted in
|
||||
/// <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]
|
||||
public void The_audit_schema_carries_no_pii()
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Api.Data;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
@@ -150,6 +151,12 @@ public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture
|
||||
var view = (await detail.Content.ReadFromJsonAsync<BeoordelingViewDto>())!;
|
||||
Assert.Equal("Goedgekeurd", view.Aanvraag.Status.Tag);
|
||||
Assert.False(view.Decisions.CanBesluiten); // terminal — no further decision allowed
|
||||
|
||||
// RB-07/BIO-007: the gate records that a behandelaar was allowed to act; this records
|
||||
// what they decided, which is the question /beheer/audit exists to answer.
|
||||
Assert.Contains(AuthzAuditStore.List(), e =>
|
||||
e.Action == "aanvraag:besluit" && e.Decision == "allow" &&
|
||||
e.Resource == $"aanvraag/{a.Id}/Goedkeuren");
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -28,10 +28,14 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
|
||||
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();
|
||||
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);
|
||||
return view.Brief;
|
||||
}
|
||||
@@ -44,10 +48,26 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
|
||||
return req;
|
||||
}
|
||||
|
||||
// --- RB-23/CQ-007: GET /brief is a pure query — it must not create a row. ---
|
||||
|
||||
[Fact]
|
||||
public async Task Get_creates_a_draft_with_expected_sections_locked_and_empty()
|
||||
public async Task Get_returns_404_and_writes_no_row_when_no_brief_exists_for_the_owner()
|
||||
{
|
||||
var brief = await Get();
|
||||
BriefStore.Reset();
|
||||
|
||||
var res = await _client.GetAsync("/api/v1/brief");
|
||||
Assert.Equal(HttpStatusCode.NotFound, res.StatusCode);
|
||||
|
||||
// The non-idempotent write CQ-007 flagged: a GET that allocated a row on first call.
|
||||
// Assert directly against the store, not only the HTTP status, so a regression that
|
||||
// reintroduces GetOrCreate-style seeding fails here even if the response shape stays 404.
|
||||
Assert.Null(BriefStore.Get(DocumentStore.DemoOwner));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SeedBrief_creates_a_draft_with_expected_sections_locked_and_empty()
|
||||
{
|
||||
var brief = await SeedBrief();
|
||||
Assert.Equal("draft", brief.Status.Tag);
|
||||
Assert.Equal(new[] { "aanhef", "kern", "slot" }, brief.Sections.Select(s => s.SectionKey));
|
||||
// aanhef + slot are locked, predefined and prefilled; only kern is editable + empty.
|
||||
@@ -63,7 +83,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
|
||||
[Fact]
|
||||
public async Task Get_offers_only_global_and_arts_scoped_besluit_tagged_passages()
|
||||
{
|
||||
await Get();
|
||||
await SeedBrief();
|
||||
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
|
||||
Assert.NotNull(view);
|
||||
// global passages + the arts-scoped one; no other-beroep passages leak in.
|
||||
@@ -78,7 +98,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
|
||||
[Fact]
|
||||
public async Task Get_joins_the_case_context_with_the_BIG_nummer_masked()
|
||||
{
|
||||
await Get();
|
||||
await SeedBrief();
|
||||
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
|
||||
Assert.NotNull(view);
|
||||
// Case context is joined onto the screen DTO for the behandel scherm header.
|
||||
@@ -128,7 +148,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
|
||||
[Fact]
|
||||
public async Task Save_is_drafter_only()
|
||||
{
|
||||
var brief = await Get();
|
||||
var brief = await SeedBrief();
|
||||
var save = FilledFrom(brief);
|
||||
|
||||
var approver = Post("/api/v1/brief", role: "approver");
|
||||
@@ -142,7 +162,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
|
||||
[Fact]
|
||||
public async Task Submit_blocks_on_empty_required_section()
|
||||
{
|
||||
await Get();
|
||||
await SeedBrief();
|
||||
// Nothing filled yet → required sections empty → 409.
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/submit"))).StatusCode);
|
||||
}
|
||||
@@ -150,7 +170,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
|
||||
[Fact]
|
||||
public async Task Submit_succeeds_when_required_sections_filled()
|
||||
{
|
||||
await Get();
|
||||
await SeedBrief();
|
||||
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
|
||||
Assert.NotNull(view);
|
||||
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(view.Brief));
|
||||
@@ -160,12 +180,17 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
|
||||
var submitted = await res.Content.ReadFromJsonAsync<BriefViewDto>();
|
||||
Assert.NotNull(submitted);
|
||||
Assert.Equal("submitted", submitted.Brief.Status.Tag);
|
||||
|
||||
// RB-07/BIO-007: the allow side of the transition leaves a row, not just a log line.
|
||||
// Resource is the bare "brief" — never the owner's BSN (RB-02).
|
||||
Assert.Contains(AuthzAuditStore.List(),
|
||||
e => e.Action == "brief:submit" && e.Decision == "allow" && e.Resource == "brief");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Drafter_cannot_approve_own_letter_but_a_different_reviewer_can()
|
||||
{
|
||||
var brief = await Get();
|
||||
var brief = await SeedBrief();
|
||||
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||
|
||||
@@ -182,7 +207,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
|
||||
[Fact]
|
||||
public async Task Reject_returns_comments()
|
||||
{
|
||||
var brief = await Get();
|
||||
var brief = await SeedBrief();
|
||||
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||
|
||||
@@ -197,7 +222,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
|
||||
[Fact]
|
||||
public async Task Editing_a_rejected_letter_reopens_it_to_draft()
|
||||
{
|
||||
var brief = await Get();
|
||||
var brief = await SeedBrief();
|
||||
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||
await _client.SendAsync(
|
||||
@@ -213,7 +238,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
|
||||
[Fact]
|
||||
public async Task Send_only_from_approved()
|
||||
{
|
||||
var brief = await Get();
|
||||
var brief = await SeedBrief();
|
||||
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||
|
||||
@@ -231,7 +256,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
|
||||
[Fact]
|
||||
public async Task Decisions_on_the_view_mirror_the_acting_principal_and_live_status()
|
||||
{
|
||||
var brief = await Get();
|
||||
var brief = await SeedBrief();
|
||||
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
|
||||
Assert.NotNull(view);
|
||||
Assert.True(view.Decisions.CanEdit); // default (no X-Role) = drafter, draft status
|
||||
@@ -264,7 +289,7 @@ public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixtu
|
||||
[Fact]
|
||||
public async Task Reset_recreates_a_fresh_draft_with_locked_prefilled_sections()
|
||||
{
|
||||
var brief = await Get();
|
||||
var brief = await SeedBrief();
|
||||
// Advance out of draft so the reset back to draft is observable.
|
||||
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
||||
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||
|
||||
@@ -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
|
||||
{
|
||||
[Fact]
|
||||
public void Manual_diploma_is_rejected() =>
|
||||
Assert.NotNull(SubmissionRules.RejectRegistratie("handmatig"));
|
||||
|
||||
[Fact]
|
||||
public void Duo_diploma_is_accepted() =>
|
||||
Assert.Null(SubmissionRules.RejectRegistratie("duo"));
|
||||
|
||||
[Fact]
|
||||
public void Zero_hours_is_rejected() =>
|
||||
Assert.NotNull(SubmissionRules.RejectZeroUren(0));
|
||||
|
||||
@@ -69,26 +69,6 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<Te
|
||||
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]
|
||||
public async Task Change_request_with_valid_phone_succeeds()
|
||||
{
|
||||
@@ -101,11 +81,16 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<Te
|
||||
}
|
||||
|
||||
[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",
|
||||
new { telefoon = "nope" });
|
||||
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]
|
||||
@@ -215,8 +200,12 @@ public class EndpointTests(TestWebApplicationFactory factory) : IClassFixture<Te
|
||||
public async Task User_delete_blocked_with_409_once_linked_to_submission()
|
||||
{
|
||||
var doc = await Upload(Guid.NewGuid().ToString());
|
||||
var submit = await _client.PostAsJsonAsync("/api/v1/registrations",
|
||||
new RegistratieRequest("duo", new[] { new DocumentRefDto("diploma", "digital", doc.DocumentId) }));
|
||||
// Through the real submit path (RB-06 deleted POST /registrations, which was the only
|
||||
// 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();
|
||||
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]
|
||||
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());
|
||||
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}");
|
||||
req.Headers.Add("X-Admin", "true");
|
||||
req.Headers.Add("X-Role", "admin");
|
||||
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);
|
||||
}
|
||||
|
||||
// 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]
|
||||
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");
|
||||
|
||||
// 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]
|
||||
public void Render_matches_the_golden_file()
|
||||
{
|
||||
@@ -88,6 +126,29 @@ public class LetterHtmlTests
|
||||
Assert.Equal(golden, html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_resolves_the_body_datum_placeholder_from_the_given_at_not_the_wall_clock()
|
||||
{
|
||||
const string historicalAt = "2019-03-14T08:00:00.0000000+00:00";
|
||||
|
||||
var html = LetterHtml.Render(FixtureBriefWithDatumInBody(), Template, historicalAt, watermark: false);
|
||||
|
||||
Assert.Equal("14 maart 2019", ExtractBodyDatumParagraph(html));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_keeps_the_letterhead_date_and_the_body_datum_in_agreement_for_a_historical_at()
|
||||
{
|
||||
// A historical `at` (an archive re-render, a back-dated letter) is the case
|
||||
// where the letterhead and the body datum placeholder could disagree within
|
||||
// one document, if the body still read the wall clock (TE-007).
|
||||
const string historicalAt = "2019-03-14T08:00:00.0000000+00:00";
|
||||
|
||||
var html = LetterHtml.Render(FixtureBriefWithDatumInBody(), Template, historicalAt, watermark: false);
|
||||
|
||||
Assert.Equal(ExtractLetterheadDate(html), ExtractBodyDatumParagraph(html));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_letter_prefixed_class_exists_in_letter_css()
|
||||
{
|
||||
|
||||
@@ -58,8 +58,8 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
|
||||
public async Task Publish_increments_the_version()
|
||||
{
|
||||
ResetStores();
|
||||
// One unsent brief for this sub-org (GetOrCreate on first read).
|
||||
await _client.GetAsync("/api/v1/brief");
|
||||
// One unsent brief for this sub-org (RB-23: GET no longer seeds — create explicitly).
|
||||
await _client.PostAsync("/api/v1/brief/reset", null);
|
||||
|
||||
var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
@@ -74,7 +74,7 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
|
||||
public async Task Publish_appends_to_the_version_history()
|
||||
{
|
||||
ResetStores();
|
||||
await _client.GetAsync("/api/v1/brief");
|
||||
await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: GET no longer seeds — create explicitly
|
||||
|
||||
var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
@@ -87,8 +87,8 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
|
||||
public async Task Publish_counts_the_unsent_briefs_it_affects()
|
||||
{
|
||||
ResetStores();
|
||||
// One unsent brief for this sub-org (GetOrCreate on first read).
|
||||
await _client.GetAsync("/api/v1/brief");
|
||||
// One unsent brief for this sub-org (RB-23: GET no longer seeds — create explicitly).
|
||||
await _client.PostAsync("/api/v1/brief/reset", null);
|
||||
|
||||
var res = await _client.SendAsync(Req(HttpMethod.Post, $"/api/v1/admin/org-template/{Registers}/publish", role: "admin"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
@@ -154,7 +154,8 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
|
||||
private async Task WalkBriefToSentThenRepublish()
|
||||
{
|
||||
ResetStores();
|
||||
var brief = (await _client.GetFromJsonAsync<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
|
||||
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required,
|
||||
s.Required && s.Blocks.Count == 0
|
||||
@@ -210,7 +211,8 @@ public class OrgTemplateEndpointTests(TestWebApplicationFactory factory) : IClas
|
||||
public async Task Admin_cannot_slip_into_the_brief_review_flow()
|
||||
{
|
||||
ResetStores();
|
||||
var brief = (await _client.GetFromJsonAsync<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
|
||||
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required,
|
||||
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()
|
||||
{
|
||||
ResetStores();
|
||||
await _client.GetAsync("/api/v1/brief"); // GetOrCreate the demo draft
|
||||
await _client.PostAsync("/api/v1/brief/reset", null); // RB-23: GET no longer seeds — create explicitly
|
||||
|
||||
var res = await _client.GetAsync("/api/v1/brief/preview");
|
||||
res.EnsureSuccessStatusCode();
|
||||
@@ -54,7 +54,8 @@ public class PreviewEndpointTests(TestWebApplicationFactory factory) : IClassFix
|
||||
public async Task Preview_of_a_sent_brief_serves_the_archive_unchanged_after_a_republish()
|
||||
{
|
||||
ResetStores();
|
||||
var brief = (await _client.GetFromJsonAsync<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.SendAsync(Req(HttpMethod.Post, "/api/v1/brief/submit"));
|
||||
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);
|
||||
}
|
||||
|
||||
/// RB-16/BIO-019: DateOnly.Parse used to throw FormatException on unparseable input,
|
||||
/// surfacing as an unhandled 500 instead of the 400-with-problem-details every other
|
||||
/// bad-input check in this endpoint file returns.
|
||||
[Fact]
|
||||
public async Task Unparseable_peildatum_is_400_not_500()
|
||||
{
|
||||
var res = await _client.SendAsync(Req(HttpMethod.Get, "/api/v1/stamdata/professions?peildatum=not-a-date", role: "admin"));
|
||||
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_table_is_404()
|
||||
{
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using BigRegister.Api.Data;
|
||||
using BigRegister.Domain.Authorization;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
|
||||
@@ -93,4 +95,32 @@ public class StubIdentityProviderTests
|
||||
var caller = Resolve(role: "admin", medewerker: "m.jansen");
|
||||
Assert.Equal(PrincipalRole.Admin, caller.Role);
|
||||
}
|
||||
|
||||
/// RB-09/BIO-002: IIdentityProvider.Resolve can now return null ("no identity"), but this
|
||||
/// stub's own contract stays non-nullable — it is a developer convenience that always invents
|
||||
/// a caller, never a source of "no identity" itself. A request with genuinely no headers at
|
||||
/// all still resolves to the seeded citizen, unchanged.
|
||||
[Fact]
|
||||
public void Never_returns_null_even_with_no_headers_at_all()
|
||||
{
|
||||
Assert.NotNull(new StubIdentityProvider().Resolve(new DefaultHttpContext()));
|
||||
}
|
||||
}
|
||||
|
||||
/// RB-09/BIO-002: in Production, StubIdentityProvider is not registered at all (it is
|
||||
/// Development-only) and there is no real DigiD/employee-SSO IIdentityProvider in this POC yet —
|
||||
/// so a Production build must fail at startup rather than silently resolving every request to
|
||||
/// the seeded citizen (the failure mode BIO-002 documents).
|
||||
public class ProductionIdentityProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Production_environment_with_no_real_identity_provider_fails_at_startup()
|
||||
{
|
||||
using var factory = new WebApplicationFactory<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 mine = queue.Single(x => x.Id == a.Id);
|
||||
Assert.Equal("InBehandeling", mine.Status.Tag);
|
||||
Assert.False(string.IsNullOrEmpty(mine.Owner)); // cross-owner, like /admin/cases
|
||||
// RB-03/BIO-003: masked, like /admin/cases — both inherit ToAdminSummaryDto.
|
||||
Assert.Equal("******782", mine.Owner);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -111,6 +111,32 @@ public class ZgwDivergenceTests
|
||||
Assert.Null(stored.ZgwError);
|
||||
}
|
||||
|
||||
/// RB-05/BIO-009: `ZgwError` is persisted to SQLite and written to the application log, so
|
||||
/// the message it carries may not include the response body (OpenZaak echoes the request in
|
||||
/// its errors) or the request's query string (ZGW filters travel there, and one of them is
|
||||
/// `rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn`).
|
||||
[Fact]
|
||||
public async Task A_recorded_divergence_carries_no_response_body_and_no_query_string()
|
||||
{
|
||||
// The zaak POST succeeds; the statustypen GET — the one call here that carries a query
|
||||
// string — fails, so the recorded message is built from a url that has one.
|
||||
var stub = new ZgwStubHandler(SuccessBody,
|
||||
(url, _) => url.StartsWith($"{ZtBase}/statustypen") ? HttpStatusCode.ServiceUnavailable : HttpStatusCode.OK);
|
||||
using var factory = Factory(stub);
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
var id = await CreateConcept(client);
|
||||
(await client.PostAsJsonAsync($"/api/v1/applications/{id}/submit", new { diplomaHerkomst = "duo" }))
|
||||
.EnsureSuccessStatusCode();
|
||||
|
||||
var error = ApplicationStore.ListAll().Single(a => a.Id == id).ZgwError;
|
||||
Assert.NotNull(error);
|
||||
Assert.DoesNotContain("stub failure", error); // no response-body snippet
|
||||
Assert.DoesNotContain("?", error); // no query string
|
||||
Assert.Contains($"{ZtBase}/statustypen", error); // the path still routes the failure
|
||||
Assert.Contains("503", error);
|
||||
}
|
||||
|
||||
private static HttpRequestMessage AdminRequest(HttpMethod method, string path)
|
||||
{
|
||||
var req = new HttpRequestMessage(method, path);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user