Compare commits

..

11 Commits

Author SHA1 Message Date
6e65738c04 fix(bff): pin test JWT schemes to a static config so no OIDC metadata fetch (refs #13)
All checks were successful
CI / lint (pull_request) Successful in 1m28s
CI / build (pull_request) Successful in 1m18s
CI / unit (pull_request) Successful in 1m40s
CI / frontend (pull_request) Successful in 3m2s
CI / mutation (pull_request) Successful in 6m16s
CI / verify-stack (pull_request) Successful in 5m14s
The medewerker scheme intermittently hung ~2s fetching OIDC metadata from its
(unreachable) test authority and then 401'd — clearing Authority alone left
JwtBearer's PostConfigure free to build a ConfigurationManager under CI timing. Give
both test schemes a StaticConfigurationManager so metadata is never fetched, making
token validation deterministic. Also harden AddRealmRoles to never throw (a throw in
OnTokenValidated surfaces as a 401) — a malformed realm_access yields no roles (403).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:24:36 +02:00
e0db3ace6e test(acceptance): implement GetWerkbakAsync on the acceptance domain stub (refs #13)
Some checks failed
CI / lint (pull_request) Successful in 1m25s
CI / build (pull_request) Successful in 1m13s
CI / unit (pull_request) Failing after 1m17s
CI / frontend (pull_request) Successful in 2m44s
CI / mutation (pull_request) Successful in 5m18s
CI / verify-stack (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:09:54 +02:00
6f5a61dea6 docs(architecture): ADR-0013 — behandel-portal wiring (auth, werkbak, decision) (refs #13)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:09:14 +02:00
1a6daecc47 feat(bff): medewerker-realm auth + behandelaar policy + GET /behandel/werkbak (refs #13)
Adds a second JWT bearer scheme for the medewerker realm; on validation it lifts
Keycloak's realm_access.roles onto the principal so the behandelaar policy can
require the role. /behandel/werkbak proxies the domain werkbak behind that policy
(401 without a token, 403 without the role). openapi.json + api-client regenerated;
Keycloak__MedewerkerAuthority wired into compose.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:07:11 +02:00
08515b9bba test(bff): /behandel/werkbak needs a medewerker token with the behandelaar role (refs #13)
Red — the medewerker JWT scheme, the behandelaar policy, and the werkbak endpoint
do not exist yet (endpoint 404s).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:03:36 +02:00
69db103e0e feat(domain): werkbak query + GET /behandel/werkbak (refs #13)
The Werkbak use-case reads the open Beoordelen tasks (§8.2) and enriches each with
its aggregate's bsn + status; exposed as GET /behandel/werkbak for the BFF to proxy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:01:33 +02:00
ffd59c0aac test(domain): werkbak query lists open beoordelingen enriched from the store (refs #13)
Red — the Werkbak use-case and WerkbakItem do not exist yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:00:46 +02:00
4085bdead7 feat(#13): S-12b — Workflow Client user-tasks + Beoordelen userTask (#83)
All checks were successful
CI / lint (push) Successful in 1m23s
CI / build (push) Successful in 1m10s
CI / unit (push) Successful in 1m14s
CI / frontend (push) Successful in 2m23s
CI / mutation (push) Successful in 5m45s
CI / verify-stack (push) Successful in 7m7s
## What & why

Second sub-slice of **S-12 (#13)** — the **Workflow Client gains behandelaar user-task operations**, and the process model gains the beoordeling step.

- **BPMN:** `registratie.bpmn` now parks at a `Beoordelen` **userTask** (candidate group `behandelaar`) after `OpenZaakAanmaken`; `registrationId` rides along as a process variable so the werkbak can correlate each task to its aggregate.
- **Workflow Client** (`IUserTaskClient`, the only code that talks to Flowable §8.2):
  - `GetOpenBeoordelingenAsync()` — the werkbak (open `Beoordelen` tasks + their `registrationId`)
  - `ClaimAsync(taskId, behandelaar)`
  - `CompleteBeoordelingAsync(taskId, besluit)` — carries the decision into the process as the `besluit` variable
- **Live integration:** `verify-domain` now drives the full user-task lifecycle against a real Flowable — after the worker opens the zaak, it polls for the task, claims it as `merel-behandelaar`, completes it (`goedkeuren`), and asserts the process finishes. This proves the exact REST contract (`service/runtime/tasks/query` + `…/{id}` claim/complete) the client depends on.

The walking skeleton is unaffected: the temporary `/approve` path still sets the zaak status directly; wiring the domain decision to *complete this task* (and driving the werkbak from the BFF) lands in **S-12c**.

## Definition of Done

- [x] Linked issue: #13 (umbrella; `refs`, does not close)
- [x] Tests first; red → green
- [x] Unit + acceptance green (`make unit`): domain 76, acceptance 9 (acl/event-subscriber/bff unaffected)
- [x] Mutation ≥ break(90): **domain 100%** (killed the new survivors *and* the pre-existing `FlowableWorkflowClient` baseline)
- [x] Live Flowable user-task lifecycle asserted in `verify-domain`
- [ ] CI green (pending)

Part of #13.

Reviewed-on: #83
2026-07-15 08:53:33 +00:00
d4ed0ffc22 feat(#13): S-12a — beoordeling decision model (domain) (#82)
All checks were successful
CI / lint (push) Successful in 1m15s
CI / build (push) Successful in 58s
CI / unit (push) Successful in 1m9s
CI / frontend (push) Successful in 2m23s
CI / mutation (push) Successful in 5m3s
CI / verify-stack (push) Successful in 8m37s
## What & why

First sub-slice of **S-12 (#13)** — the **beoordeling decision model** in the Domain Service. Foundation for the behandel-portal: it gives the domain a proper decision lifecycle before any UI/Flowable/BFF work.

- **Statuses:** add `InBehandeling` and `Afgewezen` to `RegistrationStatus`.
- **Aggregate:** `TakeIntoBehandeling()` (`Ingediend → InBehandeling`, idempotent, guards terminal states); generalise the behandelaar decision — `Approve()` (requires a zaak) and new `Reject()` both act on an `Ingediend`/`InBehandeling` registration → `Ingeschreven`/`Afgewezen`.
- **Use-case:** `BeoordeelRegistratie` (`goedkeuren` sets the zaak's final status via the ACL §8.1 → `Ingeschreven`; `afwijzen` → `Afgewezen`, domain-only for now). Idempotent.
- **Endpoint:** `POST /registrations/{id}/decide` (`{ "besluit": "goedkeuren" | "afwijzen" }`), superseding the temporary `/approve` (retired when the portal lands, S-12d).
- **BDD:** `EenRegistratieBeoordelen.feature` — goedkeuren + afwijzen scenarios (feature-scoped bindings).

**Scoped out** to later S-12 sub-slices: Flowable user-task claim/complete + BPMN `userTask` (S-12b), BFF `/behandel/*` + medewerker authz (S-12c), the Angular behandel-portal + e2e (S-12d), and propagating a *rejection* to the zaak/projection via the ACL.

## Definition of Done

- [x] Linked issue: #13 (umbrella; this PR `refs`, does not close)
- [x] Tests first; red → green per behaviour
- [x] Unit + acceptance green (`make unit`): domain 65, acceptance 9
- [x] Mutation ≥ break(90): domain 98.77%, no survivors in new code (the one unkilled mutant is the pre-existing `FlowableWorkflowClient` baseline)
- [ ] CI green (pending)

Part of #13.

Reviewed-on: #82
2026-07-15 07:12:19 +00:00
3023bb6fbe chore(release): 2026.07.0 (#81)
All checks were successful
CI / lint (push) Successful in 1m28s
CI / build (push) Successful in 1m25s
CI / unit (push) Successful in 1m33s
CI / frontend (push) Successful in 2m58s
CI / mutation (push) Successful in 6m43s
CI / verify-stack (push) Successful in 7m42s
Cuts the first CalVer release **2026.07.0** (tag ), marking the end of **Iteration 1 — Walking Skeleton**.

 regenerated from Conventional Commits by git-cliff (covers Iterations 0 and 1, through #79).

After merge: tag  on main and publish the Gitea Release.

closes #80

Reviewed-on: #81
2026-07-14 14:46:55 +00:00
9997da8beb feat(#78): one citizen reference across self-service and the openbaar register (#79)
All checks were successful
CI / lint (push) Successful in 1m25s
CI / build (push) Successful in 1m17s
CI / unit (push) Successful in 1m33s
CI / frontend (push) Successful in 2m54s
CI / mutation (push) Successful in 6m34s
CI / verify-stack (push) Successful in 7m39s
## What & why

Before this change the self-service confirmation and the openbaar register showed **different** identifiers, so a citizen could not look their registration back up (#78). Now both surface the same **reference**:

- **domain → ACL (write):** the domain `registrationId` is set as the zaak's `identificatie` on `POST /zaken`.
- **event-subscriber → ACL (read):** the subscriber reads the zaak's `identificatie` back through the ACL (§8.1 — only the ACL talks to ZGW) via a new `POST /zaken/reference`, and stores it on the projection row **and** the `processed_notifications` replay log.
- **BFF + openbaar:** the public view exposes `id/status/reference` (never bsn/naam) and searches by id or reference; the register's "Referentie" column shows the reference.

Storing the reference in the replay log keeps ADR-0008's **rebuild-is-log-only** invariant intact — `/admin/rebuild` reproduces the reference without re-reading the ACL.

Decision recorded in **ADR-0012**.

## Definition of Done

- [x] Linked issue: #78
- [x] Tests written first; red → green per layer
- [x] Unit + acceptance green (`make unit`): domain 49, acl 27, bff 20, event-subscriber 19, acceptance 7
- [x] Frontend lint + test green (`nx run-many -t lint test`)
- [x] Mutation ≥ break(90): acl 100%, event-subscriber 100%, bff 100%, domain 98.41% (pre-existing FlowableWorkflowClient baseline, untouched)
- [x] e2e extended: confirmation reference == register reference
- [x] openapi.json + api-client regenerated (drift guard green)
- [x] ADR-0012 added; demo-script note appended
- [x] `Acl__BaseUrl` wired for the subscriber in compose

closes #78

Reviewed-on: #79
2026-07-14 14:01:49 +00:00
29 changed files with 1324 additions and 56 deletions

View File

@@ -2,19 +2,130 @@
All notable changes to this project. Generated from Conventional Commits by git-cliff. All notable changes to this project. Generated from Conventional Commits by git-cliff.
## Unreleased ## v2026.07.0 — 2026-07-14
### Architecture
- ADR-0005 adopt Stryker.NET for mutation testing (refs #47)
- ADR-0006 — provision the ACL integration test against the compose stack (refs #46)
- ADR-0007 + runbooks for the OZ→NRC notification wiring (refs #56)
- ADR-0009 external-task job-worker pattern (refs #6, #60)
- ADR-0010 BFF OIDC validation + downstream boundaries (refs #8, #63)
### Bug Fixes
- Pin OpenZaak/NRC image tags; add smoke log capture on failure (refs #30)
- Harden oz-db healthcheck and raise compose-up timeout (refs #30)
- Bake config into images so compose-smoke passes on CI (refs #30)
- Nrc-init runs migrations only, not setup_configuration (refs #30)
- Smoke waits on durable services, not the whole project (refs #30)
- Portable health poll instead of compose --wait (refs #30)
- Pin upload-artifact to @v3@v4 refuses to run on Gitea (refs #47)
- Buffer the zaak POST body so OpenZaak accepts it (refs #46)
- Keep dotnet format green under the shared .editorconfig (refs #65)
- Re-export the full Utrecht package from libs/ui (refs #67)
- Run checkAuth() at startup to end the login redirect loop (refs #67)
- Health-check nginx over IPv4 (127.0.0.1) (refs #68)
- Treat the http portal origin as secure so DigiD PKCE login works (refs #68)
- Attach the DigiD token to relative BFF calls (refs #68)
### Build
- Pin Stryker.NET as a local dotnet tool (refs #47)
### CI ### CI
- Gitea Actions pipeline + runner runbook (refs #30) (#37) - Gitea Actions pipeline + runner runbook (refs #30) (#37)
- ACL Dockerfile + full compose stack for smoke test (refs #30)
- Switch runner label to ubuntu-latest (refs #30)
- Run the mutation ratchet as a parallel CI job (refs #47)
- Publish the Stryker HTML report as a CI artifact (refs #47)
- Run the ACL integration test as a Gitea Actions job (refs #46)
- Keep the integration lane local-only; document the runner gap (refs #46)
- Run the ACL integration test in CI inside the compose network (closes #55) (refs #46)
- Run the Event Subscriber + projection-api in compose and verify end-to-end (refs #7)
- Containerize, wire into compose, and verify end-to-end (refs #6)
- Make Stryker report upload best-effort (refs #62)
- Retrigger after runner cleanup (refs #6)
- Retrigger CI (refs #6)
- Retrigger CI after gitea restart (refs #6)
- Compose wiring, verify-bff live check, mutation baseline (refs #8)
- Nx frontend lane (lint/test/build) (refs #65)
- Serve the self-service app in compose (refs #68)
- Run Vitest ahead of the production build to stop worker-start timeout (refs #68)
- Cache the NuGet package store across the .NET jobs (refs #73)
- Run Playwright from the prebuilt image instead of downloading browsers (refs #73)
### Chores ### Chores
- Add idempotent Gitea backlog seeder - Add idempotent Gitea backlog seeder
- Remove bootstrap scripts from main (#35) - Remove bootstrap scripts from main (#35)
- Contributor workflow — templates, git-cliff, gitea-workflow doc (closes #31) (#38)
### Documentation ### Documentation
- Split S-00 into sub-slices (refs #1) (#33) - Split S-00 into sub-slices (refs #1) (#33)
- MkDocs scaffold + ADR-0001 + README quickstart (closes #32) (#39)
- Tighten gitea-actions-gotchas, add local compose (refs #30)
- ADR-0008 read projection store + demo note for the event path (refs #7)
- Demo note for submitting a registration (S-05) (refs #6)
- Demo note for the BFF front door (S-07) (refs #8)
- Split S-08 into S-08a-d (refs #65)
- Frontend-decisions + demo note for S-08a (refs #65)
- Record the orval generator choice (refs #66)
- Record NL DS + DigiD decisions and demo note (refs #67)
- Serving/e2e decisions + walking-skeleton demo note (refs #68)
### Features ### Features
- Placeholder BFF + /health endpoint (closes #28) (#34) - Placeholder BFF + /health endpoint (closes #28) (#34)
- Containerize BFF + compose-up smoke (closes #29) (#36) - Containerize BFF + compose-up smoke (closes #29) (#36)
- OpenZaak + Postgres + Redis up in compose (refs #10) (#40)
- Seed BIG catalogus + JWT client for OpenZaak (refs #2) (#41)
- Open Notificaties up + shared network (closes #2) (#42)
- Keycloak with four mock realms (closes #3) (#43)
- Flowable + registratie.bpmn external task (closes #4) (#44)
- ACL skeleton — OpenZaak default-fill (refs #5) (#45)
- Add bind-mount local compose for no-make/Windows dev (refs #30)
- Publish the BIG zaaktype on demand via OZ_PUBLISH (refs #46)
- Wire OpenZaak → Open Notificaties notifications (refs #56)
- Project zaak-created notifications into the read projection (refs #7)
- Persist the read projection and expose webhook + read APIs (refs #7)
- Enforce the callback bearer before reading the body (refs #7)
- Implement the Registration aggregate invariants (refs #6)
- Implement SubmitRegistration and OpenZaakWorker (refs #6)
- Implement the Flowable Workflow Client and ACL client (refs #6)
- Expose POST /registrations and the read endpoint (refs #6)
- Implement self-service submit and openbaar lookup (refs #8)
- Committed OpenAPI contract + drift guard (refs #8)
- Self-service portal placeholder page (refs #65)
- Expose the generated BFF client + repeatable generate target (refs #66)
- Implement the DigiD registration submit page (refs #67)
- Runtime config + nginx serve/proxy image (refs #68)
- Surface submit failures with a retryable alert (refs #68)
- One citizen reference across self-service and the openbaar register (#79)
### Other
- Openbaar Register portal — public lookup (#76)
- Approval flow — temp admin endpoint + status transition to projection (#77)
### Refactor
- Bake config via dockerfile_inline, drop Dockerfile files (refs #30)
- Use upstream images verbatim, seed config via docker cp (refs #30)
- One verify-stack stage for all live-stack checks (closes #58) (refs #46 #56)
### Tests
- BDD acceptance scenario for opening a zaak (closes #5) (#49)
- Kill surviving mutants — assert CRS headers, guards, error paths, JWT claims (refs #47)
- Add Stryker config + mutation make target recording the 95% baseline (refs #47)
- Integration test opens a real zaak against OpenZaak (refs #46)
- Verify-notifications smoke + CI job for the OZ→NRC path (refs #56)
- Project zaak-created notifications into the read projection (refs #7)
- Ratchet projector mutation baseline to 100% (refs #7)
- Registration aggregate invariants (refs #6)
- SubmitRegistration + OpenZaakWorker use cases (refs #6)
- Workflow Client, ACL client, store and job processor (refs #6)
- Acceptance scenario for submitting a registration (refs #6)
- Mutation baseline 90 (achieved 97.7%) + CI/Makefile wiring (refs #6)
- Endpoints, JWT auth and public-safe projection (refs #8)
- Acceptance scenario for BFF access (valid/invalid tokens) (refs #8)
- Self-service portal placeholder renders (refs #65)
- Generated BFF client is exposed and calls the endpoints (refs #66)
- DigiD-guarded registration submit page (refs #67)
- Walking-skeleton Playwright happy path + verify-e2e lane (refs #68)
- Submit surfaces BFF failures instead of swallowing them (refs #68)
- Guard that the DigiD token attaches to relative BFF calls (refs #68)

View File

@@ -0,0 +1,76 @@
# ADR-0013: Behandel-portal wiring — multi-realm BFF auth, werkbak from Flowable tasks, decision completes the task
- **Status:** Accepted
- **Date:** 2026-07-15
- **Deciders:** Respellion engineering
- **Relates to:** #84 (adr-proposal), S-12 (#13); builds on ADR-0010 (BFF OIDC), ADR-0011 (approval status flow), ADR-0009 (external-task worker), ADR-0008 (read projection)
## Context
S-12 adds the behandel-portal: a behandelaar logs in, sees a **werkbak** of registrations awaiting
beoordeling, and decides each (goedkeuren/afwijzen). Three questions had no obvious answer and shape
the whole slice.
1. **Which realm authenticates behandelaars, and how does the BFF accept it?** Citizens use the
`digid` realm (ADR-0010); staff use a separate `medewerker` realm with roles (`behandelaar`,
`teamlead`). Keycloak realms are distinct issuers with distinct signing keys, so the BFF's single
`digid`-realm JWT validation rejects a medewerker token outright.
2. **Where does the werkbak get its data?** The registrations awaiting beoordeling could come from
the read projection (status-filtered rows) or from the Flowable `Beoordelen` user tasks (S-12b).
3. **How does a decision correlate to the workflow?** The process parks at the `Beoordelen` user
task; the decision must advance it, and also apply the domain transition (ADR-0011).
## Decision
**The BFF validates a second realm for behandel endpoints; the werkbak is the set of open Flowable
`Beoordelen` tasks (read through the domain); and a decision both applies the domain transition and
completes the Flowable task.**
- **Multi-realm BFF auth.** The BFF registers a second JWT bearer scheme (`medewerker`, authority =
the medewerker realm) alongside the default `digid` scheme. `/behandel/*` endpoints require an
authorization policy bound to the `medewerker` scheme **and** the `behandelaar` role. Keycloak puts
realm roles in the nested `realm_access.roles` claim, which ASP.NET does not map automatically, so
the scheme's `OnTokenValidated` lifts those roles onto the principal as role claims. Self-service
keeps the `digid` scheme. Audience validation stays off (ADR-0010's deferred hardening).
- **Werkbak = Flowable user tasks (via the domain).** The domain's `Werkbak` query reads the open
`Beoordelen` tasks from the Workflow Client (§8.2, `IUserTaskClient`) and enriches each with its
aggregate's bsn + status; `GET /behandel/werkbak` exposes it and the BFF proxies it behind the
behandelaar policy. The list **is** the authoritative set of claimable/decidable work items, so a
decision acts on a real task with no separate correlation store. The read projection stays the
anonymous openbaar model — we do **not** project `IN_BEHANDELING` or populate staff-only personal
data (both deferred in ADR-0008) just to render a staff view.
- **Decision completes the task (S-12c-2).** A behandelaar decision applies the domain transition
(aggregate + ACL for approval, per ADR-0011) **and** completes the Flowable `Beoordelen` task
(looked up by registrationId), so the process advances. Implemented in the next sub-slice; recorded
here so the boundary is decided up front.
Delivery is split: **S-12c-1** (this PR) = multi-realm auth + werkbak read; **S-12c-2** = the decide
endpoint + task completion.
## Consequences
**Positive**
- Staff and citizens are cleanly separated by realm; the `behandelaar` role gates the behandel API.
- The werkbak reflects exactly what a behandelaar can act on; claim/decide need no extra correlation.
- No premature projection changes — the openbaar read model stays focused and personal-data-free.
- Only the ACL/Workflow Client talk to their peers; the BFF still fans out only to domain/projection
(§8.3).
**Negative / costs**
- The BFF now depends on two Keycloak realms being reachable (`Keycloak:MedewerkerAuthority`).
- Rendering the werkbak fans out to Flowable (one task query) plus a store read per task — acceptable
for the caseload sizes here; a denormalized staff read model is an additive follow-up if needed.
- Realm separation (distinct issuers/keys) is validated live, not in the BFF unit tests, where issuer
validation is off and one test key signs both realms; the tests exercise the role-based authorization.
## Alternatives considered
- **Werkbak from the read projection** — rejected for now: needs new plumbing to project
`IN_BEHANDELING` and to populate staff-only bsn/naam (deferred, ADR-0008), plus a separate way to
find the Flowable task at decide-time. Revisit if a high-volume denormalized staff view is needed.
- **One JWT scheme accepting both realms (issuer validation off)** — rejected: trusting multiple
issuers without validation is a security regression; two schemes keep each realm's issuer/key checked.
- **A dedicated behandel BFF/service** — rejected as premature; one BFF with per-endpoint policies is
enough at this size and keeps §8.3 simple.

View File

@@ -349,6 +349,8 @@ services:
# Keycloak (start-dev) derives the issuer from the request host, so the BFF authority and the # Keycloak (start-dev) derives the issuer from the request host, so the BFF authority and the
# verify token request both use keycloak:8080 to keep the issuer consistent. # verify token request both use keycloak:8080 to keep the issuer consistent.
Keycloak__Authority: http://keycloak:8080/realms/digid Keycloak__Authority: http://keycloak:8080/realms/digid
# Behandelaars authenticate against the medewerker realm; the BFF validates it for /behandel/* (S-12c).
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
Downstream__Domain__BaseUrl: http://domain:8080/ Downstream__Domain__BaseUrl: http://domain:8080/
Downstream__Projection__BaseUrl: http://projection-api:8080/ Downstream__Projection__BaseUrl: http://projection-api:8080/
ports: ports:

View File

@@ -51,18 +51,71 @@ loc="$(docker run --rm --network "$net" curlimages/curl:latest \
echo ">> registration accepted at $loc" echo ">> registration accepted at $loc"
echo ">> polling the domain until the worker records the opened zaak" echo ">> polling the domain until the worker records the opened zaak"
zaak_ok=""
for _ in $(seq 1 30); do for _ in $(seq 1 30); do
body="$(docker run --rm --network "$net" curlimages/curl:latest \ body="$(docker run --rm --network "$net" curlimages/curl:latest \
-fsS "http://$dom_ip:8080$loc" 2>/dev/null || true)" -fsS "http://$dom_ip:8080$loc" 2>/dev/null || true)"
if echo "$body" | grep -q '/zaken/api/v1/zaken/'; then if echo "$body" | grep -q '/zaken/api/v1/zaken/'; then
echo "OK — the domain opened a zaak and recorded it on the registration:" echo "OK — the domain opened a zaak and recorded it on the registration:"
echo "$body" | cut -c1-300 echo "$body" | cut -c1-300
exit 0 zaak_ok=1
break
fi fi
sleep 2 sleep 2
done done
if [ -z "$zaak_ok" ]; then
echo "FAIL — the registration never received a zaak URL" >&2 echo "FAIL — the registration never received a zaak URL" >&2
echo "--- domain log ---" >&2; docker logs "$dom" 2>&1 | tail -15 >&2 echo "--- domain log ---" >&2; docker logs "$dom" 2>&1 | tail -15 >&2
acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)" acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)"
[ -n "$acl" ] && { echo "--- acl log ---" >&2; docker logs "$acl" 2>&1 | tail -15 >&2; } [ -n "$acl" ] && { echo "--- acl log ---" >&2; docker logs "$acl" 2>&1 | tail -15 >&2; }
exit 1 exit 1
fi
# ── S-12b: the process now parks at the Beoordelen user task. Exercise the exact Flowable REST
# contract the Workflow Client uses (query/claim/complete) against the live engine, reaching
# flowable-rest by container IP (same in-network constraint as above). ──────────────────────────
fl="$(docker ps -q --filter 'name=flowable-rest' | head -1)"
[ -n "$fl" ] || { echo "ERROR: no running flowable-rest container" >&2; exit 1; }
fl_base="http://$(ip "$fl"):8080/flowable-rest/service"
reg_id="${loc##*/}"
# Extracts the Beoordelen task id for our registration from a Flowable task-query response on stdin.
# Tolerates an empty/non-JSON body (a transient failure during the poll) by printing nothing.
task_for_reg() { REG_ID="$reg_id" python3 -c "import os,sys,json
try:
d=json.load(sys.stdin)
except Exception:
d={}
rid=os.environ['REG_ID']
# Flowable's task-query returns the included process variables under 'variables'.
print(next((t['id'] for t in (d.get('data') or [])
if any(v.get('name')=='registrationId' and v.get('value')==rid for v in (t.get('variables') or []))), ''))"; }
flcurl() { docker run --rm --network "$net" curlimages/curl:latest -fsS -u rest-admin:test "$@"; }
query='{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen","includeProcessVariables":true}'
echo ">> polling Flowable for the Beoordelen user task (werkbak)"
task_id=""
for _ in $(seq 1 30); do
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
task_id="$(printf '%s' "$resp" | task_for_reg)"
[ -n "$task_id" ] && break
sleep 2
done
[ -n "$task_id" ] || { echo "FAIL — no Beoordelen task appeared for registration $reg_id" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo ">> Beoordelen task $task_id is waiting"
echo ">> claiming the task as merel-behandelaar"
flcurl -X POST "$fl_base/runtime/tasks/$task_id" -H 'Content-Type: application/json' \
-d '{"action":"claim","assignee":"merel-behandelaar"}' >/dev/null
echo ">> completing the beoordeling (goedkeuren)"
flcurl -X POST "$fl_base/runtime/tasks/$task_id" -H 'Content-Type: application/json' \
-d '{"action":"complete","variables":[{"name":"besluit","type":"string","value":"goedkeuren"}]}' >/dev/null
echo ">> asserting the process finished (no Beoordelen task remains for the registration)"
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query")"
still="$(printf '%s' "$resp" | task_for_reg)"
[ -z "$still" ] || { echo "FAIL — Beoordelen task $still still active after completion" >&2; exit 1; }
echo "OK — behandelaar claimed and completed the Beoordelen task; the registratie process finished"
exit 0

View File

@@ -36,6 +36,12 @@ export interface SubmitAccepted {
status: string; status: string;
} }
export interface WerkbakItem {
registrationId: string;
bsn: string;
status: string;
}
export type GetOpenbaarRegisterParams = { export type GetOpenbaarRegisterParams = {
q?: string; q?: string;
}; };
@@ -215,4 +221,35 @@ export class BffApiV1Service {
); );
} }
getBehandelWerkbak<TData = WerkbakItem[]>( options?: HttpClientBodyOptions): Observable<TData>;
getBehandelWerkbak<TData = WerkbakItem[]>( options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
getBehandelWerkbak<TData = WerkbakItem[]>( options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
getBehandelWerkbak<TData = WerkbakItem[]>(
options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
if (options?.observe === 'events') {
return this.http.get<TData>(
`/behandel/werkbak`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
}
);
}
if (options?.observe === 'response') {
return this.http.get<TData>(
`/behandel/werkbak`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
}
);
}
return this.http.get<TData>(
`/behandel/werkbak`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'body',
}
);
}
}; };

View File

@@ -13,10 +13,17 @@ public sealed record ProjectionEntry(string Id, string Status, string? Reference
/// <summary>A public-safe openbaar register row — only non-sensitive fields leave the BFF.</summary> /// <summary>A public-safe openbaar register row — only non-sensitive fields leave the BFF.</summary>
public sealed record OpenbaarEntry(string Id, string Status, string? Reference); public sealed record OpenbaarEntry(string Id, string Status, string? Reference);
/// <summary>A behandelaar's werkbak row: a registration awaiting beoordeling, with the bsn + status a
/// behandelaar sees (staff view — reached only behind medewerker/behandelaar authorization, S-12c).</summary>
public sealed record WerkbakItem(string RegistrationId, string Bsn, string Status);
/// <summary>Port to the Domain Service (§8.3: the BFF is the portals' only backend; it fans out).</summary> /// <summary>Port to the Domain Service (§8.3: the BFF is the portals' only backend; it fans out).</summary>
public interface IDomainClient public interface IDomainClient
{ {
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default); Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
/// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary>
Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default);
} }
/// <summary>Port to the read projection.</summary> /// <summary>Port to the read projection.</summary>
@@ -37,6 +44,9 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
return new SubmitAccepted(dto.RegistrationId, dto.Status); return new SubmitAccepted(dto.RegistrationId, dto.Status);
} }
public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
=> await http.GetFromJsonAsync<List<WerkbakItem>>("behandel/werkbak", ct) ?? [];
private sealed record DomainResponse(string RegistrationId, string Status, string? ZaakUrl); private sealed record DomainResponse(string RegistrationId, string Status, string? ZaakUrl);
} }

View File

@@ -1,4 +1,6 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json;
using System.Text.Json.Serialization;
using Bff.Api; using Bff.Api;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
@@ -6,6 +8,10 @@ var builder = WebApplication.CreateBuilder(args);
var keycloakAuthority = builder.Configuration["Keycloak:Authority"] var keycloakAuthority = builder.Configuration["Keycloak:Authority"]
?? throw new InvalidOperationException("Missing configuration 'Keycloak:Authority'"); ?? throw new InvalidOperationException("Missing configuration 'Keycloak:Authority'");
// Behandelaars authenticate against a *different* Keycloak realm (medewerker) than citizens (digid),
// so the BFF validates a second issuer for the behandel endpoints (ADR-0013).
var medewerkerAuthority = builder.Configuration["Keycloak:MedewerkerAuthority"]
?? throw new InvalidOperationException("Missing configuration 'Keycloak:MedewerkerAuthority'");
var domainBaseUrl = builder.Configuration["Downstream:Domain:BaseUrl"] var domainBaseUrl = builder.Configuration["Downstream:Domain:BaseUrl"]
?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'"); ?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'");
var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"] var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"]
@@ -19,8 +25,28 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
options.Authority = keycloakAuthority; options.Authority = keycloakAuthority;
options.RequireHttpsMetadata = false; options.RequireHttpsMetadata = false;
options.TokenValidationParameters.ValidateAudience = false; options.TokenValidationParameters.ValidateAudience = false;
})
// The medewerker realm — behandel endpoints only. On validation we lift Keycloak's realm roles
// (the nested realm_access.roles claim) into role claims so authorization policies can require them.
.AddJwtBearer(BehandelAuth.Scheme, options =>
{
options.Authority = medewerkerAuthority;
options.RequireHttpsMetadata = false;
options.TokenValidationParameters.ValidateAudience = false;
options.Events = new JwtBearerEvents
{
OnTokenValidated = context =>
{
BehandelAuth.AddRealmRoles(context.Principal);
return Task.CompletedTask;
},
};
}); });
builder.Services.AddAuthorization(); builder.Services.AddAuthorization(options =>
options.AddPolicy(BehandelAuth.Policy, policy => policy
.AddAuthenticationSchemes(BehandelAuth.Scheme)
.RequireAuthenticatedUser()
.RequireRole(BehandelAuth.BehandelaarRole)));
// The BFF is the portals' only backend; it fans out to the domain and projection (§8.3). // The BFF is the portals' only backend; it fans out to the domain and projection (§8.3).
builder.Services.AddHttpClient<IDomainClient, DomainClient>(c => c.BaseAddress = new Uri(domainBaseUrl)); builder.Services.AddHttpClient<IDomainClient, DomainClient>(c => c.BaseAddress = new Uri(domainBaseUrl));
@@ -68,7 +94,53 @@ app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection,
}) })
.Produces<IReadOnlyList<OpenbaarEntry>>(StatusCodes.Status200OK); .Produces<IReadOnlyList<OpenbaarEntry>>(StatusCodes.Status200OK);
// Behandelaar's werkbak: registrations awaiting beoordeling. Reached only with a medewerker-realm
// token carrying the behandelaar role; the BFF proxies the domain's werkbak (staff view, ADR-0013).
app.MapGet("/behandel/werkbak", async (IDomainClient domain, CancellationToken ct) =>
Results.Ok(await domain.GetWerkbakAsync(ct)))
.RequireAuthorization(BehandelAuth.Policy)
.Produces<IReadOnlyList<WerkbakItem>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden);
app.Run(); app.Run();
// Behandel (medewerker-realm) authentication + authorization wiring (ADR-0013).
internal static class BehandelAuth
{
public const string Scheme = "medewerker";
public const string Policy = "behandelaar";
public const string BehandelaarRole = "behandelaar";
/// <summary>Lift Keycloak's realm roles (the nested <c>realm_access.roles</c> claim) onto the
/// principal as role claims, so <c>RequireRole</c> can authorize on them.</summary>
public static void AddRealmRoles(ClaimsPrincipal? principal)
{
if (principal?.Identity is not ClaimsIdentity identity)
return;
var realmAccess = principal.FindFirst("realm_access")?.Value;
if (string.IsNullOrWhiteSpace(realmAccess))
return;
// A malformed realm_access claim must not fail authentication (a throw here becomes a 401);
// it simply yields no roles, so the authorization policy answers 403.
string[] roles;
try
{
roles = JsonSerializer.Deserialize<RealmAccess>(realmAccess)?.Roles ?? [];
}
catch (JsonException)
{
return;
}
foreach (var role in roles)
identity.AddClaim(new Claim(identity.RoleClaimType, role));
}
private sealed record RealmAccess([property: JsonPropertyName("roles")] string[] Roles);
}
// Exposed so the test host (WebApplicationFactory<Program>) can boot the app. // Exposed so the test host (WebApplicationFactory<Program>) can boot the app.
public partial class Program; public partial class Program;

View File

@@ -7,7 +7,8 @@
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"Keycloak": { "Keycloak": {
"Authority": "http://localhost:8180/realms/digid" "Authority": "http://localhost:8180/realms/digid",
"MedewerkerAuthority": "http://localhost:8180/realms/medewerker"
}, },
"Downstream": { "Downstream": {
"Domain": { "BaseUrl": "http://localhost:8130/" }, "Domain": { "BaseUrl": "http://localhost:8130/" },

View File

@@ -0,0 +1,57 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Bff.Api;
namespace Bff.Tests;
/// <summary>
/// The behandel werkbak endpoint (S-12c): reached only with a medewerker-realm token that carries the
/// <c>behandelaar</c> role. A missing token is 401; an authenticated medewerker without the role is
/// 403; a behandelaar gets the werkbak (staff view, incl. bsn).
/// </summary>
public class BehandelEndpointTests
{
private static HttpRequestMessage Werkbak(string? bearer)
{
var request = new HttpRequestMessage(HttpMethod.Get, "/behandel/werkbak");
if (bearer is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
return request;
}
[Fact]
public async Task Rejects_the_werkbak_without_a_token()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Werkbak(bearer: null));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Rejects_a_medewerker_without_the_behandelaar_role()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Werkbak(TestTokens.Medewerker("teamlead")));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task Serves_the_werkbak_to_a_behandelaar()
{
using var factory = new BffFactory();
factory.Domain.Werkbak.Add(new WerkbakItem("reg-1", "123456782", "InBehandeling"));
var response = await factory.CreateClient().SendAsync(Werkbak(TestTokens.Medewerker("behandelaar")));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var items = await response.Content.ReadFromJsonAsync<List<WerkbakItem>>();
var item = Assert.Single(items!);
Assert.Equal("reg-1", item.RegistrationId);
Assert.Equal("123456782", item.Bsn);
}
}

View File

@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost; using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
@@ -23,24 +24,19 @@ internal sealed class BffFactory : WebApplicationFactory<Program>
public FakeDomainClient Domain { get; } = new(); public FakeDomainClient Domain { get; } = new();
public FakeProjectionClient Projection { get; } = new(); public FakeProjectionClient Projection { get; } = new();
protected override void ConfigureWebHost(IWebHostBuilder builder) private static void ValidateWithTestKey(IServiceCollection services, string scheme) =>
services.Configure<JwtBearerOptions>(scheme, options =>
{ {
builder.UseSetting("Keycloak:Authority", "https://keycloak.invalid/realms/digid"); // Validate locally against the test key and NEVER reach out for OIDC metadata. A static
builder.UseSetting("Downstream:Domain:BaseUrl", "http://domain.invalid/"); // configuration manager guarantees this regardless of Configure/PostConfigure ordering —
builder.UseSetting("Downstream:Projection:BaseUrl", "http://projection.invalid/"); // clearing Authority alone left the medewerker scheme fetching metadata under CI timing
// (2s hang → 401), because JwtBearer's PostConfigure could still build a ConfigurationManager.
builder.ConfigureTestServices(services =>
{
services.AddSingleton<IDomainClient>(Domain);
services.AddSingleton<IProjectionClient>(Projection);
services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
{
// Validate locally against the test key; never reach out for OIDC metadata.
options.Authority = null; options.Authority = null;
options.MetadataAddress = null!; options.MetadataAddress = null!;
options.RequireHttpsMetadata = false; options.RequireHttpsMetadata = false;
options.Configuration = new OpenIdConnectConfiguration(); options.Configuration = new OpenIdConnectConfiguration();
options.ConfigurationManager =
new StaticConfigurationManager<OpenIdConnectConfiguration>(new OpenIdConnectConfiguration());
options.TokenValidationParameters = new TokenValidationParameters options.TokenValidationParameters = new TokenValidationParameters
{ {
ValidateIssuer = false, ValidateIssuer = false,
@@ -51,6 +47,24 @@ internal sealed class BffFactory : WebApplicationFactory<Program>
ClockSkew = TimeSpan.Zero, ClockSkew = TimeSpan.Zero,
}; };
}); });
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseSetting("Keycloak:Authority", "https://keycloak.invalid/realms/digid");
builder.UseSetting("Keycloak:MedewerkerAuthority", "https://keycloak.invalid/realms/medewerker");
builder.UseSetting("Downstream:Domain:BaseUrl", "http://domain.invalid/");
builder.UseSetting("Downstream:Projection:BaseUrl", "http://projection.invalid/");
builder.ConfigureTestServices(services =>
{
services.AddSingleton<IDomainClient>(Domain);
services.AddSingleton<IProjectionClient>(Projection);
// Both realms validate locally against the test key (no live Keycloak). The medewerker
// scheme keeps its OnTokenValidated role-lifting from Program.cs — only the validation
// parameters are swapped here.
ValidateWithTestKey(services, JwtBearerDefaults.AuthenticationScheme);
ValidateWithTestKey(services, "medewerker");
}); });
} }
} }
@@ -60,12 +74,16 @@ internal sealed class FakeDomainClient : IDomainClient
{ {
public string? SubmittedBsn { get; private set; } public string? SubmittedBsn { get; private set; }
public SubmitAccepted Result { get; set; } = new("reg-123", "Ingediend"); public SubmitAccepted Result { get; set; } = new("reg-123", "Ingediend");
public List<WerkbakItem> Werkbak { get; } = [];
public Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default) public Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default)
{ {
SubmittedBsn = bsn; SubmittedBsn = bsn;
return Task.FromResult(Result); return Task.FromResult(Result);
} }
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<WerkbakItem>>(Werkbak);
} }
/// <summary>Serves a configurable set of projection rows.</summary> /// <summary>Serves a configurable set of projection rows.</summary>

View File

@@ -17,6 +17,22 @@ internal static class TestTokens
new SymmetricSecurityKey(Encoding.UTF8.GetBytes("a-different-signing-key-256-bits-long-indeed-yes!")), new SymmetricSecurityKey(Encoding.UTF8.GetBytes("a-different-signing-key-256-bits-long-indeed-yes!")),
expired: false); expired: false);
/// <summary>A medewerker-realm token carrying the given realm roles under <c>realm_access.roles</c>
/// (Keycloak's shape), signed with the valid test key. Used to exercise behandel authorization.</summary>
public static string Medewerker(params string[] roles)
{
var handler = new JsonWebTokenHandler();
return handler.CreateToken(new SecurityTokenDescriptor
{
Claims = new Dictionary<string, object>
{
["realm_access"] = new Dictionary<string, object> { ["roles"] = roles },
},
Expires = DateTime.UtcNow.AddMinutes(30),
SigningCredentials = new SigningCredentials(BffFactory.TestSigningKey, SecurityAlgorithms.HmacSha256),
});
}
private static string Create(string bsn, SymmetricSecurityKey key, bool expired) private static string Create(string bsn, SymmetricSecurityKey key, bool expired)
{ {
var handler = new JsonWebTokenHandler(); var handler = new JsonWebTokenHandler();

View File

@@ -60,6 +60,34 @@
} }
} }
} }
},
"/behandel/werkbak": {
"get": {
"tags": [
"Bff.Api"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/WerkbakItem"
}
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
}
} }
}, },
"components": { "components": {
@@ -100,6 +128,25 @@
"type": "string" "type": "string"
} }
} }
},
"WerkbakItem": {
"required": [
"registrationId",
"bsn",
"status"
],
"type": "object",
"properties": {
"registrationId": {
"type": "string"
},
"bsn": {
"type": "string"
},
"status": {
"type": "string"
}
}
} }
} }
}, },

View File

@@ -20,10 +20,13 @@ builder.Services.AddSingleton<IRegistrationStore, InMemoryRegistrationStore>();
builder.Services.AddHttpClient<FlowableWorkflowClient>(); builder.Services.AddHttpClient<FlowableWorkflowClient>();
builder.Services.AddTransient<IWorkflowClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>()); builder.Services.AddTransient<IWorkflowClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddTransient<IExternalWorkerClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>()); builder.Services.AddTransient<IExternalWorkerClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddTransient<IUserTaskClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddHttpClient<IAclClient, AclHttpClient>(); builder.Services.AddHttpClient<IAclClient, AclHttpClient>();
builder.Services.AddScoped<SubmitRegistration>(); builder.Services.AddScoped<SubmitRegistration>();
builder.Services.AddScoped<ApproveRegistration>(); builder.Services.AddScoped<ApproveRegistration>();
builder.Services.AddScoped<BeoordeelRegistratie>();
builder.Services.AddScoped<Werkbak>();
builder.Services.AddScoped<OpenZaakWorker>(); builder.Services.AddScoped<OpenZaakWorker>();
builder.Services.AddScoped<OpenZaakJobProcessor>(); builder.Services.AddScoped<OpenZaakJobProcessor>();
@@ -55,6 +58,28 @@ app.MapPost("/registrations/{id}/approve", async (string id, ApproveRegistration
return Results.NoContent(); return Results.NoContent();
}); });
// The behandelaar's beoordeling (S-12): decide a registration goedkeuren (→ INGESCHREVEN, sets the
// zaak's final status via the ACL) or afwijzen (→ AFGEWEZEN). Idempotent. This is the domain contract
// the behandel-portal's decision reaches through the BFF; it supersedes the temporary /approve above,
// which is retired once the portal lands.
app.MapPost("/registrations/{id}/decide", async (string id, DecideRequest body, BeoordeelRegistratie beoordeel, CancellationToken ct) =>
{
if (!Guid.TryParse(id, out var guid))
return Results.NotFound();
if (!Enum.TryParse<BeoordelingsBesluit>(body.Besluit, ignoreCase: true, out var besluit))
return Results.BadRequest(new { error = $"Unknown besluit '{body.Besluit}'. Expected 'goedkeuren' or 'afwijzen'." });
await beoordeel.HandleAsync(new BeoordeelRegistratieCommand(new RegistrationId(guid), besluit), ct);
return Results.NoContent();
});
// The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open
// Beoordelen user tasks (§8.2) and enriched with bsn + status. The BFF proxies this behind
// medewerker-realm + behandelaar-role authorization; the domain trusts its callers (§8.3).
app.MapGet("/behandel/werkbak", async (Werkbak werkbak, CancellationToken ct) =>
Results.Ok(await werkbak.GetAsync(ct)));
// Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually). // Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually).
app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) => app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) =>
{ {
@@ -72,6 +97,8 @@ await app.RunAsync();
public sealed record SubmitRegistrationRequest(string Bsn); public sealed record SubmitRegistrationRequest(string Bsn);
public sealed record DecideRequest(string Besluit);
public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl); public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl);
public partial class Program; public partial class Program;

View File

@@ -0,0 +1,57 @@
using Big.Domain;
namespace Big.Application;
/// <summary>A behandelaar's beoordeling outcome, in domain language.</summary>
public enum BeoordelingsBesluit
{
/// <summary>Approve — enter the registration in the register.</summary>
Goedkeuren,
/// <summary>Reject — turn the registration down.</summary>
Afwijzen,
}
/// <summary>A behandelaar's decision on a registration.</summary>
public sealed record BeoordeelRegistratieCommand(RegistrationId RegistrationId, BeoordelingsBesluit Besluit);
/// <summary>
/// The beoordeling use case (S-12): apply a behandelaar's decision to a registration.
/// <see cref="BeoordelingsBesluit.Goedkeuren"/> sets the zaak's final status via the ACL (§8.1) and
/// advances the aggregate to INGESCHREVEN; <see cref="BeoordelingsBesluit.Afwijzen"/> advances it to
/// AFGEWEZEN in the domain (propagating a rejection to the zaak, so the openbaar projection reflects
/// it, is a later sub-slice of S-12). Both decisions are idempotent — a repeated or redelivered
/// decision that matches the current terminal state is a no-op, so the ACL is not called twice.
/// </summary>
public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient acl)
{
public async Task HandleAsync(BeoordeelRegistratieCommand command, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(command);
var registration = await store.GetAsync(command.RegistrationId, ct)
?? throw new InvalidOperationException($"No registration {command.RegistrationId} to decide.");
switch (command.Besluit)
{
case BeoordelingsBesluit.Goedkeuren:
// A repeated approval is a no-op: don't set the zaak status a second time.
if (registration.Status == RegistrationStatus.Ingeschreven)
return;
if (registration.ZaakUrl is null)
throw new InvalidOperationException(
$"Registration {command.RegistrationId} has no zaak yet; it cannot be approved.");
await acl.ApproveZaakAsync(registration.ZaakUrl, ct);
registration.Approve();
break;
case BeoordelingsBesluit.Afwijzen:
if (registration.Status == RegistrationStatus.Afgewezen)
return;
registration.Reject();
break;
}
await store.SaveAsync(registration, ct);
}
}

View File

@@ -36,6 +36,30 @@ public interface IAclClient
Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default); Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default);
} }
/// <summary>
/// The port to the behandelaar's user tasks in the workflow engine (S-12). Implemented by the
/// Workflow Client — the only code that talks to Flowable (§8.2). The application lists the open
/// <c>Beoordelen</c> tasks (the werkbak), claims one for a behandelaar, and completes it with the
/// decision; it never names Flowable's REST shapes.
/// </summary>
public interface IUserTaskClient
{
/// <summary>The werkbak: the <c>Beoordelen</c> tasks awaiting a behandelaar, each with the
/// registration it belongs to.</summary>
Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default);
/// <summary>Claim a beoordeling task for a behandelaar (assigns it to them).</summary>
Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default);
/// <summary>Complete a beoordeling task, carrying the decision into the process as the
/// <c>besluit</c> variable so the workflow can continue on the chosen branch.</summary>
Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default);
}
/// <summary>A <c>Beoordelen</c> user task in the werkbak: the Flowable task id (needed to claim and
/// complete it) and the registration it carries as a process variable.</summary>
public sealed record BeoordelingTask(string TaskId, RegistrationId RegistrationId);
/// <summary> /// <summary>
/// Persistence port for the <see cref="Registration"/> aggregate. In-memory for the minimal slice /// Persistence port for the <see cref="Registration"/> aggregate. In-memory for the minimal slice
/// (ADR-0009); an EF-backed store is a documented follow-up, and this port keeps that change additive. /// (ADR-0009); an EF-backed store is a documented follow-up, and this port keeps that change additive.

View File

@@ -0,0 +1,32 @@
namespace Big.Application;
/// <summary>One row of the behandelaar's werkbak: a registration awaiting beoordeling, with the
/// public-facing reference (its id) plus the bsn and status a behandelaar needs to triage it.</summary>
public sealed record WerkbakItem(string RegistrationId, string Bsn, string Status);
/// <summary>
/// The werkbak query (S-12c): the registrations awaiting a behandelaar's beoordeling. It reads the
/// open <c>Beoordelen</c> tasks from the workflow engine (§8.2, via <see cref="IUserTaskClient"/>) —
/// the authoritative set of work items — and enriches each with its aggregate (bsn + status). A task
/// whose registration the domain doesn't know is skipped rather than invented.
/// </summary>
public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store)
{
public async Task<IReadOnlyList<WerkbakItem>> GetAsync(CancellationToken ct = default)
{
var open = await tasks.GetOpenBeoordelingenAsync(ct);
var items = new List<WerkbakItem>(open.Count);
foreach (var task in open)
{
var registration = await store.GetAsync(task.RegistrationId, ct);
if (registration is null)
continue;
items.Add(new WerkbakItem(
registration.Id.ToString(), registration.Bsn, registration.Status.ToString()));
}
return items;
}
}

View File

@@ -66,10 +66,28 @@ public sealed class Registration
} }
/// <summary> /// <summary>
/// Approve the registration — the behandelaar's decision to enter it in the register. Advances /// A behandelaar picks the registration up for beoordeling. Advances
/// <see cref="RegistrationStatus.Ingediend"/> → <see cref="RegistrationStatus.Ingeschreven"/>. /// <see cref="RegistrationStatus.Ingediend"/> → <see cref="RegistrationStatus.InBehandeling"/>.
/// Requires an opened zaak (the approval sets that zaak's status via the ACL), and only a /// Re-taking one already <see cref="RegistrationStatus.InBehandeling"/> is a no-op (the same or
/// submitted registration can be approved: re-approving is rejected as an invalid transition. /// another behandelaar re-opens it); a decided registration can no longer be taken into behandeling.
/// </summary>
public void TakeIntoBehandeling()
{
if (Status == RegistrationStatus.InBehandeling)
return;
if (Status != RegistrationStatus.Ingediend)
throw new InvalidOperationException(
$"Registration {Id} is {Status}; only an INGEDIEND registration can be taken into behandeling.");
Status = RegistrationStatus.InBehandeling;
}
/// <summary>
/// Approve the registration — the behandelaar's decision to enter it in the register. Advances a
/// submitted or in-behandeling registration to <see cref="RegistrationStatus.Ingeschreven"/>.
/// Requires an opened zaak (the approval sets that zaak's status via the ACL); a registration that
/// has already been decided cannot be approved again.
/// </summary> /// </summary>
public void Approve() public void Approve()
{ {
@@ -77,10 +95,27 @@ public sealed class Registration
throw new InvalidOperationException( throw new InvalidOperationException(
$"Registration {Id} has no zaak yet; it cannot be approved before its zaak is opened."); $"Registration {Id} has no zaak yet; it cannot be approved before its zaak is opened.");
if (Status != RegistrationStatus.Ingediend) RequireOpenForDecision(nameof(Approve));
throw new InvalidOperationException(
$"Registration {Id} is {Status}; only an INGEDIEND registration can be approved.");
Status = RegistrationStatus.Ingeschreven; Status = RegistrationStatus.Ingeschreven;
} }
/// <summary>
/// Reject the registration — the behandelaar's decision not to enter it in the register. Advances a
/// submitted or in-behandeling registration to <see cref="RegistrationStatus.Afgewezen"/>. Unlike
/// approval this needs no zaak: a registration can be rejected before or after its zaak is opened.
/// A registration that has already been decided cannot be rejected again.
/// </summary>
public void Reject()
{
RequireOpenForDecision(nameof(Reject));
Status = RegistrationStatus.Afgewezen;
}
// A decision is only valid while the registration is still open (INGEDIEND or IN_BEHANDELING).
private void RequireOpenForDecision(string decision)
{
if (Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling))
throw new InvalidOperationException(
$"Registration {Id} is {Status}; only an INGEDIEND or IN_BEHANDELING registration can be decided ({decision}).");
}
} }

View File

@@ -1,13 +1,20 @@
namespace Big.Domain; namespace Big.Domain;
/// <summary>The lifecycle states a <see cref="Registration"/> moves through. The walking /// <summary>The lifecycle states a <see cref="Registration"/> moves through. Submission starts in
/// skeleton knows <see cref="Ingediend"/> and the terminal <see cref="Ingeschreven"/>; withdrawal, /// <see cref="Ingediend"/>; a behandelaar takes it <see cref="InBehandeling"/> and decides it into one
/// beoordeling and herregistratie states arrive in their own slices (Iteration 2+).</summary> /// of the terminal states <see cref="Ingeschreven"/> (approved) or <see cref="Afgewezen"/> (rejected).
/// Withdrawal and herregistratie states arrive in their own slices (S-11+).</summary>
public enum RegistrationStatus public enum RegistrationStatus
{ {
/// <summary>Submitted by the zorgprofessional; the registratie process has been started.</summary> /// <summary>Submitted by the zorgprofessional; the registratie process has been started.</summary>
Ingediend, Ingediend,
/// <summary>Approved: entered in the register. Terminal in the walking skeleton (S-09b).</summary> /// <summary>Picked up by a behandelaar for beoordeling (S-12).</summary>
InBehandeling,
/// <summary>Approved: entered in the register. Terminal.</summary>
Ingeschreven, Ingeschreven,
/// <summary>Rejected by the behandelaar. Terminal.</summary>
Afgewezen,
} }

View File

@@ -15,12 +15,14 @@ namespace Big.Infrastructure;
/// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009). /// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009).
/// </summary> /// </summary>
public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options) public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options)
: IWorkflowClient, IExternalWorkerClient : IWorkflowClient, IExternalWorkerClient, IUserTaskClient
{ {
private const string Topic = "OpenZaakAanmaken"; private const string Topic = "OpenZaakAanmaken";
private const string ProcessDefinitionKey = "registratie"; private const string ProcessDefinitionKey = "registratie";
private const string BeoordelenTaskKey = "Beoordelen";
private const string RegistrationIdVariable = "registrationId"; private const string RegistrationIdVariable = "registrationId";
private const string ZaakUrlVariable = "zaakUrl"; private const string ZaakUrlVariable = "zaakUrl";
private const string BesluitVariable = "besluit";
public async Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) public async Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
{ {
@@ -55,6 +57,34 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
public async Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default)
{
var request = new TaskQueryRequest(ProcessDefinitionKey, BeoordelenTaskKey, IncludeProcessVariables: true);
var page = await PostAsync<TaskQueryRequest, TaskQueryResult>(
"service/query/tasks", request, ct);
var tasks = page?.Data ?? [];
return [.. tasks.Select(t => new BeoordelingTask(t.Id, RegistrationId.Parse(t.RegistrationId())))];
}
public async Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default)
{
using var response = await SendAsync(
$"service/runtime/tasks/{taskId}", new ClaimTaskRequest("claim", behandelaar), ct);
response.EnsureSuccessStatusCode();
}
public async Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default)
{
var request = new CompleteTaskRequest(
"complete",
[new Variable(BesluitVariable, "string", besluit.ToString().ToLowerInvariant())]);
using var response = await SendAsync($"service/runtime/tasks/{taskId}", request, ct);
response.EnsureSuccessStatusCode();
}
private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct) private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct)
{ {
using var response = await SendAsync(path, body, ct); using var response = await SendAsync(path, body, ct);
@@ -89,6 +119,34 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
[property: JsonPropertyName("workerId")] string WorkerId, [property: JsonPropertyName("workerId")] string WorkerId,
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables); [property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables);
private sealed record TaskQueryRequest(
[property: JsonPropertyName("processDefinitionKey")] string ProcessDefinitionKey,
[property: JsonPropertyName("taskDefinitionKey")] string TaskDefinitionKey,
[property: JsonPropertyName("includeProcessVariables")] bool IncludeProcessVariables);
private sealed record ClaimTaskRequest(
[property: JsonPropertyName("action")] string Action,
[property: JsonPropertyName("assignee")] string Assignee);
private sealed record CompleteTaskRequest(
[property: JsonPropertyName("action")] string Action,
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables);
private sealed record TaskQueryResult(
[property: JsonPropertyName("data")] IReadOnlyList<UserTaskDto>? Data);
private sealed record UserTaskDto(
[property: JsonPropertyName("id")] string Id,
// Flowable's task-query returns the (included) process variables under "variables", not
// "processVariables"; the request opts in via includeProcessVariables.
[property: JsonPropertyName("variables")] IReadOnlyList<Variable>? Variables)
{
/// <summary>The registration id this task carries as a process variable.</summary>
public string RegistrationId() =>
Variables?.SingleOrDefault(v => v.Name == "registrationId")?.Value
?? throw new InvalidOperationException($"Beoordelen task {Id} carries no registrationId variable.");
}
private sealed record Variable( private sealed record Variable(
[property: JsonPropertyName("name")] string Name, [property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("type")] string Type, [property: JsonPropertyName("type")] string Type,

View File

@@ -0,0 +1,142 @@
using Big.Application;
using Big.Domain;
namespace Big.Tests;
/// <summary>
/// The beoordeling use case (S-12): a behandelaar's decision on a registration. Goedkeuren sets the
/// zaak's final status via the ACL (§8.1) and marks the aggregate INGESCHREVEN; Afwijzen marks it
/// AFGEWEZEN in the domain (propagating a rejection to the zaak is a later sub-slice). Both are
/// idempotent so a repeated or redelivered decision is a no-op.
/// </summary>
public class BeoordeelRegistratieTests
{
private static Registration WithZaak(string bsn = "123456782")
{
var registration = Registration.Submit(bsn);
registration.AttachZaak(FakeAclClient.DefaultZaakUrl);
return registration;
}
[Fact]
public async Task Goedkeuren_sets_the_zaak_status_via_the_acl_and_marks_the_registration_ingeschreven()
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var registration = WithZaak();
store.Seed(registration);
var handler = new BeoordeelRegistratie(store, acl);
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
var saved = await store.GetAsync(registration.Id);
Assert.Equal(RegistrationStatus.Ingeschreven, saved!.Status);
Assert.Equal(FakeAclClient.DefaultZaakUrl, acl.ApprovedZaakUrl);
Assert.Equal(1, acl.ApproveCallCount);
Assert.Equal(1, store.SaveCount);
}
[Fact]
public async Task Afwijzen_marks_the_registration_afgewezen_without_calling_the_acl()
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var registration = WithZaak();
store.Seed(registration);
var handler = new BeoordeelRegistratie(store, acl);
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
var saved = await store.GetAsync(registration.Id);
Assert.Equal(RegistrationStatus.Afgewezen, saved!.Status);
Assert.Equal(0, acl.ApproveCallCount);
Assert.Equal(1, store.SaveCount);
}
[Fact]
public async Task Goedkeuren_an_in_behandeling_registration_marks_it_ingeschreven()
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var registration = WithZaak();
registration.TakeIntoBehandeling();
store.Seed(registration);
var handler = new BeoordeelRegistratie(store, acl);
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
Assert.Equal(RegistrationStatus.Ingeschreven, (await store.GetAsync(registration.Id))!.Status);
}
[Fact]
public async Task Rejects_a_null_command_without_touching_the_store_or_acl()
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var handler = new BeoordeelRegistratie(store, acl);
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
Assert.Equal(0, acl.ApproveCallCount);
Assert.Equal(0, store.SaveCount);
}
[Fact]
public async Task Deciding_an_unknown_registration_throws_and_does_not_call_the_acl()
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var handler = new BeoordeelRegistratie(store, acl);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
handler.HandleAsync(new BeoordeelRegistratieCommand(RegistrationId.New(), BeoordelingsBesluit.Goedkeuren)));
Assert.Contains("No registration", ex.Message);
Assert.Equal(0, acl.ApproveCallCount);
}
[Fact]
public async Task Goedkeuren_before_the_zaak_is_opened_throws_without_calling_the_acl()
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var registration = Registration.Submit("123456782"); // no zaak yet
store.Seed(registration);
var handler = new BeoordeelRegistratie(store, acl);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren)));
Assert.Contains("no zaak", ex.Message);
Assert.Equal(0, acl.ApproveCallCount);
}
[Fact]
public async Task Re_goedkeuren_an_already_ingeschreven_registration_is_idempotent()
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var registration = WithZaak();
store.Seed(registration);
var handler = new BeoordeelRegistratie(store, acl);
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
Assert.Equal(1, acl.ApproveCallCount);
Assert.Equal(RegistrationStatus.Ingeschreven, (await store.GetAsync(registration.Id))!.Status);
}
[Fact]
public async Task Re_afwijzen_an_already_afgewezen_registration_is_idempotent()
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var registration = WithZaak();
store.Seed(registration);
var handler = new BeoordeelRegistratie(store, acl);
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
Assert.Equal(1, store.SaveCount);
Assert.Equal(RegistrationStatus.Afgewezen, (await store.GetAsync(registration.Id))!.Status);
}
}

View File

@@ -41,6 +41,29 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
} }
} }
/// <summary>A fake user-task client for the werkbak/decision use cases: returns a scripted set of
/// open beoordeling tasks and records claim/complete calls.</summary>
internal sealed class FakeUserTaskClient(IReadOnlyList<BeoordelingTask> open) : IUserTaskClient
{
public (string TaskId, string Behandelaar)? Claimed { get; private set; }
public (string TaskId, BeoordelingsBesluit Besluit)? Completed { get; private set; }
public Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default)
=> Task.FromResult(open);
public Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default)
{
Claimed = (taskId, behandelaar);
return Task.CompletedTask;
}
public Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default)
{
Completed = (taskId, besluit);
return Task.CompletedTask;
}
}
/// <summary>A fake ACL client that records the bsn it was asked to open a zaak for and returns a /// <summary>A fake ACL client that records the bsn it was asked to open a zaak for and returns a
/// fixed zaak URL.</summary> /// fixed zaak URL.</summary>
internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient

View File

@@ -1,5 +1,6 @@
using System.Net; using System.Net;
using System.Text; using System.Text;
using Big.Application;
using Big.Domain; using Big.Domain;
using Big.Infrastructure; using Big.Infrastructure;
@@ -127,6 +128,17 @@ public class FlowableWorkflowClientTests
() => client.StartRegistrationProcessAsync(RegistrationId.New())); () => client.StartRegistrationProcessAsync(RegistrationId.New()));
} }
[Fact]
public async Task Start_throws_when_flowable_returns_an_empty_body()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.Created, "null"));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => client.StartRegistrationProcessAsync(RegistrationId.New()));
Assert.Contains("empty process-instance", ex.Message);
}
[Fact] [Fact]
public async Task Complete_throws_when_flowable_rejects_the_request() public async Task Complete_throws_when_flowable_rejects_the_request()
{ {
@@ -136,4 +148,107 @@ public class FlowableWorkflowClientTests
await Assert.ThrowsAsync<HttpRequestException>( await Assert.ThrowsAsync<HttpRequestException>(
() => client.CompleteOpenZaakJobAsync("job-1", new Uri("http://openzaak/zaken/api/v1/zaken/abc"))); () => client.CompleteOpenZaakJobAsync("job-1", new Uri("http://openzaak/zaken/api/v1/zaken/abc")));
} }
// ── S-12b: behandelaar user tasks (werkbak / claim / complete) ────────────────────────────────
[Fact]
public async Task Open_beoordelingen_queries_beoordelen_tasks_with_their_registration_id()
{
var rid = RegistrationId.New();
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK,
$$"""
{"data":[{"id":"task-1","variables":[{"name":"registrationId","type":"string","value":"{{rid}}"}]}],"total":1}
"""));
var tasks = await client.GetOpenBeoordelingenAsync();
var task = Assert.Single(tasks);
Assert.Equal("task-1", task.TaskId);
Assert.Equal(rid, task.RegistrationId);
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://flowable/flowable-rest/service/query/tasks",
capture.Seen.RequestUri!.ToString());
Assert.Equal("rest-admin:test", DecodeBasic(capture.Seen));
Assert.Contains("\"processDefinitionKey\":\"registratie\"", capture.Body);
Assert.Contains("\"taskDefinitionKey\":\"Beoordelen\"", capture.Body);
Assert.Contains("\"includeProcessVariables\":true", capture.Body);
}
[Theory]
[InlineData("""{"data":[],"total":0}""")]
[InlineData("""{"data":null,"total":0}""")]
public async Task Open_beoordelingen_is_empty_when_no_tasks_are_waiting(string body)
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK, body));
Assert.Empty(await client.GetOpenBeoordelingenAsync());
Assert.NotNull(capture.Seen);
}
[Fact]
public async Task Open_beoordelingen_throws_when_a_task_carries_no_registration_id()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK,
"""{"data":[{"id":"task-1","variables":[]}],"total":1}"""));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => client.GetOpenBeoordelingenAsync());
Assert.Contains("task-1", ex.Message);
Assert.Contains("registrationId", ex.Message);
}
[Fact]
public async Task Claim_assigns_the_task_to_the_behandelaar()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK));
await client.ClaimAsync("task-1", "merel-behandelaar");
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://flowable/flowable-rest/service/runtime/tasks/task-1",
capture.Seen.RequestUri!.ToString());
Assert.Contains("\"action\":\"claim\"", capture.Body);
Assert.Contains("\"assignee\":\"merel-behandelaar\"", capture.Body);
}
[Fact]
public async Task Claim_throws_when_flowable_rejects_the_request()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.Conflict));
await Assert.ThrowsAsync<HttpRequestException>(() => client.ClaimAsync("task-1", "merel-behandelaar"));
}
[Theory]
[InlineData(BeoordelingsBesluit.Goedkeuren, "goedkeuren")]
[InlineData(BeoordelingsBesluit.Afwijzen, "afwijzen")]
public async Task Complete_posts_the_besluit_variable_to_the_task(BeoordelingsBesluit besluit, string expected)
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK));
await client.CompleteBeoordelingAsync("task-1", besluit);
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://flowable/flowable-rest/service/runtime/tasks/task-1",
capture.Seen.RequestUri!.ToString());
Assert.Contains("\"action\":\"complete\"", capture.Body);
Assert.Contains("\"name\":\"besluit\"", capture.Body);
Assert.Contains("\"type\":\"string\"", capture.Body);
Assert.Contains($"\"value\":\"{expected}\"", capture.Body);
}
[Fact]
public async Task Complete_beoordeling_throws_when_flowable_rejects_the_request()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.InternalServerError));
await Assert.ThrowsAsync<HttpRequestException>(
() => client.CompleteBeoordelingAsync("task-1", BeoordelingsBesluit.Goedkeuren));
}
} }

View File

@@ -121,4 +121,100 @@ public class RegistrationTests
Assert.Contains("only an INGEDIEND", ex.Message); Assert.Contains("only an INGEDIEND", ex.Message);
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status); Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
} }
[Fact]
public void Taking_a_registration_into_behandeling_moves_it_to_in_behandeling()
{
var registration = Registration.Submit("123456782");
registration.TakeIntoBehandeling();
Assert.Equal(RegistrationStatus.InBehandeling, registration.Status);
}
[Fact]
public void Re_taking_an_in_behandeling_registration_is_idempotent()
{
var registration = Registration.Submit("123456782");
registration.TakeIntoBehandeling();
registration.TakeIntoBehandeling();
Assert.Equal(RegistrationStatus.InBehandeling, registration.Status);
}
[Fact]
public void Taking_a_decided_registration_into_behandeling_is_rejected()
{
var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve();
var ex = Assert.Throws<InvalidOperationException>(() => registration.TakeIntoBehandeling());
Assert.Contains("only an INGEDIEND", ex.Message);
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
}
[Fact]
public void Approving_an_in_behandeling_registration_with_a_zaak_sets_it_ingeschreven()
{
var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.TakeIntoBehandeling();
registration.Approve();
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
}
[Fact]
public void Rejecting_a_registration_sets_it_afgewezen()
{
var registration = Registration.Submit("123456782");
registration.Reject();
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
}
[Fact]
public void Rejecting_needs_no_zaak()
{
// A registration can be turned down before its zaak is ever opened, so Reject must not require one.
var registration = Registration.Submit("123456782");
registration.Reject();
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
Assert.Null(registration.ZaakUrl);
}
[Fact]
public void Rejecting_an_in_behandeling_registration_sets_it_afgewezen()
{
var registration = Registration.Submit("123456782");
registration.TakeIntoBehandeling();
registration.Reject();
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
}
[Fact]
public void Deciding_an_already_decided_registration_is_rejected()
{
var registration = Registration.Submit("123456782");
registration.Reject();
var approveEx = Assert.Throws<InvalidOperationException>(() =>
{
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve();
});
Assert.Contains("IN_BEHANDELING", approveEx.Message);
var rejectEx = Assert.Throws<InvalidOperationException>(() => registration.Reject());
Assert.Contains("Afgewezen", rejectEx.Message);
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
}
} }

View File

@@ -0,0 +1,49 @@
using Big.Application;
using Big.Domain;
namespace Big.Tests;
/// <summary>
/// The werkbak query (S-12c): the behandelaar's list of registrations awaiting beoordeling. It reads
/// the open Beoordelen tasks from the workflow engine (§8.2) and enriches each with its registration
/// (bsn + status) from the store. A task whose registration is unknown is skipped defensively.
/// </summary>
public class WerkbakTests
{
[Fact]
public async Task Lists_an_item_per_open_beoordeling_enriched_from_the_registration()
{
var store = new FakeRegistrationStore();
var registration = Registration.Submit("123456782");
registration.AttachZaak(FakeAclClient.DefaultZaakUrl);
registration.TakeIntoBehandeling();
store.Seed(registration);
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", registration.Id)]);
var werkbak = new Werkbak(tasks, store);
var items = await werkbak.GetAsync();
var item = Assert.Single(items);
Assert.Equal(registration.Id.ToString(), item.RegistrationId);
Assert.Equal("123456782", item.Bsn);
Assert.Equal("InBehandeling", item.Status);
}
[Fact]
public async Task Is_empty_when_no_beoordelingen_are_open()
{
var werkbak = new Werkbak(new FakeUserTaskClient([]), new FakeRegistrationStore());
Assert.Empty(await werkbak.GetAsync());
}
[Fact]
public async Task Skips_a_task_whose_registration_is_unknown()
{
// Defensive: the werkbak never invents an item for a task the domain has no aggregate for.
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", RegistrationId.New())]);
var werkbak = new Werkbak(tasks, new FakeRegistrationStore());
Assert.Empty(await werkbak.GetAsync());
}
}

View File

@@ -0,0 +1,24 @@
# language: en
# Drives S-12 (#13). A behandelaar picks up a submitted registration for beoordeling and decides it:
# goedkeuren enters it in the register (INGESCHREVEN, the zaak's final status set via the ACL, §8.1),
# afwijzen turns it down (AFGEWEZEN). This scenario exercises the use cases against in-memory
# stand-ins for the ACL and the store; real Flowable user-task claim/complete arrives in a later
# sub-slice and is verified live.
Feature: Een registratie beoordelen
Als behandelaar wil ik een ingediende registratie beoordelen
zodat deze wordt ingeschreven of afgewezen.
Scenario: Goedkeuren schrijft de registratie in via de ACL
Given a submitted registration with an opened zaak
When the behandelaar takes it into behandeling
Then the registration has status "INBEHANDELING"
When the behandelaar decides "goedkeuren"
Then the registration has status "INGESCHREVEN"
And the zaak's final status is set via the ACL
Scenario: Afwijzen wijst de registratie af zonder de ACL
Given a submitted registration with an opened zaak
When the behandelaar takes it into behandeling
And the behandelaar decides "afwijzen"
Then the registration has status "AFGEWEZEN"
And the ACL is not asked to set the zaak status

View File

@@ -0,0 +1,58 @@
using Acceptance.Support;
using Big.Application;
using Big.Domain;
using Reqnroll;
using Xunit;
namespace Acceptance.Steps;
/// <summary>Bindings for <c>EenRegistratieBeoordelen.feature</c> (S-12). Drives the TakeIntoBehandeling
/// transition and the BeoordeelRegistratie use case against in-memory ports; one instance per scenario.
/// Scoped to this feature so its "the registration has status" step does not clash with the identically
/// phrased (but differently-asserting) step in <see cref="RegistratieIndienenSteps"/>.</summary>
[Binding]
[Scope(Feature = "Een registratie beoordelen")]
public sealed class EenRegistratieBeoordelenSteps
{
private readonly InMemoryAclClient _acl = new();
private readonly InMemoryRegistrationStore _store = new();
private RegistrationId _id;
[Given("a submitted registration with an opened zaak")]
public async Task GivenASubmittedRegistrationWithAnOpenedZaak()
{
var registration = Registration.Submit("123456782");
registration.AttachZaak(InMemoryAclClient.OpenedZaakUrl);
await _store.SaveAsync(registration);
_id = registration.Id;
}
[When("the behandelaar takes it into behandeling")]
public async Task WhenTheBehandelaarTakesItIntoBehandeling()
{
var registration = await _store.GetAsync(_id);
registration!.TakeIntoBehandeling();
await _store.SaveAsync(registration);
}
[When("the behandelaar decides \"(.*)\"")]
public async Task WhenTheBehandelaarDecides(string besluit)
=> await new BeoordeelRegistratie(_store, _acl).HandleAsync(
new BeoordeelRegistratieCommand(_id, Enum.Parse<BeoordelingsBesluit>(besluit, ignoreCase: true)));
[Then("the registration has status \"(.*)\"")]
public async Task ThenTheRegistrationHasStatus(string expected)
{
var registration = await _store.GetAsync(_id);
Assert.NotNull(registration);
Assert.Equal(expected, registration.Status.ToString().ToUpperInvariant());
}
[Then("the zaak's final status is set via the ACL")]
public void ThenTheZaakFinalStatusIsSetViaTheAcl()
=> Assert.Equal(InMemoryAclClient.OpenedZaakUrl, _acl.ApprovedZaakUrl);
[Then("the ACL is not asked to set the zaak status")]
public void ThenTheAclIsNotAskedToSetTheZaakStatus()
=> Assert.Null(_acl.ApprovedZaakUrl);
}

View File

@@ -68,6 +68,9 @@ public sealed class CapturingDomainClient : IDomainClient
SubmittedBsn = bsn; SubmittedBsn = bsn;
return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend")); return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend"));
} }
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<WerkbakItem>>([]);
} }
/// <summary>Serves configurable projection rows.</summary> /// <summary>Serves configurable projection rows.</summary>

View File

@@ -26,19 +26,22 @@ test('DigiD login → submit → public INGEDIEND → approve → public INGESCH
// The openbaar register (anonymous, its own origin) shows the submitted entry once the projection // The openbaar register (anonymous, its own origin) shows the submitted entry once the projection
// catches up. The projection updates asynchronously (NRC → event-subscriber), and the register loads // catches up. The projection updates asynchronously (NRC → event-subscriber), and the register loads
// on open, so reload until a submitted (INGEDIEND) row appears. // on open, so reload until *this* submission's row appears. We poll on the reference cell (not a
// generic INGEDIEND cell): the shared verify stack already holds INGEDIEND rows from earlier checks,
// so a status-only poll would short-circuit on a stale row before our row is projected.
await page.goto('http://openbaar/'); await page.goto('http://openbaar/');
await expect(page.getByRole('heading', { name: /Openbaar BIG-register/i })).toBeVisible(); await expect(page.getByRole('heading', { name: /Openbaar BIG-register/i })).toBeVisible();
await expect
.poll(async () => {
await page.reload();
return page.getByRole('cell', { name: 'INGEDIEND' }).count();
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
.toBeGreaterThan(0);
// #78: the reference shown in the public register must be the exact one the citizen saw on the // #78: the reference shown in the public register must be the exact one the citizen saw on the
// submit confirmation — no mismatch between the two portals. // submit confirmation — no mismatch between the two portals.
await expect(page.getByRole('cell', { name: reference })).toBeVisible(); await expect
.poll(async () => {
await page.reload();
return page.getByRole('cell', { name: reference }).count();
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
.toBeGreaterThan(0);
await expect(page.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' }))
.toBeVisible();
// Approve via the temporary admin endpoint (reached directly on the compose network, as a // Approve via the temporary admin endpoint (reached directly on the compose network, as a
// behandelaar would until the behandel-portal exists — S-12). The zaak is opened off the request // behandelaar would until the behandel-portal exists — S-12). The zaak is opened off the request
@@ -53,11 +56,12 @@ test('DigiD login → submit → public INGEDIEND → approve → public INGESCH
const approve = await request.post(`http://domain:8080/registrations/${reference}/approve`); const approve = await request.post(`http://domain:8080/registrations/${reference}/approve`);
expect(approve.status()).toBe(204); expect(approve.status()).toBe(204);
// The approval flows back to the projection; the openbaar register now shows it as INGESCHREVEN. // The approval flows back to the projection; the openbaar register now shows *our* row (matched by
// its reference) as INGESCHREVEN.
await expect await expect
.poll(async () => { .poll(async () => {
await page.reload(); await page.reload();
return page.getByRole('cell', { name: 'INGESCHREVEN' }).count(); return page.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGESCHREVEN' }).count();
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] }) }, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
.toBeGreaterThan(0); .toBeGreaterThan(0);
}); });

View File

@@ -6,9 +6,12 @@
xmlns:omgdi="http://www.omg.org/spec/DD/20100524/DI" xmlns:omgdi="http://www.omg.org/spec/DD/20100524/DI"
targetNamespace="http://respellion.nl/big"> targetNamespace="http://respellion.nl/big">
<!-- S-03: minimal "Registratie ontvangen" flow. <!-- Registratie ontvangen flow.
start -> external-worker task (OpenZaakAanmaken) -> end. start -> external-worker task (OpenZaakAanmaken) -> Beoordelen (behandelaar user task) -> end.
The external task is handled by the Workflow Client / ACL in later slices. --> S-03 added the external task (Workflow Client / ACL). S-12 adds the behandelaar's beoordeling
as a user task: the process parks here until a behandelaar claims and completes it with a
`besluit` (goedkeuren/afwijzen). The registrationId set at start rides along as a process
variable so the werkbak can correlate each task back to its aggregate. -->
<process id="registratie" name="Registratie ontvangen" isExecutable="true"> <process id="registratie" name="Registratie ontvangen" isExecutable="true">
<startEvent id="start" name="Registratie ontvangen"/> <startEvent id="start" name="Registratie ontvangen"/>
@@ -19,9 +22,13 @@
flowable:type="external-worker" flowable:type="external-worker"
flowable:topic="OpenZaakAanmaken"/> flowable:topic="OpenZaakAanmaken"/>
<sequenceFlow id="flow2" sourceRef="OpenZaakAanmaken" targetRef="end"/> <sequenceFlow id="flow2" sourceRef="OpenZaakAanmaken" targetRef="Beoordelen"/>
<endEvent id="end" name="Zaak aangemaakt"/> <userTask id="Beoordelen" name="Beoordelen" flowable:candidateGroups="behandelaar"/>
<sequenceFlow id="flow3" sourceRef="Beoordelen" targetRef="end"/>
<endEvent id="end" name="Registratie beoordeeld"/>
</process> </process>
<bpmndi:BPMNDiagram id="diagram"> <bpmndi:BPMNDiagram id="diagram">
@@ -32,8 +39,11 @@
<bpmndi:BPMNShape id="s_task" bpmnElement="OpenZaakAanmaken"> <bpmndi:BPMNShape id="s_task" bpmnElement="OpenZaakAanmaken">
<omgdc:Bounds x="200" y="85" width="120" height="60"/> <omgdc:Bounds x="200" y="85" width="120" height="60"/>
</bpmndi:BPMNShape> </bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_beoordelen" bpmnElement="Beoordelen">
<omgdc:Bounds x="390" y="85" width="120" height="60"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_end" bpmnElement="end"> <bpmndi:BPMNShape id="s_end" bpmnElement="end">
<omgdc:Bounds x="400" y="100" width="30" height="30"/> <omgdc:Bounds x="580" y="100" width="30" height="30"/>
</bpmndi:BPMNShape> </bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="e_flow1" bpmnElement="flow1"> <bpmndi:BPMNEdge id="e_flow1" bpmnElement="flow1">
<omgdi:waypoint x="130" y="115"/> <omgdi:waypoint x="130" y="115"/>
@@ -41,7 +51,11 @@
</bpmndi:BPMNEdge> </bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_flow2" bpmnElement="flow2"> <bpmndi:BPMNEdge id="e_flow2" bpmnElement="flow2">
<omgdi:waypoint x="320" y="115"/> <omgdi:waypoint x="320" y="115"/>
<omgdi:waypoint x="400" y="115"/> <omgdi:waypoint x="390" y="115"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_flow3" bpmnElement="flow3">
<omgdi:waypoint x="510" y="115"/>
<omgdi:waypoint x="580" y="115"/>
</bpmndi:BPMNEdge> </bpmndi:BPMNEdge>
</bpmndi:BPMNPlane> </bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram> </bpmndi:BPMNDiagram>