Compare commits

...
85 Commits
Author SHA1 Message Date
ehoandClaude Opus 5 637d500c96 Merge refactor/adr-c-006-shared-route-guards — RB-01..RB-33 + 4 ADR-fixes
CI / changes (push) Successful in 12s
CI / lint (push) Successful in 2m45s
CI / frontend (push) Failing after 11m9s
CI / backend (push) Successful in 2m22s
CI / e2e (push) Successful in 3m25s
CI / semgrep (push) Successful in 1m11s
CI / api-client-drift (push) Successful in 1m55s
CI / storybook-a11y (push) Failing after 15m10s
Closes the CD refactor backlog (docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md).
All 33 code tickets and the four gated ADR-fixes (ADR-C-001, ADR-C-003,
ADR-C-007, ADR-C-009) are merged, one commit per ticket, across six CD
batches plus the ADR-fix batch. npm run ci is green after every merge in
the arc, each verified independently.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:48:10 +02:00
ehoandClaude Opus 5 c30d5ec5a5 docs(backlog): CD batch 6 complete, close the refactor backlog arc
All three tickets RB-31 to RB-33 merged, one commit per ticket. RB-31 found a
genuine ADR-0006 violation: two registratie-wizard tests asserted a state the
real reducer cannot produce. RB-32 closed ADR-0003's own predicted failure
mode with a permanent CI drift guard rather than a one-time fix. RB-33 chose
deletion over adoption for an unused test helper, since manufacturing a first
caller would have removed no real duplication.

This closes the CD implementation phase. All 33 code tickets and the four
gated ADR-fixes are merged; npm run ci is green after every merge in this
arc, each verified independently rather than trusting an agent's own report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:33:09 +02:00
eho 3441dd4c4e Merge RB-31 — replay real messages in 4 machine specs
ADR-C-010: intake, registratie-wizard, besluit and brief machine specs
hand-rolled a state literal, three of them hardcoding errors: {} by hand
instead of running real Msgs through the real reduce (ADR-0006 §2). intake
now shares the existing intake.testing.ts with the acceptance spec instead
of ignoring it; the other three each get a *.testing.ts one-liner.

Found real drift: two registratie-wizard tests asserted a cursor-2 state
reached before any diploma was chosen, which the real reducer cannot
produce (advancing past beroep requires a diploma already set). Replayed at
cursor 1 instead; submit() validates the whole draft regardless of cursor,
so the assertions are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
2026-08-28 13:27:16 +02:00
ehoandClaude Opus 5 dfc6c419f4 refactor(specs): replay real messages in 4 machine specs (RB-31)
Four machine specs built their starting state with a hand-rolled object
literal instead of replaying real Msgs through the real reduce, the
exact anti-pattern ADR-0006 section 2 forbids. Three of the four also
hardcoded errors: {}, a shape the reducer might never actually produce.

intake.machine.spec.ts now imports the existing givenIntake from
intake.testing.ts (previously used only by intake.acceptance.spec.ts).
Three new one-line *.testing.ts files export the same given(reduce,
initial) wrapper for registratie-wizard, besluit, and brief. Every old
literal helper (answering, invullen, editingWith, loaded) is replaced
by a message replay that reaches the same state.

Two tests in registratie-wizard.machine.spec.ts asserted a cursor value
the real reducer cannot reach (cursor 2 with no diploma chosen yet,
which requires a diploma to already be set). Both are re-pointed at the
reachable cursor-1 equivalent; submit() validates the whole draft
regardless of cursor, so no assertion changed. Recorded in
implementation/rb-31.md, not worked around.

No *.machine.ts reducer was touched. All four specs pass; npm run ci
is green (lint, typecheck, dep:check, format, tokens, seam, all four
test suites, both app builds, backend 293/293, api-client drift).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:26:12 +02:00
eho 1a6652b417 Merge RB-32 — add the missing language-switcher row to the CIBG gap register
ADR-C-008: 9 files carried a CIBG-GAP EXTENSION marker against 8 register
rows. language-switcher had a well-formed marker and no row. Adds the row,
plus an optional ~14-line drift guard in check-tokens.sh that diffs the
marker set against the register and fails naming any missing row, so this
drift cannot silently reoccur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
2026-08-28 13:20:31 +02:00
ehoandClaude Opus 5 dd5fd66fb8 docs(backlog): mark RB-33 done after merge
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:20:14 +02:00
ehoandClaude Opus 5 6cfba81a39 docs(shared): add the missing language-switcher row to the CIBG gap register (RB-32)
The register at libs/shared/docs/cibg-gaps.mdx had 8 rows for 9
CIBG-GAP EXTENSION markers in code. language-switcher carries a
well-formed marker with no matching row, exactly as ADR-C-008 and
adr-c-007.md's handoff note flag. Add the row from the component's
own marker comment.

Also add a small guard to check-tokens.sh (folded into check:tokens,
as ADR-C-008 suggests as an optional step): it diffs the marker set
in code against the register's rows and fails CI on drift. Verified
working with a scratch marker file before removing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:15:31 +02:00
ehoandClaude Opus 5 0714b2af34 Merge RB-33 — delete unwrapOk, the unadopted test value-object helper
ADR-C-011: unwrapOk had zero consumers anywhere in apps/ or libs/ since it
shipped, and the one candidate call site (submit-change-request.spec.ts's
inline parse-and-throw guard) already satisfies ADR-0006 section 3's real
requirement, never a cast. Manufacturing a first caller to justify keeping
the helper would remove no real duplication. Deleted rather than adopted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:14:52 +02:00
ehoandClaude Opus 5 531817259e refactor(shared): delete unwrapOk, the unadopted test value-object helper (RB-33)
unwrapOk had zero consumers in apps/ or libs/ since ADR-0006 shipped it.
The one call site the finding named already satisfies the ADR's real
rule (call the real parser, never a cast) with an inline guard, so
adding a manufactured first caller was not the better fix. This commit
deletes the helper and its file, and updates the one doc sentence that
named it. The finding's call site is unchanged. See rb-33.md for the
full adopt-or-delete reasoning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 13:14:21 +02:00
ehoandClaude Opus 5 03c6e09306 docs(backlog): CD batch 5 complete
All seven tickets RB-24 to RB-30 merged, one commit per ticket. Records the
actual wave split, since the backlog's own depends-on column missed that
RB-24 rewrites imports in two of RB-28's target files.

RB-24 expanded its own scope to fix a second, real boundary violation that
deleting its acceptance criterion exposed, reviewed and accepted. Two more
findings were shown stale or overstated, on top of the nine from earlier
batches. RB-26 and RB-27 both correctly declined part of their own ticket's
proposed shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:55:39 +02:00
ehoandClaude Opus 5 c4a5d20202 docs(backlog): mark RB-27 done after merge
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:53:00 +02:00
ehoandClaude Opus 5 a260af6120 Merge RB-27 — extract uploadOutcome from the XHR closure
TE-005: xhrUpload buried the 2xx-vs-not check, JSON.parse-with-fallback and
ProblemDetails mapping inside XHR listener bodies, unreachable without
stubbing the XHR global. uploadOutcome(status, responseText) is now a pure
function with no DOM and no XHR stub in its spec. Abort-vs-error
disambiguation stays where it is: it fires on a different event with no
status or responseText, so it cannot fit the extracted signature. The
optional currentScenario() move into KeepaliveTransport.send() was not
taken, since it would cross into upload-shell.service.ts, outside this
ticket's scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:52:50 +02:00
ehoandClaude Opus 5 e63db509ef refactor(shared): extract uploadOutcome from the XHR load closure (RB-27)
UploadAdapter.xhrUpload built new XMLHttpRequest() directly and put the
actual decisions inside its load listener: 2xx-vs-not, JSON.parse of the
body with a fallback, and ProblemDetails mapping via parseError. None of
it was reachable without stubbing the XHR global, so it had no spec
(TE-005; file LH 5/64, BRH 3/57).

Extract uploadOutcome(status, responseText): Result<string, {
documentId }>, a pure function next to genericError/parseError. It holds
the 2xx check, the JSON.parse-with-fallback, and the ProblemDetails
mapping. The load listener is now a two-line dispatch into it.

Abort-vs-error disambiguation stays where it is: it decides whether a
response exists at all, before uploadOutcome would even run, and the
proposed signature has no field for "aborted". It is already a one-line
ternary with no DOM-only logic to extract.

Add upload.adapter.spec.ts: plain describe/it, no DOM, no XHR stub,
covering a 2xx success, a 2xx unparseable body, a non-2xx ProblemDetails
body, a non-2xx non-ProblemDetails body, and the 200/300 boundary.
Verified red by editing uploadOutcome down to one line (an Edit, not
git checkout): 4 of 5 new specs failed. Re-applied with a second Edit.
Coverage for upload.adapter.ts: LH 5/64 -> 12/65, BRH 3/57 -> 7/59.

Skip TE-005's optional half (moving the currentScenario() branch into
KeepaliveTransport.send()): it needs a second file, upload-shell.
service.ts, and this ticket's own scope fences it to upload.adapter.ts
and its spec. The dev simulator's behaviour is unchanged.

Mark RB-27 implemented in 99-backlog.md and add its implementation note,
including a batch 5 close-out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:52:17 +02:00
eho 6372d452a4 Merge RB-28 — add BLOB_PRESENTER, unlock the blob-to-browser success paths
TE-006: StamdataStore.download(), BriefStore.previewLetter() and
OrgTemplateStore.proefbrief() each ended in raw DOM blob calls jsdom cannot
meaningfully execute, so their success paths were unassertable and
download()'s two-clause guard true-branch was permanently dark.
BLOB_PRESENTER mirrors the SESSION_PORT shape; all three commands go through
it. download()'s branch coverage goes from 40.5% to 67.6%, and
org-template.store.ts gets its first spec at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
#	libs/shared/docs/behaviour-spec.mdx
2026-08-28 08:39:09 +02:00
ehoandClaude Opus 5 ce952941bb refactor(shared): add BLOB_PRESENTER, unlock the blob-to-browser success paths (RB-28)
Three application-layer commands ended in raw DOM calls (URL.createObjectURL,
window.open, document.createElement('a').click(), URL.revokeObjectURL) as
their last statement. jsdom cannot assert a call that is also the end of the
function, so each command's success path stayed unassertable, and
StamdataStore.download()'s two-clause guard stayed permanently dark on its
true branch (TE-006).

Add BLOB_PRESENTER (libs/shared/src/application/blob-presenter.ts), an
InjectionToken mirroring SESSION_PORT's shape: an interface with open()/
download(), a real implementation preserving the existing open()-never-
revokes vs download()-always-revokes asymmetry, provided in root. Route
StamdataStore.download(), BriefStore.previewLetter(), and
OrgTemplateStore.proefbrief() through it.

Add specs with a recording fake presenter: StamdataStore.download()'s guard
(both clauses) and its success path, asserting toJson(...)'s exact output
reaches the file; BriefStore.previewLetter()'s existing success test now
goes through the seam instead of spying on window/URL directly; a new
org-template.store.spec.ts (none existed before) covers proefbrief()'s
success and failure paths.

Verified red without the fix by editing the download() filename to the
wrong extension, watching the success-path spec fail, then restoring it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:38:14 +02:00
eho a0e2985fb3 Merge RB-25 — add the UPLOAD_TRANSPORT injection token
TE-003: UploadShellService documented UploadTransport as the swap seam, then
bound the concrete, unexported KeepaliveTransport class directly, so a spec
could not fake it. UPLOAD_TRANSPORT copies the SESSION_PORT shape; the
default factory returns the same instance, so runtime behaviour is
unchanged. upload-shell.service.ts goes from 0% to 88.57% line coverage
across 16 new specs for upload(), cancel(), delete() and pollReturning().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
#	libs/shared/docs/behaviour-spec.mdx
2026-08-28 08:33:36 +02:00
ehoandClaude Opus 5 adad4513d0 refactor(shared): add UPLOAD_TRANSPORT injection token (RB-25)
UploadShellService injected the concrete KeepaliveTransport class instead
of a token. The class was not exported, so a spec could not fake it, and
could not provide against the UploadTransport interface either, since an
interface is not a DI token. The port existed only on paper.

Add UPLOAD_TRANSPORT, an InjectionToken with a default factory that
resolves the same KeepaliveTransport singleton, following the
SessionPort/SESSION_PORT shape. UploadShellService now injects the token.
Runtime behaviour is unchanged.

Add upload-shell.service.spec.ts: a recording fake transport plus a fake
UploadAdapter exercise upload(), delete(), cancel() and pollReturning(),
the four methods the missing seam left unreachable. Coverage for
upload-shell.service.ts goes from 0% to 88.6% line / 85% branch.

Mark RB-25 done in 99-backlog.md and add its implementation note.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:31:34 +02:00
eho 3162c755d6 Merge RB-26 — extract planFileSelection from the upload controller
TE-004: createUploadController performed three inject() calls, an effect()
registration and a window listener before returning, so the real policy
buried inside it — deciding per file whether to reject or start an upload —
was reachable only through a TestBed. planFileSelection in upload.machine.ts
is now that decision as a pure function taking plain {name, type, size}
objects; the controller executes the plan and keeps the one impure step
(crypto.randomUUID()) it can't move. No change to the controller's public
surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
#	libs/shared/docs/behaviour-spec.mdx
2026-08-28 08:29:36 +02:00
ehoandClaude Opus 5 95bb77395e refactor(shared): move the accept/reject decision into planFileSelection (RB-26)
createUploadController required inject(), an effect(), and a window listener
before a test could reach it. The file-selection policy trapped behind that
cost now lives in a pure function, planFileSelection, in upload.machine.ts.

planFileSelection takes plain { name, type, size } objects, not File, and
decides per file whether to reject it or accept it, with no I/O. The
controller executes the plan: it dispatches a rejection as-is, and starts the
upload for an accepted file (the one step that needs crypto.randomUUID()).

A new spec covers the three outcomes: the 'multiple' batch rejection, a
rejectReason-based rejection, and the accept case, plus order in a mixed
batch. Verified red-then-green with a temporary stub, undone by a second edit.

No change to the controller's public surface or to the calling organism.
previewUrlFor (added by RB-24) is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:28:30 +02:00
eho e304211715 Merge RB-30 — extract BriefStore's guards into Domain/Letters/BriefRules.cs
TE-008: five guard decisions in BriefStore (Save, Submit, Send, the shared
Approve/Reject review path) were pure functions of status tag, actor role and
entity completeness, but each sat inside a lock-held, DB-opening method, so a
spec could not exercise a decision without a booted host and a real SQLite
file. BriefRules.cs holds the five pure statics; BriefStore keeps its lock,
its Db.Create(), its static shape and every method signature. 29 new
free-running unit assertions in BriefRuleTests.cs; the existing host-booting
brief endpoint tests are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
#	libs/shared/docs/behaviour-spec.mdx
2026-08-28 08:08:00 +02:00
eho 80b792caa9 Merge RB-29 — resolve the body datum placeholder from at, not UtcNow
TE-007: Render already accepts the letter's instant and uses it correctly for
the letterhead, but the body's datum placeholder resolved through ResolveAuto,
which ignored at and read DateTimeOffset.UtcNow. Threaded at through
RenderParagraphs and RenderNode, both already in Render's call chain with at
in scope. Zero public API change, zero call-site change. The bug this
prevents: re-rendering an archive or back-dating a letter would otherwise make
the letterhead and body dates disagree within a single document.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
2026-08-28 08:01:39 +02:00
ehoandClaude Opus 5 ddd02f65bc fix(backend): resolve the body datum placeholder from at, not UtcNow (RB-29)
LetterHtml.Render already receives the letter's instant and uses it
for the letterhead date. The body's "datum" placeholder resolved
through ResolveAuto, which ignored that instant and read the wall
clock instead. This is not a shipped bug today, because every current
caller passes Now() at render time. It becomes one the moment Render
runs with a historical instant (an archive re-render, a back-dated
letter): the letterhead and the body would then disagree within one
document.

Thread the existing "at" parameter down through RenderParagraphs and
RenderNode into ResolveAuto's "datum" case. Render's own signature,
and every call site, stays unchanged.

Add two tests with a fixed historical "at": one pins the body's
rendered date to the expected Dutch string, the other asserts the
letterhead date and the body date agree. Both fail red against the
old code, showing today's date instead of the pinned one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 20:42:58 +02:00
ehoandClaude Opus 5 07bb6277c0 refactor(backend): extract brief guards into Domain/Letters/BriefRules.cs (RB-30)
BriefStore's five guard decisions (Save, Submit, Send, and the shared
Approve/Reject review path) were pure functions of status tag, actor role,
and entity completeness, but each sat inside a lock-held, DB-opening
method. A spec could not exercise the decision without a booted host and
a real SQLite file.

Extract the guards into a pure Domain/Letters/BriefRules.cs. BriefStore
keeps its lock, its Db.Create(), its static shape, and every method
signature — only the if cascades move. Add BriefRuleTests.cs (29
assertions, ~120 ms, no host boot) covering every branch, including the
rejected-to-draft reopen on save, the required-filled gate on submit,
and the non-drafter and self-review denials. The existing host-booting
brief endpoint tests are unchanged and still pass, proving the
extraction preserved behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 20:42:00 +02:00
ehoandClaude Opus 5 693016445b Merge RB-24 — move upload/ into its proper layers, delete the carve-out
ADR-C-002: libs/shared/src/upload/ held a network adapter outside
infrastructure/ and the only Elm machine outside a domain/ folder, and the
dependency-cruiser rule was written around the violation rather than the
violation being fixed. The five files move to infrastructure/, domain/ and
application/, and the ^libs/shared/src/upload/ carve-out is gone.

Deleting the carve-out exposed a second, real violation that the old path had
hidden from the ui-not-infrastructure rule: three UI components injected
UploadAdapter for nothing but a one-line wrapper over its own exported pure
uploadContentUrl. They now read previewUrlFor from their application-layer
collaborator. dep:check passes for both apps with the clause removed, which is
the ticket's acceptance criterion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 20:41:54 +02:00
ehoandClaude Opus 5 9520d6c24e refactor(shared): move upload/ into infrastructure/domain/application (RB-24)
libs/shared/src/upload/ held a network adapter, an Elm machine, and two
application-layer coordinators outside the folder-per-layer convention every
other context follows. The dependency-cruiser rule carved an exception around
the misplaced adapter instead of the violation being fixed.

Move all five files to the layer each belongs to (git mv), update every
import across 24 consumer files, then delete the carve-out clause from
.dependency-cruiser.base.js. No export renamed, no file split, no spec
content changed.

Deleting the carve-out exposed a second, pre-existing rule violation:
ui-not-infrastructure had never fired against upload.adapter.ts because its
old path did not match /infrastructure/. Three UI components injected
UploadAdapter directly for its one-line contentUrl() wrapper. Route each
through the existing pure uploadContentUrl() function via the application
layer (upload-controller's new previewUrlFor, OrgTemplateStore's new
previewUrlFor) instead — the same idiom brief.store.ts already used.

npm run ci passes; dep:check is clean for both apps with the carve-out gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 20:40:41 +02:00
ehoandClaude Opus 5 424ceb604b docs(backlog): CD batch 4 complete
All six tickets RB-18 to RB-23 merged, one commit per ticket. Records the two
incomplete tickets that the agents reported, RB-22's deliberate departure from
the runResult idiom, and how RB-19 was verified as a pure reorder.

Adds five dispatch lessons. The stale worktree base is now the rule at 11 of 13
agent-runs. A spend limit killed four agents mid-flight and a message resumed
each one from its own transcript, so no work was redone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:21:01 +02:00
ehoandClaude Opus 5 dc096d98e2 Merge RB-19 — reorder Program.cs sections into reads then writes
CQ-006: the file declared direction as its organising principle, then switched
to feature grouping without saying so, and five sections interleaved reads and
writes. Each section now orders reads first, with the WP-65 sub-banner pair.
DELETE /admin/cases/{id} and GET /admin/audit move up beside GET /admin/cases,
129 lines closer. The org-template preview moves to the org-template section.

Pure reordering. Verified centrally: the sorted list of all 47 route strings is
identical before and after, and so is every (route, .Gate marker, wrapper called
in the handler) triple. The swagger.json and api-client.ts diffs are ordering
only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:20:11 +02:00
ehoandClaude Opus 5 e270b8612f refactor(backend): reorder Program.cs sections into reads-then-writes (RB-19)
CQ-006 found that Program.cs states a reads-then-writes principle at the
top of the file, then abandons it for five feature sections that mix GET
and mutating endpoints in mapping order. This is a pure reorder: within
Document upload, Applications, Admin cases, Brief, and Organization
templates, every GET now precedes every POST/PUT/DELETE, each split by a
`--- reads ---`/`--- writes ---` sub-banner in the style WP-65 already
established for Beoordeling/Besluit.

DELETE /admin/cases/{id} and GET /admin/audit move up beside GET
/admin/cases, closing the 129-line gap CQ-006 measured. GET
/admin/org-template/{subOrgId}/preview moves from the Brief section to
the Organization-templates section it actually belongs to.

No route, signature, DTO, or handler body changed. Every block was cut
by exact line-range slicing, never retyped. The sorted list of mapped
HTTP-method-plus-path strings is byte-identical before and after; every
.Gate(...) count is unchanged; the three routes that moved with a gate
were checked by eye against the wrapper their handler actually calls,
per RB-12's stated limitation that the route-table test only proves a
marker is present, not that it still matches the handler.

npm run gen:api regenerated backend/swagger.json and
libs/shared/src/infrastructure/api-client.ts; both diffs are ordering
only (sorted-file diff is empty), committed alongside per the ticket's
own guidance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:16:05 +02:00
ehoandClaude Opus 5 edd20c06df docs(backlog): mark RB-23 done after merge
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:02:09 +02:00
ehoandClaude Opus 5 9f3814d8b9 Merge RB-23 — make GET /brief a pure query that 404s when absent
CQ-007 contract half. GET /brief was the only backend endpoint where a GET
performed a persisted write, and the FE retries GETs automatically, so a
transient failure could enter the create path. BriefStore.GetOrCreate splits
into Get plus the existing ResetAndCreate. GET /brief/preview shared the same
call site and gets the same treatment. RB-22 already made the FE tolerate the
404, so the pair is complete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:01:42 +02:00
ehoandClaude Opus 5 d0fda08bcc fix(brief): make GET /brief a pure query, 404 when absent (RB-23)
GET /brief allocated a row on first call (BriefStore.GetOrCreate) — the
one endpoint in the backend where a read performed a persisted write.
The FE retries GETs automatically, so a transient failure could enter
the create path more than once; a lock prevented a duplicate row, but
the safety depended on the lock, not on the endpoint being a query.

Split GetOrCreate into Get (a pure query) and the already-existing
ResetAndCreate (POST /brief/reset owns creation). GET /brief now 404s
when the owner has no brief yet. GET /brief/preview used GetOrCreate
too, so it gets the same Get + 404 treatment, forced by the split.

RB-22 already made BriefStore.load() on the FE tolerate a 404 by
calling reset() once; this ticket is what makes that branch live.

Updated the brief/preview/org-template backend tests that assumed
GET seeded a brief on first call to create one explicitly first, and
added a test that GET 404s and writes no row without the fix (verified
red beforehand). Regenerated the API client (npm run gen:api).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 19:01:06 +02:00
eho 05dff974bf Merge RB-22 — tolerate a 404 on GET /brief with a one-shot reset
CQ-007 expand half. BriefStore.load() treats a 404 as 'no brief yet' and calls
the existing reset() command once. load()'s error channel becomes the
BriefLoadFailure union, because runResult folds the HTTP status away and the
store needs it. Today's backend never 404s, so the branch is a no-op until
RB-23 lands the contract half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 18:44:20 +02:00
ehoandClaude Opus 5 7a29f5facc feat(brief): tolerate a 404 on GET /brief with a one-shot reset (RB-22)
BriefStore.load() now treats a 404 from GET /brief as "no brief exists
yet" and calls the existing reset() command once, instead of showing
the generic load-failed error. BriefAdapter.load() gains a
BriefLoadFailure error channel (notFound | error) so the store can
tell a 404 apart from every other failure; every other adapter method
stays on runSubmit, unchanged.

The once-only bound is a field on the store, not a comment: a second
404 (from a later load() call) always falls through to the ordinary
error path, and the recovery path never calls load() again, so no
loop can form.

This is the expand half of CQ-007's split (04-cqrs-light.md). Today's
backend never 404s GET /brief, so the new branch is dead code until
RB-23 (the backend contract half) ships in a later merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:43:27 +02:00
eho 6c5c4cb249 Merge RB-20 — route cancel and delete through runSubmit, surface the error
CQ-002: ApplicationsStore.cancel and AdminCasesStore.delete reached the raw
ApiClient and swallowed the failure in a bare catch, so a failed cancel made the
row reappear with no message. Both now fold through runSubmit and expose
lastError, which the two pages render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 18:33:38 +02:00
eho 9666790d65 Merge RB-18 — key IdempotencyStore on caller plus idem key
BIO-018: the store was a process-global dictionary keyed on the client-supplied
Idempotency-Key alone, so one caller could replay another caller's key and
receive their cached response. The key is now scoped with the caller SubjectId.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 18:33:12 +02:00
ehoandClaude Opus 5 7def4a7552 fix(ssp): route cancel/delete through runSubmit, surface the error (RB-20)
ApplicationsStore.cancel and AdminCasesStore.delete rolled an optimistic
write back on failure but showed no message — a bare catch with no
Result and no error channel (CQ-002). Both now call runSubmit and set a
lastError signal on failure, mirroring createSubmitChangeRequest in the
same folder. Each page renders the error with the existing app-alert
atom, the same pattern brief.page.ts already uses for lastError.

Added a spec file for ApplicationsStore (none existed) and extended
AdminCasesStore's spec, each asserting the rollback AND the surfaced
error. Verified both new assertions fail without the fix (an Edit
undo/redo of the store method, not git checkout).

Regenerated libs/shared/docs/behaviour-spec.mdx (gen:behaviour-spec) to
pick up the new/renamed test names. Marked RB-20 done in 99-backlog.md
and recorded the change in implementation/rb-20.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:32:51 +02:00
ehoandClaude Opus 5 0adf831fb9 Merge RB-21 — extract the read half of createDraftSync into find-concept.ts
CQ-001: createDraftSync was the longest function in the repo and owned three
query paths next to its write path. findConcept and loadConcept are now free
functions that take the adapter, so they have a direct spec without TestBed.
The closure state (id, ensuring, resumeGate) stays where it was, because the
coupling is load-bearing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:29:20 +02:00
ehoandClaude Opus 5 25a5d415a5 docs(adr): land ADR-C-001, ADR-C-003, ADR-C-007 and ADR-C-009
The architect approved the four ADR-fix tickets. All four change what the
architecture documents claim. No code changes.

ADR-0001, ADR-C-001: the worked example claimed the POC has no real backend.
It rewrites against `backend/src/BigRegister.Api`. Every path it named is
repointed. The out-of-scope list drops two discharged bullets: 33 `parse*`
boundaries exist, and `npm run gen:api` is real.

ADR-0001, ADR-C-003: a new section states that the generated client is the wire
contract. A hand-written `contracts/*.dto.ts` is the exception for two cases
only. The four survivors stay, because NSwag emits every property as optional
and flattens `RegistrationStatusDto` into five optional strings. The `parse*`
trust boundary stays mandatory, because a generated type is a compile-time
claim about the wire and not a runtime guarantee.

ADR-0003, ADR-C-007: four paths moved in WP-67 and are repointed. Point 4 kept
the principle and changed its example to `skeleton` and `spinner`. Two of its
claims were false and the amendment says so: `app-alert` wraps the vendored
`.feedback` classes, and `site-header` composes the vendored `.titlebar`.

ADR-0004, ADR-C-009: the exception section states a four-part test instead of
one named exception. `OrgTemplateStore` and `FeatureFlagStore` both pass it. RB-07
gated this ticket, because clause 4 needs an audited allow path. RB-07 landed
that, so the ADR does not ratify a control that the code lacks.

Three tickets need a matching CLAUDE.md correction in the same diff. CLAUDE.md
section 2 loses the false `alert` example. Section 4 gets the generated-client
rule and the four-part test.

Two findings were wrong. ADR-C-001 asked to keep an out-of-scope bullet that
reads "SessionStore is in-memory". The session persists to `localStorage` now,
so the bullet covers multi-tab sync only. ADR-C-007 flagged one half of point 4
and missed that the other half is equally false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:29:05 +02:00
ehoandClaude Opus 5 4631556e68 fix(backend): key IdempotencyStore on caller + idem key (RB-18)
IdempotencyStore keyed a replayed submission on the raw Idempotency-Key
header alone. Two different callers who send the same header value
shared one cache slot: the second caller received the first caller's
cached reference instead of running its own submission.

Program.cs now composes the key as "{SubjectId}:{idemKey}" in the
Submit helper, so the cache is scoped per caller. Add a test that
proves a caller cannot replay another caller's idempotency key and
receive their cached result.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:27:52 +02:00
ehoandClaude Opus 5 d518a1466c refactor(registratie): extract the read half of createDraftSync (RB-21)
createDraftSync mixed a read path (findConcept, load, the read half of
resume) with its write path (ensureId, flush, submit, reset) in one
187-line function -- CQ-001's finding. Move findConcept and loadConcept
into a new application/find-concept.ts as free functions that take the
adapter, so they get a direct spec with no Angular TestBed.

createDraftSync keeps the closure state (id, ensuring, resumeGate) and
the whole write path unchanged -- this is a move, not a redesign. The
resumeGate coupling that lets the write path wait for the read path
stays exactly where it was.

createDraftSync shrinks from 187 to 169 lines. draft-sync.spec.ts is
unchanged -- it never called resume()/load() directly, and its 409
recovery test for submit() still exercises the extracted findConcept
through ensureId's catch branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:23:13 +02:00
ehoandClaude Opus 5 7fbac8fca5 docs: write English prose in Simplified Technical English
Adds a Conventions rule for Simplified Technical English (ASD-STE100). It
covers documentation, code comments, commit messages, ADRs, and the backlog
notes. STE is a controlled language. It makes text easy to read for people
who do not have English as a first language, and easy to translate. The
readers of this project are mostly non-native English readers.

The rule states that STE governs form, not content. Split a long sentence.
Never remove a caveat, a measurement, or a precise term to make text shorter.

The rule does not apply to Dutch identifiers, $localize copy, quoted output,
or existing documents that you are not already editing. It therefore does not
change the Naming convention above it, which keeps domain contexts in Dutch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:03:37 +02:00
ehoandClaude Opus 5 bc5b2c4b2d docs(backlog): CD batch 3 complete
All six merged, gate green (14 steps, backend 260/260). Records the three
tickets that could not be built as written — RB-12's wrapper/public binary
does not fit the route table, RB-14's command exits 0 on a High advisory, and
RB-15 needed a third environment name because RB-09 makes Production fail to
boot — plus RB-13's measured duplication drop (168 -> 32 lines per side).

Adds a section on dispatching implementation agents. Four of six agent-runs
were handed a worktree branched from a stale ancestor; batch 3 was three for
three. That, the background-task parking, and the git-checkout-destroys-work
trap are all cheap to prevent in the prompt and expensive to discover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 17:01:41 +02:00
eho 2a28db4aac Merge RB-12 + RB-15 + RB-16 — route-table authz gate, Swagger dev-only, peildatum 400
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 16:58:41 +02:00
eho ab0ec62f6a Merge RB-13 — land Session -> Principal, add MedewerkerAdapter
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 16:58:34 +02:00
ehoandClaude Opus 5 f19185ed81 refactor(auth): land Session -> Principal, add MedewerkerAdapter (RB-13)
ADR-0002 SS3 models Zorgverlener/Medewerker as different Principal
variants with different login flows. Actor #2 (apps/behandelportal)
landed in WP-61/67 and the union never followed: grep -rn "Principal"
returned one hit, a comment. Both apps' auth/domain/session.ts stayed
byte-identical (`{ bsn, naam }`), so the backoffice's Behandelaar
carried a BSN and logged into the backoffice as a citizen, by DigiD,
under a fabricated citizen's name (login.page.ts). The divergence
ADR-0002 predicted took an orthogonal side door instead
(medewerker.interceptor.ts's X-Medewerker/X-Rollen stamp, which never
touches SessionStore) -- which is why ssp/auth and bhp/auth still
measured as 100%/84% duplicated after ADR-C-006 shared the route
guards. RB-09 (landed the day before) made the backend's
IIdentityProvider able to say "no identity" and fail closed; this
ticket is its named FE half.

Each app's auth/domain/session.ts becomes principal.ts, holding the
one Principal variant that app actually has an actor for: ssp keeps
`{ kind: 'zorgverlener', bsn, naam }` (G1 still strips the BSN before
persisting); behandelportal gets `{ kind: 'medewerker', medewerkerId,
naam, rollen }` (no BSN to strip -- G2 shape validation only). A new
MedewerkerAdapter replaces DigidAdapter in behandelportal, resolving
the existing MEDEWERKER_ID/currentRollen() dev stand-in into a
Principal; because there is no credential to check, it returns
Principal directly rather than a Result whose error variant could
never occur. login.page.ts stops being a BSN/wachtwoord form -- one
explainer line and an "Inloggen met SSO" button -- and its dead
error-handling branch goes with the Result wrapper that justified it.

Measured with tools/baseline-scan.mjs --dup: auth duplication drops
from 168/168 (ssp) and 168/200 (bhp) to 32/179 and 32/259 -- under the
backlog's <40 target. What remains is the ADR-C-006 route-guard
re-export (deliberately identical), generic test/story-file
boilerplate, and one shared fragment of the root-singleton-store
idiom -- not re-converged identity or login-flow logic. SS3's
prediction that the two actors would authenticate differently enough
to justify not sharing auth has now actually been tested, not just
asserted, and held.

Also: renamed Session.bsn to Principal.bsn in two doc comments
(libs/shared/src/infrastructure/subject.ts, subject.interceptor.ts)
that cited the old type name; regenerated
libs/shared/docs/behaviour-spec.mdx (generated file, per its own
banner); recorded the resolution in ADR-0002 as a new amendment,
replacing its "Known debt" section.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:54:26 +02:00
ehoandClaude Opus 5 b617d2f09a docs: regenerate behaviour-spec for RB-12/RB-15/RB-16
New backend test classes (RouteInventoryTests, SwaggerGateTests) plus
one added case to StamdataEndpointTests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:53:52 +02:00
ehoandClaude Opus 5 2627799284 fix(backend): 400 instead of 500 on an unparseable peildatum (RB-16)
BIO-019: GET /stamdata/{table}?peildatum= called DateOnly.Parse
directly, which throws FormatException on anything unparseable — an
unhandled 500 (leaking exception detail in Development) instead of
the 400-with-problem-details every other bad-input check in this file
returns. §3c named backend/Stamdata's 71.7% branch coverage (BL-005)
as the weak spot this bug lived in.

Switched to DateOnly.TryParse; an unparseable value now returns
Results.Problem(detail: ..., statusCode: 400), matching the shape the
upload/change-request endpoints already use. Endpoint doc gained
.ProducesProblem(400), so the OpenAPI doc + generated client were
regenerated and committed in this same diff (RB-09's note records a
prior incident where a response-shape change shipped without this and
the drift went unnoticed).

No FE change needed: libs/beheer's stamdata adapter already funnels
every call through runSubmit, which folds any thrown ApiException
(now including this 400) into a generic Result error — ADR-0001's
"the FE renders the decision" already covers "the server rejected
this input".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:53:29 +02:00
ehoandClaude Opus 5 a93218e8ac fix(backend): gate Swagger + the OpenAPI doc behind IsDevelopment (RB-15)
BIO-015: app.UseSwagger()/app.UseSwaggerUI() ran unconditionally, so
the full OpenAPI document (every route + request/response shape) and
SwaggerUI's interactive "Try it out" were reachable in every
environment, including a real deployment.

Both now run only inside `if (app.Environment.IsDevelopment())`.
AddSwaggerGen/AddEndpointsApiExplorer stay unconditional — DI
registration only, no HTTP surface by itself.

RB-09 already made a non-Development environment throw at startup,
which broke `npm run gen:api` until that script pinned
ASPNETCORE_ENVIRONMENT=Development for its one CLI invocation. This
change sits in the same pipeline, so it was verified rather than
assumed: `dotnet swagger tofile` resolves ISwaggerProvider straight
out of DI and never sends an HTTP request through this middleware, so
gating it can't affect that tool by construction. Ran the real
`npm run gen:api` to confirm — exit 0, regenerated files byte-identical
to what's committed.

New tests exercise the gate on a third ("Staging") environment name,
not Production — Production already can't boot at all post-RB-09, so
a Production-environment test would only re-prove that unrelated
startup throw, not this gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:40:23 +02:00
ehoandClaude Opus 5 ee0d449510 test(backend): assert every route is authz-gated (RB-12)
BL-006: the backend has zero automated architecture enforcement.
BIO-016 names the concrete consequence for authorization — nothing
asserted the *set* of gated endpoints, so BIO-003's X-Admin gate
(outside Authz) and BIO-004's two ungated endpoints were caught only
by a human reading Program.cs, not by CI.

Adds RouteInventoryTests: walks the real app's EndpointDataSource and
asserts every mapped route either carries a .Gate("XAdmin") metadata
marker (added at the 16 call sites that already call one of the five
admin wrappers — OrgAdmin/StamdataAdmin/CasesAdmin/Beoordelen/
FlagsAdmin) or appears in a written-down, reasoned allow-list. Proved
it's hard to fool by adding a throwaway unguarded route, watching the
test go red, and reverting.

The allow-list is not "public routes" as the ticket's shorthand put
it — 19 of its 31 entries are ownership-scoped inline (ctx.Zorgverlener()/
ctx.Caller()) endpoints, not public ones, and labelling them public
would misrepresent the exact property BIO-004 was about. Each entry
instead carries its own reason. Implementation note has the full
route-by-route breakdown and judgement calls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:36:30 +02:00
ehoandClaude Opus 5 1c5442d797 Merge RB-17 — split runResult out of runSubmit
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:34:16 +02:00
ehoandClaude Opus 5 80de261299 refactor(shared): split runResult out of runSubmit (RB-17)
runSubmit did two things at once: fold a call into a Result, and mint
an Idempotency-Key for it. Five call sites are reads and had no
business minting one — brief.adapter.ts:load, org-template.adapter.ts
:list/:load, and stamdata.adapter.ts:list/:load. stamdata.adapter.ts's
own docstring already said "Both endpoints are reads … There is no
write method" while both called runSubmit; that mismatch is the
sharpest evidence, and the reason the baseline's original "~13
mutations" count (derived from the helper's name, not the code) was
wrong by five in one direction.

Split submit.ts in place: runResult is the try/catch + problemDetail
fold with no mint; runSubmit is runResult wrapping
withIdempotencyKey. Zero behaviour change for the 8 real mutations
(brief save/submit/approve/reject/send/reset, org-template
save/publish/rollback) — same fold, same mint, same timing. The five
reads now run the fold with no pendingIdempotencyKey touched.

submit.spec.ts asserts the split behaviourally via
currentIdempotencyKey() (two reads inside the same call agree only
when a key was minted and reused) rather than mocking a relative
import, matching this repo's existing vitest convention. Verified red
without the fix by temporarily reintroducing the mint into runResult.

ApplicationsStore.cancel/AdminCasesStore.delete (RB-20) and
FeatureFlagStore.set are out of scope and untouched — the latter
already calls runSubmit correctly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:31:13 +02:00
ehoandClaude Opus 5 adfaa32a42 ci: gate on known advisories in the .NET dependency tree (RB-14)
npm audit --omit=dev gates the shipped frontend bundle; nothing equivalent
existed for the backend, so the entire .NET dependency tree — direct and
transitive — was unscanned (BIO-016 lists it first under "Absent").

The ticket's literal wording would not have worked. `dotnet list package
--vulnerable` is a reporting command: it prints the advisory table and exits
0 regardless. Verified with a throwaway project on System.Net.Http 4.3.0 —
severity High, GHSA-7jgj-8wvc-jh57, exit code 0. A bare `- run: dotnet list
package --vulnerable` would have added a line that reads like coverage in a
compliance review and enforces nothing, which is worse than leaving the gap
visible.

scripts/dotnet-audit.sh runs the scan and matches "has the following
vulnerable packages" — the exact sentence dotnet prints per project on a hit.
One script, two callers (ci.yml and ci-local.sh), so the workflow and the
local gate cannot drift apart.

No severity threshold and no suppression list: picking either before a real
advisory forces the question would be guessing at a policy nobody needs yet.
Secret scanning, BIO-016's other named absence, stays on the checklist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:24:16 +02:00
ehoandClaude Opus 5 988612cd7e docs(backlog): CD batch 2 complete
All five tickets merged and green on the fixed gate (13/13 steps, exit 0).
RB-07 unblocks ADR-C-009; RB-09 unblocks RB-13 in batch 3.

Adds a "Gate integrity" section recording that every earlier "ci green" in
this file predates the ci-local.sh errexit fix and is weaker than it reads.
Batch 1 has not been re-verified under the honest gate, and the note says so
rather than leaving a reader to assume it was.

Also records what batch 2 leaves open: RB-01's residual is NOT solved by
RB-09 (the upload-content link is still a plain browser navigation with no
credential), and a non-Development non-Production environment fails fast at
GetRequiredService rather than at RB-09's deliberate throw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:37:20 +02:00
ehoandClaude Opus 5 6bdfa35abb build: stop ci-local.sh swallowing the first half of every paired step
Under `set -e` bash exempts every command of an AND-OR list except the last,
so `npm run gen:api && git diff --exit-code ...` silently swallowed a CRASH
in gen:api: the diff never ran and the script sailed on to print "local CI
passed". Verified directly — `bash -c 'set -e; false && true; echo hi'`
prints hi and exits 0, while `false; true` exits 1.

This was not hypothetical. It hid a real gen:api crash introduced by RB-09
(dotnet swagger's design-time host defaults to Production, which that ticket
made throw at startup). .github/workflows/ci.yml would have caught it, since
it runs each step as its own `- run:` — so the local gate was strictly weaker
than the remote one, which is the opposite of its stated purpose.

Six steps were affected. The worst was `ng build ssp --localize && ng build
behandelportal --localize`: a missing English translation in ssp — the exact
thing the second-locale gate exists to catch — could not fail the run.

The one `( cd backend && ... )` step is safe as-is and left alone: a subshell
propagates its own non-zero status, so errexit sees it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:31:50 +02:00
eho 2fa96c300c Merge RB-08 + RB-09 — CasesAdmin on the admin upload delete; no-identity representable
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 14:30:41 +02:00
ehoandClaude Opus 5 c6bc6dd4c3 docs(backlog): RB-11 done
Also records what RB-11 turned up: BIO-012 was factually wrong that the
proefbrief error mapping was already a separate function (it was inlined in a
try/catch), and the step-up literal is still a literal, moved one layer up to
the only caller rather than eliminated — BIO-006(c) stays a production gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:29:50 +02:00
ehoandClaude Opus 5 d089151dbd docs: record the gen:api regression found while verifying RB-09
Documents the dotnet swagger tofile crash discovered by actually
running the affected command (not just trusting ci-local.sh's local
"passed" line, which turned out to mask this exact failure via a
set -e && short-circuit gotcha), its root cause, and the two follow-up
fixes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:22:26 +02:00
eho c336328cff Merge RB-11 — keep the dev hatches out of production builds
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 14:21:51 +02:00
ehoandClaude Opus 5 436e18421b fix(tooling): keep gen:api working under RB-09's Development-only stub
RB-09 registers StubIdentityProvider only under IsDevelopment() and
throws for Production. `dotnet swagger tofile` (npm run gen:api) loads
the same Program.cs through .NET's design-time HostFactoryResolver,
which executes the app's startup code (including the unconditional
app.Services.GetRequiredService<IIdentityProvider>() the identity
middleware already relied on) without ever setting
ASPNETCORE_ENVIRONMENT - so it now defaults to Production and crashes
(dotnet swagger tofile exited 134), breaking `npm run gen:api`
entirely, including the "api-client drift" job in
.github/workflows/ci.yml (a separate `- run:` step there, so this
would fail real CI even though ci-local.sh's `cmd1 && cmd2` step
shape happens to swallow a cmd1 failure under `set -e` and reports
"passed" - a separate, pre-existing script fragility, not touched
here).

Real usage is unaffected: dotnet run already sets
ASPNETCORE_ENVIRONMENT=Development via launchSettings.json, and
docker-compose.yml/docker-compose.prod.yml already set
Development/Production explicitly - gen:api's bare CLI invocation was
the one place with no environment variable at all. Set it to
Development inline, the same value launchSettings.json already uses
for the real app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:21:34 +02:00
ehoandClaude Opus 5 1fad740606 fix(api): regenerate client for RB-08's 403 response shape
RB-08 changed DELETE /admin/uploads/{documentId}'s 403 mapping from
.Produces to .ProducesProblem, matching CasesAdmin's actual
Results.Problem() response - but the generated OpenAPI doc and typed
client were never regenerated alongside it, so CI's api-client-drift
check (npm run gen:api && git diff --exit-code) was left red. No
frontend consumes this admin-only endpoint (confirmed by grep), so
this is a pure regeneration with no consumer impact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:21:26 +02:00
ehoandClaude Opus 5 772c47ea43 fix(brief): keep the dev hatches out of production builds (RB-11)
BIO-012: roleInterceptor/subjectInterceptor are correctly registered
only under isDevMode(), but three hand-written fetch adapters
(reveal-bignummer, letter-preview, org-template's proefbrief) bypass
HttpClient and set X-Role/X-Subject themselves with no guard. The
readers underneath, role.ts and subject.ts, were ungated too: they
read ?role=/?subject= and wrote it into sessionStorage on any
navigation, in any build -- for ?subject= that value is a BSN, which
is exactly what SessionStore's G1 comment promises never happens.

Gate both layers: currentRole()/currentSubject() return their safe
default immediately outside isDevMode() (no query-param read, no
sessionStorage write), and the three adapters additionally wrap their
headers in isDevMode() so a production request carries neither header
at all, matching what an HttpClient request already does once the
interceptors aren't registered.

TE-002: reveal-bignummer's response-shape validation was a "Trust
boundary" a spec could only reach by stubbing globalThis.fetch.
Exported it as parseRevealed(body), matching the other 30 parse*
boundaries in the repo. Same treatment for letter-preview's
errorMessage and org-template's proefbrief error mapping (extracted
from an inline try/catch into a named, exported function first, since
it wasn't already separate).

BIO-006(a): reveal-bignummer sent X-Step-Up: 'true' unconditionally,
so the backend's step-up precondition constrained nothing. reveal()
now takes a stepUp flag; BriefStore.revealBigNummer() -- reachable
only after the UI's confirm() gesture -- is the one that supplies it,
so the literal no longer lives in the transport adapter.

BIO-006(b): documented in roles-and-access.md that drafter is also
the backend's fallback identity (StubIdentityProvider's catch-all
arm), not just the dev switcher's initial choice -- so the
least-privilege consequence of it also being the only role that may
reveal a BSN is visible.

Doc correction, same diff: roles-and-access.md's "wired only under
isDevMode()" claim was false for the three hand-written fetch paths;
it now says where the gate lives (interceptor registration and the
reader functions) so it doesn't go stale the same way again.
CLAUDE.md's dev-only claims needed no correction -- they already
noted these three calls bypass the interceptor.

Every fix has a test confirmed red by temporarily reverting the
source change and rerunning the suite before restoring it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:20:47 +02:00
ehoandClaude Opus 5 b5432d2c63 docs(backlog): RB-07 and RB-10 done, batch 2 in progress
RB-07 unblocks signing ADR-C-009 (its clause 4, "writes are
admin-capability-gated and audited", now holds) and closes CQ-004's
outstanding half. RB-10 landed parseStoredSession twice, once per app,
deliberately — recorded so a later reader does not file it as duplication.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:15:43 +02:00
ehoandClaude Opus 5 4ac13f6cb5 docs: regenerate behaviour spec (RB-07 drift, RB-08, RB-09)
`npm run gen:behaviour-spec`'s drift check (part of `npm run ci`)
caught two things: RB-07 had already left this generated doc stale
(three of its new AuthzAuditTests cases were never picked up), and
RB-08/RB-09 added more test names since. Regenerated so the doc
matches the suite it claims to mirror.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:11:51 +02:00
ehoandClaude Opus 5 9bff19a3e6 build: keep agent worktrees out of prettier and git
Running implementation agents with worktree isolation puts full checkouts of
this repo under .claude/worktrees/. `prettier --check .` walks into them, so
`npm run ci` went red on 70 files that belong to another checkout — including
the vendored CIBG design system, which the top-level ignore already excludes.

Ignored in both .prettierignore and .gitignore; the latter so a worktree can
never be committed into the repo it is a checkout of.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:11:18 +02:00
eho 5118b0da95 Merge RB-10 — extract and spec the stored-session parse boundary
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	libs/shared/docs/behaviour-spec.mdx
2026-08-27 14:08:48 +02:00
ehoandClaude Opus 5 3545023af8 docs: regenerate the behaviour spec for RB-07's new tests
The RB-07 commit added three AuthzAuditTests cases and did not regenerate
libs/shared/docs/behaviour-spec.mdx, so the "behaviour spec drift" CI step
was left red on that branch. My mistake: I committed RB-07 and answered a
question before running the gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:08:27 +02:00
ehoandClaude Opus 5 de349e702e test(auth): extract and spec the stored-session parse boundary (RB-10)
SessionStore.restore() — identical in both apps — read localStorage itself
and did the parse plus shape validation in the same module-private function,
invoked from a field initializer, so the storage read happened the instant
the singleton was constructed and no spec could feed it a raw string. The
logic it guards is a trust boundary, not incidental validation: the comment
above it names G1 (never persist the BSN) and G2 (validate the shape before
trusting it), and CLAUDE.md mandates a spec for boundary parse* adapters.
ssp/auth and bhp/auth were jointly the worst-covered frontend modules.

parseStoredSession(raw) moves into each app's auth/domain/session.ts, which
is pure TS and already had a spec, so no new scaffolding was needed;
restore() collapses to one line. Four cases: absent, non-JSON, wrong shape,
and — BIO-017's addition — a stored {"bsn":…,"naam":…} restoring with bsn
'', which makes the G1 guarantee executable rather than merely commented.
Verified red without the fix.

Landed twice, once per app, deliberately. TE-001 and BL-002 both say an
extract-to-shared here would contradict ADR-0002, which models the two
actors as different Principal variants and expects the two auth contexts to
diverge; RB-13 is what differentiates them.

Also specs redactProfile (BIO-017's second half) — a pure exported
PII-redaction function that had none.

behaviour-spec.mdx is regenerated, which also picks up the test names RB-07
added; that commit should have carried them and did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:08:06 +02:00
ehoandClaude Opus 5 8b8b522052 fix(auth): make no-identity representable; stub dev-only (RB-09)
IIdentityProvider.Resolve returned a non-nullable CallerIdentity, so
the interface could not express "no identity" - StubIdentityProvider
was forced to invent one for any request carrying no credential at
all. Consequence: a production behandelportal build sends no
X-Medewerker header (medewerkerInterceptor is dev-only), so it used
to authenticate as the seeded citizen, role drafter - failing closed
on backoffice capabilities but open on every citizen-scoped endpoint,
including CanRevealBigNummer.

Resolve now returns CallerIdentity?. StubIdentityProvider keeps a
non-nullable return type (a valid narrower override) since it never
itself has "no identity" to report - it is registered only under
IsDevelopment() now. Production registers nothing and throws an
InvalidOperationException immediately during startup instead: there
is no real DigiD/employee-SSO provider in this POC yet, so a
misconfigured Production deploy must fail before serving a single
request, not resolve one per request. The identity-resolution
middleware turns a null resolution into a 401 rather than passing it
downstream.

Added StubIdentityProviderTests.Never_returns_null_even_with_no_headers_at_all
and ProductionIdentityProviderTests, which builds its own
WebApplicationFactory<Program> with UseEnvironment("Production") and
asserts startup throws. Verified both new tests fail red against the
pre-fix code.

RB-01's residual (GET /uploads/{id}/content reached via plain browser
navigation, no identity header) is confirmed unchanged in Development
and its Production consequence is written up in
implementation/rb-09.md for whoever lands the real identity provider -
no signed-URL/cookie scheme was designed here, per scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:06:49 +02:00
ehoandClaude Opus 5 494cee9d08 fix(uploads): route the admin delete through CasesAdmin (RB-08)
DELETE /admin/uploads/{documentId} was gated by a standalone
`X-Admin: true` header check (`IsAdmin`), outside the `Authz` module
entirely and outside the `CasesAdmin`/`StamdataAdmin`/`OrgAdmin`/
`FlagsAdmin` wrappers the four sibling admin surfaces use. It wrote no
AuthzAuditStore row, so a destructive cross-owner document delete never
appeared on /beheer/audit. A repo-wide grep confirmed the only sender of
X-Admin was the backend test itself — no frontend or e2e path depends on
it — so the gate was safe to delete outright.

Routed the endpoint through CasesAdmin (Authz.CanManageCases), the same
wrapper the other admin-cases endpoints use. RB-07 already moved
AuditAuthz onto every *Admin wrapper's allow path, so this gets the
missing audit row for free with no second AuditAuthz call. Deleted the
now-unused IsAdmin function and updated the two comments that referenced
the old X-Admin seam.

Updated EndpointTests.cs's Admin_delete_requires_admin_role to send
X-Role: admin instead of X-Admin: true, and added
AuthzAuditTests.An_admin_upload_delete_is_recorded, which asserts the
cases:manage/allow row count increases by exactly one (a plain
Contains would already be satisfied by this test class's other
cases:manage calls). Verified both tests fail red against the
pre-fix gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:55:41 +02:00
ehoandClaude Opus 5 e89525eef6 feat(audit): record the allow path, not just the denial (RB-07)
All five authorization gates audited only their deny branch, so /beheer/audit
could answer "who was turned away" but never "who changed this" — for a
register whose integrity is the product, the wrong half. Nothing recorded the
flag toggle, either org-template write, the admin case or upload delete, the
three brief transitions, or the besluit; the comment claiming endpoints log
their own effect held for two of the eight.

Each gate now computes the decision once, audits it, and then acts. The row
is written by the gate rather than the endpoint, so a new admin endpoint
cannot be added that forgets to audit itself. Same reasoning for the brief:
every transition already funnelled through LogBrief for its log line, so the
audit row goes there too — submit/approve/reject/send in one place, with the
transition's own outcome as the decision, so a 403 or 409 is as visible as a
success.

FlagsAdmin gained a per-call resource, the one deviation from BIO-007's
minimal remediation: the toggle endpoint writes no log line of its own, so a
constant "feature-flags" row would say a flag changed without saying which.
It now records feature-flags/<key>=<value>. OrgAdmin and CasesAdmin keep
coarse refs because those endpoints do log the specific object.

The besluit gets a second row: the gate records that a behandelaar was
allowed to act, aanvraag:besluit records what they decided.

Row volume goes up — StamdataAdmin gates read endpoints, so admin page loads
now write rows. That is what auditing the allow path means; it is also what
would make retention on AuthzAuditStore necessary later.

Closes CQ-004's outstanding half and unblocks signing ADR-C-009.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:12:22 +02:00
ehoandClaude Opus 5 d682f91b54 docs(backlog): lift the HALT, record CD batch 1 as complete
The operator approved 99-backlog.md, so Phase 3 started. RB-01..RB-06 are
marked done in the table, _status.md gains a Phase 3 section tracking all six
batches, and the halt notice is replaced by what batch 1 leaves for later
batches to pick up:

- RB-01's residual is RB-09's problem — the document-content endpoint is
  reached by a plain browser navigation with no identity header, so it
  resolves to the seeded citizen and works only because one citizen owns
  every document in the POC. That is BIO-002.
- Pii.MaskTail now lives in Domain/People/Pii.cs; RB-11 should use it rather
  than hand-rolling a second masker.
- RB-06 additionally deleted SubmissionRules.RejectRegistratie, which its row
  did not ask for.

Also records the standing OpenZaakIntegrationTests failure, which needs a
live container and is unrelated to any of these tickets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:06:10 +02:00
ehoandClaude Opus 5 0298ecc506 fix(uploads): delete the dead POST /registrations (RB-06)
POST /registrations passed its Documents list straight to Submit, which calls
DocumentStore.Link on every digital documentId in it — and linking a document
blocks its owner from ever deleting it (DeleteOwned returns 409 Linked). That
path had no ForeignIds ownership check, so any authenticated citizen could
post another citizen's document id and permanently block them from deleting
their own diploma scan. POST /applications/{id}/submit, the endpoint actually
in use, has had that guard since it was written.

Deleted rather than guarded: the endpoint is dead. No frontend caller, and
the whole registratie flow goes through /applications/{id}/submit.
RegistratieRequest went with it, and so did SubmissionRules.RejectRegistratie
— reachable only from here, and contradicted by the live path, which treats a
handmatig diploma as "does not auto-approve" rather than a 422 rejection. Its
own message said as much while being returned as a rejection. That last part
is a judgement call beyond the ticket's wording; reverting the two
SubmissionRules hunks restores it in isolation.

Coverage moved rather than vanished: the problem+json shape assertion is now
on /change-requests (the other endpoint on the same Submit helper), and the
linked-delete 409 test goes through the real submit path.

swagger.json, the generated client and the behaviour spec regenerated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:04:03 +02:00
ehoandClaude Opus 5 5187bfa19a fix(zgw): keep the BSN out of the recorded ZGW failure message (RB-05)
ZgwHttpClient interpolated the full request uri and up to 500 characters of
the response body into its failure message. That message is persisted as
Aanvraag.ZgwError in SQLite and written to the log, and both halves can carry
a BSN: ZGW filters travel as query parameters (the citizen-scoped zaken list
filters on rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn), and
OpenZaak echoes the offending request in its error bodies, so a rejected POST
/rollen comes back holding the owner BSN it was sent.

All three interpolation sites now use Redact(url) — the path without its
query — and the body snippet is replaced by the reason phrase. Status plus
path still routes a failure to the right endpoint; the lost detail already
has a deliberate home in ZGW_DEBUG_HTTP=1 (ZgwDiagnosticHandler), which is
opt-in, dev-only and not persisted.

The new test fails the one call in the fixture whose url carries a query
string and asserts the persisted ZgwError has neither the body snippet nor a
"?", while keeping the path and the 503. Verified red without the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:57:09 +02:00
ehoandClaude Opus 5 fbd27ed641 fix(privacy): mask the BSN recorded as the document audit Actor (RB-04)
DocumentStore wrote one audit row per upload and per user delete carrying the
acting citizen's raw BSN as AuditEntry.Actor, persisted to SQLite — on a
store whose own doc comment says it holds metadata only, never file content
"or other PII". Same shape as RB-02, in a second store.

Masked at the two citizen call sites rather than inside Audit, because the
third actor is the literal "admin" and MaskTail("admin", 3) is "**min";
masking centrally would mean guessing which actors are BSNs and which are
role names. Audit's doc comment now states that actors arrive redacted.

StoredDocument.Owner is untouched: it is the authorization key that
DeleteOwned, ForeignIds and RB-01's content check all compare against, so the
BSN stays where it is load-bearing and leaves the trail where it was only
decoration. No endpoint exposes AuditLog, so no response shape changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:54:07 +02:00
ehoandClaude Opus 5 487818e67a fix(privacy): mask the owner BSN on the cross-owner case lists (RB-03)
Mappers.ToAdminSummaryDto set Owner to the raw BSN. Both consumers are
cross-owner lists read by someone who is not the subject — GET /admin/cases
and GET /werkvoorraad — while GET /beoordeling/{id}, the detail view of the
same data, already masked it. The detail screen showed ******782 and the list
one click earlier showed the whole thing.

Masked in the mapper rather than at each endpoint, so a third cross-owner
list cannot be added that forgets to.

MaskTail moves out of Program.cs into Domain/People/Pii.cs: it now has
callers in Contracts, Program.cs and (once RB-04 lands) Data, and a second
hand-rolled copy is how one of them drifts into leaking. Documented as
idempotent, which is what lets /beoordeling/{id} keep its own call —
IZaakSource has a second implementation whose Owner is mapped from the
OpenZaak zaak identificatie, so that endpoint should not depend on which
source answered.

No frontend change: all three consumers display the value, and the parse
boundaries only require a non-empty string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:52:29 +02:00
ehoandClaude Opus 5 6ffd3643b1 fix(audit): stop writing a BSN into the authz audit Resource (RB-02)
Program.cs built the BIG-nummer reveal's audit resource ref as
"brief/" + ctx.Zorgverlener().Bsn. AuditAuthz persists that to the
AuthzAudit.Resource column in SQLite and /admin/audit renders it, so a BSN
reached durable storage and a UI on the one trail four documents describe as
data-minimised and PII-free — on the endpoint whose own comment promises the
audit carries no PII.

The ref is now "brief". Nothing is lost: BriefStore keys one brief per owner,
so the id named what the row's acting principal already implies.

The existing guard, The_audit_schema_carries_no_pii, asserts on column names,
so a BSN inside a column called Resource could never fail it. Added
No_audit_row_carries_a_subjects_bsn, which drives a denied reveal as a
non-default subject and scans every string field of every row for that BSN
and for DemoOwner — asserting on the two BSNs actually in play rather than a
\d{9} shape, since a hex correlation id can hold nine digits by chance.
Verified it goes red when only the Program.cs line is reverted.

AuditEntry.Actor on document audit rows holds a raw BSN too; that is a
different store and stays with RB-04.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:49:57 +02:00
ehoandClaude Opus 5 a2e935d1d8 fix(uploads): authorize the document-content and status endpoints (RB-01)
GET /uploads/{documentId}/content took only (string documentId) — no
HttpContext, so no authorization was possible. It streams diploma and
identity scans, protected by GUID unguessability alone, while DELETE on the
same resource has always been owner-scoped. GET /uploads/status had the same
shape and confirmed whether any client-chosen localId exists, plus its
documentId.

Both now take HttpContext. Content is readable by the owning
ZorgverlenerCaller or a caller passing Authz.CanBeoordelen — matched on the
caller kind rather than branched on a boolean, because ctx.Zorgverlener()
throws for a MedewerkerCaller and the behandelportal's beoordeling screen is
a legitimate reader. Status is scoped to ctx.Zorgverlener().Bsn via a new
owner parameter on DocumentStore.ByLocalIds (one call site).

404, not 403, on both: a foreign document id must not be distinguishable
from one that never existed, and a foreign localId reads back as "unknown".

Residual, recorded in the implementation note: both callers reach the URL as
a plain browser navigation (<a href> / previewUrl), which carries no identity
header and no interceptor, so StubIdentityProvider resolves it to the seeded
citizen. That is BIO-002 and belongs to RB-09; the links keep working today
only because one citizen owns every document in the POC.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 10:48:04 +02:00
ehoandClaude Opus 5 176e5baef8 docs: BIO2 compliance pass + consolidated backlog (agents 07, 08)
Completes the pipeline's analysis phase. Agent 07 (BIO2/ISO 27002:2022,
control set stated as an assumption since none was supplied) produced 20
findings — 12 "defect now", 8 "production gate" — and agent 08 consolidated
all 47 findings across 00/02/04/06/07 into 33 tickets, 5 ADR-fixes and a
release checklist.

Two findings are live defects rather than refactoring candidates, both
verified directly:

- RB-01/BIO-004: GET /uploads/{documentId}/content takes only (string
  documentId) — no HttpContext, so no authorization is possible. It streams
  diploma and identity scans, protected by GUID unguessability alone, while
  DELETE on the same resource is owner-scoped.
- RB-02/BIO-008: Program.cs:674 concatenates the caller's BSN into the authz
  audit Resource column, which is persisted to SQLite and rendered by the
  admin audit page. Four doc comments claim that store holds no PII; the test
  cited as enforcing it asserts on column names, so a BSN inside a column
  called Resource is invisible to it.

07 also answered the handoff from 06: in a production behandelportal build no
X-Medewerker is sent, so StubIdentityProvider returns the seeded citizen. It
fails closed on backoffice capabilities but open on citizen-scoped ones,
including CanRevealBigNummer. Root cause is IIdentityProvider.Resolve
returning a non-nullable CallerIdentity — the interface cannot express "no
identity", so any provider must invent one.

08's gate was relaxed from all-seven to the four agents that ran; _status.md
records why 01/03/05 were skipped, and the backlog carries a "Coverage"
note naming what those skips leave unowned. It caught two errors in the
orchestrator's handoff: CQ-002 is not fixed (ApplicationsStore.cancel and
AdminCasesStore.delete still swallow errors -> RB-20), and CQ-004 shipped
with half its compliance criterion unmet (PUT /admin/flags/{key} writes no
audit row -> RB-07, which blocks signing ADR-C-009).

Both agents preserved a "verified clean — do not fix" list, so a later pass
does not re-spend effort on the controls that already hold.

Consolidation halted for human approval per its spec. No source file changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 09:52:13 +02:00
ehoandClaude Opus 5 8dbfa83cd6 build: make coverageExclude actually exclude the generated API client
The entry was a bare workspace-relative path ("libs/shared/src/
infrastructure/api-client.ts" in the app projects, "src/infrastructure/
api-client.ts" in the libraries) while every sibling in the same list is a
glob. It matched nothing, so the 2372-line NSwag client was instrumented in
all four projects: 987 mostly-uncovered lines that dragged the reported
libs/shared/infrastructure figure from 94.7% down to 6.9%.

Normalizes all four to "**/infrastructure/api-client.ts" and adds the entry
to the beheer project, which was missing it entirely. api-client.provider.ts
is hand-written and stays covered.

Effect on the shared project's reported coverage: lines 96.2% -> 90.5% and
branches 85.1% -> 80.2%, because the denominator is now real source instead
of generated code inflating it.

Found by the metrics baseline (BL-008).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 18:13:48 +02:00
ehoandClaude Opus 5 9440ce1345 fix(stamdata): evaluate the profession validity window per call, not at type-load
Professions.ByProgram was a `static readonly` field filtered on
DateTime.Today, so it evaluated once when the type first loaded. Two
consequences, both real:

- A long-running process kept serving the answer it computed at startup. A
  mapping whose geldigVan fell after boot never appeared; one whose geldigTot
  passed never disappeared.
- Both branches of StamdataFile.ActiveOn were unreachable from this caller,
  which is why this table's validity window had no test at all. It is the
  cleanest single explanation for Stamdata's 71.7% branch coverage (BL-005).

Adds ByProgramOn(DateOnly) — the peildatum as an argument, matching
StamdataTable.RowsOn which already parameterizes it — and makes ByProgram a
property delegating to it with today's date. Call sites (DiplomaRules) are
unchanged and keep the same behaviour, now with a current date.

ProfessionsTests covers both ActiveOn branches plus the regression itself:
the same date must give the same answer, a different date a different one.

Found by the testability pass (TE-009).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 18:13:38 +02:00
ehoandClaude Opus 5 4b94f8edb5 fix(flags): surface a failed admin toggle instead of swallowing it
FeatureFlagStore.set() was try/finally with no catch. A rejected
PUT /admin/flags/{key} escaped into the `void this.store.set(...)` call site
as an unhandled promise rejection; the finally-block reload then snapped the
control back to its old value. The admin saw a toggle that silently refused
to move, with no error rendered anywhere and nothing in the state.

set() now folds through the existing runSubmit helper and returns
Result<string, void>, reloading either way so the state still reflects the
server. The page awaits it and renders the failure in an app-alert.

Found by the CQRS-light pass (CQ-002/CQ-004) as one of three mutations that
reach the raw ApiClient without producing a Result — the baseline's BL-007
inventory had missed all three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 18:13:29 +02:00
ehoandClaude Opus 5 f2d4c900b4 refactor(auth): share the actor-agnostic route guards (ADR-C-006)
authGuard and capabilityGuard were duplicated byte-for-byte across both
apps, along with their specs — 57 of the 211 duplicated lines BL-002
measured in the two auth contexts, the largest block after session.store.ts.

They are not actor-specific. They ask "is anyone logged in" and "may they do
X", never "who are you or how did you get here". ADR-0002 §3's non-sharing
decision scopes to identity and login flow — Principal, DigiD vs employee
SSO — and a route guard is neither; §Consequences names auth.guard.ts only
as a seam that localises the change, not as something that must be
duplicated.

Moves both to libs/shared/src/application/auth.guard.ts, reading SESSION_PORT
instead of an app-local SessionStore. The port gains one member,
isAuthenticated: Signal<boolean> — free, because both SessionStores already
expose exactly that (session.store.ts:40) and both apps already register
{ provide: SESSION_PORT, useExisting: SessionStore }. The seam existed; it
was just narrower than what it already carried.

Each app keeps a re-export at @auth/auth.guard so app.routes.ts is untouched
— routing asks the auth context for its guards, which is the direction the
boundary should read. The two identical specs collapse into one, plus a case
asserting the guard resolves through the port.

Deliberately NOT merged: session.store.ts, session.ts, digid.adapter.ts,
login-form.component.ts, login.page.ts. Those are identical only because
ADR-C-004 (Session -> Principal) was never executed. Merging them would make
a citizen DigiD/BSN login the backoffice's shared login.

Measured with tools/baseline-scan.mjs: ssp/auth duplicated lines 211 -> 151,
bhp/auth 86.8% -> 82.5%, repo-wide 7.1% -> 6.6%. Both guard clone pairs drop
out of the top-clones list. What remains is exactly the three files
ADR-C-004 should differentiate.

behaviour-spec.mdx regenerated (the spec moved libraries).

Verified: lint, typecheck, dep:check (0 violations, 224 modules), prettier,
ng build --localize for both apps, and 407 tests passing across all four
projects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:49:04 +02:00
ehoandClaude Opus 5 4debf6614f docs(adr-0002): accept, and record the unbuilt Principal union as debt
ADR-C-005 from the ADR-conformance pass.

Status Proposed -> Accepted. Two apps have shipped against this ADR and its
structural rulings run in CI at severity: error with 0 violations; the other
five ADRs are all Accepted. A decision CI enforces is not "Proposed".

Drops two "out of scope, not built" bullets that have since shipped
(WP-61..67) — the Behandeling backoffice, and the backend status lifecycle +
authz DTOs: AanvraagStatusTag, GET /me (Program.cs:578), Domain/
Authorization/Authz.cs. Real authentication is the one that genuinely stays.

Replaces the `Session -> Principal` deferral with a Known debt section. The
deferral was conditional on the backoffice not existing yet; it does now, and
the union did not follow. `grep -rn "Principal" apps libs` returns one hit,
a comment. Consequently the two auth contexts are byte-identical (diff -rq:
zero content differences), and behandelportal's Behandelaar still carries a
bsn and logs in through DigiD — a backoffice user authenticating as a
citizen, which is what §3 was written to prevent. The divergence that did
happen took an orthogonal side door (medewerker.interceptor.ts) that never
touches Session.

The section says explicitly that the WP-67 amendment's "expected to diverge"
reasoning still holds but has never been tested, so the identical copies are
evidence §3 is unexecuted — not evidence §3 was wrong. Without that, a future
reader is likely to "simplify" the duplication away and cement the citizen
login into the backoffice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:48:48 +02:00
ehoandClaude Opus 5 664a43bf2d docs: refactoring-backlog workspace — baseline + 3 Phase 1 agents
Runs the multi-agent refactoring-backlog pipeline in docs/project/
refactor-backlog-setup/ up to and including three of the seven Phase 1
agents.

00-baseline.md establishes the metrics every later agent must cite, using
only tooling already in the repo (vitest lcov, coverlet cobertura, ESLint's
core `complexity` rule at threshold 0 for a full distribution, depcruise
--metrics). Duplication and C# complexity had no tooling, so
tools/baseline-scan.mjs adds a deterministic ~200-line text scan rather
than a new dependency; the approximations are labelled as such.

Headline: FE 75.1% line coverage but only over the 98 of 220 source files a
spec loads; BE 97.6% line / 79.6% branch; 0 layering violations; 7.1%
duplication; 25 of 2085 TS functions over CC 10.

Then 02-testability, 04-cqrs-light and 06-adr-conformance (27 findings).
01/03/05 were skipped deliberately — the baseline shows little for them to
find; 07 (BIO2) and 08 (consolidation) are still open.

Each agent corrected a baseline observation of mine, and in every case the
error was in something derived rather than measured:

- BL-007 counted ~13 adapter "mutations" from the `runSubmit` helper name;
  5 of those call sites are reads. It also missed 3 real mutations that
  reach the raw ApiClient and never return a Result.
- BL-002 diagnosed the 100%-duplicated auth folders as ADR-0002's
  divergence prediction failing. It never had a chance to fail: §3's
  `Principal` union was never built.
- BL-004 named libs/shared/domain and libs/beheer/contracts as coverage
  gaps; both are pure type declarations where 0% is unimprovable.

All three corrections are recorded inline in 00-baseline.md §10, so agent
08 does not inherit the bad numbers.

.prettierignore excludes the agent prompt directories — reflowing their
markdown would edit the prompt text itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 16:44:32 +02:00
ehoandClaude Sonnet 5 0ad02e651a docs: rewrite the README as the entry point to all documentation
The repo had good docs that nobody could find. The root README linked to
exactly two documents, while learning-path.mdx — a 375-line paced three-day
onboarding curriculum — had zero inbound links and was reachable only by
running Storybook and spotting it in the sidebar. docs/README.md indexes
~20 documents and nothing at the root pointed at it either.

The README was also describing the pre-monorepo repo. Its centrepiece
atomic-design table was fictional: it claimed the folder structure IS the
hierarchy, with atoms/molecules/organisms/templates/pages directories that
exist nowhere. The truth is a better story and now replaces it — two
orthogonal axes, DDD on disk (context, then layer) and the atomic ladder in
the Storybook sidebar, which comes from story titles. Every other path was
stale too (src/app/, src/styles.scss, src/locale/, proxy.conf.json), the
second app was entirely absent, and 6 of 36 npm scripts were documented,
omitting `npm run ci` — the pre-push gate.

Adds a signpost table organised by what you are trying to do, a repo map,
the commands that matter, and keeps one corrected showcase section so the
repo still makes its case. Also fixes the index it now points at:
docs/README.md cited Foundations pages at src/docs/*.mdx, claimed the
backlog ran to WP-48 (it is at 75 — the range is dropped so it cannot go
stale again), and was missing ADR-0006 and the OpenZaak harness.

Verified rather than assumed: all 12 README links and every docs/README.md
link resolve, every named npm script exists, no stale path survives, and
the quick start was executed — backend serves swagger and the API on :5000,
behandelportal serves on :4201.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 14:08:40 +02:00
223 changed files with 13953 additions and 1555 deletions
+2 -2
View File
@@ -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'],
+5
View File
@@ -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
+4
View File
@@ -58,3 +58,7 @@ backend/openzaak/seeded.env
# WP-55: render-prod-secrets.sh's output — the real client secret, never committed
backend/openzaak/setup_configuration/data.prod.yaml
# Agent git worktrees (Claude Code `isolation: "worktree"`) — full checkouts of
# this repo nested inside it; never commit one.
.claude/worktrees/
+9
View File
@@ -4,6 +4,11 @@ storybook-static*/
coverage/
.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/
+29 -6
View File
@@ -124,8 +124,10 @@ than hardcoding one app's content — the two apps' primary nav genuinely differ
should be **composition of existing blocks** — adding building blocks is the
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
+140 -172
View File
@@ -1,205 +1,173 @@
# BIG-register Self-Service Portal — Atomic Design POC
# BIG-register Portals — Atomic Design POC
A small Angular app that shows how **atomic design** makes a frontend cheap to build,
reuse and extend. The domain is the **BIG-register** self-service portal (the Dutch
register of healthcare professionals, run by CIBG). It is styled with the **CIBG
Huisstijl** design system (a customized Bootstrap 5.2 build, vendored — see ADR-0003),
and demonstrates a robust **async-state pattern** where the UI can never reach an
inconsistent state.
A two-app Angular monorepo showing how **atomic design** plus **domain-driven boundaries** make
a frontend cheap to build, reuse and extend. The domain is the **BIG-register** (the Dutch
register of healthcare professionals, run by CIBG): a citizen self-service portal and a
case-handler backoffice, sharing one design system and one backend.
> Demo / POC — **no real login** (DigiD is faked) and synthetic seed data. The
> business rules and data _are_ served by a real **ASP.NET Core backend**
> (`backend/`) consumed through a generated typed client, so the BFF + DDD design
> is demonstrable, not hand-waved. A system-font stack stands in for the licensed
> Rijksoverheid font and a text wordmark for the logo.
It is styled with the **CIBG Huisstijl** (a customized Bootstrap 5.2 build, vendored — ADR-0003)
and built around one idea: **make illegal states unrepresentable** — in the UI's async states, in
the domain types, and in the tests.
> **Demo / POC** — **no real login** (DigiD is faked) and synthetic seed data. But the business
> rules and data _are_ served by a real **ASP.NET Core backend** (`backend/`) through a generated
> typed client, so the BFF + DDD design is demonstrable rather than hand-waved. A system-font
> stack stands in for the licensed Rijksoverheid font, and a text wordmark for the logo.
> **New here?** Run `npm run storybook` and open **Foundations → Learning Path** — a paced,
> hands-on three-day route through the codebase, written for a strong programmer who is new to
> frontend functional programming. For everything else, **[`docs/README.md`](docs/README.md)** is
> the documentation index.
---
## Run it
## Quick start
Everything at once — API, both portals:
```bash
docker compose up # frontend + backend together → app http://localhost:4200, Swagger http://localhost:5000/swagger
docker compose up
# self-service portal → http://localhost:4200
# behandelportal → http://localhost:4201
# API + Swagger → http://localhost:5000/swagger
```
Or run the two halves separately:
Or run the pieces yourself:
```bash
npm install
npm start # app → http://localhost:4200 (proxies /api → backend, proxy.conf.json)
# in another terminal:
cd backend && dotnet run --project src/BigRegister.Api # API → http://localhost:5000/swagger
npm start # self-service portal → :4200 (proxies /api → backend)
npm run start:behandelportal # case-handler portal → :4201
npm run storybook # component library, organized by atomic layer
npm run gen:api # regenerate the typed API client from the backend OpenAPI doc
npm run e2e # Playwright smoke tests against the running app + backend (both must be up)
# in another terminal:
cd backend && dotnet run --project src/BigRegister.Api # API → :5000/swagger
npm run storybook # the design system + Foundations curriculum
npm run e2e # Playwright — starts the backend and app itself
```
Flow: **Login → Dashboard → Mijn gegevens (wijziging) → Herregistratie → Intake**.
The backend hosts the business rules (profession derivation, policy questions,
eligibility, thresholds); see **[backend/README.md](backend/README.md)**.
**Self-service flow:** Login → Dashboard → Mijn gegevens → Registreren → Herregistratie → Intake
→ Brief. **Behandelportal:** Login → Werkvoorraad → Beoordeling (approve / reject / ask for more).
Admin pages (`/beheer/*`, `/brief/huisstijl`) need the `admin` role — see
[roles and access](docs/reference/roles-and-access.md).
> **New here:** a **branching intake questionnaire** (`/intake`) where later questions
> appear based on earlier answers and progress survives a page reload, plus a visual
> walkthrough of the state-management ideas. See
> **[docs/reference/architecture/ARCHITECTURE.md](docs/reference/architecture/ARCHITECTURE.md)** for diagrams (atomic-design pyramid,
> the dispatch→reduce→view loop, RemoteData states, and "why not just signals") and a
> section on **connecting to a .NET backend**.
---
### See every data state (scenario toggle)
## Where to find things
Append `?scenario=` to any data page (e.g. `/dashboard`) to force an async state:
| I want to… | Go to |
| --------------------------------- | -------------------------------------------------------------------------------------------------- |
| learn the codebase from scratch | `npm run storybook` → **Foundations → Learning Path** |
| find any document | **[docs/README.md](docs/README.md)** — the full index |
| understand the architecture | [ARCHITECTURE.md](docs/reference/architecture/ARCHITECTURE.md) |
| know _why_ a decision was made | [the ADRs](docs/reference/architecture/) — BFF-lite, contexts, huisstijl, stamdata, ZGW, test data |
| work on the backend / BFF | [backend/README.md](backend/README.md) |
| run OpenZaak locally | [backend/openzaak/README.md](backend/openzaak/README.md) |
| see what shipped, or pick up work | [docs/project/backlog/README.md](docs/project/backlog/README.md) |
| build a feature the house way | [`.claude/skills/`](.claude/skills/) — invocable recipes (`new-feature`, `form-machine`, …) |
| know the import rules | [dependencies.md](docs/reference/architecture/dependencies.md) — enforced by `dep:check` |
| work on this repo as an AI agent | [CLAUDE.md](CLAUDE.md) |
## Repo map
| Path | What lives there |
| ---------------------- | ----------------------------------------------------------------------- |
| `apps/ssp/` | Zorgverlener self-service portal (:4200) |
| `apps/behandelportal/` | Behandelaar backoffice (:4201) |
| `libs/shared/` | Design system, kernel, generated API client, Storybook Foundations docs |
| `libs/beheer/` | Admin/stamdata context, used identically by both apps |
| `backend/` | ASP.NET Core BFF (.NET 10, EF Core/SQLite), OpenZaak seam |
| `e2e/` | Playwright specs |
| `docs/` | `reference/` (how + why) and `project/` (backlog, PRDs) |
| `public/` | Vendored CIBG Huisstijl + assets |
| `scripts/` | CI gate, drift checks, generators |
---
## Commands
```bash
npm run ci # ← run this before pushing: the whole gate, exactly what CI runs
```
| Task | Command |
| -------- | ------------------------------------------------------------------------------------------- |
| Run | `npm start`, `npm run start:behandelportal`, `docker compose up` |
| Test | `npm test`, `npm run e2e`, `npm run test-storybook` (axe on every story) |
| Check | `npm run lint`, `typecheck`, `dep:check`, `check:tokens`, `check:seam` |
| Build | `npm run build`, `npm run build-storybook` |
| Generate | `npm run gen:api` (typed client), `npm run gen` (plop: value object, form machine, context) |
| Docs | `npm run storybook`, `npm run dep:graph`, `npm run gen:behaviour-spec` |
`npm run ci` chains lint, typecheck, dependency boundaries, formatting, token and seam drift
checks, all four test projects, both localized builds, the backend suite, and the generated-artifact
drift gates. The full script list is in `package.json`; `CLAUDE.md` explains the traps.
---
## Where atomic design pays off
**Two orthogonal axes, and that is the point.** On disk, code is grouped by **ownership** —
bounded context, then layer (`domain/` → `application/` → `infrastructure/` → `ui/`, dependencies
pointing inward). In Storybook, the same components are grouped by **atomic level** — story titles
put them under `Design System/Atoms|Molecules|Organisms|Templates`. So `libs/shared/src/ui/` is a
flat folder of 26 components, and the atomic ladder lives in the sidebar where you actually browse
it. See [Atomic design](libs/shared/docs/atomic-design.mdx) and
[Domain-driven design](libs/shared/docs/layers.mdx) in Foundations.
**Reuse is the payoff.** `button`, `form-field`, `async`, `page-shell`, `site-header` appear on
essentially every screen across _both_ apps. Change one, every screen follows.
**A new page is composition, not new components.** The branching intake wizard — the most complex
flow in the app — needed exactly **one** new atom (`radio-group`) and one new organism
(`intake-wizard`). Everything else was already there.
**Theming is one stylesheet and a token bridge.** `libs/shared/styles.scss` maps the app's
semantic `--rhc-*` vocabulary onto CIBG/`--bs-*` values, so components reference tokens, never
colours. Re-point the bridge to re-theme both apps with no component changes (ADR-0003).
`npm run check:tokens` fails the build on a hardcoded colour.
---
## Try it: dev affordances
Append `?scenario=` to any data page to force an async state — the states are mutually exclusive
by construction, via the `<app-async>` molecule:
| URL | What you see |
| ----------------------------- | -------------------------------------------- |
| `/dashboard` | real data (fast) |
| `/dashboard` | real data |
| `/dashboard?scenario=slow` | skeletons for ~2.5s, then data |
| `/dashboard?scenario=loading` | the loading state, held open |
| `/dashboard?scenario=empty` | "geen gegevens" empty state |
| `/dashboard?scenario=error` | error message + **Opnieuw proberen** (retry) |
---
## How atomic design works here (folder = layer)
Atomic design organizes UI into five layers, each built from the one below. In this repo
the folder structure _is_ the hierarchy (`src/app/`):
| Layer | What it is | Examples here |
| -------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| **atoms/** | smallest building blocks; wrap one design-system element | `button`, `text-input`, `heading`, `link`, `alert`, `status-badge`, `spinner`, `skeleton` |
| **molecules/** | a few atoms combined into a unit | `form-field` (label + input + error), `data-row`, `async` (state wrapper) |
| **organisms/** | larger, self-contained sections | `site-header`, `site-footer`, `login-form`, `registration-summary`, `registration-table`, `change-request-form` |
| **templates/** | page skeletons that define layout; content is projected in | `page-layout` (header/content/footer chrome), `page-shell` (back-link + heading + intro + content) |
| **pages/** | a template filled with real data | `login`, `dashboard`, `registration-detail`, `herregistratie` |
Each atom is a thin Angular standalone component that applies CIBG Huisstijl
(Bootstrap 5.2) CSS classes (`btn`, `form-control`, `card`, …) — so the design system
does the visual work and we only own a small, typed component API.
Append `?role=drafter|approver|admin` to switch the dev role stand-in and unlock the admin pages.
Both toggles are **dev-only** — neither interceptor is wired into production builds.
---
## Where you actually notice the benefit
**1. Reuse — the same blocks appear everywhere.**
| Component | Appears in |
| ----------------------------- | ------------------------------------------------------------- |
| `button` | login, change-request, herregistratie, async retry, Storybook |
| `form-field` + `text-input` | login form _and_ change-request _and_ herregistratie |
| `status-badge` | dashboard summary, detail summary |
| `page-shell` / `page-layout` | all four pages |
| `site-header` / `site-footer` | every page |
| `async` + `skeleton` | dashboard, detail |
Change a component once and every screen that uses it updates.
**2. A whole new page = composition, no new components.**
`pages/herregistratie/herregistratie.page.ts` is a complete new flow assembled entirely
from existing atoms/molecules/templates — zero new building blocks. The branching
**intake wizard** went further: it needed only **one new atom** (`radio-group`) and **one
new organism** (`intake-wizard`); the form fields, buttons, alerts, spinner and page shell
were all reused. That's the payoff: new screens cost almost nothing.
**3. Templates remove per-page boilerplate.**
Every page used to repeat its own back-link + heading + intro markup. `page-shell`
captures that once; pages now read like `<app-page-shell heading="…" backLink="…">…`.
**4. Theming is one stylesheet + a token bridge.**
The look comes from **CIBG Huisstijl**, vendored under `public/cibg-huisstijl/` and
loaded via a `<link>` in `index.html`; `body.brand--cibg` activates CIBG's
robijn/lintblauw palette. `src/styles.scss` is a **token bridge** mapping the app's
semantic `--rhc-*` token vocabulary onto CIBG/`--bs-*` values, so components keep
referencing tokens — swap the vendored CSS and re-point the bridge to re-theme the
whole app, no component changes (ADR-0003). `npm run check:tokens` fails the build
on any hardcoded colour outside that bridge.
---
## State management (no impossible states)
Data fetching uses Angular's native, signal-based **`resource`** over the generated
typed client (no NgRx, no extra dependency). Each context's `infrastructure/*.adapter.ts`
exposes a resource that carries `status()`, `value()`, `error()` and `reload()` as
signals, and a `parse*` function validates the response at the trust boundary
(DTO → domain). The screen-shaped ("BFF-lite") endpoints return server-computed
decisions the FE renders rather than recomputes (see ADR-0001).
The molecule **`<app-async>`** turns those signals into UI. It renders **exactly one** of
four slots, chosen by a single `computed` — so loading, empty, error and loaded are
mutually exclusive _by construction_. You cannot render data and an error at the same
time, or show stale content during a hard failure: those states are unrepresentable.
```html
<app-async [resource]="reg" [isEmpty]="regEmpty">
<ng-template appAsyncLoaded let-r> <app-registration-summary [reg]="r" /> </ng-template>
<ng-template appAsyncLoading> <app-skeleton [count]="6" /> </ng-template>
<!-- appAsyncEmpty / appAsyncError are optional → sensible defaults -->
</app-async>
```
- **Loaded** — your content, with the value.
- **Loading** — your skeleton, or a default **delayed spinner** (only appears after
~250ms, so fast connections never flash a spinner; slow ones get feedback). Skeletons
are also delay-gated. → _handles slow vs fast connections._
- **Empty** — your message, or a default "Geen gegevens gevonden" (driven by an
`isEmpty` predicate).
- **Error** — your template, or a default alert + a **retry** button that calls
`resource.reload()`.
Because each data-fetching page wraps its content in `<app-async>`, correct
loading/empty/error handling is automatic and consistent across the app.
---
## Page transitions
The chrome (`templates/shell` — header + footer) is **persistent**: it mounts once and
hosts the `<router-outlet>`, so navigating doesn't re-create it (no white flash). Only
the routed content cross-fades, via Angular's native **`withViewTransitions()`** — the
header/footer get a stable `view-transition-name` in `styles.scss` so they're excluded
from the fade. `prefers-reduced-motion` disables the animation; non-Chromium browsers
degrade to an instant navigation.
## Tech notes
- Angular 22 (standalone components, signals, `httpResource`, view transitions,
control flow `@if/@for`).
- Styling: **CIBG Huisstijl** (customized Bootstrap 5.2) vendored in
`public/cibg-huisstijl/`, loaded via `<link>`; `src/styles.scss` holds the
`--rhc-*` → CIBG/`--bs-*` token bridge (ADR-0003). No styling npm dependency.
- Data: ASP.NET Core backend (`backend/`, EF Core/SQLite-persisted; BRP/DUO
reference data stays in-memory-seeded) exposed via an OpenAPI contract; the FE
consumes an **NSwag-generated** typed client (`npm run gen:api`).
The `?scenario=` toggle (`shared/infrastructure/scenario.interceptor.ts`) is
**dev-only** — it is not wired into production builds.
- `.npmrc` sets `legacy-peer-deps=true` because `@storybook/angular`'s peer range lags
Angular 22; the builder runs fine (build verified).
- **i18n**: every user-facing string is `$localize`-wrapped with a stable `@@id`
(source locale `nl`). `npx ng build --localize` (CI runs this) builds both `nl` and
`en` — a genuine second-locale build, not just an unexercised claim — into
`dist/atomic-design-poc/browser/{nl,en}/`; `ng serve --configuration=en` serves the
English build locally. `src/locale/messages.en.xlf` is real (if demo-quality)
English, not machine-untranslated placeholders; `angular.json`'s
`i18nMissingTranslation: "error"` fails the build if a new `$localize` string ships
without a translation. `npm run extract-i18n` regenerates the `nl` reference file.
- **Angular 22** — standalone components, signals, `resource()`, native control flow, view
transitions. No NgRx: shared state is a root singleton store with a pure reducer.
- **Backend** — ASP.NET Core (.NET 10), EF Core/SQLite for applications, documents, the brief and
the audit trail; BRP/DUO reference data stays in-memory seeded. Screen-shaped ("BFF-lite")
endpoints return server-computed decisions the frontend renders rather than recomputes
(ADR-0001). Cases can be sourced from **OpenZaak/ZGW** behind the same seam (ADR-0005).
- **Typed client** — NSwag-generated from the backend's OpenAPI doc (`npm run gen:api`); CI fails
on drift.
- **Boundaries are enforced, not hoped for** — `dep:check` fails the build if `domain/` imports
Angular, a context imports upward, or an app reaches into the other app.
- **i18n** — every user-facing string is `$localize`-wrapped with a stable id (source locale `nl`).
`ng build --localize` builds both `nl` and `en`, and `i18nMissingTranslation: "error"` fails the
build if a string ships untranslated.
- **Dependencies** — the shipped bundle audits clean (`npm audit --omit=dev`: 0 vulnerabilities).
`.npmrc` sets `legacy-peer-deps=true` because Storybook's peer range lags Angular 22. Never run
`npm audit fix --force` — it downgrades Angular 22 → 21.
### Dependency security
### Deliberately out of scope
The **shipped app has 0 known vulnerabilities** (`npm audit --omit=dev`) — and, since the
`@babel/core` pin below, the **full dev audit is 0 too**. All advisories live(d) in
dev/build tooling (Storybook + the Angular build chain) and never reach the bundle.
`package.json` `overrides` pin patched transitive versions; the last remaining cluster
cascaded from `@babel/core`'s low-severity sourceMappingURL issue, closed by pinning
`@babel/core` to a patched **7.x** (`^7.29.7`) — no jump to Babel 8, no breaking change.
We do **not** run `npm audit fix --force`: its proposed fix downgrades Angular 22 → 21.
### Deliberately out of scope (POC)
Real auth/DigiD, real BRP/DUO upstreams, a production-grade database (Postgres/SQL
Server — SQLite persists applications/documents/the brief + a real audit table,
see `backend/README.md`, WP-22), NgRx, licensed RO/Rijks fonts + logo (system-font
stack; text wordmark). (The backend itself _is_ implemented.) i18n's build seam is
proven (see above) but
production-quality translation, a runtime locale switcher, and RTL/pluralization
edge cases are not — the `en` file is demo-quality, and locale is a build-time
choice, not a switch in the running app.
Real auth/DigiD, real BRP/DUO upstreams, a production-grade database, NgRx, licensed RO/Rijks
fonts and logo. The i18n build seam is proven, but the `en` translation is demo-quality and locale
is a build-time choice, not a runtime switch.
+4 -3
View File
@@ -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() {
+7 -31
View File
@@ -1,34 +1,10 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AccessStore } from '@shared/application/access.store';
import { Capability } from '@shared/domain/capability';
import { SessionStore } from './application/session.store';
/** Route guard: only let authenticated users in; otherwise redirect to /login. */
export const authGuard: CanActivateFn = () => {
const store = inject(SessionStore);
const router = inject(Router);
return store.isAuthenticated() ? true : router.createUrlTree(['/login']);
};
/**
* Route guard factory (PRD-0002 §6): authenticated AND holding `capability`, else
* 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>
</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>
<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>
`,
})
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);
+15 -43
View File
@@ -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;
-62
View File
@@ -1,62 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { describe, it, expect, vi } from 'vitest';
import { AccessStore } from '@shared/application/access.store';
import { SessionStore } from './application/session.store';
import { authGuard, capabilityGuard } from './auth.guard';
type Opts = {
authed: boolean;
can?: (c: string) => boolean;
whenReady?: () => Promise<void>;
};
function setup({ authed, can = () => false, whenReady = () => Promise.resolve() }: Opts) {
const createUrlTree = vi.fn((cmds: string[]) => ({ tree: cmds }));
const readySpy = vi.fn(whenReady);
TestBed.configureTestingModule({
providers: [
{ provide: SessionStore, useValue: { isAuthenticated: () => authed } },
{ provide: AccessStore, useValue: { whenReady: readySpy, can } },
{ provide: Router, useValue: { createUrlTree } },
],
});
return { createUrlTree, readySpy };
}
// The guards ignore their (route, state) args; cast to call with none.
const call = <T>(fn: unknown) => TestBed.runInInjectionContext(() => (fn as () => T)());
describe('authGuard', () => {
it('allows an authenticated user', () => {
setup({ authed: true });
expect(call(authGuard)).toBe(true);
});
it('redirects an anonymous user to /login', () => {
const { createUrlTree } = setup({ authed: false });
expect(call(authGuard)).toEqual({ tree: ['/login'] });
expect(createUrlTree).toHaveBeenCalledWith(['/login']);
});
});
describe('capabilityGuard', () => {
const guard = () => capabilityGuard('stamdata:edit');
it('waits for /me, then allows an entitled admin', async () => {
const { readySpy } = setup({ authed: true, can: (c) => c === 'stamdata:edit' });
await expect(call<Promise<unknown>>(guard())).resolves.toBe(true);
expect(readySpy).toHaveBeenCalledOnce(); // it awaited caps before deciding
});
it('sends an authenticated-but-unentitled user to /dashboard (not a login loop)', async () => {
setup({ authed: true, can: () => false });
await expect(call<Promise<unknown>>(guard())).resolves.toEqual({ tree: ['/dashboard'] });
});
it('redirects an anonymous user to /login without waiting for caps', async () => {
const { readySpy } = setup({ authed: false, can: () => true });
await expect(call<Promise<unknown>>(guard())).resolves.toEqual({ tree: ['/login'] });
expect(readySpy).not.toHaveBeenCalled();
});
});
+7 -31
View File
@@ -1,34 +1,10 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AccessStore } from '@shared/application/access.store';
import { Capability } from '@shared/domain/capability';
import { SessionStore } from './application/session.store';
/** Route guard: only let authenticated users in; otherwise redirect to /login. */
export const authGuard: CanActivateFn = () => {
const store = inject(SessionStore);
const router = inject(Router);
return store.isAuthenticated() ? true : router.createUrlTree(['/login']);
};
/**
* Route guard factory (PRD-0002 §6): authenticated AND holding `capability`, else
* 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' });
});
});
+39
View File
@@ -0,0 +1,39 @@
/**
* Who is logged in. Framework-free domain type.
*
* The `zorgverlener` variant of ADR-0002 §3's `Principal` union — the SSP has exactly
* one actor kind (a citizen, authenticated via DigiD/BSN), so this app's own copy of
* the union only ever holds this one member. `kind` is still a discriminant, not
* decoration: it is what makes `apps/behandelportal`'s `medewerker` variant a
* genuinely different type rather than a same-shaped coincidence, and what a future
* third actor (§4 — admin/auditor/institution-rep) would add a member to.
*/
export interface Principal {
readonly kind: 'zorgverlener';
readonly bsn: string;
readonly naam: string;
}
export function isAuthenticated(p: Principal | null): p is Principal {
return p !== null;
}
/**
* Parse a persisted principal out of a raw `localStorage` string (best-effort;
* anything that isn't a well-shaped record → logged out). G2: validate the
* shape before trusting it. G1: even if a stored entry carries a `bsn`, the
* restored principal's `bsn` is always `''` — the BSN is never persisted (see
* the `SessionStore` effect that writes it), so a legacy or tampered entry
* cannot resurrect one.
*/
export function parseStoredPrincipal(raw: string | null): Principal | null {
try {
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<Principal>;
return typeof parsed?.naam === 'string'
? { kind: 'zorgverlener', bsn: '', naam: parsed.naam }
: null;
} catch {
return null;
}
}
@@ -1,14 +0,0 @@
import { describe, it, expect } from 'vitest';
import { isAuthenticated, Session } from './session';
const session: Session = { bsn: '19012345601', naam: 'Test' };
describe('isAuthenticated', () => {
it('narrows a present session to Session', () => {
expect(isAuthenticated(session)).toBe(true);
});
it('reports no session as not authenticated', () => {
expect(isAuthenticated(null)).toBe(false);
});
});
-9
View File
@@ -1,9 +0,0 @@
/** Who is logged in. Framework-free domain type. */
export interface Session {
readonly bsn: string;
readonly naam: string;
}
export function isAuthenticated(s: Session | null): s is Session {
return s !== null;
}
@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { 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.history.clear();
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
this.applyLoadedView(r.value);
} else if (r.error.tag === 'notFound' && !this.hasRecoveredFromMissingBrief) {
this.hasRecoveredFromMissingBrief = true;
await this.recoverFromMissingBrief();
} else {
const reason = r.error.tag === 'notFound' ? BRIEF_LOAD_FAILED : r.error.reason;
this.store.dispatch({ tag: 'BriefLoadFailed', reason });
}
}
private applyLoadedView(view: BriefView) {
this.orgTemplate.set(view.orgTemplate);
this.caseContext.set(view.caseContext);
this.history.clear();
this.store.dispatch({ tag: 'BriefLoaded', ...view });
}
/** `GET /brief` 404'd — no brief exists yet for this owner. Recover by calling the
existing `reset()` command directly (the same POST `resetDemo()` uses) and
applying whatever it returns; this NEVER calls `load()` again, so a second 404
(e.g. `reset()` itself failing) cannot loop back into this method. */
private async recoverFromMissingBrief() {
const r = await this.adapter.reset();
if (r.ok) {
this.applyLoadedView(r.value);
} else {
this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
}
@@ -217,8 +246,8 @@ export class BriefStore implements PendingSave {
send = () => this.transition(() => this.adapter.send());
/** Explicit action, never a live re-render (PRD §8): opens the server-composed
letter in a new tab. ponytail: the blob URL is never revoked — it's cheap and
the tab outlives this call; not worth a teardown hook for a POC. */
letter in a new tab via `BLOB_PRESENTER.open` — see its doc comment for why the
object URL is never revoked. */
async previewLetter() {
this.actionState.set({ tag: 'Busy' });
const r = await this.previewAdapter.preview();
@@ -227,15 +256,18 @@ export class BriefStore implements PendingSave {
return;
}
this.actionState.set({ tag: 'Idle' });
window.open(URL.createObjectURL(r.value), '_blank');
this.blobPresenter.open(r.value);
}
/** Reveal the masked case BIG-nummer (PRD-0002 §5c). Server re-checks the capability
+ step-up and audits the attempt; on success we swap the masked value in the
already-loaded caseContext (a field update, not a reload). The step-up gesture
itself is the UI's concern — this command just runs the audited server call. */
itself is the UI's concern (`behandel-scherm.component.ts`'s `onReveal()` confirm)
— this command is only reachable once that gesture has happened, so it is the one
that tells the adapter to send `X-Step-Up` (BIO-006a: the adapter itself no longer
hardcodes the header). */
async revealBigNummer() {
const r = await this.revealAdapter.reveal();
const r = await this.revealAdapter.reveal(true);
if (!r.ok) {
this.actionState.set({ tag: 'Failed', error: r.error });
return;
@@ -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,15 +65,15 @@ const decisions: BriefDecisions = {
canRevealBigNummer: true,
};
const loaded = (
status: BriefStatus = { tag: 'draft' },
sections?: Brief['sections'],
): BriefState => ({
tag: 'loaded',
brief: briefWith(status, sections),
availablePassages: lib,
decisions,
});
// Replays a real `BriefLoaded` message through the real `reduce` (ADR-0006 §2)
// instead of hand-assembling the 'loaded' state directly.
const loaded = (status: BriefStatus = { tag: 'draft' }, sections?: Brief['sections']): BriefState =>
givenBrief({
tag: 'BriefLoaded',
brief: briefWith(status, sections),
availablePassages: lib,
decisions,
});
const sectionBlocks = (s: BriefState, key: string) =>
s.tag === 'loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : [];
@@ -128,12 +129,12 @@ describe('brief.machine reduce', () => {
it('BesluitSelected deep-copies content — later library mutation does not leak in', () => {
const passage = libPassage('intro', 'kern'); // shared → offered for any besluit
const st: BriefState = {
tag: 'loaded',
const st = givenBrief({
tag: 'BriefLoaded',
brief: briefWith({ tag: 'draft' }),
availablePassages: [passage],
decisions,
};
});
const s = reduce(st, besluit('positief'));
// Mutate the source passage object after composition.
(passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED';
@@ -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,47 +1,62 @@
import { Injectable } from '@angular/core';
import { Injectable, isDevMode } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { currentRole } from '@shared/infrastructure/role';
import { problemDetail } from '@shared/infrastructure/api-error';
import { environment } from '@shared/environments/environment';
const REVEAL_FAILED = $localize`:@@brief.reveal.failed:Het BIG-nummer kon niet worden getoond.`;
/** Exported so specs can assert against the same message id instead of retyping the
Dutch sentence (matches `letter-preview.adapter.ts`'s `PREVIEW_FAILED`). */
export const REVEAL_FAILED = $localize`:@@brief.reveal.failed:Het BIG-nummer kon niet worden getoond.`;
/**
* Field-level PII reveal (PRD-0002 §5c). The case screen ships the BIG-nummer masked;
* this unmasks it, gated server-side by the reveal capability AND a step-up. The
* step-up is stubbed as the `X-Step-Up` header — the caller sends it only after the
* user's confirm gesture, so a plain call (or a role without the capability) 403s.
* step-up is stubbed as the `X-Step-Up` header, sent only when the caller passes
* `stepUp: true` — `BriefStore.revealBigNummer()` is the only caller and it is only
* ever reachable after `behandel-scherm.component.ts`'s `onReveal()` confirm gesture,
* so the header now reflects that gesture instead of being a constant baked into this
* adapter (BIO-006a — a call that skips confirmation sends no step-up at all).
*
* Hand-written fetch (not the `ApiClient`) because the call needs a per-request header;
* `.ExcludeFromDescription()` on the endpoint keeps the generated client JSON-only, the
* same seam as `/brief/preview` and uploads — which also means `X-Role` is set here.
* same seam as `/brief/preview` and uploads. `X-Role` is a dev-only identity stand-in
* (see `role.ts`) and is therefore only sent under `isDevMode()`, mirroring the
* `roleInterceptor` registration in `app.config.ts` — a production build never sends it
* from this hand-written call either (BIO-012).
*/
@Injectable({ providedIn: 'root' })
export class RevealBigNummerAdapter {
async reveal(): Promise<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.
if (
typeof body === 'object' &&
body !== null &&
typeof (body as { bigNummer?: unknown }).bigNummer === 'string'
) {
return ok((body as { bigNummer: string }).bigNummer);
}
return err(REVEAL_FAILED);
return parseRevealed(await res.json().catch(() => null));
}
}
/** Trust boundary: validate the untrusted response shape before handing back a plain
string (TE-002) — exported so a spec can call it without stubbing `globalThis.fetch`. */
export function parseRevealed(body: unknown): Result<string, string> {
if (
typeof body === 'object' &&
body !== null &&
typeof (body as { bigNummer?: unknown }).bigNummer === 'string'
) {
return ok((body as { bigNummer: string }).bigNummer);
}
return err(REVEAL_FAILED);
}
async function errorMessage(res: Response): Promise<string> {
try {
return problemDetail(await res.json(), REVEAL_FAILED);
@@ -5,7 +5,7 @@ import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { 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');
});
});
@@ -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);
@@ -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,32 +138,14 @@ export function createDraftSync(deps: DraftSyncDeps) {
// (submitted/gone) id is treated as fresh so it can't reopen as an editable draft.
const load = (linked: string): Promise<void> => {
id = linked;
return adapter
.detail(linked)
.then((dto) => {
if (dto.status && dto.status.tag !== 'Concept') {
id = undefined;
applyResume(null);
return;
}
applyResume(dto.draft ?? null);
})
.catch(() => {
return loadConcept(adapter, linked).then((result) => {
if (result.tag === 'not-concept') {
id = undefined;
applyResume(null); // unknown/deleted id → start fresh
});
};
// Find the user's existing Concept of this type (at most one), if any.
const findConcept = async (): Promise<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;
}
applyResume(null);
return;
}
applyResume(result.draft);
});
};
return {
@@ -189,7 +169,7 @@ export function createDraftSync(deps: DraftSyncDeps) {
await load(linked);
return;
}
const existing = await findConcept();
const existing = await findConcept(adapter, deps.type);
if (existing) {
await load(existing);
// Stamp the id into the URL so a reload resumes the same Concept.
@@ -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,
});
/**
* Every fixture below is built by replaying real `RegistratieMsg`s through the
* real `reduce` (ADR-0006 §2) — never a hand-assembled `RegistratieState`
* literal. Each helper reaches a named point in the wizard one transition at a
* time, so a spec can only assert on a state the reducer can actually produce.
*/
const toAdresValid = (): RegistratieState =>
givenRegistratieWizard(
{
tag: 'PrefillAdres',
straat: 'Lange Voorhout 9',
postcode: '2514 EA',
woonplaats: 'Den Haag',
},
{ tag: 'SetCorrespondentie', value: 'post' },
);
const validAdres = {
straat: 'Lange Voorhout 9',
postcode: '2514 EA',
woonplaats: 'Den Haag',
correspondentie: 'post' as const,
adresHerkomst: 'brp' as const,
};
const validDraft: Partial<Draft> = {
...validAdres,
diplomaId: 'd1',
beroep: 'Arts',
diplomaHerkomst: 'duo',
};
const toBeroepStep = (): RegistratieState => reduce(toAdresValid(), { tag: 'Next' }); // cursor 0 -> 1, no diploma yet
const toBeroepStepWithDiploma = (): RegistratieState =>
reduce(toBeroepStep(), { tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] });
const toControleStep = (): RegistratieState => reduce(toBeroepStepWithDiploma(), { tag: 'Next' }); // cursor 1 -> 2
const toIndienen = (): RegistratieState => reduce(toControleStep(), { tag: 'Submit' });
// A complete, valid draft assembled WITHOUT ever advancing the cursor. Setting a
// field or choosing a diploma is never gated by cursor position, so this is a
// real, reachable 'Invullen' state at cursor 0 — matching what `submit()`
// (which validates the whole draft regardless of cursor) is exercised against
// in the tests below.
const toFullDraftAtCursor0 = (): RegistratieState =>
givenRegistratieWizard(
{
tag: 'PrefillAdres',
straat: 'Lange Voorhout 9',
postcode: '2514 EA',
woonplaats: 'Den Haag',
},
{ tag: 'SetCorrespondentie', value: 'post' },
{ tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] },
);
describe('STEPS (fixed)', () => {
it('always has the same three steps', () => {
@@ -60,46 +81,55 @@ describe('navigation', () => {
});
it('Next advances once the adres step is valid', () => {
const s = expectTag(next(invullen(validAdres)), 'Invullen');
const s = expectTag(next(toAdresValid()), 'Invullen');
expect(s.cursor).toBe(1);
expect(currentStep(s)).toBe('beroep');
});
it('requires a valid e-mail only when the channel is email', () => {
const bad = expectTag(next(invullen({ ...validAdres, correspondentie: 'email' })), 'Invullen');
const withEmailChannel = givenRegistratieWizard(
{
tag: 'PrefillAdres',
straat: 'Lange Voorhout 9',
postcode: '2514 EA',
woonplaats: 'Den Haag',
},
{ tag: 'SetCorrespondentie', value: 'email' },
);
const bad = expectTag(next(withEmailChannel), 'Invullen');
expect(bad.errors.email).toBeTruthy();
const good = expectTag(
next(invullen({ ...validAdres, correspondentie: 'email', email: 'a@b.nl' })),
next(given(reduce, withEmailChannel)({ tag: 'SetField', key: 'email', value: 'a@b.nl' })),
'Invullen',
);
expect(good.cursor).toBe(1);
});
it('beroep step requires a chosen diploma', () => {
const noDiploma = expectTag(next(invullen(validAdres, 1)), 'Invullen');
const noDiploma = expectTag(next(toBeroepStep()), 'Invullen');
expect(noDiploma.cursor).toBe(1);
expect(noDiploma.errors.diploma).toBeTruthy();
const withDiploma = expectTag(next(invullen(validDraft, 1)), 'Invullen');
const withDiploma = expectTag(next(toBeroepStepWithDiploma()), 'Invullen');
expect(withDiploma.cursor).toBe(2);
});
it('Back never goes below the first step and preserves the draft', () => {
expect(back(initial)).toBe(initial);
const s = expectTag(back(invullen(validDraft, 2)), 'Invullen');
const s = expectTag(back(toControleStep()), 'Invullen');
expect(s.cursor).toBe(1);
expect(s.draft.beroep).toBe('Arts');
});
it('GaNaarStap only jumps backwards', () => {
expect(expectTag(gaNaarStap(invullen(validDraft, 2), 0), 'Invullen').cursor).toBe(0);
expect(expectTag(gaNaarStap(invullen(validDraft, 1), 2), 'Invullen').cursor).toBe(1); // forward jump rejected
expect(expectTag(gaNaarStap(toControleStep(), 0), 'Invullen').cursor).toBe(0);
expect(expectTag(gaNaarStap(toBeroepStepWithDiploma(), 2), 'Invullen').cursor).toBe(1); // forward jump rejected
});
});
describe('adres origin (BRP vs handmatig)', () => {
it('prefillAdres flags origin brp', () => {
const s = expectTag(
prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag'),
prefillAdres(initial, 'Lange Voorhout 9', '2514 EA', 'Den Haag'),
'Invullen',
);
expect(s.draft.adresHerkomst).toBe('brp');
@@ -107,43 +137,38 @@ describe('adres origin (BRP vs handmatig)', () => {
});
it('editing a prefilled address field flips origin to handmatig', () => {
const prefilled = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag');
const prefilled = prefillAdres(initial, 'Lange Voorhout 9', '2514 EA', 'Den Haag');
const edited = expectTag(setField(prefilled, 'woonplaats', 'Rotterdam'), 'Invullen');
expect(edited.draft.adresHerkomst).toBe('handmatig');
});
it('typing an address with no BRP prefill yields handmatig', () => {
const s = expectTag(setField(invullen({}), 'straat', 'Kerkstraat 1'), 'Invullen');
const s = expectTag(setField(initial, 'straat', 'Kerkstraat 1'), 'Invullen');
expect(s.draft.adresHerkomst).toBe('handmatig');
});
it('editing the e-mail field does not change the address origin', () => {
const prefilled = prefillAdres(invullen({}), 'Lange Voorhout 9', '2514 EA', 'Den Haag');
const prefilled = prefillAdres(initial, 'Lange Voorhout 9', '2514 EA', 'Den Haag');
const edited = expectTag(setField(prefilled, 'email', 'a@b.nl'), 'Invullen');
expect(edited.draft.adresHerkomst).toBe('brp');
});
it('a manually entered address still submits (only manual diploma is gated)', () => {
const s = submit(
invullen({
straat: 'Kerkstraat 1',
postcode: '1234 AB',
woonplaats: 'Utrecht',
correspondentie: 'post',
adresHerkomst: 'handmatig',
diplomaId: 'd1',
beroep: 'Arts',
diplomaHerkomst: 'duo',
}),
const manualAdres = givenRegistratieWizard(
{ tag: 'SetField', key: 'straat', value: 'Kerkstraat 1' },
{ tag: 'SetField', key: 'postcode', value: '1234 AB' },
{ tag: 'SetField', key: 'woonplaats', value: 'Utrecht' },
{ tag: 'SetCorrespondentie', value: 'post' },
{ tag: 'KiesDiploma', diplomaId: 'd1', beroep: 'Arts', vraagIds: [] },
);
const indienen = expectTag(s, 'Indienen');
const indienen = expectTag(submit(manualAdres), 'Indienen');
expect(indienen.data.adresHerkomst).toBe('handmatig');
});
});
describe('kiesDiploma', () => {
it('derives the beroep from the chosen diploma and flags origin duo', () => {
const s = expectTag(kiesDiploma(invullen({}), 'd9', 'Verpleegkundige', []), 'Invullen');
const s = expectTag(kiesDiploma(initial, 'd9', 'Verpleegkundige', []), 'Invullen');
expect(s.draft.diplomaId).toBe('d9');
expect(s.draft.beroep).toBe('Verpleegkundige');
expect(s.draft.diplomaHerkomst).toBe('duo');
@@ -152,7 +177,7 @@ describe('kiesDiploma', () => {
describe('policy questions (geldigheidsvragen)', () => {
it('a diploma with questions blocks Next until they are answered', () => {
let s = kiesDiploma(invullen(validAdres, 1), 'd2', 'Arts', ['nl-taalvaardigheid']);
let s = kiesDiploma(toBeroepStep(), 'd2', 'Arts', ['nl-taalvaardigheid']);
const blocked = expectTag(next(s), 'Invullen');
expect(blocked.cursor).toBe(1);
expect(blocked.errors.antwoorden?.['nl-taalvaardigheid']).toBeTruthy();
@@ -161,7 +186,12 @@ describe('policy questions (geldigheidsvragen)', () => {
});
it('validateAll keeps only the answers to the questions that applied', () => {
let s = kiesDiploma(invullen(validAdres, 2), 'd2', 'Arts', ['nl-taalvaardigheid']);
// DRIFT (see rb-31.md): the old literal put the wizard at cursor 2 before any
// diploma was chosen. That combination cannot occur in the real reducer —
// advancing past 'beroep' (cursor 1 -> 2) requires a diploma to already be
// set. Replayed here at cursor 1 instead; submit() validates the whole draft
// regardless of cursor, so the assertion below is unaffected.
let s = kiesDiploma(toBeroepStep(), 'd2', 'Arts', ['nl-taalvaardigheid']);
s = setAntwoord(s, 'nl-taalvaardigheid', 'ja');
s = setAntwoord(s, 'stale', 'x'); // not in vraagIds
const done = expectTag(submit(s), 'Indienen');
@@ -173,14 +203,16 @@ describe('manual diploma fallback', () => {
const maxIds = ['nl-taalvaardigheid', 'diploma-erkend', 'toelichting'];
it('KiesHandmatig flags handmatig with the maximal question set and no beroep yet', () => {
const s = expectTag(kiesHandmatig(invullen(validAdres, 1), maxIds), 'Invullen');
const s = expectTag(kiesHandmatig(toBeroepStep(), maxIds), 'Invullen');
expect(s.draft.diplomaHerkomst).toBe('handmatig');
expect(s.draft.beroep).toBeUndefined();
expect(s.draft.vraagIds).toEqual(maxIds);
});
it('requires a declared beroep + all maximal questions before submit', () => {
let s = kiesHandmatig(invullen(validAdres, 2), maxIds);
// DRIFT (see rb-31.md): same unreachable cursor-2-before-diploma combination
// as above. Replayed at cursor 1; submit() is cursor-agnostic.
let s = kiesHandmatig(toBeroepStep(), maxIds);
expect(submit(s).tag).toBe('Invullen'); // no beroep declared
s = declareerBeroep(s, 'Fysiotherapeut');
expect(submit(s).tag).toBe('Invullen'); // questions unanswered
@@ -193,11 +225,11 @@ describe('manual diploma fallback', () => {
describe('submit', () => {
it('stays in Invullen when the draft is incomplete (no diploma)', () => {
expect(submit(invullen(validAdres)).tag).toBe('Invullen');
expect(submit(toAdresValid()).tag).toBe('Invullen');
});
it('reaches Indienen with a complete, valid draft, carrying its data', () => {
const good = expectTag(submit(invullen(validDraft)), 'Indienen');
const good = expectTag(submit(toFullDraftAtCursor0()), 'Indienen');
expect(good.data.beroep).toBe('Arts');
expect(good.data.adres.postcode).toBe('2514 EA');
expect(good.data.adresHerkomst).toBe('brp');
@@ -205,43 +237,18 @@ describe('submit', () => {
it('resolve maps Indienen to Ingediend with the referentie', () => {
const ingediend = expectTag(
resolve(submit(invullen(validDraft)), ok('BIG-2026-001')),
resolve(submit(toFullDraftAtCursor0()), ok('BIG-2026-001')),
'Ingediend',
);
expect(ingediend.referentie).toBe('BIG-2026-001');
});
it('resolve maps Indienen to Mislukt on a failed submit', () => {
expect(resolve(submit(invullen(validDraft)), err('boom')).tag).toBe('Mislukt');
expect(resolve(submit(toFullDraftAtCursor0()), err('boom')).tag).toBe('Mislukt');
});
});
describe('reduce (message-driven happy path)', () => {
// Each helper replays real messages through the real reducer up to the named
// point — no hand-assembled state literal — so each test below Givens its own
// starting point independently, one transition at a time.
const toBeroepStep = (): RegistratieState => {
let s: RegistratieState = initial;
s = reduce(s, {
tag: 'PrefillAdres',
straat: 'Lange Voorhout 9',
postcode: '2514 EA',
woonplaats: 'Den Haag',
});
s = reduce(s, { tag: 'SetCorrespondentie', value: 'post' });
return reduce(s, { tag: 'Next' });
};
const toControleStep = (): RegistratieState => {
const s = reduce(toBeroepStep(), {
tag: 'KiesDiploma',
diplomaId: 'd1',
beroep: 'Arts',
vraagIds: [],
});
return reduce(s, { tag: 'Next' });
};
const toIndienen = (): RegistratieState => reduce(toControleStep(), { tag: 'Submit' });
it('adres and correspondentie set, Next advances from adres to beroep', () => {
// Given the initial wizard.
// When the adres is prefilled, correspondentie chosen, and Next dispatched...
@@ -279,7 +286,7 @@ describe('reduce (message-driven happy path)', () => {
});
it('SubmitFailed moves Indienen to Mislukt', () => {
const s = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), {
const s = reduce(reduce(toFullDraftAtCursor0(), { tag: 'Submit' }), {
tag: 'SubmitFailed',
error: 'boom',
});
@@ -287,7 +294,7 @@ describe('reduce (message-driven happy path)', () => {
});
it('Retry returns Mislukt to Indienen with the same data', () => {
const mislukt = reduce(reduce(invullen(validDraft), { tag: 'Submit' }), {
const mislukt = reduce(reduce(toFullDraftAtCursor0(), { tag: 'Submit' }), {
tag: 'SubmitFailed',
error: 'boom',
});
@@ -310,7 +317,7 @@ describe('inline document upload (beroep step)', () => {
it('routes Upload messages through the upload reducer', () => {
const s = expectTag(
reduce(invullen(validDraft), {
reduce(toFullDraftAtCursor0(), {
tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [cat] },
}),
@@ -320,7 +327,7 @@ describe('inline document upload (beroep step)', () => {
});
it('blocks the beroep step until a required category is satisfied', () => {
let s = reduce(invullen(validDraft, 1), {
let s = reduce(toBeroepStepWithDiploma(), {
tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [cat] },
});
@@ -339,7 +346,7 @@ describe('inline document upload (beroep step)', () => {
});
it('includes delivery refs in the submitted data', () => {
let s = reduce(invullen(validDraft), {
let s = reduce(toFullDraftAtCursor0(), {
tag: 'Upload',
msg: { type: 'CategoriesLoaded', categories: [cat] },
});
@@ -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);
});
});
+4
View File
@@ -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>());
+15 -18
View File
@@ -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;
+274 -128
View File
@@ -12,6 +12,7 @@ using BigRegister.Domain.Documents;
using BigRegister.Domain.Features;
using BigRegister.Domain.Intake;
using BigRegister.Domain.Letters;
using BigRegister.Domain.People;
using BigRegister.Domain.Registrations;
using BigRegister.Domain.Submissions;
using BigRegister.Api.Zgw;
@@ -50,7 +51,22 @@ Db.ConnectionString = builder.Configuration.GetConnectionString("AppDb") ?? Db.C
// every store call site that used to hardcode DocumentStore.DemoOwner. Stub today (X-Role/
// X-Subject for a zorgverlener, X-Medewerker/X-Rollen for a medewerker); a real
// DigiD/employee-SSO provider swaps in without touching a consumer.
builder.Services.AddSingleton<IIdentityProvider, StubIdentityProvider>();
//
// RB-09/BIO-002: StubIdentityProvider invents a citizen identity for any request with no
// credential at all — a production behandelportal build sends no X-Medewerker header, so it
// used to authenticate every request as the seeded citizen (open on that citizen's own rights,
// including CanRevealBigNummer). Registering the stub only in Development, and failing to
// start in Production rather than falling through to a per-request 401, means a misconfigured
// deploy never serves a single request. The real DigiD/employee-SSO provider is out of scope
// for this POC (BIO-002's remediation says so explicitly) — until one exists, Production simply
// cannot start, which is the correct fail-closed behaviour for "no identity provider available".
if (builder.Environment.IsDevelopment())
builder.Services.AddSingleton<IIdentityProvider, StubIdentityProvider>();
else if (builder.Environment.IsProduction())
throw new InvalidOperationException(
"No IIdentityProvider is registered for a Production environment. StubIdentityProvider " +
"is Development-only (RB-09/BIO-002); there is no real DigiD/employee-SSO provider in " +
"this POC yet. Register one before deploying to Production.");
// WP-49: the cases (zaken) READ path goes through IZaakSource so a real ZGW backend
// (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);
});
app.UseSwagger();
app.UseSwaggerUI();
// RB-15/BIO-015: the OpenAPI document + its UI are a genuine attack-surface reduction to
// gate — they enumerate every route, request/response shape and (via SwaggerUI's "Try it
// out") let a caller fire requests straight from the browser. Development-only, like the
// dev-role/scenario-toggle hatches this POC already keeps out of production builds
// (docker-compose.prod.yml runs Production; only docker-compose.yml's dev image runs
// Development). `dotnet swagger tofile` (npm run gen:api) is unaffected: Swashbuckle's CLI
// resolves ISwaggerProvider straight out of the DI container to build swagger.json — it
// never sends an HTTP request through this pipeline, so it never touches this middleware at
// all, gated or not. Verified empirically (see rb-15.md) rather than assumed, per RB-09's
// note that this exact file has already broken that tool once.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCors(SpaCors);
// Liveness/readiness for orchestrators (k8s probes, load balancers). No data, no PII.
@@ -164,6 +201,7 @@ api.MapGet("/intake/policy", () => new IntakePolicyDto(IntakePolicy.ScholingThre
api.MapGet("/stamdata", (HttpContext ctx) => StamdataAdmin(ctx, () =>
Results.Ok(StamdataCatalog.All.Select(t =>
new StamdataTableSummaryDto(t.Id, t.Label, t.Columns.Select(ToColumnDto).ToList(), t.Temporal)).ToList())))
.Gate("StamdataAdmin")
.WithName("stamdataTables")
.Produces<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, () =>
FeatureFlagStore.Set(key, req.Enabled) ? Results.NoContent() : Results.NotFound()))
api.MapPut("/admin/flags/{key}", (string key, SetFeatureFlagRequest req, HttpContext ctx) =>
FlagsAdmin(ctx, $"feature-flags/{key}={req.Enabled}", () =>
FeatureFlagStore.Set(key, req.Enabled) ? Results.NoContent() : Results.NotFound()))
.Gate("FlagsAdmin")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status403Forbidden);
// --- Brief (letter composition). One demo brief per owner; the server owns the
// status machine + authorization (Authz, PRD-0002 phase P1). Principal is a
// dev-only stand-in via X-Role (mirrors the X-Admin seam and the FE ?role=
// toggle) — no real identities in this POC. ---
// dev-only stand-in via X-Role (mirrors the FE ?role= toggle) — no real
// identities in this POC. ---
// --- reads ---
api.MapGet("/brief", (HttpContext ctx) =>
{
var e = BriefStore.GetOrCreate(ctx.Zorgverlener().Bsn);
return ToView(ctx, e);
// RB-23/CQ-007: a read that used to allocate a row on first call. The owner's first
// draft now comes only from the explicit POST /brief/reset (BriefStore.ResetAndCreate)
// — this GET is a pure query and 404s when there is nothing to read yet.
var e = BriefStore.Get(ctx.Zorgverlener().Bsn);
if (e is null) return Results.NotFound();
return Results.Ok(ToView(ctx, e));
})
.Produces<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) =>
app.Logger.LogInformation("brief {Action} outcome={Outcome} status={Status}",
action, r.outcome, r.entity?.Status.Tag ?? "-");
// RB-07/BIO-007: every brief transition already funnelled through here for its log line,
// so the audit row goes here too — a fifth transition cannot be added that logs but leaves
// no trail. Resource is the bare "brief" (RB-02: never the owner's BSN); the decision is
// the transition's own outcome, so a 403 or a 409 is as visible as a success.
void LogBrief(HttpContext ctx, string action, (BriefStore.Outcome outcome, BriefEntity? entity) r)
{
app.Logger.LogInformation("brief {Action} outcome={Outcome} status={Status}",
action, r.outcome, r.entity?.Status.Tag ?? "-");
AuditAuthz(ctx, "brief:" + action, "brief", r.outcome == BriefStore.Outcome.Ok, Authz.ResolvePrincipal(ctx));
}
// Audit + outcome for a submit, with NO personal data: only kind, outcome,
// generated reference and the caller's correlation id (the observability seam — a
// real system ships this to structured logging / an audit store). A repeated
// Idempotency-Key short-circuits to the first call's result — see IdempotencyStore
// — so a retried submit dedupes instead of minting a second reference.
// — so a retried submit dedupes instead of minting a second reference. The key is
// scoped to the caller (RB-18/BIO-018): two callers who happen to send the same
// client-chosen header value do not share a cached result.
IResult Submit(HttpContext ctx, string kind, string? reject, IReadOnlyList<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
View File
@@ -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
{

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