Compare commits

...

19 Commits

Author SHA1 Message Date
01a32b884f chore(release): 2026.07.0 (refs #80)
All checks were successful
CI / lint (pull_request) Successful in 1m27s
CI / build (pull_request) Successful in 1m19s
CI / unit (pull_request) Successful in 1m35s
CI / frontend (pull_request) Successful in 2m51s
CI / mutation (pull_request) Successful in 6m20s
CI / verify-stack (pull_request) Successful in 7m36s
First CalVer release, marking the end of Iteration 1 (Walking Skeleton).
CHANGELOG.md regenerated from Conventional Commits via git-cliff for tag v2026.07.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:11:34 +02: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
1c185e6686 S-09b: Approval flow — temp admin endpoint + status transition to projection (#77)
All checks were successful
CI / lint (push) Successful in 1m25s
CI / build (push) Successful in 1m13s
CI / unit (push) Successful in 1m26s
CI / frontend (push) Successful in 2m35s
CI / mutation (push) Successful in 6m6s
CI / verify-stack (push) Successful in 7m34s
## What & why

S-09b (#75, split from #10) — the **approval flow** that completes the walking skeleton. A behandelaar can now approve a submitted registration; the entry flips from `INGEDIEND` to `INGESCHREVEN` in the public register. Flow: `POST /registrations/{id}/approve` (domain) → ACL sets the zaak eindstatus (ZGW `/statussen`) → OpenZaak → NRC → event-subscriber → projection → openbaar.

## Changes (bottom-up, each red→green TDD)

- **Domain** — `RegistrationStatus.Ingeschreven` + `Registration.Approve()` (guards: opened zaak, only from INGEDIEND); `ApproveRegistration` use case (idempotent) + temp `POST /registrations/{id}/approve` endpoint; `IAclClient.ApproveZaakAsync`.
- **ACL** — resolves the zaaktype's **eindstatus** from the catalogus (`isEindstatus` / highest volgnummer) and POSTs a ZGW status; exposed as `POST /statussen`. Unit + real-OpenZaak integration test.
- **Event-subscriber** — binds NRC `hoofdObject`, projects a `status`/`create` as `INGESCHREVEN` keyed on the zaak (updates the existing row), **without reading OpenZaak** (§8.1). Retains the ZGW `resource` in the log (new column + EF migration) so a rebuild reproduces the status.
- **e2e** — extended: submit → public INGEDIEND → approve → public INGESCHREVEN.
- **Docs** — ADR-0011 (the two non-obvious decisions + the walking-skeleton assumption) + demo note.

## Key decisions (see ADR-0011)

- **ACL discovers the eindstatus** (chosen over injecting a statustype URL): no new config/seed plumbing, domain stays ZGW-ignorant.
- **Any post-creation status-set ⇒ INGESCHREVEN**: in the walking skeleton the only status ever set after creation is the approval, and the subscriber may not read ZGW — documented to tighten when more transitions arrive (S-12+).

## Verification

- All .NET unit suites green locally (domain 47, acl 11, event-subscriber 14, bff 16, acceptance 7); Release build + `dotnet format` clean.
- No new compose config (the eindstatus-discovery approach avoided it).
- The real-OpenZaak integration test (ACL status-set) and the full submit→approve→visible e2e run in CI `verify-stack` (live NRC→projection + selectielijst egress, not reproducible locally).

closes #75

Reviewed-on: #77
2026-07-14 09:04:57 +00:00
bc9831c113 S-09: Openbaar Register portal — public lookup (#76)
All checks were successful
CI / lint (push) Successful in 1m9s
CI / build (push) Successful in 53s
CI / unit (push) Successful in 1m4s
CI / frontend (push) Successful in 1m57s
CI / mutation (push) Successful in 5m19s
CI / verify-stack (push) Successful in 6m31s
Anonymous openbaar portal completing the walking skeleton (submit → projection → public visibility).

closes #10
2026-07-13 14:35:34 +00:00
7e8c5d7b51 Merge pull request 'ci: speed up pipeline — NuGet cache + prebuilt Playwright image' (#74) from chore/73-ci-speedups into main
All checks were successful
CI / lint (push) Successful in 1m13s
CI / build (push) Successful in 57s
CI / unit (push) Successful in 1m4s
CI / frontend (push) Successful in 1m46s
CI / mutation (push) Successful in 4m5s
CI / verify-stack (push) Successful in 6m22s
Reviewed-on: #74
2026-07-13 13:59:15 +00:00
2b9eb5eb41 ci(e2e): run Playwright from the prebuilt image instead of downloading browsers (refs #73)
All checks were successful
CI / lint (pull_request) Successful in 5m43s
CI / build (pull_request) Successful in 56s
CI / unit (pull_request) Successful in 1m0s
CI / frontend (pull_request) Successful in 1m50s
CI / mutation (pull_request) Successful in 4m6s
CI / verify-stack (pull_request) Successful in 7m3s
The verify-e2e lane downloaded ~150 MB of Chromium (npx playwright install) on
every verify-stack run. Use the official mcr.microsoft.com/playwright image with
browsers pre-baked; npm install still pins @playwright/test from tests/e2e, and
the image tag is kept in lockstep with that version. Verified the exact
create + docker cp + start flow launches the baked browser with no download.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:29:11 +02:00
60df0845aa ci: cache the NuGet package store across the .NET jobs (refs #73)
lint, build, unit and mutation each restored packages from the network on every
run. There are no lock files (so setup-dotnet's built-in cache doesn't apply), so
cache ~/.nuget/packages keyed on the project files via actions/cache. Pinned @v3
to avoid the GHES guard that breaks @v4 on Gitea (gitea-actions-gotchas.md); the
cache is best-effort, so a miss simply restores from the network.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:29:11 +02:00
2a746736dc Merge pull request 'test(e2e): serve the portal + walking-skeleton Playwright e2e (closes #68)' (#72) from feat/68-e2e into main
All checks were successful
CI / lint (push) Successful in 1m10s
CI / build (push) Successful in 54s
CI / unit (push) Successful in 1m0s
CI / frontend (push) Successful in 1m52s
CI / mutation (push) Successful in 4m11s
CI / verify-stack (push) Successful in 7m19s
Reviewed-on: #72
2026-07-13 13:20:57 +00:00
986e36bc7d test(portal-self-service): guard that the DigiD token attaches to relative BFF calls (refs #68)
All checks were successful
CI / lint (pull_request) Successful in 1m8s
CI / build (pull_request) Successful in 53s
CI / unit (pull_request) Successful in 58s
CI / frontend (pull_request) Successful in 2m10s
CI / mutation (pull_request) Successful in 3m58s
CI / verify-stack (pull_request) Successful in 7m48s
The token-attachment bug (secureRoutes set to the app origin, which a relative
api-client URL never matches) was only caught by the full-stack e2e. Add a fast
unit guard: drive the REAL angular-auth-oidc-client interceptor and the REAL
api-client against the production route value, faking only the config source and
the token storage. Asserts the bearer token rides the relative /self-service/
call and is withheld from the anonymous /openbaar/ call.

Extract the value to a shared SECURE_API_ROUTES constant so the test binds to
exactly what the app configures. Verified the guard fails (Authorization null)
if the value regresses to an origin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:00:32 +02:00
7e152e4432 feat(portal-self-service): surface submit failures with a retryable alert (refs #68)
All checks were successful
CI / lint (pull_request) Successful in 1m11s
CI / build (pull_request) Successful in 54s
CI / unit (pull_request) Successful in 1m0s
CI / frontend (pull_request) Successful in 1m49s
CI / mutation (pull_request) Successful in 4m3s
CI / verify-stack (pull_request) Successful in 7m42s
Add an error branch to submit(): on a failed BFF call, set a `failed` signal,
re-enable the button, and render a role="alert" message so the user knows the
submit did not go through and can retry — instead of the click silently doing
nothing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:33:09 +02:00
5bf25f094d test(portal-self-service): submit surfaces BFF failures instead of swallowing them (refs #68)
Failing test: when postSelfServiceRegistrations errors, the page should show an
alert, not the confirmation, and keep the submit button available for retry.
Currently submit() has no error handler, so the rejection is swallowed and the
page silently stays put — exactly the failure mode that hid the missing-token
bug behind a 90s e2e timeout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:32:30 +02:00
0e6c7d2066 fix(portal-self-service): attach the DigiD token to relative BFF calls (refs #68)
All checks were successful
CI / lint (pull_request) Successful in 1m12s
CI / build (pull_request) Successful in 51s
CI / unit (pull_request) Successful in 1m3s
CI / frontend (pull_request) Successful in 1m48s
CI / mutation (pull_request) Successful in 3m57s
CI / verify-stack (pull_request) Successful in 7m59s
After login the submit silently did nothing: the confirmation ("...is
ontvangen...") never rendered because the POST to the BFF went out with no
Authorization header, so the BFF rejected it and the no-error-handler
subscribe left the page unchanged.

Root cause: angular-auth-oidc-client's interceptor attaches the token when
`req.url.startsWith(secureRoute)`. The api-client calls the BFF with RELATIVE
URLs (same-origin via the nginx proxy), so `req.url` is `/self-service/...` —
but secureRoutes was configured as the app ORIGIN (`http://self-service`),
which a relative URL never starts with. No match → no token.

Configure secureRoutes with the relative `/self-service/` prefix instead. The
unit test mocked the api-client, so only the walking-skeleton e2e exercises the
real token attachment — now green.

Verified against a focused stack (keycloak + self-service + real BFF + stub
domain): the submit now carries the bearer token, the BFF forwards to the
domain, and the portal shows the confirmation with the returned reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:09:15 +02:00
39923e0e68 fix(e2e): treat the http portal origin as secure so DigiD PKCE login works (refs #68)
Some checks failed
CI / lint (pull_request) Successful in 1m12s
CI / build (pull_request) Successful in 53s
CI / unit (pull_request) Successful in 1m3s
CI / frontend (pull_request) Successful in 1m47s
CI / mutation (pull_request) Successful in 4m2s
CI / verify-stack (pull_request) Failing after 7m51s
The walking-skeleton e2e timed out waiting for the Keycloak login form
(`#username`). Root cause: in the compose network the portal is served over
plain HTTP on a non-localhost origin (http://self-service), which is not a
secure context, so Web Crypto (`crypto.subtle`) is undefined. angular-auth-
oidc-client needs SubtleCrypto to build the PKCE code challenge, so
`authorize()` threw ("Cannot read properties of undefined (reading 'digest')")
and the login redirect never fired.

Production serves the portal over HTTPS, where this works. Instead of
terminating TLS in the throwaway e2e stack, tell Chromium to treat the origin
as secure via --unsafely-treat-insecure-origin-as-secure. The flag is only
honoured by the full Chromium build (new headless), not Playwright's default
headless-shell, so pin channel: 'chromium'.

Verified against a minimal in-network stack (keycloak + self-service): login
redirect now reaches the Keycloak form, and the full login → token exchange →
authenticated portal renders with no console errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 13:37:31 +02:00
2e00ad38ba ci(portal-self-service): run Vitest ahead of the production build to stop worker-start timeout (refs #68)
Some checks failed
CI / lint (pull_request) Successful in 1m11s
CI / build (pull_request) Successful in 54s
CI / unit (pull_request) Successful in 1m3s
CI / frontend (pull_request) Successful in 2m0s
CI / mutation (pull_request) Successful in 4m5s
CI / verify-stack (pull_request) Failing after 10m20s
The frontend lane ran `nx run-many -t lint test build`, so the ~5min
self-service production build shared nx's task pool with the Vitest test
worker. @angular/build:unit-test's Vitest worker has hard-coded 60s/90s
startup timeouts (not configurable); on a CPU-constrained CI runner the
concurrent build starved the worker and it failed with "Timeout waiting
for worker to respond" — flaky, since it passed on the prior commit.

Split the target into a light lint+test phase and a separate build phase
so tests get CPU and the worker starts well inside its window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:51:56 +02:00
be016f920c fix(portal-self-service): health-check nginx over IPv4 (127.0.0.1) (refs #68)
Some checks failed
CI / lint (pull_request) Successful in 1m13s
CI / build (pull_request) Successful in 1m0s
CI / unit (pull_request) Successful in 1m6s
CI / frontend (pull_request) Failing after 7m11s
CI / mutation (pull_request) Successful in 3m59s
CI / verify-stack (pull_request) Failing after 7m47s
nginx listens on IPv4 only (listen 80), but 'localhost' inside the container resolves
to ::1 first, so the wget healthcheck got connection-refused and self-service never
went healthy — timing out the CI stack bring-up. Probe 127.0.0.1 instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:37:56 +02:00
d3f23a4da3 docs(portal-self-service): serving/e2e decisions + walking-skeleton demo note (refs #68)
Some checks failed
CI / lint (pull_request) Successful in 1m5s
CI / build (pull_request) Successful in 52s
CI / unit (pull_request) Successful in 1m2s
CI / frontend (pull_request) Successful in 1m31s
CI / mutation (pull_request) Successful in 3m55s
CI / verify-stack (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:09:04 +02:00
490e7347b0 test(e2e): walking-skeleton Playwright happy path + verify-e2e lane (refs #68)
tests/e2e Playwright spec drives DigiD login (jan-burger/test123) → submit →
confirmation against the compose-served portal. run-e2e-check.sh runs it inside the
compose network (node container, browser installed at runtime) so the token issuer
(keycloak:8080) matches the BFF authority (ADR-0010). Wired as verify-e2e (Makefile +
verify chain + a verify-stack CI step).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:08:10 +02:00
4f311c9b5a ci(portal-self-service): serve the self-service app in compose (refs #68)
Add the self-service nginx service (build the app image, depends_on bff healthy +
keycloak started, health-checked, host port 8140). Add it to WAIT_SVCS and the CI
log dump.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:05:10 +02:00
a55ba1160d feat(portal-self-service): runtime config + nginx serve/proxy image (refs #68)
The app loads /config.json at startup (main.ts) so the OIDC authority is set per
environment from one build; appConfig becomes a factory and derives redirectUrl +
secureApiOrigin from the app origin (same-origin as the BFF). A multi-stage
Dockerfile builds the app and serves it via nginx, reverse-proxying /self-service
+ /openbaar to the bff (relative URLs → no CORS); nginx resolves the BFF at request
time. The compose image bakes config.json with the keycloak:8080 authority so the
browser's token issuer matches the BFF (ADR-0010).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:03:59 +02:00
98 changed files with 2875 additions and 114 deletions

View File

@@ -23,6 +23,16 @@ jobs:
- uses: https://github.com/actions/setup-dotnet@v4 - uses: https://github.com/actions/setup-dotnet@v4
with: with:
dotnet-version: '10.0.x' dotnet-version: '10.0.x'
# Cache the NuGet package store so each .NET job restores from disk, not the network. There are
# no lock files (so setup-dotnet's built-in cache doesn't apply); key on the project files. @v3
# avoids the GHES guard that breaks @v4 on Gitea (gitea-actions-gotchas.md); cache is best-effort
# — a miss just restores from the network. See issue #73.
- uses: https://github.com/actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
restore-keys: |
nuget-${{ runner.os }}-
- run: make lint - run: make lint
build: build:
@@ -32,6 +42,12 @@ jobs:
- uses: https://github.com/actions/setup-dotnet@v4 - uses: https://github.com/actions/setup-dotnet@v4
with: with:
dotnet-version: '10.0.x' dotnet-version: '10.0.x'
- uses: https://github.com/actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
restore-keys: |
nuget-${{ runner.os }}-
- run: make build - run: make build
unit: unit:
@@ -41,6 +57,12 @@ jobs:
- uses: https://github.com/actions/setup-dotnet@v4 - uses: https://github.com/actions/setup-dotnet@v4
with: with:
dotnet-version: '10.0.x' dotnet-version: '10.0.x'
- uses: https://github.com/actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
restore-keys: |
nuget-${{ runner.os }}-
- run: make unit - run: make unit
# Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build. # Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build.
@@ -64,6 +86,12 @@ jobs:
- uses: https://github.com/actions/setup-dotnet@v4 - uses: https://github.com/actions/setup-dotnet@v4
with: with:
dotnet-version: '10.0.x' dotnet-version: '10.0.x'
- uses: https://github.com/actions/cache@v3
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj') }}
restore-keys: |
nuget-${{ runner.os }}-
- run: make mutation - run: make mutation
# Publish the Stryker HTML reports. `if: always()` uploads them even when the # Publish the Stryker HTML reports. `if: always()` uploads them even when the
# ratchet fails — that is exactly when you want to inspect the survivors. # ratchet fails — that is exactly when you want to inspect the survivors.
@@ -124,10 +152,12 @@ jobs:
run: make verify-domain run: make verify-domain
- name: BFF → Keycloak + domain + projection - name: BFF → Keycloak + domain + projection
run: make verify-bff run: make verify-bff
- name: Self-service e2e (Playwright, login → submit → success)
run: make verify-e2e
# Log dump must precede teardown (which removes the containers). # Log dump must precede teardown (which removes the containers).
- name: Dump container logs on failure - name: Dump container logs on failure
if: failure() if: failure()
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api 2>&1 || true run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service 2>&1 || true
- name: Tear down - name: Tear down
if: always() if: always()
run: make down run: make down

5
.gitignore vendored
View File

@@ -52,3 +52,8 @@ vite.config.*.timestamp*
vitest.config.*.timestamp* vitest.config.*.timestamp*
.angular .angular
# Playwright e2e (installed/generated in-container or on local runs)
tests/e2e/node_modules/
tests/e2e/test-results/
tests/e2e/playwright-report/

View File

@@ -162,20 +162,36 @@ The skeleton proves the spine end-to-end: a registration, a workflow, a zaak in
**Out of scope (whole of S-08):** document upload, status tracking page. **Out of scope (whole of S-08):** document upload, status tracking page.
### S-09 · Openbaar Register portal — public lookup ### S-09 · Openbaar Register portal — public lookup *(#10)*
**Outcome:** The openbaar Angular app shows a search box. Anonymous. Queries the BFF's `/openbaar/register` which reads only the projection's **public-safe** fields. Confirms the walking skeleton end-to-end. **Outcome:** The openbaar Angular app shows a search box. Anonymous. Queries the BFF's `/openbaar/register` which reads only the projection's **public-safe** fields. Shows the public-visibility half of the walking skeleton.
_Split from the original S-09 — scoped to the portal only; the approval flow is **S-09b (#75)**._
**Acceptance:** **Acceptance:**
- E2E test: zorgprofessional registers via self-service (S-08), behandelaar approves via a temporary admin endpoint (no behandel-portal yet), openbaar register shows the entry. - E2E test: after a zorgprofessional registers via self-service (S-08), the openbaar register shows the entry (as `INGEDIEND`).
- Public-safe field whitelist enforced and tested. - Public-safe field whitelist enforced and tested (already in the BFF; add a portal component test + a11y check).
**Touches:** `apps/openbaar/`, projection-api hardening, tests. **Touches:** `apps/openbaar/`, compose serving, e2e, docs.
**Out of scope:** advanced search filters, sorting. **Out of scope:** approval/status transition (S-09b), advanced search filters, sorting.
**End of walking skeleton.** Demo: submit → process → projection → public visibility. All CI gates green on Gitea Actions. Cut release `vYYYY.MM.0` and publish via Gitea Releases. ### S-09b · Approval flow — temp admin endpoint + status transition to projection *(#75)*
**Outcome:** A behandelaar approves a submitted registration via a temporary admin endpoint (no behandel-portal yet — S-12). The approval transitions the zaak status through the ACL → NRC → event-subscriber → projection, and the openbaar register then shows the entry as approved.
**Acceptance:**
- A new terminal/approved status (e.g. `INGESCHREVEN`) exists and is projected.
- Temporary admin approve endpoint transitions a registration via a real ZGW status set (behind the ACL, §8).
- E2E: register (S-08) → approve → openbaar shows the entry as approved.
**Touches:** `services/domain`, `services/acl`, `services/event-subscriber`, `services/projection-api`, e2e.
**Out of scope:** behandel-portal UI (S-12), assessment logic (S-13), escalation (S-15).
**End of walking skeleton** (S-09 + S-09b). Demo: submit → process → projection → public visibility. All CI gates green on Gitea Actions. Cut release `vYYYY.MM.0` and publish via Gitea Releases.
--- ---

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

@@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml
# Long-running services with a healthcheck — the smoke polls these for readiness # Long-running services with a healthcheck — the smoke polls these for readiness
# (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init) # (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init)
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md. # are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed # Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
# into external named volumes via `docker cp` (infra/seed-config.sh) instead of # into external named volumes via `docker cp` (infra/seed-config.sh) instead of
# bind-mounted, because bind mounts don't reach sibling containers on the # bind-mounted, because bind mounts don't reach sibling containers on the
@@ -50,9 +50,16 @@ endif
ci: lint build unit mutation frontend verify ci: lint build unit mutation frontend verify
## frontend: install deps and run the Nx lint/test/build for the portals (pnpm + Node required) ## frontend: install deps and run the Nx lint/test/build for the portals (pnpm + Node required)
# Tests run in their own phase, ahead of the build. The @angular/build:unit-test
# (Vitest) runner spawns a worker with a hard-coded 60s/90s startup timeout that is
# not configurable. When the ~5min production build shares the run-many pool, it
# starves that worker of CPU on constrained CI runners and Vitest fails with
# "Timeout waiting for worker to respond". Splitting the phases keeps tests off the
# heavy build's back so the worker starts well inside its window.
frontend: frontend:
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
pnpm nx run-many -t lint test build pnpm nx run-many -t lint test
pnpm nx run-many -t build
## lint: verify formatting (no changes) ## lint: verify formatting (no changes)
lint: lint:
@@ -153,6 +160,11 @@ verify-domain:
verify-bff: verify-bff:
bash infra/run-bff-check.sh bash infra/run-bff-check.sh
## verify-e2e: walking-skeleton Playwright e2e (S-08d) against the up stack — DigiD login →
## submit → confirmation, driven inside the compose network.
verify-e2e:
bash infra/run-e2e-check.sh
## verify: local mirror of the CI verify-stack job — full stack up once, all checks, ## verify: local mirror of the CI verify-stack job — full stack up once, all checks,
## tear down (always). For fast single-concern local iteration use `integration` ## tear down (always). For fast single-concern local iteration use `integration`
## (oz-only) or `verify-notifications` (oz+nrc) instead. ## (oz-only) or `verify-notifications` (oz+nrc) instead.
@@ -165,7 +177,8 @@ verify:
&& bash infra/run-notification-check.sh \ && bash infra/run-notification-check.sh \
&& bash infra/run-projection-check.sh \ && bash infra/run-projection-check.sh \
&& bash infra/run-domain-check.sh \ && bash infra/run-domain-check.sh \
&& bash infra/run-bff-check.sh || rc=$$?; \ && bash infra/run-bff-check.sh \
&& bash infra/run-e2e-check.sh || rc=$$?; \
docker compose -f $(COMPOSE) down --volumes >/dev/null 2>&1; \ docker compose -f $(COMPOSE) down --volumes >/dev/null 2>&1; \
docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; \ docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; \
exit $$rc' exit $$rc'

21
apps/openbaar/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
# Multi-stage build for the openbaar portal (Angular → nginx).
# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
FROM node:24-slim AS build
WORKDIR /src
RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
# Restore first (cached unless the manifests change).
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml nx.json tsconfig.base.json eslint.config.mjs ./
RUN pnpm install --frozen-lockfile
# Sources (only what the app + its libs need).
COPY apps/openbaar apps/openbaar
COPY libs libs
RUN pnpm nx build openbaar
FROM nginx:1.27-alpine AS runtime
COPY apps/openbaar/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/apps/openbaar/browser /usr/share/nginx/html
# No runtime config: the openbaar register is anonymous (no OIDC authority to inject).
EXPOSE 80

View File

@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

23
apps/openbaar/nginx.conf Normal file
View File

@@ -0,0 +1,23 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
# even before the BFF is up and picks up restarts — instead of failing to load the config.
resolver 127.0.0.11 ipv6=off valid=30s;
# Same-origin API: proxy the anonymous openbaar endpoint group to the bff service. The api-client
# uses relative URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS.
location /openbaar/ {
set $bff http://bff:8080;
proxy_pass $bff;
proxy_set_header Host $host;
}
# SPA fallback — Angular client-side routing.
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -0,0 +1,80 @@
{
"name": "openbaar",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/openbaar/src",
"tags": [],
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/apps/openbaar",
"browser": "apps/openbaar/src/main.ts",
"tsConfig": "apps/openbaar/tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "apps/openbaar/public"
}
],
"styles": ["apps/openbaar/src/styles.css"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1mb",
"maximumError": "2mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "8kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
}
},
"serve": {
"continuous": true,
"executor": "@angular/build:dev-server",
"defaultConfiguration": "development",
"configurations": {
"production": {
"buildTarget": "openbaar:build:production"
},
"development": {
"buildTarget": "openbaar:build:development"
}
}
},
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false
}
},
"serve-static": {
"continuous": true,
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "openbaar:build",
"staticFilePath": "dist/apps/openbaar/browser",
"spa": true
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,19 @@
import { provideHttpClient } from '@angular/common/http';
import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { appRoutes } from './app.routes';
/**
* The openbaar register is a public, anonymous read: no DigiD, no auth interceptor. The app is served
* same-origin as the BFF (nginx proxies /openbaar), so the api-client's relative calls stay same-origin.
*/
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(appRoutes),
provideHttpClient(),
],
};

View File

View File

@@ -0,0 +1 @@
<router-outlet></router-outlet>

View File

@@ -0,0 +1,4 @@
import { Route } from '@angular/router';
import { RegisterPage } from './register/register-page';
export const appRoutes: Route[] = [{ path: '', component: RegisterPage }];

View File

@@ -0,0 +1,14 @@
import { provideRouter } from '@angular/router';
import { render } from '@testing-library/angular';
import { App } from './app';
describe('App', () => {
it('renders the router outlet shell', async () => {
const { container } = await render(App, {
providers: [provideRouter([])],
});
// The shell is a thin host for routed pages (the RegisterPage owns the heading).
expect(container.querySelector('router-outlet')).toBeTruthy();
});
});

View File

@@ -0,0 +1,12 @@
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
@Component({
imports: [RouterModule],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {
protected title = 'openbaar';
}

View File

@@ -0,0 +1,54 @@
<main utrecht-document class="utrecht-theme">
<utrecht-article>
<utrecht-heading-1>Openbaar BIG-register</utrecht-heading-1>
<p utrecht-paragraph>
Zoek in het openbare register van BIG-registraties. Alleen publieke gegevens worden getoond.
</p>
<div role="search">
<label for="register-search" utrecht-form-label>Zoek op referentie</label>
<input
id="register-search"
type="search"
utrecht-textbox
[ngModel]="query()"
(ngModelChange)="query.set($event)"
[ngModelOptions]="{ standalone: true }"
(keyup.enter)="search()"
/>
<button
utrecht-button
appearance="primary-action-button"
type="button"
[disabled]="loading()"
(click)="search()"
>
Zoeken
</button>
</div>
@if (loading()) {
<p utrecht-paragraph role="status">Bezig met laden…</p>
} @else if (searched() && entries().length === 0) {
<p utrecht-paragraph role="status">Geen inschrijvingen gevonden.</p>
} @else if (entries().length > 0) {
<table utrecht-table>
<caption>Inschrijvingen in het openbaar register</caption>
<thead>
<tr>
<th scope="col">Referentie</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody>
@for (entry of entries(); track entry.id) {
<tr>
<td>{{ entry.reference }}</td>
<td>{{ entry.status }}</td>
</tr>
}
</tbody>
</table>
}
</utrecht-article>
</main>

View File

@@ -0,0 +1,59 @@
import { fireEvent, render, screen } from '@testing-library/angular';
import { of } from 'rxjs';
import { BffApiV1Service, type OpenbaarEntry } from 'api-client';
import { axe } from 'vitest-axe';
import { RegisterPage } from './register-page';
const sample: OpenbaarEntry[] = [
{ id: 'zaak-abc', status: 'INGEDIEND', reference: 'REG-abc' },
{ id: 'zaak-def', status: 'INGESCHREVEN', reference: 'REG-def' },
];
function providers(get = vi.fn().mockReturnValue(of(sample))) {
return {
get,
providers: [{ provide: BffApiV1Service, useValue: { getOpenbaarRegister: get } }],
};
}
describe('RegisterPage', () => {
it('lists the public register entries from the BFF on open', async () => {
const { get } = providers();
await render(RegisterPage, { providers: providers(get).providers });
expect(get).toHaveBeenCalled();
// The Referentie column shows the citizen's reference (matches the submit confirmation, #78),
// not the internal zaak id.
expect(await screen.findByText(/REG-abc/)).toBeTruthy();
expect(screen.getByText(/INGEDIEND/)).toBeTruthy();
expect(screen.getByText(/REG-def/)).toBeTruthy();
});
it('searches by the entered term', async () => {
const get = vi.fn().mockReturnValue(of(sample));
await render(RegisterPage, { providers: providers(get).providers });
fireEvent.input(screen.getByRole('searchbox'), { target: { value: 'zaak-abc' } });
fireEvent.click(screen.getByRole('button', { name: /zoek/i }));
expect(get).toHaveBeenLastCalledWith({ q: 'zaak-abc' });
});
it('shows an empty-state message when the register has no matches', async () => {
const get = vi.fn().mockReturnValue(of([] as OpenbaarEntry[]));
await render(RegisterPage, { providers: providers(get).providers });
expect(await screen.findByText(/geen inschrijvingen gevonden/i)).toBeTruthy();
});
it('has no WCAG 2.1 AA violations', async () => {
document.documentElement.lang = 'nl';
const { container } = await render(RegisterPage, { providers: providers().providers });
const results = await axe(container, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
});
expect(results.violations).toEqual([]);
});
});

View File

@@ -0,0 +1,45 @@
import { Component, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { BffApiV1Service, type OpenbaarEntry } from 'api-client';
import { UtrechtComponentsModule } from 'ui';
/**
* The openbaar (public) BIG-register: an anonymous search over the read projection's public-safe
* view (id + status only — bsn/naam never leave the BFF; ADR-0010). Loads the full register on open
* and filters by the search term via the BFF's `/openbaar/register?q=` endpoint (S-09).
*/
@Component({
selector: 'app-register-page',
imports: [FormsModule, UtrechtComponentsModule],
templateUrl: './register-page.html',
})
export class RegisterPage {
private readonly bff = inject(BffApiV1Service);
protected readonly query = signal('');
protected readonly entries = signal<OpenbaarEntry[]>([]);
protected readonly loading = signal(false);
protected readonly searched = signal(false);
constructor() {
// Show the full register on open; the search box narrows it.
this.search();
}
search(): void {
const q = this.query().trim();
this.loading.set(true);
this.bff.getOpenbaarRegister(q ? { q } : {}).subscribe({
next: (rows: OpenbaarEntry[]) => {
this.entries.set(rows);
this.loading.set(false);
this.searched.set(true);
},
error: () => {
this.entries.set([]);
this.loading.set(false);
this.searched.set(true);
},
});
}
}

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<title>Openbaar BIG-register</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig } from './app/app.config';
// The openbaar register is anonymous (no DigiD, no runtime config) — bootstrap directly.
bootstrapApplication(App, appConfig).catch((err) => console.error(err));

View File

@@ -0,0 +1,2 @@
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
@import '@utrecht/design-tokens/dist/index.css';

View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}

View File

@@ -0,0 +1,31 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}

View File

@@ -0,0 +1,23 @@
# Multi-stage build for the self-service portal (Angular → nginx).
# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
FROM node:24-slim AS build
WORKDIR /src
RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
# Restore first (cached unless the manifests change).
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml nx.json tsconfig.base.json eslint.config.mjs ./
RUN pnpm install --frozen-lockfile
# Sources (only what the app + its libs need).
COPY apps/self-service apps/self-service
COPY libs libs
RUN pnpm nx build self-service
FROM nginx:1.27-alpine AS runtime
COPY apps/self-service/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/apps/self-service/browser /usr/share/nginx/html
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
# service name, so the token issuer matches the BFF's authority (host-consistent, ADR-0010).
RUN printf '{ "authority": "http://keycloak:8080/realms/digid" }\n' > /usr/share/nginx/html/config.json
EXPOSE 80

View File

@@ -0,0 +1,29 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
# even before the BFF is up and picks up restarts — instead of failing to load the config.
resolver 127.0.0.11 ipv6=off valid=30s;
# Same-origin API: proxy the BFF endpoint groups to the bff service. The api-client uses relative
# URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS, and the DigiD
# token (same-origin) is attached by the app's interceptor (S-08d/ADR-0010).
location /self-service/ {
set $bff http://bff:8080;
proxy_pass $bff;
proxy_set_header Host $host;
}
location /openbaar/ {
set $bff http://bff:8080;
proxy_pass $bff;
proxy_set_header Host $host;
}
# SPA fallback — Angular client-side routing.
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -0,0 +1,3 @@
{
"authority": "http://localhost:8180/realms/digid"
}

View File

@@ -0,0 +1,65 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { BffApiV1Service } from 'api-client';
import { authInterceptor } from 'auth';
import { AbstractSecurityStorage, ConfigurationService } from 'angular-auth-oidc-client';
import { SECURE_API_ROUTES } from './app.config';
// Guards the DigiD token wiring end-to-end. The api-client calls the BFF with RELATIVE URLs, and the
// angular-auth-oidc-client interceptor attaches the token only when `req.url` starts with a configured
// secureRoute. A regression to an absolute origin (as once shipped) makes the relative URL never match,
// so the submit goes out unauthenticated and fails silently. This drives the REAL interceptor and the
// REAL api-client against the REAL production route value (SECURE_API_ROUTES); only the config source
// and the token storage are faked, so the assertion turns on the actual route-matching.
describe('self-service DigiD token wiring', () => {
let http: HttpTestingController;
let bff: BffApiV1Service;
const token = 'digid-access-token';
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor()])),
provideHttpClientTesting(),
{
provide: ConfigurationService,
useValue: {
hasAtLeastOneConfig: () => true,
getAllConfigurations: () => [{ configId: 'digid', secureRoutes: SECURE_API_ROUTES }],
},
},
{
// A signed-in session: the storage the interceptor's token lookup reads from.
provide: AbstractSecurityStorage,
useValue: {
read: () => JSON.stringify({ authzData: token, authnResult: { id_token: 'id-token' } }),
write: () => undefined,
remove: () => undefined,
clear: () => undefined,
},
},
],
});
http = TestBed.inject(HttpTestingController);
bff = TestBed.inject(BffApiV1Service);
});
afterEach(() => http.verify());
it('attaches the bearer token to the relative self-service BFF call', () => {
bff.postSelfServiceRegistrations().subscribe();
const req = http.expectOne('/self-service/registrations');
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
req.flush({ registrationId: 'reg-1', status: 'Ingediend' });
});
it('leaves the anonymous openbaar register call unauthenticated', () => {
bff.getOpenbaarRegister().subscribe();
const req = http.expectOne((r) => r.url === '/openbaar/register');
expect(req.request.headers.has('Authorization')).toBe(false);
req.flush([]);
});
});

View File

@@ -7,16 +7,36 @@ import { provideRouter } from '@angular/router';
import { authInterceptor, provideDigiadAuth } from 'auth'; import { authInterceptor, provideDigiadAuth } from 'auth';
import { appRoutes } from './app.routes'; import { appRoutes } from './app.routes';
export const appConfig: ApplicationConfig = { /** Environment-specific settings fetched from /config.json at startup (see main.ts). */
providers: [ export interface RuntimeConfig {
provideBrowserGlobalErrorListeners(), /** The Keycloak `digid` realm issuer as the browser reaches it (dev: localhost; compose: keycloak:8080). */
provideRouter(appRoutes), authority: string;
provideHttpClient(withInterceptors([authInterceptor()])), }
// Dev defaults (host ports). The compose-served app overrides these for the stack (S-08d).
provideDigiadAuth({ /**
authority: 'http://localhost:8180/realms/digid', * Route prefixes whose requests carry the DigiD token. These MUST match the **relative** URLs the
redirectUrl: typeof window !== 'undefined' ? window.location.origin : '/', * api-client actually calls (same-origin via the nginx proxy) — the interceptor matches on `req.url`,
secureApiOrigin: 'http://localhost:8080', * which stays relative, so an absolute origin would never match and the token would go unattached.
}), * `/openbaar/` is deliberately excluded: it is the anonymous public register.
], */
}; export const SECURE_API_ROUTES = ['/self-service/'];
/**
* Build the app providers from runtime config. `redirectUrl` is the app's own origin (where Keycloak
* redirects back). `secureRoutes` uses {@link SECURE_API_ROUTES} — relative prefixes, not the origin.
*/
export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
const origin = typeof window !== 'undefined' ? window.location.origin : '/';
return {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(appRoutes),
provideHttpClient(withInterceptors([authInterceptor()])),
provideDigiadAuth({
authority: runtime.authority,
redirectUrl: origin,
secureRoutes: SECURE_API_ROUTES,
}),
],
};
}

View File

@@ -8,6 +8,11 @@
</p> </p>
} @else { } @else {
<p utrecht-paragraph>U bent ingelogd met BSN {{ bsn() }}.</p> <p utrecht-paragraph>U bent ingelogd met BSN {{ bsn() }}.</p>
@if (failed()) {
<p utrecht-paragraph role="alert">
Er ging iets mis bij het indienen van uw registratie. Probeer het opnieuw.
</p>
}
<button <button
utrecht-button utrecht-button
appearance="primary-action-button" appearance="primary-action-button"

View File

@@ -1,6 +1,6 @@
import { signal } from '@angular/core'; import { signal } from '@angular/core';
import { fireEvent, render, screen } from '@testing-library/angular'; import { fireEvent, render, screen } from '@testing-library/angular';
import { of } from 'rxjs'; import { of, throwError } from 'rxjs';
import { AuthService } from 'auth'; import { AuthService } from 'auth';
import { BffApiV1Service } from 'api-client'; import { BffApiV1Service } from 'api-client';
import { axe } from 'vitest-axe'; import { axe } from 'vitest-axe';
@@ -43,6 +43,19 @@ describe('RegistrationPage', () => {
expect(await screen.findByText(/ontvangen/i)).toBeTruthy(); expect(await screen.findByText(/ontvangen/i)).toBeTruthy();
}); });
it('shows an error and keeps the submit available when the BFF call fails', async () => {
const { post, providers: p } = providers(vi.fn().mockReturnValue(throwError(() => new Error('BFF rejected'))));
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
expect(post).toHaveBeenCalledTimes(1);
// The failure is surfaced (not swallowed), the confirmation is not shown, and the user can retry.
expect(await screen.findByRole('alert')).toBeTruthy();
expect(screen.queryByText(/ontvangen/i)).toBeNull();
expect(screen.getByRole('button', { name: /indienen/i })).toBeTruthy();
});
it('has no WCAG 2.1 AA violations on the submit page', async () => { it('has no WCAG 2.1 AA violations on the submit page', async () => {
// The portal is Dutch; the real index.html sets lang. Set it here so the document-level // The portal is Dutch; the real index.html sets lang. Set it here so the document-level
// html-has-lang rule reflects the app, not the bare jsdom document. // html-has-lang rule reflects the app, not the bare jsdom document.

View File

@@ -21,13 +21,22 @@ export class RegistrationPage {
protected readonly submitting = signal(false); protected readonly submitting = signal(false);
protected readonly reference = signal<string | undefined>(undefined); protected readonly reference = signal<string | undefined>(undefined);
protected readonly submitted = signal(false); protected readonly submitted = signal(false);
protected readonly failed = signal(false);
submit(): void { submit(): void {
this.submitting.set(true); this.submitting.set(true);
this.bff.postSelfServiceRegistrations().subscribe((accepted: SubmitAccepted) => { this.failed.set(false);
this.reference.set(accepted.registrationId); this.bff.postSelfServiceRegistrations().subscribe({
this.submitted.set(true); next: (accepted: SubmitAccepted) => {
this.submitting.set(false); this.reference.set(accepted.registrationId);
this.submitted.set(true);
this.submitting.set(false);
},
// Surface the failure instead of swallowing it: re-enable the button so the user can retry.
error: () => {
this.failed.set(true);
this.submitting.set(false);
},
}); });
} }
} }

View File

@@ -1,5 +1,10 @@
import { bootstrapApplication } from '@angular/platform-browser'; import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app'; import { App } from './app/app';
import { appConfig, type RuntimeConfig } from './app/app.config';
bootstrapApplication(App, appConfig).catch((err) => console.error(err)); // Load environment config before bootstrap so the OIDC authority is set per environment
// (dev: localhost; compose: keycloak:8080) from a single build — 12-factor (S-08d).
fetch('config.json')
.then((response) => response.json() as Promise<RuntimeConfig>)
.then((config) => bootstrapApplication(App, appConfig(config)))
.catch((err) => console.error(err));

View File

@@ -0,0 +1,64 @@
# ADR-0011: Approval sets the zaak eindstatus via the ACL and projects INGESCHREVEN from the notification alone
- **Status:** Accepted
- **Date:** 2026-07-13
- **Deciders:** Respellion engineering
- **Relates to:** S-09b (#75); split from S-09 (#10); builds on ADR-0001 (§8 loose coupling), ADR-0003 (ACL default-fill), ADR-0007 (OZ→NRC wiring), ADR-0008 (read projection), ADR-0009 (external-task worker)
## Context
The walking skeleton could submit a registration (INGEDIEND) and show it in the openbaar register,
but nothing could **approve** it. S-09b adds a behandelaar approval that must make the entry publicly
visible as a terminal status. There is no behandel-portal yet (S-12), so approval is triggered by a
**temporary admin endpoint** on the Domain Service.
Two decisions are non-obvious (§14) and cross service boundaries:
1. **Who resolves the ZGW statustype?** Approval means "set the zaak to its final status", but the
domain must stay ZGW-ignorant (§8.1 — only the ACL talks to ZGW) and does not know statustype URLs.
2. **How does the projection learn the new status?** The status is set in OpenZaak, which notifies over
NRC; the Event Subscriber projects it. But the subscriber **may not read OpenZaak** (§8.1), and an
NRC `status`/`create` notification's `resourceUrl` is the *status* resource, not the zaak, and does
not carry the statustype.
## Decision
**Approval flows Domain → ACL → OpenZaak → NRC → Event Subscriber → projection, using only the
notification's own fields on the read side.**
- **Domain.** `Registration.Approve()` advances INGEDIEND → INGESCHREVEN (requires an opened zaak; a
repeat is a no-op). The `ApproveRegistration` use case calls the ACL to set the zaak status, then
advances the aggregate. A temporary `POST /registrations/{id}/approve` endpoint drives it.
- **ACL.** A new `POST /statussen` operation takes only the zaak URL. The ACL resolves the zaaktype's
**eindstatus** from the catalogus (`isEindstatus`, falling back to the highest `volgnummer`) and
POSTs a ZGW status against the zaak. The domain never names statustypen — the ACL owns the ZGW
translation (§8.1, ADR-0003).
- **Event Subscriber.** It binds the NRC `hoofdObject` (always the zaak URL) and keys the projection on
it, so a `zaken`/`status`/`create` notification updates the **same** row the zaak-create created,
flipping it to INGESCHREVEN. It takes **any** status-create as the approval — in the walking skeleton
the only status ever set after creation is the approval — so it never has to read OpenZaak to learn
the statustype. The ZGW `resource` is retained in the notification log (new column) so a rebuild
reproduces the right status.
## Consequences
- The domain↔ACL boundary stays clean: the domain hands over a zaak URL and says "approve"; ZGW
statustype knowledge lives only in the ACL.
- The projection remains rebuildable without OpenZaak (§8.1, ADR-0008): the log now records the ZGW
resource, which is all a rebuild needs to reproject the status.
- The openbaar register shows real lifecycle: INGEDIEND on submit, INGESCHREVEN on approval.
- **Walking-skeleton assumption:** "any status-create ⇒ INGESCHREVEN" holds only while approval is the
sole post-creation status transition. When more transitions arrive (beoordeling, afwijzing — S-12+),
the subscriber must distinguish statustypen. The honest options then are to carry the statustype
omschrijving in the notification `kenmerken`, or to have the ACL resolve it and re-notify — recorded
here so future-me revisits this rather than assuming it generalises.
## Alternatives considered
- **Inject the approved statustype URL into the ACL as config** (like the zaaktype URL). Rejected:
couples ACL config to seed output and adds compose/run-domain-check plumbing; runtime eindstatus
discovery keeps the ACL self-contained for one extra ZGW GET per approval.
- **Have the Event Subscriber GET the status/statustype from OpenZaak** to map precisely. Rejected:
violates §8.1 (only the ACL talks to ZGW) and makes the projection depend on OpenZaak being up.
- **Record the derived status in the notification log** instead of the ZGW resource. Rejected: the log
should retain notification *facts*, not projection semantics; the mapping stays in the projector.

View File

@@ -0,0 +1,104 @@
# ADR-0012: One citizen-facing reference across self-service and the openbaar register
- **Status:** Accepted
- **Date:** 2026-07-14
- **Deciders:** Respellion engineering
- **Relates to:** #78 (adr-proposal); builds on ADR-0008 (read projection), ADR-0001 (loose coupling), ADR-0009 (external-task worker / zaak creation)
## Context
A citizen submits through the self-service portal and is shown a confirmation with a
**reference** so they can find their registration back in the public register. But the two
sides showed **different identifiers**:
- The self-service confirmation shows the **domain `registrationId`** — a GUID minted by the
domain aggregate (`RegistrationId.New()`) when the registration is created, before any zaak
exists.
- The openbaar register showed the **zaak id** — the UUID from the NRC `hoofdObject` URL,
assigned by OpenZaak when the ACL opens the zaak.
These never match, so the reference on the confirmation was useless for looking the entry up.
The two identifiers live on opposite sides of the ACL boundary and are generated by different
systems at different times, so there is no way to reconcile them after the fact without a
correlating value carried across the boundary.
The NRC notification the Event Subscriber consumes carries only the zaak URL plus the fixed
`kenmerken` (`bronorganisatie`, `zaaktype`, `vertrouwelijkheidaanduiding`) — **not** the
`registrationId`, the bsn, or the `identificatie`. ADR-0008 already recorded that filling any
such field means reading the zaak **through the ACL** (§8.1) and deferred it as a follow-up.
This is that follow-up, scoped to the one field the citizen actually needs.
## Decision
**Use the domain `registrationId` as the zaak's `identificatie`, and surface that single value
as the citizen-facing `reference` on both portals. The Event Subscriber enriches the projection
with the reference by reading the zaak through the ACL, and stores it in the replay log so
rebuild stays log-only.**
Concretely, following the request path:
1. **Domain → ACL (write).** When the OpenZaak worker opens a zaak, it passes
`registration.Id` to the ACL (`IAclClient.OpenZaakAsync(bsn, reference, …)`). The ACL sets
it as the zaak's `identificatie` on `POST /zaken`. OpenZaak's `identificatie` is unique per
`bronorganisatie` and ≤ 40 chars — a GUID string fits. The ACL remains the only code that
constructs ZGW payloads (§8.1); the domain never sees a ZGW URL.
2. **Event Subscriber → ACL (read).** On a notification, the subscriber asks the ACL for the
zaak's reference via a new `POST /zaken/reference` endpoint (`{ zaakUrl } → { reference }`),
which reads the zaak's `identificatie` through the ACL's OpenZaak gateway. The subscriber
still never talks to ZGW itself (§8.1) — it depends only on the ACL, over HTTP.
3. **Projection + replay log.** The reference is written both to the `register_projection` row
**and** to the `processed_notifications` replay log (a new nullable `reference` column on
each). Storing it in the log is what keeps ADR-0008's "**rebuild replays the log, not
OpenZaak**" invariant true: `POST /admin/rebuild` reproduces the reference from the log
without re-reading the ACL.
4. **BFF + openbaar.** The public view (`OpenbaarProjection.PublicView`) exposes
`id`, `status`, and `reference` (never bsn/naam), and the openbaar search matches on either
`id` or `reference`. The openbaar register's "Referentie" column now renders `reference`.
The end-to-end guarantee is asserted in the Playwright walking-skeleton: the reference captured
from the submit confirmation must appear as a cell in the public register.
### Why HTTP to the ACL, not the ACL as a library
ADR-0008 floated "extend the ACL with a zaak-read operation, consumed as a library." We instead
call the ACL **over HTTP**, consistent with every other cross-service hop in this system
(portals→BFF, domain→ACL). Sharing the ACL as a library would couple the subscriber to the
ACL's infrastructure assembly and its ZGW client configuration, defeating the anti-corruption
boundary. The HTTP endpoint keeps the ACL the single owner of ZGW access and its config.
## Consequences
**Positive**
- One reference, end to end: the citizen's confirmation value is exactly what the public
register shows and searches by.
- §8.1 stays intact — only the ACL reads or writes ZGW; the subscriber depends on the ACL, not
OpenZaak.
- Rebuild stays log-only (ADR-0008): the reference is replayed from `processed_notifications`,
so `/admin/rebuild` needs no ACL/ZGW access.
- The column additions are nullable and additive; older rows without a reference are tolerated.
**Negative / costs**
- A new coupling: the Event Subscriber now depends on the ACL being reachable
(`Acl__BaseUrl`, compose `depends_on: acl`). A registration whose reference read fails will
need the notification redelivered (NRC already redelivers; the projection upsert is
idempotent).
- One extra HTTP hop per notification (subscriber→ACL→OpenZaak) on the projection path. Bounded:
one small GET per zaak, off the citizen's request path.
- `identificatie` now carries semantic meaning (it equals the `registrationId`). If OpenZaak
were ever configured to auto-generate `identificatie`, the correlation would break; the ACL
setting it explicitly is now load-bearing.
## Alternatives considered
- **Carry the `registrationId` in the notification** — rejected: NRC `kenmerken` are fixed and
the notification content is not ours to extend; it would also couple the projection to a
bespoke notification shape.
- **Show the zaak id on the confirmation instead** — rejected: the zaak does not exist yet when
the confirmation is returned (the worker opens it asynchronously, ADR-0009), so the domain has
no zaak id to show at submit time.
- **Store only on the projection row, re-read the ACL on rebuild** — rejected: it would make
rebuild depend on the ACL/ZGW, breaking ADR-0008's log-only rebuild invariant.
- **Reconcile the two ids in a lookup table** — rejected: adds write-only state and a second
source of truth for a value that can simply be the same on both sides.

View File

@@ -5,6 +5,28 @@ copy-pasteable walkthrough against a local `make up` stack.
--- ---
## S-08d — Walking skeleton complete: browser → submit, end-to-end
**Outcome:** the self-service portal is served in the stack and the full front-of-house happy path
runs in a real browser — **mock DigiD login → submit → confirmation** — closing the walking skeleton
(portal → BFF → domain → Flowable → ACL → OpenZaak, with the openbaar register reading the projection).
```bash
# 1. Bring the whole stack up (portal served on :8140, BFF :8080, Keycloak :8180).
make up
# 2. Automated happy path — Playwright, inside the compose network (issuer-consistent):
make verify-e2e # → login as jan-burger → submit → "ontvangen" confirmation
# 3. By hand: open the portal, log in as jan-burger / test123, click "Registratie indienen".
open http://localhost:8140
```
> The portal is served same-origin with the BFF (nginx proxies `/self-service` + `/openbaar`), so no
> CORS; the OIDC authority comes from `/config.json` at runtime. See `docs/frontend-decisions.md`.
---
## S-08c — Self-service submit form (NL Design System + DigiD) ## S-08c — Self-service submit form (NL Design System + DigiD)
**Outcome:** a zorgprofessional logs in via mock DigiD and submits a BIG registration through the **Outcome:** a zorgprofessional logs in via mock DigiD and submits a BIG registration through the
@@ -146,3 +168,83 @@ curl -fsS http://localhost:8120/register | jq 'length' # → unchanged
> `bsn` / `naam_placeholder` are deferred (ADR-0008) — the notification doesn't carry them and > `bsn` / `naam_placeholder` are deferred (ADR-0008) — the notification doesn't carry them and
> the subscriber may not read OpenZaak directly (§8.1). They surface in a later slice. > the subscriber may not read OpenZaak directly (§8.1). They surface in a later slice.
---
## S-09 — Openbaar Register portal (public visibility)
**Outcome:** the entry a zorgprofessional submits via self-service becomes publicly visible in the
anonymous openbaar register portal — closing the walking-skeleton loop (submit → process → projection
→ public visibility).
**The path:** self-service submit → BFF → domain → (zaak) OpenZaak → NRC → Event Subscriber →
projection → openbaar portal reads the BFF's public-safe `GET /openbaar/register`.
```bash
# 1. Bring the full stack up (self-service :8140, openbaar :8141).
make up
# 2. Submit a registration via the self-service portal (mock DigiD: jan-burger / test123),
# or drive the whole happy path automatically (login → submit → public visibility):
make verify-e2e
# 3. Open the public register — no login. It lists the submitted entry (id + status only).
# Only public-safe fields cross the BFF: bsn / naam never appear.
open http://localhost:8141/ # search box; searches the BFF by referentie
curl -fsS http://localhost:8140/openbaar/register | jq # same public-safe view via the BFF proxy
# → [ { "id": "<zaak-uuid>", "status": "INGEDIEND" } ]
```
> The register shows `INGEDIEND` on submit; approval flips it to `INGESCHREVEN` — see S-09b below.
---
## S-09b — Approval flow (public visibility flips to INGESCHREVEN)
**Outcome:** a behandelaar approves a submitted registration via a temporary admin endpoint (no
behandel-portal yet — S-12). The approval sets the zaak's final status through the ACL, which flows
back to the projection over NRC, and the openbaar register then shows the entry as `INGESCHREVEN`.
**The path:** `POST /registrations/{id}/approve` (domain) → ACL sets the zaak eindstatus (ZGW
`/statussen`) → OpenZaak → NRC → Event Subscriber projects `INGESCHREVEN` → openbaar register.
```bash
# 1. Full stack up, then drive submit → public INGEDIEND → approve → public INGESCHREVEN:
make up
make verify-e2e
# 2. Or by hand: submit (as in S-09), note the reference, then approve it.
# The zaak is opened off the request path, so approve once GET shows a zaakUrl.
ref="<registration-reference-from-the-confirmation>"
curl -fsS http://localhost:8130/registrations/$ref | jq # domain (host port 8130): wait for .zaakUrl
curl -fsS -X POST http://localhost:8130/registrations/$ref/approve -i # → 204 No Content
# 3. The public register now shows the entry as approved.
curl -fsS http://localhost:8140/openbaar/register | jq
# → [ { "id": "<zaak-uuid>", "status": "INGESCHREVEN" } ]
```
> **End of walking skeleton** (S-09 + S-09b): submit → process → projection → public visibility, from
> INGEDIEND through approval to INGESCHREVEN. The subscriber takes any post-creation status-set as the
> approval (ADR-0011) — a walking-skeleton assumption that tightens when more transitions arrive (S-12+).
## #78 — One reference across both portals (ADR-0012)
Before this change the self-service confirmation and the openbaar register showed **different**
identifiers, so a citizen could not look their registration back up. Now both show the same
**reference**: the domain `registrationId` is set as the zaak's `identificatie` by the ACL, and the
Event Subscriber enriches the projection with it by reading the zaak through the ACL (§8.1) — storing
it in the replay log so rebuild stays log-only (ADR-0008).
**The path:** domain passes `registrationId` → ACL sets it as `zaak.identificatie` → NRC →
Event Subscriber asks the ACL for the reference → projection row + replay log → openbaar register.
```bash
# Submit as in S-09 and note the reference on the confirmation, then find it in the public register:
ref="<registration-reference-from-the-confirmation>"
curl -fsS "http://localhost:8140/openbaar/register?q=$ref" | jq
# → [ { "id": "<zaak-uuid>", "status": "INGEDIEND", "reference": "<same-ref-as-confirmation>" } ]
```
> The openbaar register's "Referentie" column and its search now use this reference — the exact value
> the citizen saw on submit. Asserted end-to-end by the Playwright happy path.

View File

@@ -72,3 +72,48 @@ with the submit form (S-08c, #67); any deviation from NL DS will be recorded her
- **Module boundaries:** replaced the demo eslint `depConstraints` (`scope:shop`/`scope:shared`, left - **Module boundaries:** replaced the demo eslint `depConstraints` (`scope:shop`/`scope:shared`, left
over from the Nx angular template) with a permissive `*` default; scope/type tags can be over from the Nx angular template) with a permissive `*` default; scope/type tags can be
introduced when the portal set grows. introduced when the portal set grows.
---
## Serving + e2e (S-08d, #68)
- **Served by nginx, same-origin as the BFF.** The compose `self-service` image serves the built app
and **reverse-proxies** `/self-service/*` + `/openbaar/*` to the `bff` service. Because the
api-client uses **relative URLs**, the browser calls the app's own origin → nginx forwards to the
BFF: **no CORS**, and the DigiD token (same-origin) is attached by the interceptor. nginx resolves
the BFF at request time (a `resolver` + variable `proxy_pass`) so it starts before the BFF is up.
- **Runtime config.** The app fetches `/config.json` before bootstrap (`main.ts`); `appConfig` is a
factory. The dev default (`public/config.json`) points at `localhost:8180`; the Docker image bakes
the compose value (`keycloak:8080`). One build, per-environment OIDC authority.
- **e2e runs inside the compose network.** `infra/run-e2e-check.sh` runs Playwright in a container on
`cg`, so the browser reaches Keycloak as `keycloak:8080` — the **same issuer** the BFF validates
against (resolves the browser-vs-container mismatch, ADR-0010). It uses the official
`mcr.microsoft.com/playwright:<version>` image with browsers pre-baked, rather than downloading
~150 MB of Chromium on every run (issue #73) — the image tag is kept in lockstep with
`tests/e2e/package.json`'s `@playwright/test` version. The spec is copied in (`docker cp`), not
mounted, so it leaves nothing root-owned on the host. Wired as `verify-e2e` in the `verify-stack`
CI job.
- **e2e treats the portal origin as secure.** In-network the portal is served over plain HTTP on a
non-localhost origin (`http://self-service`), which is **not a secure context**, so Web Crypto
(`crypto.subtle`) is unavailable. angular-auth-oidc-client needs it for the PKCE code challenge, so
`authorize()` throws and the login redirect never fires. Production runs behind HTTPS where this is
a non-issue; rather than terminate TLS in the throwaway stack, the Playwright config passes
`--unsafely-treat-insecure-origin-as-secure` (honoured only by the full `channel: 'chromium'`
build, not the default headless-shell). This emulates the production HTTPS secure context without
touching the app or its production config.
- `tests/e2e` is a standalone Playwright project (its own `package.json`), not an Nx project — it's a
live-stack check like the other `verify-*` runners, not part of the `frontend` unit lane.
## Openbaar Register portal (S-09, #10)
- **Anonymous, no auth.** The openbaar register is a public read, so `apps/openbaar` has no
`angular-auth-oidc-client`, no interceptor, and no `config.json` — `main.ts` bootstraps `appConfig`
directly with just `provideHttpClient` + `provideRouter`. This is the deliberate contrast to
self-service and keeps the app trivially cacheable/CDN-able.
- **Same-origin via nginx, like self-service.** The compose `openbaar` image serves the built app and
reverse-proxies `/openbaar` to the BFF; the api-client's relative calls stay same-origin (no CORS).
Served on `:8141`, health-checked over IPv4 (`127.0.0.1`), no Keycloak dependency.
- **Public-safe by construction.** The portal only ever sees the BFF's `OpenbaarProjection.PublicView`
(id + status); `bsn`/`naam` never leave the BFF. The e2e asserts the bsn never renders.
- **Loads on open, filters on search.** `RegisterPage` fetches the full register on construction and
re-queries `/openbaar/register?q=` on search — no client-side filtering, the BFF owns the query.

View File

@@ -396,6 +396,9 @@ services:
image: register-referentie/event-subscriber:dev image: register-referentie/event-subscriber:dev
environment: environment:
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
# The subscriber enriches the projection with each zaak's reference (identificatie) by asking
# the ACL — the only code allowed to read ZGW (§8.1, #78).
Acl__BaseUrl: http://acl:8080/
# The bearer Open Notificaties must present on the abonnement callback. NRC's # The bearer Open Notificaties must present on the abonnement callback. NRC's
# registration probe expects a 401 without it (ADR-0007). Dev-only token. # registration probe expects a 401 without it (ADR-0007). Dev-only token.
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications} EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
@@ -410,6 +413,8 @@ services:
depends_on: depends_on:
projection-db: projection-db:
condition: service_healthy condition: service_healthy
acl:
condition: service_healthy
networks: [cg] networks: [cg]
# The read side of the projection. Shares Projection.ReadModel, so build context is root. # The read side of the projection. Shares Projection.ReadModel, so build context is root.
@@ -433,6 +438,52 @@ services:
condition: service_healthy condition: service_healthy
networks: [cg] networks: [cg]
# ── Self-Service portal (S-08d) ────────────────────────────────────────────
# nginx serves the Angular app and reverse-proxies /self-service + /openbaar to the BFF
# (same-origin, no CORS). The Playwright e2e drives it inside this network so the DigiD
# token issuer (keycloak:8080) matches the BFF's authority (ADR-0010).
self-service:
build:
context: ..
dockerfile: apps/self-service/Dockerfile
image: register-referentie/self-service:dev
ports:
- "8140:80"
healthcheck:
# 127.0.0.1, not localhost: nginx listens on IPv4 only, but localhost resolves to ::1 first.
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
bff:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg]
# The openbaar (public) register portal: nginx serves the Angular app and reverse-proxies
# /openbaar to the BFF. Anonymous — no DigiD, no Keycloak dependency (S-09).
openbaar:
build:
context: ..
dockerfile: apps/openbaar/Dockerfile
image: register-referentie/openbaar:dev
ports:
- "8141:80"
healthcheck:
# 127.0.0.1, not localhost: nginx listens on IPv4 only, but localhost resolves to ::1 first.
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
bff:
condition: service_healthy
networks: [cg]
volumes: volumes:
oz-db: oz-db:
nrc-db: nrc-db:

29
infra/run-e2e-check.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
#
# Walking-skeleton e2e (S-08d) against an ALREADY-RUNNING full stack: drive the self-service portal
# in a real browser through mock-DigiD login → submit → confirmation (login → BFF → domain).
#
# Runs Playwright INSIDE the compose network (a container on `cg`), so the browser reaches
# Keycloak by service name (keycloak:8080) — the same authority the BFF validates against, so the
# token issuer matches (ADR-0010). The spec is copied into the container (docker cp), not mounted,
# so it leaves no root-owned files on the host. The caller owns stack bring-up + teardown.
#
# Uses the official Playwright image with browsers pre-baked, instead of downloading ~150 MB of
# Chromium on every run (issue #73). The image tag MUST match tests/e2e/package.json's
# @playwright/test version — bump both together.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(cd "$here/.." && pwd)"
ss="$(docker ps -q --filter 'name=self-service' | head -1)"
[ -n "$ss" ] || { echo "ERROR: no running self-service container — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$ss" | head -1)"
echo ">> running Playwright e2e on network $net against http://self-service"
cid="$(docker create --network "$net" -w /e2e --ipc=host \
-e SELF_SERVICE_URL=http://self-service \
mcr.microsoft.com/playwright:v1.61.1-noble sh -c 'npm install --no-audit --no-fund && npx playwright test')"
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
docker cp "$root/tests/e2e/." "$cid:/e2e" >/dev/null
docker start -a "$cid"

View File

@@ -27,6 +27,8 @@ import {
export interface OpenbaarEntry { export interface OpenbaarEntry {
id: string; id: string;
status: string; status: string;
/** @nullable */
reference: string | null;
} }
export interface SubmitAccepted { export interface SubmitAccepted {

View File

@@ -13,8 +13,13 @@ export interface DigiadAuthOptions {
authority: string; authority: string;
/** Where Keycloak redirects back to after login (usually the app origin). */ /** Where Keycloak redirects back to after login (usually the app origin). */
redirectUrl: string; redirectUrl: string;
/** The BFF origin whose requests get the bearer token attached (secure route). */ /**
secureApiOrigin: string; * Route prefixes whose requests get the bearer token attached. The api-client calls the BFF with
* **relative** URLs (same-origin via the nginx proxy), so these must be relative path prefixes
* (e.g. `/self-service/`) — angular-auth-oidc-client matches `req.url.startsWith(route)`, and a
* relative `req.url` never starts with an absolute origin.
*/
secureRoutes: string[];
} }
/** /**
@@ -35,7 +40,7 @@ export function provideDigiadAuth(options: DigiadAuthOptions): EnvironmentProvid
responseType: 'code', responseType: 'code',
silentRenew: true, silentRenew: true,
useRefreshToken: true, useRefreshToken: true,
secureRoutes: [options.secureApiOrigin], secureRoutes: options.secureRoutes,
logLevel: LogLevel.Warn, logLevel: LogLevel.Warn,
}, },
}, },

View File

@@ -20,12 +20,32 @@ app.MapGet("/health", () => "Healthy");
// The ACL's single operation, exposed as a service endpoint. // The ACL's single operation, exposed as a service endpoint.
app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationToken ct) => app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationToken ct) =>
{ {
var zaakUrl = await acl.OpenZaakAsync(new DomainRegistration(body.Bsn), ct); var zaakUrl = await acl.OpenZaakAsync(new DomainRegistration(body.Bsn, body.Reference), ct);
return Results.Ok(new { zaakUrl = zaakUrl.ToString() }); return Results.Ok(new { zaakUrl = zaakUrl.ToString() });
}); });
// Approve a zaak: set it to its zaaktype's eindstatus (S-09b). The domain hands over only the zaak
// URL; the ACL owns the ZGW statustype resolution (§8.1).
app.MapPost("/statussen", async (SetStatusRequest body, AclService acl, CancellationToken ct) =>
{
await acl.ApproveZaakAsync(new Uri(body.ZaakUrl), ct);
return Results.NoContent();
});
// Read a zaak's public-safe reference (its identificatie). The Event Subscriber calls this to enrich
// the read projection without reading ZGW itself (§8.1, #78).
app.MapPost("/zaken/reference", async (ZaakReferenceRequest body, AclService acl, CancellationToken ct) =>
{
var reference = await acl.GetZaakReferenceAsync(new Uri(body.ZaakUrl), ct);
return Results.Ok(new { reference });
});
app.Run(); app.Run();
public sealed record OpenZaakRequest(string Bsn); public sealed record OpenZaakRequest(string Bsn, string Reference);
public sealed record SetStatusRequest(string ZaakUrl);
public sealed record ZaakReferenceRequest(string ZaakUrl);
public partial class Program; public partial class Program;

View File

@@ -13,8 +13,28 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
defaults.VerantwoordelijkeOrganisatie, defaults.VerantwoordelijkeOrganisatie,
defaults.Vertrouwelijkheidaanduiding, defaults.Vertrouwelijkheidaanduiding,
defaults.ZaaktypeUrl, defaults.ZaaktypeUrl,
clock.Today); clock.Today,
registration.Reference);
return gateway.OpenZaakAsync(request, ct); return gateway.OpenZaakAsync(request, ct);
} }
/// <summary>
/// Approve a zaak: set it to the eindstatus of the configured BIG zaaktype (ADR-0003 default). The
/// domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1).
/// </summary>
public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
}
/// <summary>The zaak's reference (its ZGW identificatie), for the read projection (#78).</summary>
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
return gateway.GetZaakIdentificatieAsync(zaakUrl, ct);
}
} }

View File

@@ -1,4 +1,5 @@
namespace Acl.Application; namespace Acl.Application;
/// <summary>Domain-language payload handed to the ACL. No ZGW concepts here.</summary> /// <summary>Domain-language payload handed to the ACL. No ZGW concepts here. <see cref="Reference"/>
public sealed record DomainRegistration(string Bsn); /// is the registration's own id; the ACL records it as the zaak identificatie (adr-proposal #78).</summary>
public sealed record DomainRegistration(string Bsn, string Reference);

View File

@@ -5,4 +5,15 @@ namespace Acl.Application;
public interface IZaakGateway public interface IZaakGateway
{ {
Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default); Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default);
/// <summary>
/// Set the given zaak to the <em>eindstatus</em> (final statustype) of the supplied zaaktype —
/// the ZGW translation of "approve". The gateway resolves which statustype is the eindstatus from
/// the catalogus and POSTs a status against the zaak, dated <paramref name="datumStatusGezet"/>.
/// </summary>
Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default);
/// <summary>Read the zaak's <c>identificatie</c> — the public-safe reference the register shows.
/// The Event Subscriber calls this through the ACL rather than reading ZGW itself (§8.1, #78).</summary>
Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default);
} }

View File

@@ -6,4 +6,5 @@ public sealed record ZaakRequest(
string VerantwoordelijkeOrganisatie, string VerantwoordelijkeOrganisatie,
string Vertrouwelijkheidaanduiding, string Vertrouwelijkheidaanduiding,
Uri Zaaktype, Uri Zaaktype,
DateOnly Startdatum); DateOnly Startdatum,
string Identificatie);

View File

@@ -20,7 +20,8 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
request.Zaaktype.ToString(), request.Zaaktype.ToString(),
request.VerantwoordelijkeOrganisatie, request.VerantwoordelijkeOrganisatie,
request.Startdatum.ToString("yyyy-MM-dd"), request.Startdatum.ToString("yyyy-MM-dd"),
request.Vertrouwelijkheidaanduiding)), request.Vertrouwelijkheidaanduiding,
request.Identificatie)),
}; };
message.Headers.Authorization = message.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret)); new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
@@ -41,13 +42,144 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
return new Uri(created.Url); return new Uri(created.Url);
} }
public async Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
ArgumentNullException.ThrowIfNull(zaaktypeUrl);
var eindstatus = await ResolveEindstatusAsync(zaaktypeUrl, ct);
var resultaattype = await ResolveResultaattypeAsync(zaaktypeUrl, ct);
// OpenZaak refuses to set a zaak's eindstatus unless the zaak has a resultaat
// ("resultaat-does-not-exist"), so record the resultaat first, then the status.
await PostAsync("/zaken/api/v1/resultaten",
new ResultaatDto(zaakUrl.ToString(), resultaattype.ToString()), "Setting the zaak resultaat", ct);
await PostAsync("/zaken/api/v1/statussen",
// datumStatusGezet is a ZGW date-time; set it at the start of the given day (UTC).
new StatusDto(zaakUrl.ToString(), eindstatus.ToString(),
datumStatusGezet.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ")),
"Setting the zaak status", ct);
}
public async Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
using var message = new HttpRequestMessage(HttpMethod.Get, zaakUrl);
message.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
// The zaak is a geo resource; the Zaken API requires the CRS header even on GET.
message.Headers.Add("Accept-Crs", "EPSG:4326");
using var response = await http.SendAsync(message, ct);
await EnsureSuccessAsync(response, "Reading the zaak", ct);
var zaak = await response.Content.ReadFromJsonAsync<ZaakReadDto>(ct)
?? throw new InvalidOperationException("OpenZaak returned an empty zaak response");
return zaak.Identificatie;
}
// POSTs a non-geo ZGW resource (resultaat/status — no CRS headers). Buffers the body so uwsgi gets
// a Content-Length instead of a chunked body (as with zaak-create).
private async Task PostAsync(string path, object dto, string action, CancellationToken ct)
{
using var message = new HttpRequestMessage(HttpMethod.Post, new Uri(options.BaseUrl, path))
{
Content = JsonContent.Create(dto),
};
message.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
await message.Content.LoadIntoBufferAsync(ct);
using var response = await http.SendAsync(message, ct);
await EnsureSuccessAsync(response, action, ct);
}
// EnsureSuccessStatusCode discards the response body; ZGW returns a JSON problem detail on 400 that
// is essential for diagnosing a rejected request, so surface it in the exception.
private static async Task EnsureSuccessAsync(HttpResponseMessage response, string action, CancellationToken ct)
{
if (response.IsSuccessStatusCode)
return;
var body = await response.Content.ReadAsStringAsync(ct);
throw new HttpRequestException($"{action} failed: {(int)response.StatusCode} {response.ReasonPhrase}. {body}");
}
/// <summary>Resolve the zaaktype's eindstatus (the terminal statustype) from the catalogus.</summary>
private async Task<Uri> ResolveEindstatusAsync(Uri zaaktypeUrl, CancellationToken ct)
{
var page = await GetCatalogusAsync<StatustypePage>("statustypen", zaaktypeUrl, "statustypen", ct);
var results = page.Results ?? [];
// OpenZaak flags the terminal statustype (highest volgnummer) as isEindstatus; fall back to the
// highest volgnummer if the flag is absent.
var eindstatus = results.FirstOrDefault(s => s.IsEindstatus)
?? results.OrderByDescending(s => s.Volgnummer).FirstOrDefault()
?? throw new InvalidOperationException($"No statustypen found for zaaktype {zaaktypeUrl}");
return new Uri(eindstatus.Url);
}
/// <summary>Resolve the zaaktype's resultaattype from the catalogus (the seed defines one).</summary>
private async Task<Uri> ResolveResultaattypeAsync(Uri zaaktypeUrl, CancellationToken ct)
{
var page = await GetCatalogusAsync<ResultaattypePage>("resultaattypen", zaaktypeUrl, "resultaattypen", ct);
var resultaattype = (page.Results ?? []).FirstOrDefault()
?? throw new InvalidOperationException($"No resultaattypen found for zaaktype {zaaktypeUrl}");
return new Uri(resultaattype.Url);
}
// GETs a catalogus collection filtered by zaaktype (status=alles includes concept + published).
private async Task<T> GetCatalogusAsync<T>(string resource, Uri zaaktypeUrl, string label, CancellationToken ct)
{
var query = new Uri(options.BaseUrl,
$"/catalogi/api/v1/{resource}?status=alles&zaaktype=" + Uri.EscapeDataString(zaaktypeUrl.ToString()));
using var message = new HttpRequestMessage(HttpMethod.Get, query);
message.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
using var response = await http.SendAsync(message, ct);
await EnsureSuccessAsync(response, $"Querying {label}", ct);
return await response.Content.ReadFromJsonAsync<T>(ct)
?? throw new InvalidOperationException($"OpenZaak returned an empty {label} response");
}
private sealed record ZaakDto( private sealed record ZaakDto(
[property: JsonPropertyName("bronorganisatie")] string Bronorganisatie, [property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
[property: JsonPropertyName("zaaktype")] string Zaaktype, [property: JsonPropertyName("zaaktype")] string Zaaktype,
[property: JsonPropertyName("verantwoordelijkeOrganisatie")] string VerantwoordelijkeOrganisatie, [property: JsonPropertyName("verantwoordelijkeOrganisatie")] string VerantwoordelijkeOrganisatie,
[property: JsonPropertyName("startdatum")] string Startdatum, [property: JsonPropertyName("startdatum")] string Startdatum,
[property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding); [property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding,
[property: JsonPropertyName("identificatie")] string Identificatie);
private sealed record ZaakCreatedDto( private sealed record ZaakCreatedDto(
[property: JsonPropertyName("url")] string Url); [property: JsonPropertyName("url")] string Url);
private sealed record ZaakReadDto(
[property: JsonPropertyName("identificatie")] string Identificatie);
private sealed record StatusDto(
[property: JsonPropertyName("zaak")] string Zaak,
[property: JsonPropertyName("statustype")] string Statustype,
[property: JsonPropertyName("datumStatusGezet")] string DatumStatusGezet);
private sealed record StatustypePage(
[property: JsonPropertyName("results")] IReadOnlyList<StatustypeDto>? Results);
private sealed record StatustypeDto(
[property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("volgnummer")] int Volgnummer,
[property: JsonPropertyName("isEindstatus")] bool IsEindstatus);
private sealed record ResultaatDto(
[property: JsonPropertyName("zaak")] string Zaak,
[property: JsonPropertyName("resultaattype")] string Resultaattype);
private sealed record ResultaattypePage(
[property: JsonPropertyName("results")] IReadOnlyList<ResultaattypeDto>? Results);
private sealed record ResultaattypeDto(
[property: JsonPropertyName("url")] string Url);
} }

View File

@@ -65,6 +65,43 @@ public sealed class OpenZaakFixture : IDisposable
return JsonDocument.Parse(json).RootElement.Clone(); return JsonDocument.Parse(json).RootElement.Clone();
} }
/// <summary>GETs a non-geo ZGW resource (e.g. a status) by URL — no CRS headers.</summary>
public async Task<JsonElement> GetJsonAsync(Uri url, CancellationToken ct = default)
{
using var message = new HttpRequestMessage(HttpMethod.Get, url);
message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MintToken());
using var response = await Http.SendAsync(message, ct);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(ct);
return JsonDocument.Parse(json).RootElement.Clone();
}
/// <summary>The zaaktype's eindstatus (terminal statustype) URL — the one an approval sets.</summary>
public async Task<Uri> FindEindstatustypeAsync(Uri zaaktypeUrl, CancellationToken ct = default)
{
var query = new Uri(BaseUrl,
"/catalogi/api/v1/statustypen?status=alles&zaaktype=" + Uri.EscapeDataString(zaaktypeUrl.ToString()));
var page = await GetJsonAsync(query, ct);
var results = page.GetProperty("results");
Uri? fallback = null;
var highest = int.MinValue;
foreach (var st in results.EnumerateArray())
{
if (st.TryGetProperty("isEindstatus", out var eind) && eind.GetBoolean())
return new Uri(st.GetProperty("url").GetString()!);
var volgnummer = st.GetProperty("volgnummer").GetInt32();
if (volgnummer > highest)
{
highest = volgnummer;
fallback = new Uri(st.GetProperty("url").GetString()!);
}
}
return fallback ?? throw new InvalidOperationException($"No statustypen for zaaktype {zaaktypeUrl}");
}
// A ZGW (vng-api-common) HS256 JWT, mirroring the seed's client. Minted here // A ZGW (vng-api-common) HS256 JWT, mirroring the seed's client. Minted here
// rather than reusing Acl.Infrastructure's internal minter to keep that internal. // rather than reusing Acl.Infrastructure's internal minter to keep that internal.
private string MintToken() private string MintToken()

View File

@@ -22,12 +22,14 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
"seed it with OZ_PUBLISH=1 (`make integration` does this)."); "seed it with OZ_PUBLISH=1 (`make integration` does this).");
var gateway = new OpenZaakGateway(stack.Http, stack.Options); var gateway = new OpenZaakGateway(stack.Http, stack.Options);
var reference = Guid.NewGuid().ToString(); // zaak identificatie must be unique per bronorganisatie
var request = new ZaakRequest( var request = new ZaakRequest(
Bronorganisatie: "517439943", Bronorganisatie: "517439943",
VerantwoordelijkeOrganisatie: "517439943", VerantwoordelijkeOrganisatie: "517439943",
Vertrouwelijkheidaanduiding: "openbaar", Vertrouwelijkheidaanduiding: "openbaar",
Zaaktype: zaaktype!, Zaaktype: zaaktype!,
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow)); Startdatum: DateOnly.FromDateTime(DateTime.UtcNow),
Identificatie: reference);
var zaakUrl = await gateway.OpenZaakAsync(request); var zaakUrl = await gateway.OpenZaakAsync(request);
@@ -36,10 +38,40 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
new Uri(stack.BaseUrl, "/zaken/api/v1/zaken/").ToString(), new Uri(stack.BaseUrl, "/zaken/api/v1/zaken/").ToString(),
zaakUrl.ToString()); zaakUrl.ToString());
// ...and that zaak is really persisted with the default-filled fields. // ...and that zaak is really persisted with the default-filled fields + the reference identificatie.
var zaak = await stack.GetZaakAsync(zaakUrl); var zaak = await stack.GetZaakAsync(zaakUrl);
Assert.Equal(zaaktype.ToString(), zaak.GetProperty("zaaktype").GetString()); Assert.Equal(zaaktype.ToString(), zaak.GetProperty("zaaktype").GetString());
Assert.Equal("517439943", zaak.GetProperty("bronorganisatie").GetString()); Assert.Equal("517439943", zaak.GetProperty("bronorganisatie").GetString());
Assert.Equal("openbaar", zaak.GetProperty("vertrouwelijkheidaanduiding").GetString()); Assert.Equal("openbaar", zaak.GetProperty("vertrouwelijkheidaanduiding").GetString());
Assert.Equal(reference, zaak.GetProperty("identificatie").GetString());
}
[Fact]
public async Task Setting_a_zaak_to_its_eindstatus_records_the_terminal_statustype()
{
var zaaktype = await stack.FindPublishedBigZaaktypeAsync();
Assert.True(zaaktype is not null,
"No published BIG-REGISTRATIE zaaktype found in OpenZaak — bring the stack up and " +
"seed it with OZ_PUBLISH=1 (`make integration` does this).");
var gateway = new OpenZaakGateway(stack.Http, stack.Options);
var zaakUrl = await gateway.OpenZaakAsync(new ZaakRequest(
Bronorganisatie: "517439943",
VerantwoordelijkeOrganisatie: "517439943",
Vertrouwelijkheidaanduiding: "openbaar",
Zaaktype: zaaktype!,
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow),
Identificatie: Guid.NewGuid().ToString()));
await gateway.SetZaakToEindstatusAsync(zaakUrl, zaaktype!, DateOnly.FromDateTime(DateTime.UtcNow));
// The zaak now carries a current status, and it is the zaaktype's eindstatus.
var zaak = await stack.GetZaakAsync(zaakUrl);
var statusUrl = zaak.GetProperty("status").GetString();
Assert.False(string.IsNullOrEmpty(statusUrl), "the approved zaak has no current status");
var status = await stack.GetJsonAsync(new Uri(statusUrl!));
var eindstatustype = await stack.FindEindstatustypeAsync(zaaktype!);
Assert.Equal(eindstatustype.ToString(), status.GetProperty("statustype").GetString());
} }
} }

View File

@@ -9,13 +9,37 @@ public class AclServiceTests
public ZaakRequest? Captured; public ZaakRequest? Captured;
public Uri Result { get; } = new("http://openzaak/zaken/api/v1/zaken/abc"); public Uri Result { get; } = new("http://openzaak/zaken/api/v1/zaken/abc");
public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Approved;
public Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default) public Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
{ {
Captured = request; Captured = request;
return Task.FromResult(Result); return Task.FromResult(Result);
} }
public Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default)
{
Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet);
return Task.CompletedTask;
}
public Uri? ReadReferenceFor;
public Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
{
ReadReferenceFor = zaakUrl;
return Task.FromResult("REG-FROM-ZAAK");
}
} }
private static AclDefaults Defaults() => new()
{
Bronorganisatie = "517439943",
VerantwoordelijkeOrganisatie = "517439943",
Vertrouwelijkheidaanduiding = "openbaar",
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
};
private sealed class FixedClock(DateOnly today) : IClock private sealed class FixedClock(DateOnly today) : IClock
{ {
public DateOnly Today { get; } = today; public DateOnly Today { get; } = today;
@@ -34,7 +58,7 @@ public class AclServiceTests
}; };
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4))); var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
var url = await service.OpenZaakAsync(new DomainRegistration("123456782")); var url = await service.OpenZaakAsync(new DomainRegistration("123456782", "reg-77"));
Assert.Equal(gateway.Result, url); Assert.Equal(gateway.Result, url);
var req = Assert.IsType<ZaakRequest>(gateway.Captured); var req = Assert.IsType<ZaakRequest>(gateway.Captured);
@@ -43,6 +67,8 @@ public class AclServiceTests
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding); Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding);
Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype); Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype);
Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum); Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum);
// The registration reference becomes the zaak identificatie (#78).
Assert.Equal("reg-77", req.Identificatie);
} }
[Fact] [Fact]
@@ -61,4 +87,53 @@ public class AclServiceTests
await Assert.ThrowsAsync<ArgumentNullException>(() => service.OpenZaakAsync(null!)); await Assert.ThrowsAsync<ArgumentNullException>(() => service.OpenZaakAsync(null!));
Assert.Null(gateway.Captured); Assert.Null(gateway.Captured);
} }
[Fact]
public async Task Approving_a_zaak_sets_it_to_its_zaaktypes_eindstatus_dated_today()
{
var gateway = new FakeGateway();
var defaults = Defaults();
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
await service.ApproveZaakAsync(zaak);
Assert.NotNull(gateway.Approved);
Assert.Equal(zaak, gateway.Approved!.Value.Zaak);
Assert.Equal(defaults.ZaaktypeUrl, gateway.Approved.Value.Zaaktype);
Assert.Equal(new DateOnly(2026, 6, 4), gateway.Approved.Value.Datum);
}
[Fact]
public async Task Approving_a_null_zaak_is_rejected_without_touching_the_gateway()
{
var gateway = new FakeGateway();
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!));
Assert.Null(gateway.Approved);
}
[Fact]
public async Task Reading_a_zaak_reference_returns_the_zaaks_identificatie()
{
var gateway = new FakeGateway();
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
var reference = await service.GetZaakReferenceAsync(zaak);
Assert.Equal("REG-FROM-ZAAK", reference);
Assert.Equal(zaak, gateway.ReadReferenceFor);
}
[Fact]
public async Task Reading_a_null_zaak_reference_is_rejected()
{
var gateway = new FakeGateway();
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
await Assert.ThrowsAsync<ArgumentNullException>(() => service.GetZaakReferenceAsync(null!));
Assert.Null(gateway.ReadReferenceFor);
}
} }

View File

@@ -22,7 +22,7 @@ public class OpenZaakGatewayTests
private static ZaakRequest SampleRequest() => new( private static ZaakRequest SampleRequest() => new(
"517439943", "517439943", "openbaar", "517439943", "517439943", "openbaar",
new("http://openzaak/catalogi/api/v1/zaaktypen/big"), new DateOnly(2026, 6, 4)); new("http://openzaak/catalogi/api/v1/zaaktypen/big"), new DateOnly(2026, 6, 4), "REG-REF-1");
private static StubHandler Created(out RequestCapture capture) private static StubHandler Created(out RequestCapture capture)
{ {
@@ -67,6 +67,8 @@ public class OpenZaakGatewayTests
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", capture.Body); Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", capture.Body);
Assert.Contains("\"startdatum\":\"2026-06-04\"", capture.Body); Assert.Contains("\"startdatum\":\"2026-06-04\"", capture.Body);
Assert.Contains("\"zaaktype\":\"http://openzaak/catalogi/api/v1/zaaktypen/big\"", capture.Body); Assert.Contains("\"zaaktype\":\"http://openzaak/catalogi/api/v1/zaaktypen/big\"", capture.Body);
// The registration reference is set as the zaak identificatie (#78).
Assert.Contains("\"identificatie\":\"REG-REF-1\"", capture.Body);
} }
[Fact] [Fact]
@@ -131,8 +133,9 @@ public class OpenZaakGatewayTests
Content = new StringContent("null", Encoding.UTF8, "application/json"), Content = new StringContent("null", Encoding.UTF8, "application/json"),
})); }));
await Assert.ThrowsAsync<InvalidOperationException>( var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => Gateway(handler).OpenZaakAsync(SampleRequest())); () => Gateway(handler).OpenZaakAsync(SampleRequest()));
Assert.Contains("empty zaak response", ex.Message);
} }
[Fact] [Fact]
@@ -144,6 +147,284 @@ public class OpenZaakGatewayTests
() => Gateway(handler).OpenZaakAsync(null!)); () => Gateway(handler).OpenZaakAsync(null!));
} }
// --- SetZaakToEindstatusAsync (approval / S-09b) ---
private const string ZaakUrl = "http://openzaak/zaken/api/v1/zaken/xyz";
private static readonly Uri Zaaktype = new("http://openzaak/catalogi/api/v1/zaaktypen/big");
private sealed class Recorder
{
public List<HttpRequestMessage> Requests { get; } = [];
public List<string?> Bodies { get; } = [];
public List<long?> ContentLengths { get; } = [];
public int IndexOf(string pathContains) =>
Requests.FindIndex(r => r.RequestUri!.ToString().Contains(pathContains));
// The (body, content-length, request) of the single request whose URL contains the segment.
public (string? Body, long? Length, HttpRequestMessage Request) Sent(string pathContains)
{
var i = IndexOf(pathContains);
return (Bodies[i], ContentLengths[i], Requests[i]);
}
}
// Per-route response config for the four calls the approval makes.
private sealed class OzRoutes
{
public string StatustypenJson { get; init; } = StatustypenPage(withEindstatusFlag: true);
public string ResultaattypenJson { get; init; } = """{"results":[{"url":"http://openzaak/catalogi/api/v1/resultaattypen/1"}]}""";
public HttpStatusCode StatustypenStatus { get; init; } = HttpStatusCode.OK;
public HttpStatusCode ResultaattypenStatus { get; init; } = HttpStatusCode.OK;
public HttpStatusCode ResultaatPostStatus { get; init; } = HttpStatusCode.Created;
public HttpStatusCode StatusPostStatus { get; init; } = HttpStatusCode.Created;
}
// Routes the approval's four calls by URL: GET /statustypen, GET /resultaattypen (catalogus),
// then POST /resultaten and POST /statussen (zaken).
private static StubHandler ApprovalStub(Recorder rec, OzRoutes routes) => new(async req =>
{
rec.Requests.Add(req);
// Capture the length BEFORE reading the body (ReadAsStringAsync buffers as a side effect).
rec.ContentLengths.Add(req.Content?.Headers.ContentLength);
rec.Bodies.Add(req.Content is null ? null : await req.Content.ReadAsStringAsync());
var url = req.RequestUri!.ToString();
if (req.Method == HttpMethod.Get && url.Contains("/statustypen"))
return Json(routes.StatustypenStatus, routes.StatustypenJson);
if (req.Method == HttpMethod.Get && url.Contains("/resultaattypen"))
return Json(routes.ResultaattypenStatus, routes.ResultaattypenJson);
if (url.Contains("/resultaten"))
return Json(routes.ResultaatPostStatus, """{"url":"http://openzaak/zaken/api/v1/resultaten/new"}""");
return Json(routes.StatusPostStatus, """{"url":"http://openzaak/zaken/api/v1/statussen/new"}""");
});
private static HttpResponseMessage Json(HttpStatusCode status, string body) =>
new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
// Two statustypen; the eindstatus is flagged on the *lower* volgnummer so the tests prove the
// isEindstatus flag is preferred over "highest volgnummer", not coincidentally equal to it.
private static string StatustypenPage(bool withEindstatusFlag) => JsonSerializer.Serialize(new
{
results = new object[]
{
new { url = "http://openzaak/catalogi/api/v1/statustypen/1", volgnummer = 1, isEindstatus = withEindstatusFlag },
new { url = "http://openzaak/catalogi/api/v1/statustypen/2", volgnummer = 2, isEindstatus = false },
},
});
[Fact]
public async Task Approving_sets_a_resultaat_then_posts_the_flagged_eindstatus_against_the_zaak()
{
var rec = new Recorder();
await Gateway(ApprovalStub(rec, new OzRoutes()))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4));
Assert.Equal(4, rec.Requests.Count);
// Both catalogus queries filter by the zaaktype and carry the bearer.
var statustypenGet = rec.Sent("/statustypen").Request;
Assert.Equal(HttpMethod.Get, statustypenGet.Method);
Assert.Contains(Uri.EscapeDataString(Zaaktype.ToString()), statustypenGet.RequestUri!.ToString());
Assert.Equal("Bearer", statustypenGet.Headers.Authorization!.Scheme);
Assert.Contains(Uri.EscapeDataString(Zaaktype.ToString()), rec.Sent("/resultaattypen").Request.RequestUri!.ToString());
// OpenZaak requires a resultaat before the eindstatus, so /resultaten precedes /statussen.
Assert.True(rec.IndexOf("/resultaten") < rec.IndexOf("/statussen"));
var resultaat = rec.Sent("/resultaten");
Assert.Equal("http://openzaak/zaken/api/v1/resultaten", resultaat.Request.RequestUri!.ToString());
Assert.Equal("Bearer", resultaat.Request.Headers.Authorization!.Scheme);
Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", resultaat.Body);
Assert.Contains("\"resultaattype\":\"http://openzaak/catalogi/api/v1/resultaattypen/1\"", resultaat.Body);
Assert.True(resultaat.Length > 0);
var status = rec.Sent("/statussen");
Assert.Equal("http://openzaak/zaken/api/v1/statussen", status.Request.RequestUri!.ToString());
Assert.Equal("Bearer", status.Request.Headers.Authorization!.Scheme);
Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", status.Body);
// The isEindstatus-flagged statustype (/1) is chosen — even though /2 has a higher volgnummer.
Assert.Contains("\"statustype\":\"http://openzaak/catalogi/api/v1/statustypen/1\"", status.Body);
Assert.Contains("\"datumStatusGezet\":\"2026-06-04T00:00:00Z\"", status.Body);
// Bodies are buffered (Content-Length set), so uwsgi doesn't get a chunked body.
Assert.True(status.Length > 0);
}
[Fact]
public async Task Approving_falls_back_to_the_highest_volgnummer_when_no_eindstatus_is_flagged()
{
var rec = new Recorder();
await Gateway(ApprovalStub(rec, new OzRoutes { StatustypenJson = StatustypenPage(withEindstatusFlag: false) }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4));
// No isEindstatus flag → the highest volgnummer (/2) is chosen.
Assert.Contains("\"statustype\":\"http://openzaak/catalogi/api/v1/statustypen/2\"", rec.Sent("/statussen").Body);
}
[Fact]
public async Task Approving_throws_when_the_zaaktype_has_no_statustypen()
{
var rec = new Recorder();
// A page with no `results` property (Results is null) — the eindstatus cannot be resolved.
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
Gateway(ApprovalStub(rec, new OzRoutes { StatustypenJson = "{}" }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
Assert.Contains("No statustypen found", ex.Message);
// It never posts anything when it cannot resolve the eindstatus.
Assert.Single(rec.Requests);
}
[Fact]
public async Task Approving_throws_when_the_statustypen_response_is_empty()
{
var rec = new Recorder();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
Gateway(ApprovalStub(rec, new OzRoutes { StatustypenJson = "null" }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
Assert.Contains("empty statustypen", ex.Message);
Assert.Single(rec.Requests);
}
[Fact]
public async Task Approving_throws_when_the_statustypen_query_fails()
{
var rec = new Recorder();
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
Gateway(ApprovalStub(rec, new OzRoutes { StatustypenStatus = HttpStatusCode.InternalServerError }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
Assert.Contains("Querying statustypen", ex.Message);
}
[Fact]
public async Task Approving_throws_when_the_resultaattypen_query_fails()
{
var rec = new Recorder();
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenStatus = HttpStatusCode.InternalServerError }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
Assert.Contains("Querying resultaattypen", ex.Message);
Assert.Equal(-1, rec.IndexOf("/resultaten"));
}
[Fact]
public async Task Approving_throws_when_the_zaaktype_has_no_resultaattype()
{
var rec = new Recorder();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenJson = "{}" }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
Assert.Contains("No resultaattypen found", ex.Message);
// Resolved the eindstatus + queried resultaattypen, but posted nothing.
Assert.Equal(-1, rec.IndexOf("/resultaten"));
Assert.Equal(-1, rec.IndexOf("/statussen"));
}
[Fact]
public async Task Approving_throws_when_posting_the_resultaat_fails()
{
var rec = new Recorder();
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
Gateway(ApprovalStub(rec, new OzRoutes { ResultaatPostStatus = HttpStatusCode.BadRequest }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
Assert.Contains("Setting the zaak resultaat", ex.Message);
// The status is never posted if the resultaat could not be recorded.
Assert.Equal(-1, rec.IndexOf("/statussen"));
}
[Fact]
public async Task Approving_throws_when_posting_the_status_fails()
{
var rec = new Recorder();
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
Gateway(ApprovalStub(rec, new OzRoutes { StatusPostStatus = HttpStatusCode.BadRequest }))
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
Assert.Contains("Setting the zaak status", ex.Message);
// It got as far as the resultaat + the status POST (4 calls) before failing.
Assert.Equal(4, rec.Requests.Count);
}
[Fact]
public async Task Reading_a_zaak_returns_its_identificatie_with_bearer_and_crs()
{
RequestCapture? capture = null;
var handler = new StubHandler(req =>
{
capture = new RequestCapture { Seen = req };
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(new { identificatie = "REG-XYZ", url = ZaakUrl }),
});
});
var reference = await Gateway(handler).GetZaakIdentificatieAsync(new Uri(ZaakUrl));
Assert.Equal("REG-XYZ", reference);
Assert.Equal(HttpMethod.Get, capture!.Seen!.Method);
Assert.Equal(ZaakUrl, capture.Seen.RequestUri!.ToString());
Assert.Equal("Bearer", capture.Seen.Headers.Authorization!.Scheme);
Assert.Equal("EPSG:4326", Assert.Single(capture.Seen.Headers.GetValues("Accept-Crs")));
}
[Fact]
public async Task Reading_a_zaak_throws_when_openzaak_rejects_it()
{
var handler = new StubHandler(_ =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("nope") }));
var ex = await Assert.ThrowsAsync<HttpRequestException>(
() => Gateway(handler).GetZaakIdentificatieAsync(new Uri(ZaakUrl)));
Assert.Contains("Reading the zaak", ex.Message);
}
[Fact]
public async Task Reading_a_zaak_throws_when_openzaak_returns_an_empty_body()
{
var handler = new StubHandler(_ =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("null", System.Text.Encoding.UTF8, "application/json"),
}));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => Gateway(handler).GetZaakIdentificatieAsync(new Uri(ZaakUrl)));
Assert.Contains("empty zaak response", ex.Message);
}
[Fact]
public async Task Reading_a_null_zaak_is_rejected()
{
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
await Assert.ThrowsAsync<ArgumentNullException>(() => Gateway(handler).GetZaakIdentificatieAsync(null!));
}
[Fact]
public async Task Approving_rejects_a_null_zaak_or_zaaktype()
{
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
await Assert.ThrowsAsync<ArgumentNullException>(() =>
Gateway(handler).SetZaakToEindstatusAsync(null!, Zaaktype, new DateOnly(2026, 6, 4)));
await Assert.ThrowsAsync<ArgumentNullException>(() =>
Gateway(handler).SetZaakToEindstatusAsync(new Uri(ZaakUrl), null!, new DateOnly(2026, 6, 4)));
}
// ZGW tokens are base64url with padding stripped (ZgwToken.B64Url); restore it to decode. // ZGW tokens are base64url with padding stripped (ZgwToken.B64Url); restore it to decode.
private static string DecodeSegment(string segment) private static string DecodeSegment(string segment)
{ {

View File

@@ -6,11 +6,12 @@ namespace Bff.Api;
public sealed record SubmitAccepted(string RegistrationId, string Status); public sealed record SubmitAccepted(string RegistrationId, string Status);
/// <summary>A projection row as the projection-api serves it. <c>Bsn</c>/<c>NaamPlaceholder</c> are /// <summary>A projection row as the projection-api serves it. <c>Bsn</c>/<c>NaamPlaceholder</c> are
/// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09).</summary> /// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09).
public sealed record ProjectionEntry(string Id, string Status, string? Bsn, string? NaamPlaceholder); /// <c>Reference</c> is the public-safe citizen reference (the zaak identificatie, #78).</summary>
public sealed record ProjectionEntry(string Id, string Status, string? Reference, string? Bsn, string? NaamPlaceholder);
/// <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); public sealed record OpenbaarEntry(string Id, string Status, string? Reference);
/// <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

View File

@@ -11,8 +11,10 @@ public static class OpenbaarProjection
{ {
var filtered = string.IsNullOrWhiteSpace(q) var filtered = string.IsNullOrWhiteSpace(q)
? entries ? entries
: entries.Where(e => e.Id.Contains(q, StringComparison.OrdinalIgnoreCase)); : entries.Where(e =>
e.Id.Contains(q, StringComparison.OrdinalIgnoreCase) ||
(e.Reference?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false));
return [.. filtered.Select(e => new OpenbaarEntry(e.Id, e.Status))]; return [.. filtered.Select(e => new OpenbaarEntry(e.Id, e.Status, e.Reference))];
} }
} }

View File

@@ -10,7 +10,7 @@ public class OpenbaarEndpointTests
public async Task Serves_public_safe_rows_anonymously() public async Task Serves_public_safe_rows_anonymously()
{ {
using var factory = new BffFactory(); using var factory = new BffFactory();
factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", "123456782", "Jan")); factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", "REG-abc", "123456782", "Jan"));
// No Authorization header — the openbaar register is a public lookup (ADR-0010/S-09). // No Authorization header — the openbaar register is a public lookup (ADR-0010/S-09).
var response = await factory.CreateClient().GetAsync("/openbaar/register"); var response = await factory.CreateClient().GetAsync("/openbaar/register");
@@ -19,7 +19,9 @@ public class OpenbaarEndpointTests
var body = await response.Content.ReadAsStringAsync(); var body = await response.Content.ReadAsStringAsync();
Assert.Contains("abc-111", body); Assert.Contains("abc-111", body);
Assert.Contains("INGEDIEND", body); Assert.Contains("INGEDIEND", body);
// The bsn must never appear in a public response. // The public reference is surfaced (matches the submit confirmation, #78)...
Assert.Contains("REG-abc", body);
// ...but the bsn must never appear in a public response.
Assert.DoesNotContain("123456782", body); Assert.DoesNotContain("123456782", body);
} }
@@ -27,8 +29,8 @@ public class OpenbaarEndpointTests
public async Task Filters_by_the_query_parameter() public async Task Filters_by_the_query_parameter()
{ {
using var factory = new BffFactory(); using var factory = new BffFactory();
factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", null, null)); factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", "REG-abc", null, null));
factory.Projection.Entries.Add(new ProjectionEntry("def-222", "INGEDIEND", null, null)); factory.Projection.Entries.Add(new ProjectionEntry("def-222", "INGEDIEND", "REG-def", null, null));
var rows = await factory.CreateClient() var rows = await factory.CreateClient()
.GetFromJsonAsync<List<OpenbaarEntry>>("/openbaar/register?q=abc"); .GetFromJsonAsync<List<OpenbaarEntry>>("/openbaar/register?q=abc");

View File

@@ -6,8 +6,8 @@ public class OpenbaarProjectionTests
{ {
private static readonly ProjectionEntry[] Sample = private static readonly ProjectionEntry[] Sample =
[ [
new("abc-111", "INGEDIEND", "123456782", "Jan"), new("abc-111", "INGEDIEND", Reference: "REG-A", Bsn: "123456782", NaamPlaceholder: "Jan"),
new("def-222", "INGEDIEND", "987654321", "Piet"), new("def-222", "INGESCHREVEN", Reference: "REG-B", Bsn: "987654321", NaamPlaceholder: "Piet"),
]; ];
[Fact] [Fact]
@@ -18,31 +18,47 @@ public class OpenbaarProjectionTests
Assert.Equal(2, view.Count); Assert.Equal(2, view.Count);
Assert.Equal("abc-111", view[0].Id); Assert.Equal("abc-111", view[0].Id);
Assert.Equal("INGEDIEND", view[0].Status); Assert.Equal("INGEDIEND", view[0].Status);
// The public reference (zaak identificatie) is surfaced so it matches the submit confirmation (#78).
Assert.Equal("REG-A", view[0].Reference);
} }
[Fact] [Fact]
public void Public_view_exposes_only_id_and_status() public void Public_view_exposes_only_id_status_and_reference()
{ {
// OpenbaarEntry structurally carries only Id + Status — bsn/naam can never leak. // OpenbaarEntry structurally carries only public-safe fields — bsn/naam can never leak.
var props = typeof(OpenbaarEntry).GetProperties().Select(p => p.Name).ToArray(); var props = typeof(OpenbaarEntry).GetProperties().Select(p => p.Name).ToArray();
Assert.Equal(["Id", "Status"], props); Assert.Equal(["Id", "Status", "Reference"], props);
} }
[Theory] [Theory]
[InlineData("abc", 1)] [InlineData("abc", 1)] // by id
[InlineData("ABC", 1)] [InlineData("ABC", 1)] // by id, case-insensitive
[InlineData("2", 1)] [InlineData("def", 1)] // by id
[InlineData("zzz", 0)] [InlineData("zzz", 0)] // no match
public void Filters_by_id_containing_the_query_case_insensitively(string q, int expected) public void Filters_by_id_containing_the_query_case_insensitively(string q, int expected)
{ => Assert.Equal(expected, OpenbaarProjection.PublicView(Sample, q).Count);
var view = OpenbaarProjection.PublicView(Sample, q);
Assert.Equal(expected, view.Count); [Theory]
} [InlineData("REG-A", 1)] // by reference — the citizen's confirmation reference
[InlineData("reg-a", 1)] // by reference, case-insensitive
[InlineData("REG", 2)] // both share the prefix
public void Filters_by_reference_containing_the_query_case_insensitively(string q, int expected)
=> Assert.Equal(expected, OpenbaarProjection.PublicView(Sample, q).Count);
[Fact] [Fact]
public void Blank_query_is_treated_as_no_filter() public void Blank_query_is_treated_as_no_filter()
{ {
Assert.Equal(2, OpenbaarProjection.PublicView(Sample, " ").Count); Assert.Equal(2, OpenbaarProjection.PublicView(Sample, " ").Count);
} }
[Fact]
public void A_row_without_a_reference_never_matches_a_query()
{
// Older rows can have a null reference (the column is additive, #78/ADR-0012). A search must
// simply not match them — it must not throw and must not treat "no reference" as a match.
var entries = new[] { new ProjectionEntry("xyz-999", "INGEDIEND", Reference: null, Bsn: null, NaamPlaceholder: null) };
Assert.Empty(OpenbaarProjection.PublicView(entries, "REG"));
Assert.Equal("xyz-999", Assert.Single(OpenbaarProjection.PublicView(entries, "xyz")).Id);
}
} }

View File

@@ -67,7 +67,8 @@
"OpenbaarEntry": { "OpenbaarEntry": {
"required": [ "required": [
"id", "id",
"status" "status",
"reference"
], ],
"type": "object", "type": "object",
"properties": { "properties": {
@@ -76,6 +77,12 @@
}, },
"status": { "status": {
"type": "string" "type": "string"
},
"reference": {
"type": [
"null",
"string"
]
} }
} }
}, },
@@ -101,4 +108,4 @@
"name": "Bff.Api" "name": "Bff.Api"
} }
] ]
} }

View File

@@ -23,6 +23,7 @@ builder.Services.AddTransient<IExternalWorkerClient>(sp => sp.GetRequiredService
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<OpenZaakWorker>(); builder.Services.AddScoped<OpenZaakWorker>();
builder.Services.AddScoped<OpenZaakJobProcessor>(); builder.Services.AddScoped<OpenZaakJobProcessor>();
@@ -42,6 +43,18 @@ app.MapPost("/registrations", async (SubmitRegistrationRequest body, SubmitRegis
return Results.Accepted($"/registrations/{id}", new RegistrationResponse(id.ToString(), RegistrationStatus.Ingediend.ToString(), null)); return Results.Accepted($"/registrations/{id}", new RegistrationResponse(id.ToString(), RegistrationStatus.Ingediend.ToString(), null));
}); });
// Temporary admin endpoint (S-09b): approve a registration — the behandelaar's decision, until the
// behandel-portal exists (S-12). The zaak's final status is set via the ACL, which flows back over
// NRC to the projection, making the entry publicly visible as INGESCHREVEN. Idempotent.
app.MapPost("/registrations/{id}/approve", async (string id, ApproveRegistration approve, CancellationToken ct) =>
{
if (!Guid.TryParse(id, out var guid))
return Results.NotFound();
await approve.HandleAsync(new ApproveRegistrationCommand(new RegistrationId(guid)), ct);
return Results.NoContent();
});
// 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) =>
{ {

View File

@@ -0,0 +1,36 @@
using Big.Domain;
namespace Big.Application;
/// <summary>A behandelaar's decision to approve a registration, in domain language.</summary>
public sealed record ApproveRegistrationCommand(RegistrationId RegistrationId);
/// <summary>
/// The approve use case (S-09b): set the registration's zaak to its final status via the ACL (§8.1),
/// then advance the aggregate to INGESCHREVEN. Idempotent — a redelivered or repeated approval of an
/// already-approved registration is a no-op, so the ACL is not asked to set the status twice. The
/// zaak status is the projection's source of truth (it flows back over NRC); the aggregate transition
/// keeps the domain's own view consistent.
/// </summary>
public sealed class ApproveRegistration(IRegistrationStore store, IAclClient acl)
{
public async Task HandleAsync(ApproveRegistrationCommand command, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(command);
var registration = await store.GetAsync(command.RegistrationId, ct)
?? throw new InvalidOperationException($"No registration {command.RegistrationId} to approve.");
// 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();
await store.SaveAsync(registration, ct);
}
}

View File

@@ -28,7 +28,7 @@ public sealed class OpenZaakWorker(IRegistrationStore store, IAclClient acl)
if (registration.ZaakUrl is not null) if (registration.ZaakUrl is not null)
return registration.ZaakUrl; return registration.ZaakUrl;
var zaakUrl = await acl.OpenZaakAsync(registration.Bsn, ct); var zaakUrl = await acl.OpenZaakAsync(registration.Bsn, registration.Id.ToString(), ct);
registration.AttachZaak(zaakUrl); registration.AttachZaak(zaakUrl);
await store.SaveAsync(registration, ct); await store.SaveAsync(registration, ct);
return zaakUrl; return zaakUrl;

View File

@@ -24,7 +24,16 @@ public interface IWorkflowClient
/// </summary> /// </summary>
public interface IAclClient public interface IAclClient
{ {
Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default); /// <summary>Open a zaak for the registration. <paramref name="reference"/> is the registration's
/// own reference (its id); the ACL records it as the zaak's identificatie so the openbaar register
/// can show the same reference the zorgprofessional was given (S-09b follow-up, adr-proposal #78).</summary>
Task<Uri> OpenZaakAsync(string bsn, string reference, CancellationToken ct = default);
/// <summary>
/// Record the approval on the given zaak. The ACL translates this to the ZGW concept — setting
/// the zaak's final status — which OpenZaak notifies over NRC; the domain never names statustypen.
/// </summary>
Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default);
} }
/// <summary> /// <summary>

View File

@@ -64,4 +64,23 @@ public sealed class Registration
ZaakUrl = zaakUrl; ZaakUrl = zaakUrl;
} }
/// <summary>
/// Approve the registration — the behandelaar's decision to enter it in the register. Advances
/// <see cref="RegistrationStatus.Ingediend"/> → <see cref="RegistrationStatus.Ingeschreven"/>.
/// Requires an opened zaak (the approval sets that zaak's status via the ACL), and only a
/// submitted registration can be approved: re-approving is rejected as an invalid transition.
/// </summary>
public void Approve()
{
if (ZaakUrl is null)
throw new InvalidOperationException(
$"Registration {Id} has no zaak yet; it cannot be approved before its zaak is opened.");
if (Status != RegistrationStatus.Ingediend)
throw new InvalidOperationException(
$"Registration {Id} is {Status}; only an INGEDIEND registration can be approved.");
Status = RegistrationStatus.Ingeschreven;
}
} }

View File

@@ -1,10 +1,13 @@
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. The walking
/// skeleton knows only <see cref="Ingediend"/>; withdrawal, beoordeling and herregistratie /// skeleton knows <see cref="Ingediend"/> and the terminal <see cref="Ingeschreven"/>; withdrawal,
/// states arrive in their own slices (Iteration 2+).</summary> /// beoordeling and herregistratie states arrive in their own slices (Iteration 2+).</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>
Ingeschreven,
} }

View File

@@ -11,10 +11,10 @@ namespace Big.Infrastructure;
/// </summary> /// </summary>
public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclClient public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclClient
{ {
public async Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default) public async Task<Uri> OpenZaakAsync(string bsn, string reference, CancellationToken ct = default)
{ {
using var response = await http.PostAsJsonAsync( using var response = await http.PostAsJsonAsync(
new Uri(options.BaseUrl, "zaken"), new OpenZaakRequest(bsn), ct); new Uri(options.BaseUrl, "zaken"), new OpenZaakRequest(bsn, reference), ct);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var opened = await response.Content.ReadFromJsonAsync<OpenZaakResponse>(ct) var opened = await response.Content.ReadFromJsonAsync<OpenZaakResponse>(ct)
@@ -22,7 +22,20 @@ public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclCli
return new Uri(opened.ZaakUrl); return new Uri(opened.ZaakUrl);
} }
private sealed record OpenZaakRequest([property: JsonPropertyName("bsn")] string Bsn); public async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
using var response = await http.PostAsJsonAsync(
new Uri(options.BaseUrl, "statussen"), new SetStatusRequest(zaakUrl.ToString()), ct);
response.EnsureSuccessStatusCode();
}
private sealed record OpenZaakRequest(
[property: JsonPropertyName("bsn")] string Bsn,
[property: JsonPropertyName("reference")] string Reference);
private sealed record OpenZaakResponse([property: JsonPropertyName("zaakUrl")] string ZaakUrl); private sealed record OpenZaakResponse([property: JsonPropertyName("zaakUrl")] string ZaakUrl);
private sealed record SetStatusRequest([property: JsonPropertyName("zaakUrl")] string ZaakUrl);
} }

View File

@@ -15,12 +15,13 @@ public class AclHttpClientTests
var client = Client(capture.Responds(HttpStatusCode.OK, var client = Client(capture.Responds(HttpStatusCode.OK,
"""{"zaakUrl":"http://openzaak/zaken/api/v1/zaken/abc"}""")); """{"zaakUrl":"http://openzaak/zaken/api/v1/zaken/abc"}"""));
var url = await client.OpenZaakAsync("123456782"); var url = await client.OpenZaakAsync("123456782", "reg-42");
Assert.Equal("http://openzaak/zaken/api/v1/zaken/abc", url.ToString()); Assert.Equal("http://openzaak/zaken/api/v1/zaken/abc", url.ToString());
Assert.Equal(HttpMethod.Post, capture.Seen!.Method); Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://acl/zaken", capture.Seen.RequestUri!.ToString()); Assert.Equal("http://acl/zaken", capture.Seen.RequestUri!.ToString());
Assert.Contains("\"bsn\":\"123456782\"", capture.Body); Assert.Contains("\"bsn\":\"123456782\"", capture.Body);
Assert.Contains("\"reference\":\"reg-42\"", capture.Body);
} }
[Fact] [Fact]
@@ -29,7 +30,7 @@ public class AclHttpClientTests
var capture = new RequestCapture(); var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.BadGateway)); var client = Client(capture.Responds(HttpStatusCode.BadGateway));
await Assert.ThrowsAsync<HttpRequestException>(() => client.OpenZaakAsync("123456782")); await Assert.ThrowsAsync<HttpRequestException>(() => client.OpenZaakAsync("123456782", "reg-1"));
} }
[Fact] [Fact]
@@ -38,7 +39,40 @@ public class AclHttpClientTests
var capture = new RequestCapture(); var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK, "null")); var client = Client(capture.Responds(HttpStatusCode.OK, "null"));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => client.OpenZaakAsync("123456782")); var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => client.OpenZaakAsync("123456782", "reg-1"));
Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase);
} }
[Fact]
public async Task Approves_a_zaak_by_posting_its_url_to_statussen()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.NoContent));
await client.ApproveZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://acl/statussen", capture.Seen.RequestUri!.ToString());
Assert.Contains("\"zaakUrl\":\"http://openzaak/zaken/api/v1/zaken/abc\"", capture.Body);
}
[Fact]
public async Task Approve_throws_when_the_acl_rejects_the_request()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.BadGateway));
await Assert.ThrowsAsync<HttpRequestException>(
() => client.ApproveZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc")));
}
[Fact]
public async Task Approve_rejects_a_null_zaak_url_without_sending_a_request()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.NoContent));
await Assert.ThrowsAsync<ArgumentNullException>(() => client.ApproveZaakAsync(null!));
Assert.Null(capture.Seen);
}
} }

View File

@@ -0,0 +1,89 @@
using Big.Application;
using Big.Domain;
namespace Big.Tests;
public class ApproveRegistrationTests
{
private static Registration WithZaak(string bsn = "123456782")
{
var registration = Registration.Submit(bsn);
registration.AttachZaak(FakeAclClient.DefaultZaakUrl);
return registration;
}
[Fact]
public async Task Approving_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 ApproveRegistration(store, acl);
await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id));
var saved = await store.GetAsync(registration.Id);
Assert.Equal(RegistrationStatus.Ingeschreven, saved!.Status);
Assert.Equal(FakeAclClient.DefaultZaakUrl, acl.ApprovedZaakUrl);
Assert.Equal(1, acl.ApproveCallCount);
// The approved aggregate is persisted (not just mutated in memory).
Assert.Equal(1, store.SaveCount);
}
[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 ApproveRegistration(store, acl);
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
Assert.Equal(0, acl.ApproveCallCount);
}
[Fact]
public async Task Approving_an_unknown_registration_throws_and_does_not_call_the_acl()
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var handler = new ApproveRegistration(store, acl);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => handler.HandleAsync(new ApproveRegistrationCommand(RegistrationId.New())));
Assert.Contains("No registration", ex.Message);
Assert.Equal(0, acl.ApproveCallCount);
}
[Fact]
public async Task Approving_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 ApproveRegistration(store, acl);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => handler.HandleAsync(new ApproveRegistrationCommand(registration.Id)));
Assert.Contains("no zaak", ex.Message);
Assert.Equal(0, acl.ApproveCallCount);
}
[Fact]
public async Task Re_approving_an_already_ingeschreven_registration_is_idempotent()
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var registration = WithZaak();
store.Seed(registration);
var handler = new ApproveRegistration(store, acl);
await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id));
await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id));
// The second approval is a no-op: the ACL is not asked to set the status again.
Assert.Equal(1, acl.ApproveCallCount);
Assert.Equal(RegistrationStatus.Ingeschreven, (await store.GetAsync(registration.Id))!.Status);
}
}

View File

@@ -50,12 +50,24 @@ internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient
private readonly Uri _zaakUrl = zaakUrl ?? DefaultZaakUrl; private readonly Uri _zaakUrl = zaakUrl ?? DefaultZaakUrl;
public string? OpenedForBsn { get; private set; } public string? OpenedForBsn { get; private set; }
public string? OpenedWithReference { get; private set; }
public int CallCount { get; private set; } public int CallCount { get; private set; }
public Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default) public Uri? ApprovedZaakUrl { get; private set; }
public int ApproveCallCount { get; private set; }
public Task<Uri> OpenZaakAsync(string bsn, string reference, CancellationToken ct = default)
{ {
CallCount++; CallCount++;
OpenedForBsn = bsn; OpenedForBsn = bsn;
OpenedWithReference = reference;
return Task.FromResult(_zaakUrl); return Task.FromResult(_zaakUrl);
} }
public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
{
ApproveCallCount++;
ApprovedZaakUrl = zaakUrl;
return Task.CompletedTask;
}
} }

View File

@@ -25,6 +25,9 @@ public class OpenZaakWorkerTests
Assert.Equal(FakeAclClient.DefaultZaakUrl, zaakUrl); Assert.Equal(FakeAclClient.DefaultZaakUrl, zaakUrl);
Assert.Equal("123456782", acl.OpenedForBsn); Assert.Equal("123456782", acl.OpenedForBsn);
// The registration's own id is handed to the ACL as the zaak reference (identificatie),
// so the openbaar register can show the reference the zorgprofessional received (#78).
Assert.Equal(registration.Id.ToString(), acl.OpenedWithReference);
var saved = await store.GetAsync(registration.Id); var saved = await store.GetAsync(registration.Id);
Assert.Equal(FakeAclClient.DefaultZaakUrl, saved!.ZaakUrl); Assert.Equal(FakeAclClient.DefaultZaakUrl, saved!.ZaakUrl);
Assert.Equal(1, store.SaveCount); Assert.Equal(1, store.SaveCount);

View File

@@ -87,4 +87,38 @@ public class RegistrationTests
Assert.Contains("/zaken/abc", ex.Message); Assert.Contains("/zaken/abc", ex.Message);
Assert.Contains("/zaken/other", ex.Message); Assert.Contains("/zaken/other", ex.Message);
} }
[Fact]
public void Approving_a_registration_with_a_zaak_sets_it_ingeschreven()
{
var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve();
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
}
[Fact]
public void Approving_before_the_zaak_is_opened_is_rejected()
{
var registration = Registration.Submit("123456782");
var ex = Assert.Throws<InvalidOperationException>(() => registration.Approve());
Assert.Contains("no zaak", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal(RegistrationStatus.Ingediend, registration.Status);
}
[Fact]
public void Approving_an_already_ingeschreven_registration_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.Approve());
Assert.Contains("only an INGEDIEND", ex.Message);
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
}
} }

View File

@@ -0,0 +1,30 @@
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using EventSubscriber.Application;
namespace EventSubscriber.Api;
/// <summary>
/// HTTP client to the ACL service. The subscriber enriches the projection with the zaak's reference
/// (identificatie) by asking the ACL — the only code that may read ZGW (§8.1) — rather than reading
/// OpenZaak itself (adr-proposal #78).
/// </summary>
public sealed class AclHttpClient(HttpClient http) : IAclClient
{
public async Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
using var response = await http.PostAsJsonAsync(
new Uri(http.BaseAddress!, "zaken/reference"), new ReferenceRequest(zaakUrl.ToString()), ct);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadFromJsonAsync<ReferenceResponse>(ct)
?? throw new InvalidOperationException("The ACL returned an empty reference response.");
return body.Reference;
}
private sealed record ReferenceRequest([property: JsonPropertyName("zaakUrl")] string ZaakUrl);
private sealed record ReferenceResponse([property: JsonPropertyName("reference")] string Reference);
}

View File

@@ -11,9 +11,15 @@ var connectionString = builder.Configuration.GetConnectionString("Projection")
// this value (ADR-0007), so the webhook enforces it. // this value (ADR-0007), so the webhook enforces it.
var webhookToken = builder.Configuration["EventSubscriber:Webhook:AuthToken"] var webhookToken = builder.Configuration["EventSubscriber:Webhook:AuthToken"]
?? throw new InvalidOperationException("Missing configuration 'EventSubscriber:Webhook:AuthToken'"); ?? throw new InvalidOperationException("Missing configuration 'EventSubscriber:Webhook:AuthToken'");
// The ACL is the only code that may read ZGW (§8.1); the subscriber enriches the projection with the
// zaak reference through it (adr-proposal #78).
var aclBaseUrl = builder.Configuration["Acl:BaseUrl"]
?? throw new InvalidOperationException("Missing configuration 'Acl:BaseUrl'");
builder.Services.AddProjectionReadModel(connectionString); builder.Services.AddProjectionReadModel(connectionString);
builder.Services.AddProjectionWriteSide(); builder.Services.AddProjectionWriteSide();
builder.Services.AddHttpClient<EventSubscriber.Application.IAclClient, EventSubscriber.Api.AclHttpClient>(
c => c.BaseAddress = new Uri(aclBaseUrl));
var app = builder.Build(); var app = builder.Build();
@@ -54,9 +60,9 @@ await app.RunAsync();
/// <summary>The NRC notification body, as Open Notificaties POSTs it. Only the fields the /// <summary>The NRC notification body, as Open Notificaties POSTs it. Only the fields the
/// projection needs are bound; <c>aanmaakdatum</c>/<c>kenmerken</c> are ignored for the minimal slice.</summary> /// projection needs are bound; <c>aanmaakdatum</c>/<c>kenmerken</c> are ignored for the minimal slice.</summary>
public sealed record NotificationDto(string Kanaal, string Resource, string Actie, Uri ResourceUrl) public sealed record NotificationDto(string Kanaal, string Resource, string Actie, Uri ResourceUrl, Uri? HoofdObject = null)
{ {
public Notification ToNotification() => new(Kanaal, Resource, Actie, ResourceUrl); public Notification ToNotification() => new(Kanaal, Resource, Actie, ResourceUrl, HoofdObject);
} }
public partial class Program public partial class Program

View File

@@ -11,14 +11,25 @@ public sealed record Notification(
string Kanaal, string Kanaal,
string Resource, string Resource,
string Actie, string Actie,
Uri ResourceUrl) Uri ResourceUrl,
Uri? HoofdObject = null)
{ {
/// <summary>The only event the walking-skeleton projection reacts to: a zaak being created.</summary> /// <summary>A zaak being created — projected as INGEDIEND.</summary>
public bool IsZaakCreated => public bool IsZaakCreated =>
Kanaal == "zaken" && Resource == "zaak" && Actie == "create"; Kanaal == "zaken" && Resource == "zaak" && Actie == "create";
/// <summary>The zaak UUID — the trailing path segment of the resource URL — used as the projection key.</summary> /// <summary>A status being set on a zaak — the approval, projected as INGESCHREVEN (S-09b). In the
public string ZaakId => ResourceUrl.Segments[^1].Trim('/'); /// walking skeleton the only status ever set after creation is the approval, and the subscriber may
/// not read OpenZaak (§8.1), so any status-create is taken as the approval.</summary>
public bool IsZaakStatusSet =>
Kanaal == "zaken" && Resource == "status" && Actie == "create";
/// <summary>The zaak URL this notification concerns — <c>hoofdObject</c> (the zaak) for a status
/// notification, else the resource URL (which, for a zaak-create, is the zaak).</summary>
public Uri ZaakUrl => HoofdObject ?? ResourceUrl;
/// <summary>The zaak UUID used as the projection key — the trailing segment of <see cref="ZaakUrl"/>.</summary>
public string ZaakId => ZaakUrl.Segments[^1].Trim('/');
/// <summary> /// <summary>
/// A deterministic dedup key. Open Notificaties carries no notification id and may /// A deterministic dedup key. Open Notificaties carries no notification id and may

View File

@@ -5,15 +5,19 @@ namespace EventSubscriber.Application;
/// out-of-order deliveries (CLAUDE.md §8.6): the notification log dedups, and the projection /// out-of-order deliveries (CLAUDE.md §8.6): the notification log dedups, and the projection
/// upsert is idempotent on the zaak id. Rebuilds the projection by replaying the log. /// upsert is idempotent on the zaak id. Rebuilds the projection by replaying the log.
/// </summary> /// </summary>
public sealed class NotificationProjector(INotificationLog log, IProjectionStore store) public sealed class NotificationProjector(INotificationLog log, IProjectionStore store, IAclClient acl)
{ {
/// <summary>Handle one inbound notification. Ignores anything that is not a zaak-created event.</summary> /// <summary>Handle one inbound notification. Reacts to a zaak being created (INGEDIEND) and a
/// status being set (INGESCHREVEN); ignores everything else. Enriches the row with the zaak's
/// reference via the ACL (§8.1) and records it so a rebuild needs no ZGW access (#78).</summary>
public async Task HandleAsync(Notification notification, CancellationToken ct = default) public async Task HandleAsync(Notification notification, CancellationToken ct = default)
{ {
if (!notification.IsZaakCreated) if (!notification.IsZaakCreated && !notification.IsZaakStatusSet)
return; return;
var recorded = new RecordedNotification(notification.IdempotencyKey, notification.Actie, notification.ZaakId); var reference = await acl.GetZaakReferenceAsync(notification.ZaakUrl, ct);
var recorded = new RecordedNotification(
notification.IdempotencyKey, notification.Actie, notification.ZaakId, notification.Resource, reference);
// Atomic record-or-skip: a duplicate (or concurrent) delivery is recognised and dropped // Atomic record-or-skip: a duplicate (or concurrent) delivery is recognised and dropped
// before it touches the projection, so the projection stays a faithful derived artefact. // before it touches the projection, so the projection stays a faithful derived artefact.
@@ -31,8 +35,11 @@ public sealed class NotificationProjector(INotificationLog log, IProjectionStore
await store.UpsertAsync(ToEntry(recorded), ct); await store.UpsertAsync(ToEntry(recorded), ct);
} }
/// <summary>The minimal projection row for an accepted notification. A zaak-create maps to /// <summary>The projection row for an accepted notification: a status-set maps to INGESCHREVEN,
/// INGEDIEND; bsn/naam are deferred (ADR-0008).</summary> /// a zaak-create to INGEDIEND. bsn/naam are deferred (ADR-0008).</summary>
private static RegisterEntry ToEntry(RecordedNotification recorded) private static RegisterEntry ToEntry(RecordedNotification recorded)
=> new(recorded.ZaakId, RegistrationStatus.Ingediend); => new(
recorded.ZaakId,
recorded.Resource == "status" ? RegistrationStatus.Ingeschreven : RegistrationStatus.Ingediend,
Reference: recorded.Reference);
} }

View File

@@ -19,8 +19,21 @@ public interface INotificationLog
Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default); Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default);
} }
/// <summary>A notification that has been accepted, retaining what a rebuild needs to recompute its projection row.</summary> /// <summary>A notification that has been accepted, retaining what a rebuild needs to recompute its
public sealed record RecordedNotification(string Key, string Actie, string ZaakId); /// projection row — the ZGW <c>resource</c> (zaak-create → INGEDIEND vs status-set → INGESCHREVEN) and
/// the zaak <c>reference</c> (identificatie), so a rebuild reproduces the row without re-reading ZGW (#78).</summary>
public sealed record RecordedNotification(string Key, string Actie, string ZaakId, string Resource, string? Reference);
/// <summary>
/// Port to the Anti-Corruption Layer. The subscriber enriches the projection with the zaak's
/// public-safe reference (its identificatie) by asking the ACL — the only code that may read ZGW
/// (§8.1) — rather than reading OpenZaak itself (adr-proposal #78).
/// </summary>
public interface IAclClient
{
/// <summary>The zaak's reference (identificatie) for the read projection.</summary>
Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default);
}
/// <summary>The read projection store. Owned by the projection bounded context (ADR-0008); the /// <summary>The read projection store. Owned by the projection bounded context (ADR-0008); the
/// subscriber writes to it and the projection-api reads it.</summary> /// subscriber writes to it and the projection-api reads it.</summary>

View File

@@ -9,6 +9,7 @@ namespace EventSubscriber.Application;
public sealed record RegisterEntry( public sealed record RegisterEntry(
string Id, string Id,
string Status, string Status,
string? Reference = null,
string? Bsn = null, string? Bsn = null,
string? NaamPlaceholder = null); string? NaamPlaceholder = null);
@@ -17,4 +18,7 @@ public static class RegistrationStatus
{ {
/// <summary>A zaak has been created; the registration is submitted.</summary> /// <summary>A zaak has been created; the registration is submitted.</summary>
public const string Ingediend = "INGEDIEND"; public const string Ingediend = "INGEDIEND";
/// <summary>The registration has been approved and entered in the register (S-09b).</summary>
public const string Ingeschreven = "INGESCHREVEN";
} }

View File

@@ -0,0 +1,86 @@
using System.Net;
using System.Text;
using EventSubscriber.Api;
namespace EventSubscriber.Tests;
/// <summary>
/// Unit tests for the subscriber's ACL client, which reads a zaak's reference (identificatie) through
/// the ACL — the only code allowed to talk to ZGW (§8.1, #78). Uses a scripted message handler so no
/// real ACL is required.
/// </summary>
public class AclHttpClientTests
{
private static AclHttpClient Client(StubHandler handler) =>
new(new HttpClient(handler) { BaseAddress = new Uri("http://acl/") });
[Fact]
public async Task Reads_a_zaak_reference_by_posting_the_zaak_url_and_returns_it()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK, """{"reference":"REG-42"}"""));
var reference = await client.GetZaakReferenceAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
Assert.Equal("REG-42", reference);
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://acl/zaken/reference", capture.Seen.RequestUri!.ToString());
Assert.Contains("\"zaakUrl\":\"http://openzaak/zaken/api/v1/zaken/abc\"", capture.Body);
}
[Fact]
public async Task Throws_when_the_acl_rejects_the_request()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.BadGateway));
await Assert.ThrowsAsync<HttpRequestException>(
() => client.GetZaakReferenceAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc")));
}
[Fact]
public async Task Throws_when_the_acl_returns_an_empty_body()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK, "null"));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => client.GetZaakReferenceAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc")));
Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Rejects_a_null_zaak_url_without_sending_a_request()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK, """{"reference":"REG-1"}"""));
await Assert.ThrowsAsync<ArgumentNullException>(() => client.GetZaakReferenceAsync(null!));
Assert.Null(capture.Seen);
}
}
/// <summary>A test double for <see cref="HttpMessageHandler"/> that records the last request and
/// returns a scripted response — mirrors the ACL/domain gateway tests.</summary>
internal sealed class StubHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> onSend) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
=> onSend(request);
}
/// <summary>Captures the request a client sent, including the (buffered) body.</summary>
internal sealed class RequestCapture
{
public HttpRequestMessage? Seen { get; private set; }
public string? Body { get; private set; }
public StubHandler Responds(HttpStatusCode status, string? json = null) => new(async req =>
{
Seen = req;
Body = req.Content is null ? null : await req.Content.ReadAsStringAsync();
var response = new HttpResponseMessage(status);
if (json is not null)
response.Content = new StringContent(json, Encoding.UTF8, "application/json");
return response;
});
}

View File

@@ -19,6 +19,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\EventSubscriber.Api\EventSubscriber.Api.csproj" />
<ProjectReference Include="..\EventSubscriber.Application\EventSubscriber.Application.csproj" /> <ProjectReference Include="..\EventSubscriber.Application\EventSubscriber.Application.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -5,6 +5,19 @@ namespace EventSubscriber.Tests;
/// <summary>In-memory stand-ins for the projection store and notification log, so the /// <summary>In-memory stand-ins for the projection store and notification log, so the
/// projector's behaviour is exercised without Postgres (hand-written stubs, the repo's /// projector's behaviour is exercised without Postgres (hand-written stubs, the repo's
/// convention — no mocking library).</summary> /// convention — no mocking library).</summary>
/// <summary>A fake ACL client that returns a fixed reference derived from the zaak, and records
/// how many times it was called (to prove a rebuild does not re-read via the ACL).</summary>
internal sealed class FakeAclClient : IAclClient
{
public int CallCount { get; private set; }
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
{
CallCount++;
return Task.FromResult("REG-" + zaakUrl.Segments[^1].Trim('/'));
}
}
internal sealed class InMemoryNotificationLog : INotificationLog internal sealed class InMemoryNotificationLog : INotificationLog
{ {
private readonly Dictionary<string, RecordedNotification> _byKey = []; private readonly Dictionary<string, RecordedNotification> _byKey = [];

View File

@@ -8,15 +8,21 @@ namespace EventSubscriber.Tests;
public sealed class NotificationProjectorTests public sealed class NotificationProjectorTests
{ {
private const string ZaakUrl = "http://openzaak:8000/zaken/api/v1/zaken/11111111-1111-1111-1111-111111111111"; private const string ZaakUrl = "http://openzaak:8000/zaken/api/v1/zaken/11111111-1111-1111-1111-111111111111";
private const string StatusUrl = "http://openzaak:8000/zaken/api/v1/statussen/22222222-2222-2222-2222-222222222222";
private readonly InMemoryNotificationLog _log = new(); private readonly InMemoryNotificationLog _log = new();
private readonly InMemoryProjectionStore _store = new(); private readonly InMemoryProjectionStore _store = new();
private readonly FakeAclClient _acl = new();
private NotificationProjector Projector() => new(_log, _store); private NotificationProjector Projector() => new(_log, _store, _acl);
private static Notification ZaakCreated(string url = ZaakUrl) private static Notification ZaakCreated(string url = ZaakUrl)
=> new("zaken", "zaak", "create", new Uri(url)); => new("zaken", "zaak", "create", new Uri(url));
// A status-set notification: resourceUrl is the status resource, hoofdObject is the zaak it belongs to.
private static Notification StatusSet(string zaakUrl = ZaakUrl, string statusUrl = StatusUrl)
=> new("zaken", "status", "create", new Uri(statusUrl), new Uri(zaakUrl));
[Fact] [Fact]
public async Task creating_a_zaak_writes_one_row_with_status_ingediend() public async Task creating_a_zaak_writes_one_row_with_status_ingediend()
{ {
@@ -25,6 +31,23 @@ public sealed class NotificationProjectorTests
var entry = Assert.Single(await _store.AllAsync()); var entry = Assert.Single(await _store.AllAsync());
Assert.Equal("11111111-1111-1111-1111-111111111111", entry.Id); Assert.Equal("11111111-1111-1111-1111-111111111111", entry.Id);
Assert.Equal(RegistrationStatus.Ingediend, entry.Status); Assert.Equal(RegistrationStatus.Ingediend, entry.Status);
// Enriched with the zaak's reference (identificatie), fetched via the ACL (#78).
Assert.Equal("REG-11111111-1111-1111-1111-111111111111", entry.Reference);
}
[Fact]
public async Task rebuild_reproduces_the_reference_without_re_reading_via_the_acl()
{
var projector = Projector();
await projector.HandleAsync(ZaakCreated());
var callsAfterProjection = _acl.CallCount;
await projector.RebuildAsync();
var entry = Assert.Single(await _store.AllAsync());
Assert.Equal("REG-11111111-1111-1111-1111-111111111111", entry.Reference);
// Rebuild replays the log (which stored the reference) — no extra ACL calls (#78, ADR-0008).
Assert.Equal(callsAfterProjection, _acl.CallCount);
} }
[Fact] [Fact]
@@ -62,16 +85,53 @@ public sealed class NotificationProjectorTests
[Theory] [Theory]
[InlineData("documenten", "enkelvoudiginformatieobject", "create")] // wrong kanaal + resource [InlineData("documenten", "enkelvoudiginformatieobject", "create")] // wrong kanaal + resource
[InlineData("documenten", "zaak", "create")] // wrong kanaal only [InlineData("documenten", "zaak", "create")] // wrong kanaal only
[InlineData("zaken", "status", "create")] // wrong resource only
[InlineData("zaken", "zaak", "update")] // wrong actie [InlineData("zaken", "zaak", "update")] // wrong actie
[InlineData("zaken", "zaak", "destroy")] // wrong actie [InlineData("zaken", "zaak", "destroy")] // wrong actie
public async Task only_a_zaken_zaak_create_notification_is_projected(string kanaal, string resource, string actie) [InlineData("zaken", "status", "update")] // a status change we ignore
[InlineData("zaken", "resultaat", "create")] // not a status we project
public async Task an_unrelated_notification_is_not_projected(string kanaal, string resource, string actie)
{ {
await Projector().HandleAsync(new Notification(kanaal, resource, actie, new Uri(ZaakUrl))); await Projector().HandleAsync(new Notification(kanaal, resource, actie, new Uri(ZaakUrl)));
Assert.Empty(await _store.AllAsync()); Assert.Empty(await _store.AllAsync());
} }
[Fact]
public async Task setting_a_status_projects_ingeschreven_keyed_on_the_zaak_not_the_status()
{
await Projector().HandleAsync(StatusSet());
var entry = Assert.Single(await _store.AllAsync());
// Keyed on the zaak (hoofdObject), not the status resource URL.
Assert.Equal("11111111-1111-1111-1111-111111111111", entry.Id);
Assert.Equal(RegistrationStatus.Ingeschreven, entry.Status);
}
[Fact]
public async Task approving_updates_the_existing_zaak_row_from_ingediend_to_ingeschreven()
{
var projector = Projector();
await projector.HandleAsync(ZaakCreated());
await projector.HandleAsync(StatusSet());
var entry = Assert.Single(await _store.AllAsync());
Assert.Equal("11111111-1111-1111-1111-111111111111", entry.Id);
Assert.Equal(RegistrationStatus.Ingeschreven, entry.Status);
}
[Fact]
public async Task rebuild_reproduces_the_approved_status()
{
var projector = Projector();
await projector.HandleAsync(ZaakCreated());
await projector.HandleAsync(StatusSet());
await projector.RebuildAsync();
var entry = Assert.Single(await _store.AllAsync());
Assert.Equal(RegistrationStatus.Ingeschreven, entry.Status);
}
[Fact] [Fact]
public async Task rebuild_clears_stale_rows_and_repopulates_from_the_notification_log() public async Task rebuild_clears_stale_rows_and_repopulates_from_the_notification_log()
{ {

View File

@@ -15,6 +15,8 @@ public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog
Key = notification.Key, Key = notification.Key,
Actie = notification.Actie, Actie = notification.Actie,
ZaakId = notification.ZaakId, ZaakId = notification.ZaakId,
Resource = notification.Resource,
Reference = notification.Reference,
ReceivedAt = DateTimeOffset.UtcNow, ReceivedAt = DateTimeOffset.UtcNow,
}); });
@@ -34,6 +36,6 @@ public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog
public async Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default) public async Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default)
=> await db.ProcessedNotifications => await db.ProcessedNotifications
.OrderBy(r => r.ReceivedAt) .OrderBy(r => r.ReceivedAt)
.Select(r => new RecordedNotification(r.Key, r.Actie, r.ZaakId)) .Select(r => new RecordedNotification(r.Key, r.Actie, r.ZaakId, r.Resource, r.Reference))
.ToListAsync(ct); .ToListAsync(ct);
} }

View File

@@ -15,6 +15,7 @@ public sealed class EfProjectionStore(ProjectionDbContext db) : IProjectionStore
{ {
Id = entry.Id, Id = entry.Id,
Status = entry.Status, Status = entry.Status,
Reference = entry.Reference,
Bsn = entry.Bsn, Bsn = entry.Bsn,
NaamPlaceholder = entry.NaamPlaceholder, NaamPlaceholder = entry.NaamPlaceholder,
}); });
@@ -22,6 +23,7 @@ public sealed class EfProjectionStore(ProjectionDbContext db) : IProjectionStore
else else
{ {
row.Status = entry.Status; row.Status = entry.Status;
row.Reference = entry.Reference;
row.Bsn = entry.Bsn; row.Bsn = entry.Bsn;
row.NaamPlaceholder = entry.NaamPlaceholder; row.NaamPlaceholder = entry.NaamPlaceholder;
} }
@@ -35,6 +37,6 @@ public sealed class EfProjectionStore(ProjectionDbContext db) : IProjectionStore
public async Task<IReadOnlyList<RegisterEntry>> AllAsync(CancellationToken ct = default) public async Task<IReadOnlyList<RegisterEntry>> AllAsync(CancellationToken ct = default)
=> await db.RegisterEntries => await db.RegisterEntries
.OrderBy(r => r.Id) .OrderBy(r => r.Id)
.Select(r => new RegisterEntry(r.Id, r.Status, r.Bsn, r.NaamPlaceholder)) .Select(r => new RegisterEntry(r.Id, r.Status, Reference: r.Reference, Bsn: r.Bsn, NaamPlaceholder: r.NaamPlaceholder))
.ToListAsync(ct); .ToListAsync(ct);
} }

View File

@@ -0,0 +1,84 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Projection.ReadModel;
#nullable disable
namespace Projection.ReadModel.Migrations
{
[DbContext(typeof(ProjectionDbContext))]
[Migration("20260713145944_AddNotificationResource")]
partial class AddNotificationResource
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Projection.ReadModel.ProcessedNotificationRow", b =>
{
b.Property<string>("Key")
.HasColumnType("text")
.HasColumnName("key");
b.Property<string>("Actie")
.IsRequired()
.HasColumnType("text")
.HasColumnName("actie");
b.Property<DateTimeOffset>("ReceivedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("received_at");
b.Property<string>("Resource")
.IsRequired()
.HasColumnType("text")
.HasColumnName("resource");
b.Property<string>("ZaakId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("zaak_id");
b.HasKey("Key");
b.ToTable("processed_notifications", (string)null);
});
modelBuilder.Entity("Projection.ReadModel.RegisterEntryRow", b =>
{
b.Property<string>("Id")
.HasColumnType("text")
.HasColumnName("id");
b.Property<string>("Bsn")
.HasColumnType("text")
.HasColumnName("bsn");
b.Property<string>("NaamPlaceholder")
.HasColumnType("text")
.HasColumnName("naam_placeholder");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.HasKey("Id");
b.ToTable("register_projection", (string)null);
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Projection.ReadModel.Migrations
{
/// <inheritdoc />
public partial class AddNotificationResource : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Pre-existing rows are all zaak-creates (the only notification the projector recorded
// before S-09b), so default the backfill to "zaak" rather than "".
migrationBuilder.AddColumn<string>(
name: "resource",
table: "processed_notifications",
type: "text",
nullable: false,
defaultValue: "zaak");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "resource",
table: "processed_notifications");
}
}
}

View File

@@ -0,0 +1,92 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Projection.ReadModel;
#nullable disable
namespace Projection.ReadModel.Migrations
{
[DbContext(typeof(ProjectionDbContext))]
[Migration("20260714101642_AddReferenceColumns")]
partial class AddReferenceColumns
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Projection.ReadModel.ProcessedNotificationRow", b =>
{
b.Property<string>("Key")
.HasColumnType("text")
.HasColumnName("key");
b.Property<string>("Actie")
.IsRequired()
.HasColumnType("text")
.HasColumnName("actie");
b.Property<DateTimeOffset>("ReceivedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("received_at");
b.Property<string>("Reference")
.HasColumnType("text")
.HasColumnName("reference");
b.Property<string>("Resource")
.IsRequired()
.HasColumnType("text")
.HasColumnName("resource");
b.Property<string>("ZaakId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("zaak_id");
b.HasKey("Key");
b.ToTable("processed_notifications", (string)null);
});
modelBuilder.Entity("Projection.ReadModel.RegisterEntryRow", b =>
{
b.Property<string>("Id")
.HasColumnType("text")
.HasColumnName("id");
b.Property<string>("Bsn")
.HasColumnType("text")
.HasColumnName("bsn");
b.Property<string>("NaamPlaceholder")
.HasColumnType("text")
.HasColumnName("naam_placeholder");
b.Property<string>("Reference")
.HasColumnType("text")
.HasColumnName("reference");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.HasKey("Id");
b.ToTable("register_projection", (string)null);
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Projection.ReadModel.Migrations
{
/// <inheritdoc />
public partial class AddReferenceColumns : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "reference",
table: "register_projection",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "reference",
table: "processed_notifications",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "reference",
table: "register_projection");
migrationBuilder.DropColumn(
name: "reference",
table: "processed_notifications");
}
}
}

View File

@@ -37,6 +37,15 @@ namespace Projection.ReadModel.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("received_at"); .HasColumnName("received_at");
b.Property<string>("Reference")
.HasColumnType("text")
.HasColumnName("reference");
b.Property<string>("Resource")
.IsRequired()
.HasColumnType("text")
.HasColumnName("resource");
b.Property<string>("ZaakId") b.Property<string>("ZaakId")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text")
@@ -61,6 +70,10 @@ namespace Projection.ReadModel.Migrations
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("naam_placeholder"); .HasColumnName("naam_placeholder");
b.Property<string>("Reference")
.HasColumnType("text")
.HasColumnName("reference");
b.Property<string>("Status") b.Property<string>("Status")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text")

View File

@@ -24,6 +24,7 @@ public sealed class ProjectionDbContext(DbContextOptions<ProjectionDbContext> op
e.HasKey(r => r.Id); e.HasKey(r => r.Id);
e.Property(r => r.Id).HasColumnName("id"); e.Property(r => r.Id).HasColumnName("id");
e.Property(r => r.Status).HasColumnName("status").IsRequired(); e.Property(r => r.Status).HasColumnName("status").IsRequired();
e.Property(r => r.Reference).HasColumnName("reference");
e.Property(r => r.Bsn).HasColumnName("bsn"); e.Property(r => r.Bsn).HasColumnName("bsn");
e.Property(r => r.NaamPlaceholder).HasColumnName("naam_placeholder"); e.Property(r => r.NaamPlaceholder).HasColumnName("naam_placeholder");
}); });
@@ -35,6 +36,8 @@ public sealed class ProjectionDbContext(DbContextOptions<ProjectionDbContext> op
e.Property(r => r.Key).HasColumnName("key"); e.Property(r => r.Key).HasColumnName("key");
e.Property(r => r.Actie).HasColumnName("actie").IsRequired(); e.Property(r => r.Actie).HasColumnName("actie").IsRequired();
e.Property(r => r.ZaakId).HasColumnName("zaak_id").IsRequired(); e.Property(r => r.ZaakId).HasColumnName("zaak_id").IsRequired();
e.Property(r => r.Resource).HasColumnName("resource").IsRequired();
e.Property(r => r.Reference).HasColumnName("reference");
e.Property(r => r.ReceivedAt).HasColumnName("received_at"); e.Property(r => r.ReceivedAt).HasColumnName("received_at");
}); });
} }
@@ -45,6 +48,10 @@ public sealed class RegisterEntryRow
{ {
public required string Id { get; set; } public required string Id { get; set; }
public required string Status { get; set; } public required string Status { get; set; }
/// <summary>The citizen-facing reference (the zaak's identificatie) — matches the submit confirmation (#78).</summary>
public string? Reference { get; set; }
public string? Bsn { get; set; } public string? Bsn { get; set; }
public string? NaamPlaceholder { get; set; } public string? NaamPlaceholder { get; set; }
} }
@@ -55,5 +62,13 @@ public sealed class ProcessedNotificationRow
public required string Key { get; set; } public required string Key { get; set; }
public required string Actie { get; set; } public required string Actie { get; set; }
public required string ZaakId { get; set; } public required string ZaakId { get; set; }
/// <summary>The ZGW resource (e.g. <c>zaak</c> or <c>status</c>) — retained so a rebuild reprojects
/// the right status without reading OpenZaak (S-09b).</summary>
public required string Resource { get; set; }
/// <summary>The zaak reference (identificatie), retained so a rebuild reprojects it without the ACL (#78).</summary>
public string? Reference { get; set; }
public DateTimeOffset ReceivedAt { get; set; } public DateTimeOffset ReceivedAt { get; set; }
} }

View File

@@ -19,12 +19,46 @@ public static class ServiceCollectionExtensions
return services; return services;
} }
// A fixed application-scoped key for the migration advisory lock (any stable 64-bit constant).
private const long MigrationAdvisoryLockKey = 727501;
/// <summary>Apply any pending EF migrations. Called once on service start so a fresh stack /// <summary>Apply any pending EF migrations. Called once on service start so a fresh stack
/// reaches a usable schema without a manual migration step (DoD: compose up reaches green).</summary> /// reaches a usable schema without a manual migration step (DoD: compose up reaches green).
///
/// The Event Subscriber and the projection-api share this DB and both migrate on start. EF's
/// migrations-history lock is released between individual migrations, so with more than one pending
/// migration two migrators can interleave and one re-applies a migration the other just did
/// ("column already exists"). Hold a Postgres session <c>pg_advisory_lock</c> across the whole
/// sequence so it runs exactly once; the second migrator then finds nothing pending.</summary>
public static async Task MigrateProjectionAsync(this IServiceProvider services, CancellationToken ct = default) public static async Task MigrateProjectionAsync(this IServiceProvider services, CancellationToken ct = default)
{ {
await using var scope = services.CreateAsyncScope(); await using var scope = services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<ProjectionDbContext>(); var db = scope.ServiceProvider.GetRequiredService<ProjectionDbContext>();
await db.Database.MigrateAsync(ct); var connection = db.Database.GetDbConnection();
await connection.OpenAsync(ct);
try
{
await ExecuteAsync(connection, $"SELECT pg_advisory_lock({MigrationAdvisoryLockKey})", ct);
try
{
await db.Database.MigrateAsync(ct);
}
finally
{
await ExecuteAsync(connection, $"SELECT pg_advisory_unlock({MigrationAdvisoryLockKey})", ct);
}
}
finally
{
await connection.CloseAsync();
}
}
private static async Task ExecuteAsync(System.Data.Common.DbConnection connection, string sql, CancellationToken ct)
{
await using var command = connection.CreateCommand();
command.CommandText = sql;
await command.ExecuteNonQueryAsync(ct);
} }
} }

View File

@@ -10,8 +10,9 @@ builder.Services.AddProjectionReadModel(connectionString);
var app = builder.Build(); var app = builder.Build();
// Ensure the schema exists before serving reads. EF serialises concurrent migrators via the // Ensure the schema exists before serving reads. The Event Subscriber migrates this shared DB too;
// migrations-history lock, so it is safe that the Event Subscriber migrates too. // MigrateProjectionAsync serialises concurrent migrators with a session advisory lock so the whole
// migration sequence runs exactly once (see ServiceCollectionExtensions).
await app.Services.MigrateProjectionAsync(); await app.Services.MigrateProjectionAsync();
app.MapGet("/health", () => "Healthy"); app.MapGet("/health", () => "Healthy");
@@ -22,7 +23,7 @@ app.MapGet("/register", async (ProjectionDbContext db, CancellationToken ct) =>
{ {
var rows = await db.RegisterEntries var rows = await db.RegisterEntries
.OrderBy(r => r.Id) .OrderBy(r => r.Id)
.Select(r => new RegisterEntryDto(r.Id, r.Status, r.Bsn, r.NaamPlaceholder)) .Select(r => new RegisterEntryDto(r.Id, r.Status, r.Reference, r.Bsn, r.NaamPlaceholder))
.ToListAsync(ct); .ToListAsync(ct);
return Results.Ok(rows); return Results.Ok(rows);
}); });
@@ -31,13 +32,13 @@ app.MapGet("/register/{id}", async (string id, ProjectionDbContext db, Cancellat
{ {
var row = await db.RegisterEntries var row = await db.RegisterEntries
.Where(r => r.Id == id) .Where(r => r.Id == id)
.Select(r => new RegisterEntryDto(r.Id, r.Status, r.Bsn, r.NaamPlaceholder)) .Select(r => new RegisterEntryDto(r.Id, r.Status, r.Reference, r.Bsn, r.NaamPlaceholder))
.SingleOrDefaultAsync(ct); .SingleOrDefaultAsync(ct);
return row is null ? Results.NotFound() : Results.Ok(row); return row is null ? Results.NotFound() : Results.Ok(row);
}); });
await app.RunAsync(); await app.RunAsync();
public sealed record RegisterEntryDto(string Id, string Status, string? Bsn, string? NaamPlaceholder); public sealed record RegisterEntryDto(string Id, string Status, string? Reference, string? Bsn, string? NaamPlaceholder);
public partial class Program; public partial class Program;

View File

@@ -31,7 +31,7 @@ public sealed class BffToegangSteps : IDisposable
[Given("the projection contains a zaak \"(.*)\" for BSN \"(.*)\"")] [Given("the projection contains a zaak \"(.*)\" for BSN \"(.*)\"")]
public void GivenTheProjectionContains(string zaakId, string bsn) public void GivenTheProjectionContains(string zaakId, string bsn)
{ {
_host.Projection.Entries.Add(new ProjectionEntry(zaakId, "INGEDIEND", bsn, null)); _host.Projection.Entries.Add(new ProjectionEntry(zaakId, "INGEDIEND", "REG-" + zaakId, bsn, null));
_client = _host.CreateClient(); _client = _host.CreateClient();
} }

View File

@@ -18,7 +18,7 @@ public sealed class EenZaakOpenenSteps
[Given("a domain registration for BSN \"(.*)\"")] [Given("a domain registration for BSN \"(.*)\"")]
public void GivenADomainRegistrationForBsn(string bsn) public void GivenADomainRegistrationForBsn(string bsn)
=> _registration = new DomainRegistration(bsn); => _registration = new DomainRegistration(bsn, "ACC-REF-1");
[Given("the ACL is configured with these defaults:")] [Given("the ACL is configured with these defaults:")]
public void GivenTheAclIsConfiguredWithTheseDefaults(DataTable defaults) public void GivenTheAclIsConfiguredWithTheseDefaults(DataTable defaults)

View File

@@ -17,7 +17,8 @@ public sealed class RegisterProjectieBijwerkenSteps
private readonly NotificationProjector _projector; private readonly NotificationProjector _projector;
private Notification? _notification; private Notification? _notification;
public RegisterProjectieBijwerkenSteps() => _projector = new NotificationProjector(_log, _store); public RegisterProjectieBijwerkenSteps()
=> _projector = new NotificationProjector(_log, _store, new InMemoryAclReferenceClient());
[Given("a zaak is created in OpenZaak with id \"(.*)\"")] [Given("a zaak is created in OpenZaak with id \"(.*)\"")]
public void GivenAZaakIsCreatedInOpenZaakWithId(string id) public void GivenAZaakIsCreatedInOpenZaakWithId(string id)

View File

@@ -26,12 +26,21 @@ public sealed class InMemoryAclClient : IAclClient
public static readonly Uri OpenedZaakUrl = new("http://openzaak/zaken/api/v1/zaken/acc-zaak"); public static readonly Uri OpenedZaakUrl = new("http://openzaak/zaken/api/v1/zaken/acc-zaak");
public string? OpenedForBsn { get; private set; } public string? OpenedForBsn { get; private set; }
public string? OpenedWithReference { get; private set; }
public Uri? ApprovedZaakUrl { get; private set; }
public Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default) public Task<Uri> OpenZaakAsync(string bsn, string reference, CancellationToken ct = default)
{ {
OpenedForBsn = bsn; OpenedForBsn = bsn;
OpenedWithReference = reference;
return Task.FromResult(OpenedZaakUrl); return Task.FromResult(OpenedZaakUrl);
} }
public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
{
ApprovedZaakUrl = zaakUrl;
return Task.CompletedTask;
}
} }
/// <summary>An in-memory registration store for the domain acceptance scenario.</summary> /// <summary>An in-memory registration store for the domain acceptance scenario.</summary>

View File

@@ -38,3 +38,11 @@ public sealed class InMemoryProjectionStore : IProjectionStore
public IReadOnlyList<RegisterEntry> RowsFor(string id) public IReadOnlyList<RegisterEntry> RowsFor(string id)
=> [.. _byId.Values.Where(e => e.Id == id)]; => [.. _byId.Values.Where(e => e.Id == id)];
} }
/// <summary>A fake ACL client for the projection acceptance scenario: returns a reference derived
/// from the zaak, so the projector can enrich rows without a running ACL (#78).</summary>
public sealed class InMemoryAclReferenceClient : IAclClient
{
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
=> Task.FromResult("REG-" + zaakUrl.Segments[^1].Trim('/'));
}

View File

@@ -11,10 +11,20 @@ public sealed class InMemoryZaakGateway : IZaakGateway
public static readonly Uri CreatedZaakUrl = new("http://openzaak/zaken/api/v1/zaken/created-123"); public static readonly Uri CreatedZaakUrl = new("http://openzaak/zaken/api/v1/zaken/created-123");
public ZaakRequest? Captured { get; private set; } public ZaakRequest? Captured { get; private set; }
public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Approved { get; private set; }
public Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default) public Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
{ {
Captured = request; Captured = request;
return Task.FromResult(CreatedZaakUrl); return Task.FromResult(CreatedZaakUrl);
} }
public Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default)
{
Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet);
return Task.CompletedTask;
}
public Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
=> Task.FromResult("ACC-REF-1");
} }

8
tests/e2e/package.json Normal file
View File

@@ -0,0 +1,8 @@
{
"name": "e2e",
"private": true,
"description": "Playwright walking-skeleton e2e (S-08d) — run inside the compose network by infra/run-e2e-check.sh.",
"devDependencies": {
"@playwright/test": "1.61.1"
}
}

View File

@@ -0,0 +1,28 @@
import { defineConfig, devices } from '@playwright/test';
// The e2e runs inside the compose network (infra/run-e2e-check.sh); baseURL defaults to the
// self-service service. Keep timeouts generous — the first navigation triggers the DigiD flow.
const baseURL = process.env.SELF_SERVICE_URL ?? 'http://self-service';
export default defineConfig({
testDir: '.',
timeout: 90_000,
expect: { timeout: 15_000 },
retries: 1,
reporter: [['list']],
use: {
baseURL,
trace: 'on-first-retry',
// The portal is served over plain HTTP on a non-localhost origin (http://self-service) inside the
// compose network, so it is NOT a secure context — and Web Crypto (`crypto.subtle`) is undefined
// there. angular-auth-oidc-client needs SubtleCrypto to build the PKCE code challenge, so
// `authorize()` throws and the login redirect never fires (the login form never appears). In
// production the portal runs behind HTTPS, where this works. Rather than terminate TLS in the
// throwaway e2e stack, tell Chromium to treat this origin as secure — which faithfully emulates
// the production HTTPS context. This flag is only honoured by the full Chromium build (new
// headless), not Playwright's default headless-shell, so pin `channel: 'chromium'`.
channel: 'chromium',
launchOptions: { args: [`--unsafely-treat-insecure-origin-as-secure=${baseURL}`] },
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});

View File

@@ -0,0 +1,67 @@
import { expect, test } from '@playwright/test';
// Walking-skeleton happy path (S-08d + S-09 + S-09b): a zorgprofessional logs in via mock DigiD and
// submits through the self-service portal → BFF → domain; the entry appears in the openbaar register
// as INGEDIEND; a behandelaar approves it via the temporary admin endpoint; the approval flows via the
// ACL → NRC → event-subscriber → projection, and the openbaar register then shows it as INGESCHREVEN.
test('DigiD login → submit → public INGEDIEND → approve → public INGESCHREVEN', async ({ page, request }) => {
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
await page.goto('/');
// Keycloak's default login form (stable ids across themes).
await page.locator('#username').fill('jan-burger');
await page.locator('#password').fill('test123');
await page.locator('#kc-login').click();
// Back on the portal, authenticated.
await expect(page.getByRole('heading', { name: /Zelfservice/i })).toBeVisible();
await page.getByRole('button', { name: /indienen/i }).click();
// The BFF accepted it and the page shows the confirmation with the reference.
const confirmation = page.getByText(/ontvangen/i);
await expect(confirmation).toBeVisible();
const reference = (await confirmation.textContent())?.match(/Referentie:\s*([0-9a-fA-F-]+)/)?.[1];
expect(reference, 'the confirmation shows a registration reference').toBeTruthy();
// 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
// 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 expect(page.getByRole('heading', { name: /Openbaar BIG-register/i })).toBeVisible();
// #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.
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
// behandelaar would until the behandel-portal exists — S-12). The zaak is opened off the request
// path by the worker, so wait for it before approving.
await expect
.poll(async () => {
const res = await request.get(`http://domain:8080/registrations/${reference}`);
return res.ok() ? (await res.json()).zaakUrl : null;
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
.toBeTruthy();
const approve = await request.post(`http://domain:8080/registrations/${reference}/approve`);
expect(approve.status()).toBe(204);
// The approval flows back to the projection; the openbaar register now shows *our* row (matched by
// its reference) as INGESCHREVEN.
await expect
.poll(async () => {
await page.reload();
return page.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGESCHREVEN' }).count();
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
.toBeGreaterThan(0);
});