Compare commits

...

29 Commits

Author SHA1 Message Date
0d0778036e docs(arch): ADR-0008 read projection store + demo note for the event path (refs #7)
All checks were successful
CI / lint (pull_request) Successful in 1m0s
CI / build (pull_request) Successful in 45s
CI / unit (pull_request) Successful in 54s
CI / mutation (pull_request) Successful in 2m16s
CI / verify-stack (pull_request) Successful in 5m34s
ADR-0008 records the read-projection design: one rebuildable store shared by the Event
Subscriber (writer) and projection-api (reader) as one CQRS bounded context (reconciled
with §8.5), idempotency + rebuild from the notification log (no OpenZaak access, §8.1),
the deferred bsn/naam, and the new EF Core + Npgsql dependency. Add a demo-script entry
walking the OZ→NRC→subscriber→projection-api path and wire both into the MkDocs nav.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:11:36 +02:00
fa8382fc02 ci(infra): run the Event Subscriber + projection-api in compose and verify end-to-end (refs #7)
Add projection-db + the two services to both compose files (host ports 8110/8120), their
Dockerfiles (repo-root context — they share Projection.ReadModel), and a runner-safe
verify-projection check (infra/run-projection-check.sh) that registers the abonnement at the
real subscriber, creates a zaak and asserts projection-api serves an INGEDIEND row. Wire it
into make (verify-projection, verify, WAIT_SVCS) and the CI verify-stack job, and run the
event-subscriber Stryker ratchet in `make mutation` + upload its report.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:11:24 +02:00
06d8d13e19 feat(event-subscriber): enforce the callback bearer before reading the body (refs #7)
Check the Authorization header before deserializing the notification, and read/parse the
body manually. NRC probes a new abonnement's callback with a request that has neither the
configured auth nor a valid notification body, and refuses to register unless it gets a 401
(not a 400) — ADR-0007. Mirrors the verify harness's sink contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:11:14 +02:00
a111e5cc20 test(event-subscriber): ratchet projector mutation baseline to 100% (refs #7)
Sharpen the projector tests so Stryker has no survivors (was 75%): assert a replayed
delivery never reaches the store (upsert count, not just row count), that two distinct
zaken get distinct rows (pins the idempotency key), that rebuild clears stale rows, and
a Theory over wrong kanaal/resource/actie combinations (pins the zaken/zaak/create guard).
Add the per-service Stryker config + solution; break threshold 90 (CLAUDE.md §5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:11:06 +02:00
7ef63c7ae9 feat(projection): persist the read projection and expose webhook + read APIs (refs #7)
Add the projection persistence and the two services around it:

- Projection.ReadModel: a shared EF Core (Npgsql) read model owning the projection
  schema — register_projection + the subscriber's processed_notifications log — plus
  EfProjectionStore / EfNotificationLog (atomic record-or-skip on the PK for idempotency)
  and the initial migration. One rebuildable store, written by the subscriber and read
  by projection-api (ADR-0008).
- EventSubscriber.Api: POST /notifications NRC callback (enforces the abonnement bearer,
  401 without it per ADR-0007), POST /admin/rebuild, /health. Migrates on start.
- ProjectionApi.Api: GET /register, GET /register/{id}, /health — the read side.

dotnet-ef pinned as a local tool for migrations; NuGetAuditMode=direct so EF's
design-time-only tooling transitive doesn't flag the shipped build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:55:04 +02:00
017cd5e66b feat(event-subscriber): project zaak-created notifications into the read projection (refs #7)
Implement NotificationProjector: a zaken/zaak/create notification records the delivery
in the notification log (atomic record-or-skip for idempotency, §8.6) and upserts an
INGEDIEND projection row keyed by zaak id; other channels/actions are ignored. Rebuild
clears the projection and replays the log — no OpenZaak access needed (§8.1). bsn/naam
are deferred (ADR-0008).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:48:08 +02:00
c70840e5b7 test(event-subscriber): project zaak-created notifications into the read projection (refs #7)
Failing unit + acceptance tests for the Event Subscriber's NotificationProjector:
a zaken/zaak/create notification yields one INGEDIEND projection row, duplicate
deliveries collapse to one row, non-zaak/non-create notifications are ignored, and
a rebuild repopulates the projection from the durable notification log (PRD §8.4).

The projector is a no-op stub so the tests compile and fail on the assertions; the
implementation follows in the green commit. The notification log doubles as the
idempotency guard and rebuild source so a rebuild needs no OpenZaak access (§8.1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:47:16 +02:00
32c98f00db Merge pull request 'feat(infra): wire OpenZaak → Open Notificaties notifications (closes #56)' (#57) from feat/56-nrc-notification-wiring into main
All checks were successful
CI / lint (push) Successful in 52s
CI / build (push) Successful in 40s
CI / unit (push) Successful in 48s
CI / mutation (push) Successful in 1m35s
CI / verify-stack (push) Successful in 4m44s
Reviewed-on: #57
2026-06-30 12:29:43 +00:00
d49443353e refactor(ci): one verify-stack stage for all live-stack checks (closes #58) (refs #46 #56)
All checks were successful
CI / lint (pull_request) Successful in 51s
CI / build (pull_request) Successful in 40s
CI / unit (pull_request) Successful in 48s
CI / mutation (pull_request) Successful in 1m36s
CI / verify-stack (pull_request) Successful in 4m37s
On the single self-hosted runner CI jobs run sequentially, so booting OpenZaak once
beats once-per-job. Replace the integration + notifications + compose-smoke jobs with
one verify-stack job that brings the full stack up once and runs, as clearly-named
steps: health (make verify-up, the DoD smoke) → ACL ↔ OpenZaak (verify-acl) →
OpenZaak → NRC delivery (verify-nrc) → teardown (always) + log dump on failure.

The check logic moves into stack-agnostic runners (run-acl-integration.sh,
run-notification-check.sh) that operate on whatever stack is already up, reaching
services by container IP. The local single-concern wrappers (make integration oz-only,
make verify-notifications oz+nrc) keep working by delegating to the same runners, so
nothing is duplicated. make ci now runs the consolidated 'verify' stage.

Verified locally: make verify boots the full stack once, ACL integration passes and
the NRC notification is delivered, then tears down.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:48:38 +02:00
a256db1a23 arch(infra): ADR-0007 + runbooks for the OZ→NRC notification wiring (refs #56)
All checks were successful
CI / lint (pull_request) Successful in 52s
CI / build (pull_request) Successful in 40s
CI / unit (pull_request) Successful in 49s
CI / mutation (pull_request) Successful in 1m35s
CI / integration (pull_request) Successful in 3m24s
CI / notifications (pull_request) Successful in 3m14s
CI / compose-smoke (pull_request) Successful in 4m2s
Records the wiring decision (AC-delegated auth, required celery-beat) and the two
non-obvious gotchas: single-label hosts aren't URL-valid (reach services by IP) and
abonnement callbacks must enforce auth. Documents the new notifications CI job.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:29:12 +02:00
4d07285dcd test(infra): verify-notifications smoke + CI job for the OZ→NRC path (refs #56)
make verify-notifications brings the stack up, seeds a published BIG zaaktype, and
asserts a zaak-create notification is delivered to a webhook-sink abonnement. The
sink + driver run as containers inside the compose network and reach OpenZaak/NRC by
container IP (the runner can't reach published ports, and a single-label host isn't
URL-valid). The sink enforces a bearer token because NRC refuses an unauthenticated
callback. New 'notifications' Gitea Actions job runs it (Docker-only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:29:12 +02:00
f3e9db7147 feat(infra): wire OpenZaak → Open Notificaties notifications (refs #56)
Completes the S-01-c wiring so a zaak created in OpenZaak is published to NRC:

- OpenZaak: a zgw_consumers 'nrc' service + notifications_config (setup_configuration),
  publishing as big-reference-seed. NOTIFICATIONS_DISABLED stays true for OpenZaak-only
  bring-ups (OZ_NOTIFICATIONS_DISABLED) so the ACL integration test doesn't 500; the
  full/local stacks and stack-up set it false.
- NRC: the JWT credential, an 'ac' service + autorisaties_api delegation to OpenZaak's
  Autorisaties API, and the 'zaken' kanaal. nrc-init now runs setup_configuration; its
  data.yaml is delivered via the rr-nrc-config volume (seed-config.sh nrc), mirroring oz.
- nrc-beat added to every stack: NRC accepts a notification then drains it via a
  scheduled execute_notifications task — without beat, nothing is delivered. Interval 5s.

Applied across the standalone, full, and local-bind-mount composes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:29:12 +02:00
86cc65f4d9 Merge pull request 'test(acl): ACL integration test against real OpenZaak (closes #46)' (#54) from test/46-acl-openzaak-integration into main
All checks were successful
CI / lint (push) Successful in 50s
CI / build (push) Successful in 40s
CI / unit (push) Successful in 48s
CI / mutation (push) Successful in 1m37s
CI / integration (push) Successful in 3m28s
CI / compose-smoke (push) Successful in 3m53s
Reviewed-on: #54
2026-06-29 10:48:00 +00:00
4474585606 ci(acl): run the ACL integration test in CI inside the compose network (closes #55) (refs #46)
All checks were successful
CI / lint (pull_request) Successful in 50s
CI / build (pull_request) Successful in 42s
CI / unit (pull_request) Successful in 47s
CI / mutation (pull_request) Successful in 1m31s
CI / integration (pull_request) Successful in 3m43s
CI / compose-smoke (pull_request) Successful in 4m1s
The hosted runner can't reach the stack's published ports (sibling containers),
so run the seed and the test as containers joined to the OpenZaak network,
reaching it by container IP — a single-label host like 'openzaak' isn't URL-valid
for OpenZaak's own URLValidator, but an IPv4 literal is. Code is delivered via
image build / docker cp (bind mounts don't reach the daemon either).

- infra/run-integration.sh: up -> wait healthy (docker inspect) -> seed published
  zaaktype (python container on the net) -> build + run the test image on the net
  -> always tear down. Plain docker primitives only (portable docker/podman).
- services/acl/Dockerfile.integration: builds + runs Acl.IntegrationTests; dotnet
  lives in the image, so the CI job needs only Docker (no setup-dotnet).
- make integration now delegates to the script; re-added the Gitea Actions job.

Supersedes the local-only gap documented earlier; #55 is no longer needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:28:43 +02:00
3829cb0b68 ci(acl): keep the integration lane local-only; document the runner gap (refs #46)
All checks were successful
CI / lint (pull_request) Successful in 49s
CI / build (pull_request) Successful in 42s
CI / unit (pull_request) Successful in 50s
CI / mutation (pull_request) Successful in 1m31s
CI / compose-smoke (pull_request) Successful in 3m54s
The hosted Gitea runner starts the OpenZaak stack as sibling containers via the
host daemon, so a process on the runner can't reach the published ports — the seed
and dotnet test get Connection refused on localhost:8000. Drop the (non-working)
integration CI job; make integration stays the local / host-runner gate. Document
the limitation in gitea-actions-gotchas.md §5 and the CI runbook, and track running
it inside the compose network in #55. ADR-0006 updated accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 12:04:44 +02:00
855a5565fe ci(acl): run the ACL integration test as a Gitea Actions job (refs #46)
Some checks failed
CI / lint (pull_request) Successful in 53s
CI / build (pull_request) Successful in 41s
CI / unit (pull_request) Successful in 46s
CI / mutation (pull_request) Successful in 1m37s
CI / integration (pull_request) Failing after 5m16s
CI / compose-smoke (pull_request) Successful in 4m11s
New integration job: setup-dotnet + make integration (stack up, OZ_PUBLISH=1 seed,
Integration-category tests, tear down), with on-failure log dump + teardown like
compose-smoke. Documents the job and the new make target in the CI runbook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:43:38 +02:00
09de500fb8 arch(acl): ADR-0006 — provision the ACL integration test against the compose stack (refs #46)
Records why the integration test targets the running compose stack rather than a
Testcontainers graph (no .NET compose support; not hermetic anyway due to the
Selectielijst dependency), the opt-in publish seed, and the chunked-body bug the
test caught. Proposed in #53.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:43:38 +02:00
4322c607cb fix(acl): buffer the zaak POST body so OpenZaak accepts it (refs #46)
OpenZaak runs behind uwsgi, which rejects a chunked request body with 400.
JsonContent streams without a Content-Length (Transfer-Encoding: chunked), so
buffer it first. Only a real OpenZaak surfaces this — the integration test from
the previous commit now passes. A unit test asserts a Content-Length is sent
(captured before the stub reads/buffers the body), guarding the fix in the fast
lane and killing the Stryker mutant that would otherwise survive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:43:27 +02:00
d0582cef65 test(acl): integration test opens a real zaak against OpenZaak (refs #46)
S-04a: the deferred S-04 acceptance criterion. A gated Acl.IntegrationTests
project (Category=Integration) drives the real OpenZaakGateway against the
running compose stack — real ZGW JWT auth and the real POST /zaken contract a
stubbed HttpMessageHandler cannot exercise. The lane is kept out of the fast
checks: make unit filters Category!=Integration, Stryker is pinned to Acl.Tests,
and a new make integration target brings the stack up, seeds a published zaaktype
and tears down.

Red: against real OpenZaak the gateway POST fails 400 — JsonContent streams the
body chunked and OpenZaak's uwsgi rejects it. Fixed in the next commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:43:17 +02:00
f2e575b427 feat(infra): publish the BIG zaaktype on demand via OZ_PUBLISH (refs #46)
OpenZaak rejects a zaak against a concept zaaktype (not-published). Add an
opt-in OZ_PUBLISH path that creates the relations publish requires — two
statustypen, a roltype, and a resultaattype whose Selectielijst procestype is
matched onto the zaaktype — then publishes. Default stays concept (ADR-0002);
only the ACL integration test flips it on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:43:05 +02:00
fd5fa5ac3c Merge pull request 'test(acl): ACL mutation-score baseline with Stryker.NET (closes #47)' (#52) from feat/47-acl-mutation-baseline into main
All checks were successful
CI / lint (push) Successful in 50s
CI / build (push) Successful in 41s
CI / unit (push) Successful in 48s
CI / mutation (push) Successful in 1m25s
CI / compose-smoke (push) Successful in 4m1s
Reviewed-on: #52
2026-06-29 09:00:29 +00:00
5f3dd31925 fix(ci): pin upload-artifact to @v3 — @v4 refuses to run on Gitea (refs #47)
All checks were successful
CI / lint (pull_request) Successful in 50s
CI / build (pull_request) Successful in 46s
CI / unit (pull_request) Successful in 43s
CI / mutation (pull_request) Successful in 1m49s
CI / compose-smoke (pull_request) Successful in 4m2s
The artifact step failed the mutation job: upload-artifact@v4 bundles
@actions/artifact v2, which hard-aborts on any non-github.com server ("not
supported on GHES"), even though Gitea 1.25 stores artifacts fine. @v3 uses the
older protocol Gitea speaks and has no GHES guard — a drop-in swap (same inputs).
Document it as gotcha §4 and correct the CI runbook note.

Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:28:07 +02:00
347713766e ci(acl): publish the Stryker HTML report as a CI artifact (refs #47)
Some checks failed
CI / lint (pull_request) Successful in 52s
CI / build (pull_request) Successful in 41s
CI / unit (pull_request) Successful in 47s
CI / mutation (pull_request) Failing after 1m50s
CI / compose-smoke (pull_request) Successful in 3m55s
Add an upload-artifact step to the mutation job so the ACL mutation report is
downloadable from the run summary. `if: always()` uploads it even when the
ratchet fails — exactly when the survivors matter. A glob handles Stryker's
timestamped output directory. First use of actions/upload-artifact (@v4, pinned);
Gitea 1.25.x supports it. Document it in the CI runbook.

Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:21:26 +02:00
7ecc184111 arch(acl): ADR-0005 adopt Stryker.NET for mutation testing (refs #47)
All checks were successful
CI / lint (pull_request) Successful in 51s
CI / build (pull_request) Successful in 42s
CI / unit (pull_request) Successful in 45s
CI / mutation (pull_request) Successful in 1m24s
CI / compose-smoke (pull_request) Successful in 4m1s
Record the decision to adopt Stryker.NET (pinned local tool, solution mode on
Acl.slnx) and to set the first repo-wide mutation baseline on the ACL: observed
95%, enforced break threshold 90%. Document the ratchet, local run, and report
location in the CI runbook; add the ADR to the docs nav.

Proposed in #51 (adr-proposal). Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:59:41 +02:00
e8510bf9c3 ci(acl): run the mutation ratchet as a parallel CI job (refs #47)
Add a `mutation` job mirroring the unit job (checkout + pinned setup-dotnet,
then `make mutation`). It runs in parallel with lint/build/unit/compose-smoke
and gates merges on the ACL mutation baseline (CLAUDE.md §5/§15). The job calls
the same make target developers run, so the pipeline stays a mirror of `make ci`.

Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:58:22 +02:00
6ac2fca384 test(acl): add Stryker config + mutation make target recording the 95% baseline (refs #47)
Configure Stryker.NET for the ACL in solution mode (Acl.slnx), so both
Acl.Application and Acl.Infrastructure — the two projects under test — are
mutated while Acl.Api (untested) is skipped. Record the repo-wide mutation
baseline as the ratchet (CLAUDE.md §5): observed score 95%, enforced break
threshold 90% (one-mutant headroom over the ~20-mutant surface). The ACL is the
first service with branching logic, so it sets the baseline; later slices
ratchet it up deliberately, never down.

Add a `mutation` make target (`dotnet tool restore` + `dotnet stryker`) and wire
it into the `make ci` aggregate, keeping `make ci` an exact mirror of the
pipeline.

Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:58:00 +02:00
10816f5303 test(acl): kill surviving mutants — assert CRS headers, guards, error paths, JWT claims (refs #47)
Stryker exposed thin ACL tests (35% mutation score): the suite never asserted
the geo CRS headers, the ArgumentNullException guards, the non-success and
empty-body error paths, or the structure of the minted ZGW JWT — so mutating
any of those survived.

Strengthen the unit tests to kill those mutants:
- assert Accept-Crs / Content-Crs are EPSG:4326,
- assert OpenZaakAsync rejects a null request/registration without calling out,
- assert a non-2xx response throws and an empty body throws InvalidOperationException,
- decode the Bearer token and assert the HS256 header + acl identity claims.

Raises the ACL mutation score to 95%. The one remaining survivor mutates only
the exception *message* text (an equivalent mutant — message strings are not
worth a brittle assertion).

Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:57:51 +02:00
89b097d015 build(acl): pin Stryker.NET as a local dotnet tool (refs #47)
Add a tool manifest pinning dotnet-stryker 4.15.0 so `make mutation` runs
the same mutation tester locally and in CI from a fresh clone (`dotnet tool
restore`), with no global install. Ignore the generated StrykerOutput/ report
directory.

Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:57:38 +02:00
5a83216395 Merge pull request 'ci(infra): Gitea Actions CI pipeline + full-stack compose smoke (closes #30)' (#50) from ci/30-gitea-actions-ci into main
All checks were successful
CI / lint (push) Successful in 49s
CI / build (push) Successful in 43s
CI / unit (push) Successful in 46s
CI / compose-smoke (push) Successful in 3m54s
Reviewed-on: #50
2026-06-25 12:34:43 +00:00
68 changed files with 2813 additions and 77 deletions

20
.config/dotnet-tools.json Normal file
View File

@@ -0,0 +1,20 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-stryker": {
"version": "4.15.0",
"commands": [
"dotnet-stryker"
],
"rollForward": false
},
"dotnet-ef": {
"version": "10.0.0",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}

View File

@@ -43,14 +43,55 @@ jobs:
dotnet-version: '10.0.x'
- run: make unit
compose-smoke:
mutation:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- run: make smoke
- name: dump container logs on failure
- uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- run: make mutation
# Publish the Stryker HTML report. `if: always()` uploads it even when the
# ratchet fails — that is exactly when you want to inspect the survivors.
# Glob handles Stryker's non-deterministic StrykerOutput/<timestamp>/ dir.
# Pinned to @v3 deliberately: @v4 refuses to run on Gitea (GHES guard) —
# see docs/runbooks/gitea-actions-gotchas.md §4.
- uses: https://github.com/actions/upload-artifact@v3
if: always()
with:
name: acl-mutation-report
path: services/acl/StrykerOutput/**/reports/mutation-report.html
if-no-files-found: warn
- uses: https://github.com/actions/upload-artifact@v3
if: always()
with:
name: event-subscriber-mutation-report
path: services/event-subscriber/StrykerOutput/**/reports/mutation-report.html
if-no-files-found: warn
# One stage for every check that needs the live stack. On the single self-hosted
# runner jobs run sequentially, so booting OpenZaak once (instead of once per job)
# is the cheapest layout (issue #58). No setup-dotnet: the ACL test runs in a built
# image and everything reaches services by container IP. Needs Docker + egress
# (base images, nuget, selectielijst.openzaak.nl).
verify-stack:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
# Bring the full stack up + wait for health — this also is the DoD "compose up
# reaches green health" smoke (it replaces the old compose-smoke job).
- name: Bring up the full stack & wait for health
run: make verify-up
- name: ACL ↔ OpenZaak integration tests
run: make verify-acl
- name: OpenZaak → NRC notification delivery
run: make verify-nrc
- name: OpenZaak → NRC → Event Subscriber → projection-api
run: make verify-projection
# Log dump must precede teardown (which removes the containers).
- name: Dump container logs on failure
if: failure()
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=80 oz-init openzaak nrc-init nrc-web flowable-init keycloak acl bff 2>&1 || true
- name: tear down on failure
if: failure()
run: docker compose -f infra/docker-compose.yml down --volumes 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-init keycloak acl bff projection-db event-subscriber projection-api 2>&1 || true
- name: Tear down
if: always()
run: make down

3
.gitignore vendored
View File

@@ -15,6 +15,9 @@ coverage*.json
coverage*.xml
*.coverage
# Stryker.NET mutation-testing reports (regenerated by `make mutation`)
StrykerOutput/
# Rider / VS / VS Code
.idea/
.vs/

View File

@@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml
# 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)
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
WAIT_SVCS := openzaak nrc-web acl bff
WAIT_SVCS := openzaak nrc-web acl bff event-subscriber projection-api
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
# 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
@@ -18,7 +18,7 @@ WAIT_SVCS := openzaak nrc-web acl bff
# volumes are `external`, so compose won't remove them — CFG_VOLS lists them for
# explicit teardown. See docs/runbooks/gitea-actions-gotchas.md.
SEED := bash infra/seed-config.sh
CFG_VOLS := rr-oz-config rr-kc-realms rr-fl-bpmn
CFG_VOLS := rr-oz-config rr-nrc-config rr-kc-realms rr-fl-bpmn
# Local-only stack: same services but config is bind-mounted (no seed step), so a
# plain `docker compose -f infra/docker-compose.local.yml up` works on any local
# engine. This is the no-make / Windows-friendly path. See that file's header.
@@ -43,10 +43,11 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
endif
endif
.PHONY: ci lint build unit smoke up down local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
.PHONY: ci lint build unit mutation integration verify verify-up verify-acl verify-nrc verify-projection verify-notifications smoke up down local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
## ci: run the full pipeline — lint, build, unit, smoke (mirrors Gitea Actions)
ci: lint build unit smoke
## ci: run the full pipeline — lint, build, unit, mutation, verify (mirrors Gitea Actions)
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
ci: lint build unit mutation verify
## lint: verify formatting (no changes)
lint:
@@ -56,9 +57,19 @@ lint:
build:
dotnet build $(SLN) -c Release
## unit: run unit tests
## unit: run unit tests (excludes the container-backed Integration lane)
unit:
dotnet test $(SLN) -c Release
dotnet test $(SLN) -c Release --filter "Category!=Integration"
## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline)
# Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore`
# makes `make mutation` work from a fresh clone. Each service owns its config + break
# threshold (the ratchet, CLAUDE.md §5): services/acl/stryker-config.json and
# services/event-subscriber/stryker-config.json. Scores never regress below baseline.
mutation:
dotnet tool restore
cd services/acl && dotnet stryker
cd services/event-subscriber && dotnet stryker
## smoke: seed config, bring the whole stack up, wait for health-checked services, tear down
# SEED populates the external config volumes first (upstream images used verbatim;
@@ -68,14 +79,14 @@ unit:
# podman-compose, and needing no `--wait` flag or host port access. The one-shots
# (oz-init, flowable-init) aren't polled; they just need to have run.
smoke:
$(SEED) oz kc fl
$(SEED) oz nrc kc fl
docker compose -f $(COMPOSE) up -d --build
bash -c 'WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS); rc=$$?; docker compose -f $(COMPOSE) down --volumes; docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; exit $$rc'
## up: seed config volumes and start the full stack (use instead of bare
## `docker compose up`, which can't self-seed the external config volumes)
up:
$(SEED) oz kc fl
$(SEED) oz nrc kc fl
docker compose -f $(COMPOSE) up -d --build
## down: stop and remove the local stack (incl. the external config volumes)
@@ -97,6 +108,54 @@ local-down:
changelog:
git-cliff --output CHANGELOG.md
# ── ZGW verification ───────────────────────────────────────────────────────
# On the single runner CI jobs run sequentially, so the OpenZaak-dependent checks
# share ONE full-stack bring-up: the `verify-stack` CI job runs `verify-up` then
# `verify-acl` + `verify-nrc` as steps against the same stack (issue #58). The
# check logic lives in stack-agnostic runners that reach services by container IP
# (gitea-actions-gotchas.md §5/§6); `integration` / `verify-notifications` are local
# convenience wrappers that bring up a lighter stack and call the same runners.
## verify-up: bring the FULL stack up and wait for health (CI verify-stack step 1;
## subsumes the old compose-smoke health gate — the DoD "up reaches green" check).
verify-up:
$(SEED) oz nrc kc fl
docker compose -f $(COMPOSE) up -d --build
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
## verify-acl: ACL ↔ OpenZaak integration tests against the already-running stack.
verify-acl:
bash infra/run-acl-integration.sh
## verify-nrc: OpenZaak → NRC notification delivery against the already-running stack.
verify-nrc:
bash infra/run-notification-check.sh
## verify-projection: OpenZaak → NRC → Event Subscriber → projection-api end-to-end (S-06),
## against the already-running stack.
verify-projection:
bash infra/run-projection-check.sh
## 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`
## (oz-only) or `verify-notifications` (oz+nrc) instead.
verify:
$(SEED) oz nrc kc fl
docker compose -f $(COMPOSE) up -d --build
@bash -c 'set -e; rc=0; \
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS) \
&& bash infra/run-acl-integration.sh \
&& bash infra/run-notification-check.sh \
&& bash infra/run-projection-check.sh || rc=$$?; \
docker compose -f $(COMPOSE) down --volumes >/dev/null 2>&1; \
docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; \
exit $$rc'
## integration: local convenience — ACL integration test against a throwaway
## OpenZaak-only stack (fast iteration). CI uses verify-acl on the shared stack.
integration:
bash infra/run-integration.sh
## openzaak-up: start the OpenZaak stack (migrations run on first start)
openzaak-up:
$(SEED) oz
@@ -129,10 +188,16 @@ openzaak-down:
docker compose -f $(OZ_COMPOSE) down --volumes
-docker volume rm -f rr-oz-config
## stack-up: start OpenZaak + Open Notificaties together (shared network)
## verify-notifications: local convenience — OpenZaak → NRC notification delivery
## against a throwaway oz+nrc stack (S-01-c). CI uses verify-nrc on the shared stack.
verify-notifications:
bash infra/verify-notifications.sh
## stack-up: start OpenZaak + Open Notificaties together (shared network), with
## OpenZaak publishing notifications to NRC (S-01-c).
stack-up:
$(SEED) oz
docker compose $(STACK_FILES) up -d
$(SEED) oz nrc
OZ_NOTIFICATIONS_DISABLED=false docker compose $(STACK_FILES) up -d
## stack-smoke: start both, assert OpenZaak (403/302/200) and NRC (302) are reachable
stack-smoke: stack-up
@@ -151,7 +216,7 @@ stack-smoke: stack-up
## stack-down: stop and remove both stacks (wipes data)
stack-down:
docker compose $(STACK_FILES) down --volumes
-docker volume rm -f rr-oz-config
-docker volume rm -f rr-oz-config rr-nrc-config
## keycloak-up: start Keycloak with the four imported realms
keycloak-up:

View File

@@ -0,0 +1,68 @@
# ADR-0005: Stryker.NET for mutation testing, baseline on the ACL
- **Status:** Accepted
- **Date:** 2026-06-25
- **Deciders:** Respellion engineering
- **Relates to:** S-04b (#47); proposed in #51; supports CLAUDE.md §5 (mutation ratchet) and §3 (Definition of Done)
## Context
CLAUDE.md §5 mandates Stryker on every PR with a **ratchet**: CI fails on a regression
below the established baseline, and the baseline only ever moves up. §3 lists "mutation
(ratchet)" as a Definition-of-Done gate for **every** slice. Yet no baseline existed — so,
strictly, no slice could satisfy that gate. S-04b establishes it.
The ACL is the natural place to set the first baseline: it is the first service with real
branching logic — `OpenZaakGateway` (HTTP contract, geo CRS headers, error handling),
`ZgwToken` (HS256 JWT minting), and the `AclService` default-fill mapping. We need a tool
that:
- mutates C# and runs the existing xUnit suite per mutant,
- is reproducible (same version locally and in CI, no global install),
- understands this repo's `.slnx` solution format (used repo-wide),
- emits a break threshold CI can gate on.
## Decision
**Use [Stryker.NET](https://stryker-mutator.io/docs/stryker-net/) (`dotnet-stryker`),
pinned as a local dotnet tool**, configured in solution mode against `Acl.slnx`.
- Pinned in `.config/dotnet-tools.json` (v4.15.0); `dotnet tool restore` makes
`make mutation` reproducible from a fresh clone, locally and in CI — no global install.
- **Solution mode** (`stryker-config.json``solution: Acl.slnx`) mutates the two projects
under test (`Acl.Application`, `Acl.Infrastructure`); `Acl.Api` is untested and skipped.
Stryker 4.15 reads `.slnx` directly, so no throwaway `.sln` shim is needed.
- A `mutation` make target runs it; it is wired into `make ci` and a parallel Gitea Actions
`mutation` job, keeping `make ci` an exact mirror of the pipeline.
**Baseline:** writing S-04b's tests surfaced that the ACL suite was thin — the initial
score was **35%** (survivors: unasserted CRS headers, null guards, error paths, and JWT
claims). Those tests were strengthened (killing the mutants honestly rather than lowering
the bar), raising the score to **95%**. The enforced `break` threshold is set to **90%**
one-mutant headroom over the ~20-mutant surface, since a single mutant is ≈5%.
## Consequences
- **Positive:** test *strength* is gated, not just coverage; the ratchet protects the ACL's
ZGW contract logic; the baseline is repo-wide and ratchets upward per §5.
- **Cost:** a new dependency (`dotnet-stryker`) and a slower CI job than unit tests (~25 s on
the small ACL). Pinned + tool-restored, so reproducible.
- **One accepted survivor:** a mutation of the empty-response *exception message string*.
Asserting exception message text is brittle and the behaviour (type + control flow) is
unchanged — treated as an equivalent mutant, not a test gap.
- **Commitment:** later slices ratchet the threshold up deliberately, never down (§5). New
services add their own mutation run as they gain branching logic (BFF, Domain, …).
- **Replaceable by:** no realistic .NET alternative — Stryker.NET is the tool §5 already
names; the fallback is no mutation testing, which §5 forbids.
## Alternatives considered
- **Global `dotnet tool install -g`** — rejected: not reproducible/pinned per clone; the
local manifest gives every checkout and the CI runner the same version.
- **Mutate the whole `register-referentie.slnx`** — rejected for this slice: scopes the
baseline to services with no logic yet (BFF skeleton), diluting the signal. Each service
opts in as it gains logic.
- **Application-only scope** — rejected: would leave `Acl.Infrastructure`'s HTTP/JWT logic —
the riskiest code — unguarded by the ratchet.
- **Coverage gate instead of mutation** — rejected: line coverage does not measure whether
tests would *catch* a regression; that is the whole point of §5.

View File

@@ -0,0 +1,92 @@
# ADR-0006: Provision the ACL integration test against the compose stack
- **Status:** Accepted
- **Date:** 2026-06-29
- **Deciders:** Respellion engineering
- **Relates to:** S-04a (#46); proposed in #53; builds on ADR-0001 (loose coupling), ADR-0002 (catalogus design), ADR-0003 (default-fill); supports CLAUDE.md §11 (integration tests via real containers)
## Context
S-04 delivered the ACL's one operation — `OpenZaakGateway.OpenZaakAsync` — with unit
tests against a stubbed `HttpMessageHandler` and a Reqnroll scenario over an in-memory
stand-in. The deferred S-04 acceptance criterion (S-04a) is the one a stub cannot meet:
> Integration test using Testcontainers against real OpenZaak passes.
The test must drive the gateway against a **real** OpenZaak — real ZGW JWT auth, the real
`POST /zaken/api/v1/zaken` contract, real CRS handling — and assert a zaak comes back.
Two ways to stand OpenZaak up were considered (the issue's open question): (a) a full
**Testcontainers** graph started by the test, or (b) target the **running compose stack**
the repo already defines (`infra/openzaak/docker-compose.yml`, `make openzaak-up`).
Investigation reversed the initially-favoured Testcontainers option:
1. **Testcontainers .NET has no docker-compose support.** OpenZaak needs PostGIS + Redis +
a `setup_configuration` one-shot (the JWT client) + the API. Honouring "full graph" would
mean re-implementing that five-service stack — init ordering, the config volume, health
gating — by hand in C#, duplicating the maintained compose file and rotting with it. That
rubs against CLAUDE.md §13 ("if a test is hard to write, the design is wrong").
2. **The test cannot be hermetic anyway.** OpenZaak's Zaken API rejects a zaak against a
*concept* zaaktype (`not-published`), and a *published* zaaktype requires ≥1 resultaattype,
which OpenZaak validates by fetching the external **Selectielijst** reference API
(`selectielijst.openzaak.nl`). So a real zaak POST already depends on outbound internet
from the OpenZaak container — the self-containment that motivated Testcontainers is lost
regardless of how the containers are started.
## Decision
**The ACL integration test targets the running compose stack; it does not start containers
itself. No new test dependency is added.**
- A gated test project `Acl.IntegrationTests` (`[Trait("Category","Integration")]`) talks to
OpenZaak with a plain `HttpClient`, reusing the same endpoint + JWT-client config the seed
uses (`OZ_BASE` / `OZ_CLIENT_ID` / `OZ_SECRET`, defaulting to the local stack). It locates
the published `BIG-REGISTRATIE` zaaktype via the Catalogi API and exercises the real
`OpenZaakGateway` against it.
- **The lane is kept out of the fast checks.** `make unit` runs with
`--filter "Category!=Integration"`; Stryker is pinned to `Acl.Tests` (`test-projects`), so
neither the unit nor the mutation lane needs a live stack. A `make integration` target
(`infra/run-integration.sh`) brings up a throwaway OpenZaak and runs the lane locally.
In CI the check runs as the `verify-acl` step of the consolidated `verify-stack` job
(issue #58) — one shared full-stack bring-up. This matches `make` being the single
source of truth (ADR-0005).
- **Publishing is opt-in in the seed.** `infra/openzaak/seed_catalogus.py` gains an
`OZ_PUBLISH=1` path that adds the relations OpenZaak's publish requires — two statustypen
(begin/eind), a roltype, and a resultaattype whose Selectielijst procestype is matched onto
the zaaktype — then publishes. The default seed (S-01 / ADR-0002) still leaves the zaaktype
a concept; only `make integration` flips the switch.
## Consequences
- **Positive:** a small, honest test over the real ZGW contract with no bespoke orchestration
to maintain; the compose stack is exercised exactly as operators run it; no new dependency.
- **It caught a real bug.** The gateway sent the zaak body via `JsonContent` without a
`Content-Length`, so .NET framed it as `Transfer-Encoding: chunked`, which OpenZaak's uwsgi
rejects with 400. A stubbed handler accepts either framing, so only a real OpenZaak surfaced
it. Fixed by buffering the body (`LoadIntoBufferAsync`); guarded in the fast lane by a unit
test asserting a `Content-Length` is set. This is the concrete justification for §11's
integration tier.
- **External dependency:** the integration job needs the OpenZaak container to reach
`selectielijst.openzaak.nl`. It is a stable public reference API (the same one OpenZaak uses
in production) but it is a network touchpoint, and a CI environment without egress would need
a local Selectielijst service or a recorded fixture. `OZ_SELECTIELIJST` overrides the base URL.
- **Cost:** the lane needs the stack up first, so it is separate from the fast lanes.
- **Runs on the hosted runner.** A process *on* the runner can't reach the stack's published
ports (Compose starts sibling containers via the host daemon — gitea-actions-gotchas.md §5,
same split as §1), so `infra/run-integration.sh` runs both the seed and the test as containers
*joined to the OpenZaak network*, reaching it by **container IP** (a single-label host like
`openzaak` isn't URL-valid for OpenZaak's own `URLValidator`; an IPv4 literal is). Code is
delivered by image build / `docker cp`, never bind mounts. The CI job therefore needs only
Docker — no `setup-dotnet`. (This closed the follow-up that was originally split out as #55.)
## Alternatives considered
- **Full Testcontainers graph** — rejected: re-implements the compose stack in C# (brittle,
duplicative) for no hermeticity gain, since the Selectielijst dependency remains.
- **Single OpenZaak container (sqlite/locmem)** — rejected: diverges from the real
PostGIS-backed, Redis-cached deployment; the Zaken API is a geo API and the divergence would
undermine the contract the test exists to verify.
- **Mock OpenZaak / record-replay** — rejected: that is what the existing stubbed-handler unit
tests already do; it cannot exercise the real contract, and would not have caught the chunked
body bug.

View File

@@ -0,0 +1,77 @@
# ADR-0007: Wiring OpenZaak → Open Notificaties (NRC) for notifications
- **Status:** Accepted
- **Date:** 2026-06-29
- **Deciders:** Respellion engineering
- **Relates to:** S-01-c (#56); completes S-01 (#2); unblocks the Event Subscriber (#7); builds on ADR-0002 (catalogus/seed) and ADR-0006 (runner-safe container harnesses)
## Context
S-01 brought OpenZaak + Open Notificaties (NRC) up in compose but **deferred the
notification wiring**: OpenZaak ran with `NOTIFICATIONS_DISABLED=true` and NRC's
`setup_configuration` was empty. The walking skeleton (PRD §12) needs the upstream
event path — a zaak created in OpenZaak must publish a notification NRC fans out to
subscribers — before the Event Subscriber (#7) can consume it.
The OpenZaak↔NRC handshake is intricate and several details are non-obvious; they
were nailed down by iterating `setup_configuration` against the running stack.
## Decision
**Provision both sides declaratively via `setup_configuration`, authenticate with the
existing `big-reference-seed` client, and run NRC's celery-beat so deliveries happen.**
- **OpenZaak** (`infra/openzaak/setup_configuration/data.yaml`): a `zgw_consumers`
service `nrc` (api_type `nrc`, the NRC API root) plus `notifications_config` naming
it. `NOTIFICATIONS_DISABLED` is flipped to `false` **only when NRC is present**
the full stack and the local twin set it; OpenZaak-only bring-ups (`openzaak-up`,
the ACL integration test) default it back to `true` via `OZ_NOTIFICATIONS_DISABLED`
so they don't 500 publishing to an absent NRC.
- **NRC** (`infra/opennotificaties/setup_configuration/data.yaml`): the
`big-reference-seed` JWT credential (to verify OpenZaak's token), a `zgw_consumers`
`ac` service pointing at **OpenZaak's Autorisaties API**, the `autorisaties_api`
step delegating authorization to that AC, and the `zaken` kanaal. NRC's init
container switches from `migrate` to `/setup_configuration.sh`; its data.yaml is
delivered through the `rr-nrc-config` external volume by `infra/seed-config.sh`
(the same `docker cp` pattern as OpenZaak — bind mounts don't reach the CI runner's
daemon).
- **celery-beat is required.** NRC accepts a notification and writes a
`ScheduledNotification`; a periodic `execute_notifications` task (celery-beat,
every `NOTIFICATION_SEC_INTERVAL`s) drains it to the worker for delivery. The lean
S-01 stack dropped beat — so notifications were accepted but never delivered. An
`nrc-beat` service is added to every compose; the interval is lowered to 5s.
Verification is a runner-safe smoke (`infra/run-notification-check.sh`): it seeds a
published BIG zaaktype, registers an abonnement to a webhook sink, creates a zaak, and
asserts the sink receives the `zaken`/`create` notification — all from containers
**inside** the compose network (ADR-0006). Locally it runs via `make verify-notifications`
(a throwaway oz+nrc stack); in CI it runs as the `verify-nrc` step of the consolidated
`verify-stack` job (one shared full-stack bring-up — issue #58).
## Consequences
- **Positive:** the walking-skeleton event path works end to end; #7 can consume real
notifications; the wiring is declarative and reproducible from a fresh `make`.
- **Gotchas captured (see gitea-actions-gotchas.md):**
- **Single-label hosts aren't URL-valid.** OpenZaak/NRC reject `http://openzaak…`
/`http://nrc-web…` in URLs they validate (Django `URLValidator`); the verify
harness reaches services and registers the sink callback **by container IP**.
- **Abonnement callbacks must enforce auth.** NRC probes the callback during
registration and refuses it (`no-auth-on-callback-url`) unless it returns 401
without the configured `Authorization`; the sink enforces a bearer token.
- **Cost:** an extra long-running service (`nrc-beat`) per stack, and the verify job
needs egress (base images + `selectielijst.openzaak.nl`, since the published
zaaktype the check creates a zaak against depends on it — ADR-0006).
- **Dev-only credentials** reused (`big-reference-seed` / its secret) across publish,
AC lookup, and seeding — acceptable for the reference app, not production.
## Alternatives considered
- **NRC with its own (non-AC) authorization** — rejected: delegating to OpenZaak's
Autorisaties API is the upstream-intended model and reuses the applicatie that
already grants `heeft_alle_autorisaties`.
- **Keep beat out, deliver synchronously** — not an option: Open Notificaties 1.16
delivers via scheduled notifications drained by beat; there is no sync path.
- **A persistent abonnement in `setup_configuration`** instead of registering one in
the verify harness — deferred: the real subscriber is #7; the harness's sink
abonnement is throwaway and IP-specific.

View File

@@ -0,0 +1,89 @@
# ADR-0008: The read projection — a shared, rebuildable store with a writer and a reader
- **Status:** Accepted
- **Date:** 2026-06-30
- **Deciders:** Respellion engineering
- **Relates to:** S-06 (#7); builds on ADR-0001 (loose coupling), ADR-0007 (#56, OZ→NRC wiring); first EF Core usage in the repo
## Context
S-06 (#7) adds the upstream event path's destination: an **Event Subscriber** that consumes
NRC notifications and a **read projection** the openbaar register reads. The walking-skeleton
projection (PRD §8.4) holds one row per zaak — `id`, `bsn`, `naam_placeholder`, `status`
and must be **idempotent** (NRC redelivers and reorders, CLAUDE.md §8.6) and **rebuildable**
(a derived artefact, never a write-only source of truth).
Two design questions had no obvious answer:
1. **Where does `bsn` come from?** The NRC `zaken`/`zaak`/`create` notification carries only the
zaak URL plus the fixed `kenmerken` (`bronorganisatie`, `zaaktype`, `vertrouwelijkheidaanduiding`).
It does **not** carry the bsn. Reading it means calling a ZGW API — which **only the ACL** may
do (CLAUDE.md §8.1). The issue's "Touches" lists only `event-subscriber` + `projection-api`,
not the ACL.
2. **Who owns the projection schema?** The subscriber writes the projection; the projection-api
reads it. CLAUDE.md §8.5 says "no direct DB access across services; each service owns its
schema." Two deployables on one table looks like a violation.
## Decision
**One Postgres database is the read projection. The Event Subscriber writes it (projector) and
the projection-api reads it (query); both are processes of the single "Read Projection" bounded
context and share one schema, defined in a shared `Projection.ReadModel` library. `bsn` is
deferred.**
- **Schema ownership.** The read model — `register_projection` plus the subscriber's
`processed_notifications` log — lives in `services/projection-api/Projection.ReadModel`
(EF Core + Npgsql). Both services reference it. This is the textbook CQRS read-model split
(one writer, one reader over one derived store), **not** the cross-*domain* DB reach §8.5
forbids: no domain owns write-state here; the projection is rebuildable (§8.4). §8.5 still
holds for every domain database.
- **Idempotency** is the primary key on `processed_notifications.key` (a deterministic key
derived from the immutable notification content). A duplicate insert raises a unique violation,
caught and reported as "already recorded", so the duplicate never reaches the projection. The
projection upsert is itself idempotent on the zaak id, a second line of defence.
- **Rebuild replays the log, not OpenZaak.** `POST /admin/rebuild` clears `register_projection`
and reprojects every row in `processed_notifications`. So "rebuildable" needs **no** ZGW access
(§8.1) and no ACL dependency — keeping S-06 within its stated scope.
- **`bsn` and `naam_placeholder` are deferred.** They are columns (nullable) but the minimal slice
populates only `id` + `status` (`INGEDIEND`) from the notification. Populating personal data
requires reading the zaak **through the ACL** (§8.1) and is its own follow-up; the column shape
is in place so that change is additive.
- **New dependency: EF Core 10 + `Npgsql.EntityFrameworkCore.PostgreSQL`.** What it gives us: a
migrated relational schema, LINQ queries, and a clean port implementation. What we'd write
instead: hand-rolled SQL + a migration runner. Risk: ORM complexity and an extra dependency
graph — bounded here to a tiny two-table read model. `dotnet-ef` is pinned as a local tool for
migrations; `NuGetAuditMode=direct` keeps EF's design-time-only tooling transitive out of the
audited, shipped graph.
The end-to-end path is verified by a runner-safe live-stack smoke (`infra/run-projection-check.sh`,
the `verify-projection` step of the `verify-stack` job, #58): register an abonnement at the real
Event Subscriber's callback, create a zaak, assert projection-api serves an `INGEDIEND` row — all
in-network, reaching services by container IP (ADR-0006/0007).
## Consequences
- **Positive:** the upstream event path reaches a queryable projection; idempotent and rebuildable
without OpenZaak; S-06 stays inside its stated touch-set (no ACL change); the projection-api is
ready for S-09 to tighten public-safe field filtering.
- **Negative / deferred:**
- `bsn`/`naam_placeholder` stay empty until a follow-up wires zaak reads via the ACL.
- The abonnement is registered by the verify harness (by container IP), not provisioned
persistently — ADR-0007 already deferred a persistent abonnement, and a single-label service
host is not URL-valid for NRC, so persistent registration needs a dotted network alias. Tracked
as a follow-up; a plain `make up` therefore needs the abonnement registered before the event
path flows.
- Two services share one database. Acceptable for a derived read model; revisit if the read and
write sides ever need independent scaling or storage.
## Alternatives considered
- **Subscriber reads OpenZaak directly to fill `bsn`** — rejected: breaks §8.1 (only the ACL talks
to ZGW) and would need its own ADR to bend the rule.
- **Extend the ACL with a zaak-read operation, consumed as a library** — viable and §8.1-clean, but
it grows S-06 beyond its stated scope (touches the ACL) and pulls personal-data handling forward;
deferred to a follow-up.
- **projection-api owns the DB and exposes an internal write endpoint the subscriber calls** —
rejected for the walking skeleton: adds an HTTP hop and a write surface on a read service for no
current benefit over a shared, rebuildable read model.
- **Separate databases for the log and the projection** — rejected as premature: both are the read
side's private, rebuildable state; one DB is simpler and still honours §8.5's intent.

35
docs/demo-script.md Normal file
View File

@@ -0,0 +1,35 @@
# Demo script
A running log of demoable outcomes, one section per slice. Each entry is a short,
copy-pasteable walkthrough against a local `make up` stack.
---
## S-06 — Event Subscriber + read projection
**Outcome:** a zaak created in OpenZaak flows through NRC to the Event Subscriber, which
projects it into a rebuildable read projection the projection-api serves.
**The path:** OpenZaak → (notification) NRC → (abonnement callback) Event Subscriber →
`register_projection` → projection-api `GET /register`.
```bash
# 1. Bring the full stack up (seeds config, builds our services, waits for health).
make up
# 2. Register the Event Subscriber's abonnement and create a zaak, then read it back.
# (The verify-projection check does exactly this end-to-end and asserts the result.)
make verify-projection # → "OK — projection-api serves zaak <uuid> with status INGEDIEND"
# 3. Observe the projection directly via the read API (host port 8120).
curl -fsS http://localhost:8120/register | jq
# → [ { "id": "<zaak-uuid>", "status": "INGEDIEND", "bsn": null, "naamPlaceholder": null } ]
# 4. Idempotency + rebuild: replays don't duplicate; a rebuild repopulates from the
# notification log (no OpenZaak access needed — ADR-0008).
curl -fsS -X POST http://localhost:8110/admin/rebuild # Event Subscriber, host port 8110
curl -fsS http://localhost:8120/register | jq 'length' # → unchanged
```
> `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.

View File

@@ -15,14 +15,23 @@ and CI cannot drift:
|---|---|---|
| `lint` | `make lint``dotnet format … --verify-no-changes` | .NET 10 SDK |
| `build` | `make build``dotnet build … -c Release` | .NET 10 SDK |
| `unit` | `make unit``dotnet test … -c Release` | .NET 10 SDK |
| `compose-smoke` | `make smoke` → seed config volumes → `up -d` (full stack) → `up --wait` durable services → `down` | container engine + compose v2 |
| `unit` | `make unit``dotnet test … -c Release --filter "Category!=Integration"` | .NET 10 SDK |
| `mutation` | `make mutation``dotnet tool restore``dotnet stryker` (ACL); uploads the HTML report as an artifact | .NET 10 SDK |
| `verify-stack` | the single live-stack stage — steps: `make verify-up` (full stack up + health, the DoD smoke) → `make verify-acl` (ACL ↔ OpenZaak) → `make verify-nrc` (OpenZaak → NRC delivery) → `make down` | container engine + egress (base images, nuget, `selectielijst.openzaak.nl`) |
> **Why one `verify-stack` job, not three.** The single self-hosted runner runs jobs
> **sequentially**, so booting OpenZaak once (instead of once per check) is the
> cheapest layout (issue #58). It subsumes the old `integration`, `notifications`, and
> `compose-smoke` jobs — the bring-up step *is* the "compose up reaches green health"
> gate. No `setup-dotnet`: the ACL test runs in a built image and every check reaches
> services by **container IP** (the runner can't reach published ports — see
> [gitea-actions-gotchas.md §5/§6](gitea-actions-gotchas.md)).
All `uses:` references are absolute, tag-pinned URLs (`https://github.com/actions/checkout@v4`,
`https://github.com/actions/setup-dotnet@v4`) per CLAUDE.md §8.7 and §15 — Gitea
Actions resolves them from GitHub.
> **`compose-smoke` runs on a containerized runner.** Workspace bind mounts do
> **`verify-stack` runs on a containerized runner.** Workspace bind mounts do
> **not** reach the sibling containers Compose starts, so config/assets are
> streamed into external named volumes via `docker cp` (`infra/seed-config.sh`),
> and the upstream images are used verbatim (no build). If you add a service that
@@ -30,6 +39,38 @@ Actions resolves them from GitHub.
> bare `docker compose up` no longer self-seeds; use `make up`. See
> [gitea-actions-gotchas.md](gitea-actions-gotchas.md).
## Mutation testing (the ratchet)
The `mutation` job enforces test *strength*, not just coverage (CLAUDE.md §5).
[Stryker.NET](https://stryker-mutator.io/docs/stryker-net/) is pinned as a local
dotnet tool (`.config/dotnet-tools.json`), so it runs identically locally and in CI:
```bash
make mutation # dotnet tool restore + dotnet stryker on the ACL
```
Config lives in [`services/acl/stryker-config.json`](../../services/acl/stryker-config.json).
It runs in **solution mode** against `Acl.slnx`, mutating the two projects under test
(`Acl.Application`, `Acl.Infrastructure`); `Acl.Api` has no tests and is skipped.
**Baseline (the ratchet):** the ACL is the first service with branching logic, so it
sets the repo-wide baseline. Observed score **95%**; enforced `break` threshold **90%**
(one-mutant headroom over the ~20-mutant surface). Stryker exits non-zero — failing the
job — when the score drops below `break`. Per §5 the baseline only moves **up**, and only
as a slice's stated outcome; never lower it. New services add their own mutation run as
they gain logic.
The HTML report is written to `services/acl/StrykerOutput/<timestamp>/reports/` (git-ignored);
open it to see survived vs. killed mutants.
In CI the `mutation` job publishes that report as the **`acl-mutation-report`** artifact
(download it from the run's summary page). The upload step uses `if: always()`, so the
report is available even when the ratchet *fails* — which is exactly when you want to inspect
the survivors. It is the repo's first use of `actions/upload-artifact`, pinned to **`@v3`**:
`@v4` refuses to run on Gitea (its `@actions/artifact` v2 library blocks any non-github.com
server as "GHES"), while `@v3` speaks the artifact protocol Gitea implements. See
[gitea-actions-gotchas.md §4](gitea-actions-gotchas.md) (§15).
## Running the stack locally without `make` (Windows / Docker Desktop)
`make` and the bash helpers assume a Unix shell. To bring the whole stack up on a
@@ -52,14 +93,24 @@ the containerized CI runner). Keep the two files in sync.
## Running CI locally (`make ci`)
Until the runner exists, run the full pipeline yourself before pushing:
`make ci` runs the exact same checks as the pipeline — handy to run before pushing:
```bash
make ci # lint + build + unit + smoke — what the pipeline runs
make ci # lint + build + unit + mutation + verify — mirrors the pipeline
make lint # or a single stage
make smoke # compose up --wait, curl /health, tear down
make mutation # Stryker.NET ratchet on the ACL
make verify # the live-stack stage: full stack up once → ACL + NRC checks → down
```
> **`make verify`** mirrors the CI `verify-stack` job: it boots the full stack once and
> runs both the ACL ↔ OpenZaak and OpenZaak → NRC checks against it. For fast,
> single-concern local iteration use a lighter throwaway stack instead:
>
> ```bash
> make integration # ACL ↔ OpenZaak only (no NRC)
> make verify-notifications # OpenZaak → NRC delivery only
> ```
**Prerequisites:** .NET 10 SDK, a container engine with Compose v2, and `curl`.
On a **rootless Podman** box (the default dev setup here), the `smoke` target needs

View File

@@ -13,6 +13,7 @@ those containers share a filesystem — or a `localhost` — breaks.
| Bind-mounted config arrives empty | `docker cp` config into external volumes | `infra/seed-config.sh` |
| `docker compose up --wait` is unsupported / flaky | poll health with `docker inspect` | `infra/wait-healthy.sh` |
| `pg_isready` passes before PostGIS is ready | add a `PostGIS_Version()` probe | the db healthchecks |
| `upload-artifact@v4` fails ("not supported on GHES") | pin `@v3` | `.gitea/workflows/ci.yaml` (`mutation` job) |
---
@@ -98,3 +99,83 @@ plus app start.
container that starts migrating in that window can fail on a missing PostGIS. So
the db healthchecks add a `SELECT PostGIS_Version()` probe, making dependents wait
for the extension, not just the port.
---
## 4. `actions/upload-artifact@v4` refuses to run on Gitea
**Symptom** — the `mutation` job's `make mutation` step passes (95% score), but the
upload step right after it fails the job:
```
::error::@actions/artifact v2.0.0+, upload-artifact@v4+ and download-artifact@v4+
are not currently supported on GHES.
❌ Failure - Main https://github.com/actions/upload-artifact@v4
```
**Why**`upload-artifact@v4` bundles `@actions/artifact` v2, which inspects the
server URL and **hard-aborts on anything that isn't `github.com`**, treating Gitea
as an unsupported GitHub Enterprise Server. The check fires regardless of whether
the Gitea server can actually store artifacts (1.24+ can). It is the *action*, not
the server, that refuses.
**Fix** — pin **`actions/upload-artifact@v3`** (and `download-artifact@v3` if ever
needed). v3 uses the older artifact protocol that Gitea implements, and has no GHES
guard. Inputs are the same (`name`, `path`, `if-no-files-found`), so it is a drop-in
swap. Do **not** bump to `@v4` until act_runner advertises github.com-compatible
artifact support.
---
## 5. A runner process can't reach a service container's published port
**Symptom** — green locally, but a CI step that runs *on the runner* and talks to a
compose service over `localhost` fails. The ACL integration test's seed died with:
```
OpenZaak ready (000)
urllib.error.URLError: <urlopen error [Errno 111] Connection refused>
make: *** [Makefile:114: integration] Error 1
```
OpenZaak was demonstrably up — uwsgi had been serving for ~2 minutes — yet
`curl`/`urllib` to `localhost:8000` from the runner were refused the whole time.
**Why** — the same sibling-container split as §1. Compose starts the stack via the
host daemon, so `ports: ["8000:8000"]` publishes to the *daemon host*, not to the job
container. From the runner, `localhost:8000` has nothing listening. (`make smoke`
sidesteps this by polling readiness via `docker inspect` (§2), never a service port.)
**Fix** — don't talk to service ports from the runner. Either check state via `docker
inspect` (health), or run the client **inside the compose network** so it reaches the
service by name (`http://openzaak:8000`). For a test/seed that needs the repo's own
code, deliver it via a **built image** (not a bind mount — §1), then
`docker run --network <stack>_cg …`.
**Applied**`make integration` (ADR-0006) and `make verify-notifications` (ADR-0007)
do exactly this: they run the seed/test/driver as containers on the stack network and
reach services by **container IP** (see §6).
---
## 6. OpenZaak / NRC reject single-label hosts in URLs
**Symptom** — talking to OpenZaak or NRC by compose **service name** fails where a URL
is validated: catalogus/zaaktype filters, the zaak `zaaktype` URL, and abonnement
`callbackUrl` come back `400 "Voer een geldige URL in."` — even though the host
resolves and is reachable.
**Why** — these apps validate URLs with Django's `URLValidator`, which rejects a
**single-label** host like `openzaak` or `nrc-web` (no dot, and not `localhost`).
`localhost` passes (so it's invisible in host-port-based local runs); in-network the
reality is a service name or an IPv4 literal — and only the IP passes.
**Fix** — in-network tooling reaches OpenZaak/NRC by **container IP**
(`docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'`), not
service name; the notif verify harness also registers the sink callback by IP.
(`infra/run-acl-integration.sh`, `infra/run-notification-check.sh`.)
**Related — abonnement callbacks must enforce auth.** NRC probes a callback when an
abonnement is registered and refuses it (`no-auth-on-callback-url`) unless it returns
**401** without the configured `Authorization`. The verify sink
(`infra/notification-sink.py`) enforces a bearer token for exactly this reason.

View File

@@ -59,7 +59,8 @@ services:
CELERY_BROKER_URL: redis://oz-redis:6379/1
CELERY_RESULT_BACKEND: redis://oz-redis:6379/1
DISABLE_2FA: "true"
NOTIFICATIONS_DISABLED: "true"
# Publish notifications to NRC (always present in this twin). See ADR-0007.
NOTIFICATIONS_DISABLED: "false"
OPENZAAK_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin
OPENZAAK_SUPERUSER_EMAIL: admin@localhost
@@ -122,7 +123,9 @@ services:
networks: [cg]
nrc-init:
# Migrations only (see the canonical compose / ADR-0002); no config needed.
# Migrations + setup_configuration (S-01-c): the JWT credential, Autorisaties-API
# delegation, and the `zaken` kanaal that let OpenZaak publish. Config is
# bind-mounted here (this twin is the local/no-make path). See ADR-0007.
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: &nrc-env
DJANGO_SETTINGS_MODULE: nrc.conf.docker
@@ -141,7 +144,11 @@ services:
OPENNOTIFICATIES_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin
OPENNOTIFICATIES_SUPERUSER_EMAIL: admin@localhost
command: ["sh", "-c", "/wait_for_db.sh && OTEL_SDK_DISABLED=True python src/manage.py migrate"]
RUN_SETUP_CONFIG: "true"
NOTIFICATION_SEC_INTERVAL: "5"
command: /setup_configuration.sh
volumes:
- ./opennotificaties/setup_configuration:/app/setup_configuration:ro,z
depends_on:
nrc-db:
condition: service_healthy
@@ -176,6 +183,17 @@ services:
condition: service_completed_successfully
networks: [cg]
# Celery beat drains scheduled notifications to subscribers — required for
# delivery, not optional. See ADR-0007.
nrc-beat:
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: *nrc-env
command: /celery_beat.sh
depends_on:
nrc-init:
condition: service_completed_successfully
networks: [cg]
# ── Keycloak (S-02) ──────────────────────────────────────────────────────
keycloak:
image: quay.io/keycloak/keycloak:26.1
@@ -287,10 +305,68 @@ services:
start_period: 10s
networks: [cg]
# ── Read projection (S-06) ────────────────────────────────────────────────
projection-db:
image: docker.io/library/postgres:16
environment:
POSTGRES_USER: projection
POSTGRES_PASSWORD: projection
POSTGRES_DB: projection
volumes:
- projection-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U projection -d projection"]
interval: 5s
timeout: 3s
retries: 10
networks: [cg]
event-subscriber:
build:
context: ..
dockerfile: services/event-subscriber/Dockerfile
image: register-referentie/event-subscriber:dev
environment:
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
ports:
- "8110:8080"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s
timeout: 3s
retries: 5
start_period: 15s
depends_on:
projection-db:
condition: service_healthy
networks: [cg]
projection-api:
build:
context: ..
dockerfile: services/projection-api/Dockerfile
image: register-referentie/projection-api:dev
environment:
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
ports:
- "8120:8080"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s
timeout: 3s
retries: 5
start_period: 15s
depends_on:
projection-db:
condition: service_healthy
networks: [cg]
volumes:
oz-db:
nrc-db:
flowable-db:
projection-db:
networks:
cg:

View File

@@ -9,6 +9,8 @@
# 8080 BFF GET /health → Healthy
# 8090 Flowable REST http://localhost:8090/flowable-rest/service/
# 8100 ACL GET /health → Healthy POST /zaken
# 8110 Event Subscriber GET /health → Healthy POST /notifications
# 8120 projection-api GET /health → Healthy GET /register
# 8180 Keycloak (admin: admin / admin)
#
# docker compose -f infra/docker-compose.yml up -d --build --wait
@@ -62,7 +64,10 @@ services:
CELERY_BROKER_URL: redis://oz-redis:6379/1
CELERY_RESULT_BACKEND: redis://oz-redis:6379/1
DISABLE_2FA: "true"
NOTIFICATIONS_DISABLED: "true"
# Publish notifications to NRC (always present in this full stack). The NRC
# service + notifications_config are provisioned by setup_configuration
# (infra/openzaak/setup_configuration/data.yaml). See ADR-0007 / S-01-c.
NOTIFICATIONS_DISABLED: "false"
OPENZAAK_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin
OPENZAAK_SUPERUSER_EMAIL: admin@localhost
@@ -146,11 +151,17 @@ services:
OPENNOTIFICATIES_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin
OPENNOTIFICATIES_SUPERUSER_EMAIL: admin@localhost
# Migrations only for now. No setup_configuration steps are enabled yet (the
# OZ<->NRC notification wiring lands in S-06), and NRC's `setup_configuration`
# aborts with "No steps enabled" on the empty data.yaml — so we run `migrate`
# directly instead of /setup_configuration.sh. See data.yaml and ADR-0002.
command: ["sh", "-c", "/wait_for_db.sh && OTEL_SDK_DISABLED=True python src/manage.py migrate"]
RUN_SETUP_CONFIG: "true"
# nrc-beat fires `execute_notifications` this often to drain scheduled
# notifications to subscribers (upstream default 20s). See ADR-0007.
NOTIFICATION_SEC_INTERVAL: "5"
# Runs migrations + setup_configuration (S-01-c): the JWT credential, the
# Autorisaties-API delegation, and the `zaken` kanaal that let OpenZaak publish.
# data.yaml is streamed into rr-nrc-config by infra/seed-config.sh (bind mounts
# don't reach sibling containers on the CI runner). See data.yaml + ADR-0007.
command: /setup_configuration.sh
volumes:
- nrc-config:/app/setup_configuration:ro
depends_on:
nrc-db:
condition: service_healthy
@@ -185,6 +196,18 @@ services:
condition: service_completed_successfully
networks: [cg]
# Celery beat drains the ScheduledNotification rows the API creates on publish
# and hands them to the worker. Without it, notifications are accepted but never
# delivered to subscribers — required, not optional. See ADR-0007.
nrc-beat:
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: *nrc-env
command: /celery_beat.sh
depends_on:
nrc-init:
condition: service_completed_successfully
networks: [cg]
# ── Keycloak (S-02) ──────────────────────────────────────────────────────
keycloak:
image: quay.io/keycloak/keycloak:26.1
@@ -299,16 +322,85 @@ services:
start_period: 10s
networks: [cg]
# ── Read projection (S-06) ────────────────────────────────────────────────
# One Postgres DB backing the rebuildable read projection (PRD §8.4): the Event
# Subscriber writes it, projection-api reads it. See ADR-0008.
projection-db:
image: docker.io/library/postgres:16
environment:
POSTGRES_USER: projection
POSTGRES_PASSWORD: projection
POSTGRES_DB: projection
volumes:
- projection-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U projection -d projection"]
interval: 5s
timeout: 3s
retries: 10
networks: [cg]
# Consumes NRC notifications (abonnement callback) and projects zaak-created events
# into register_projection. Build context is the repo root: it shares the read model
# in services/projection-api/Projection.ReadModel.
event-subscriber:
build:
context: ..
dockerfile: services/event-subscriber/Dockerfile
image: register-referentie/event-subscriber:dev
environment:
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
# The bearer Open Notificaties must present on the abonnement callback. NRC's
# registration probe expects a 401 without it (ADR-0007). Dev-only token.
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
ports:
- "8110:8080"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s
timeout: 3s
retries: 5
start_period: 15s
depends_on:
projection-db:
condition: service_healthy
networks: [cg]
# The read side of the projection. Shares Projection.ReadModel, so build context is root.
projection-api:
build:
context: ..
dockerfile: services/projection-api/Dockerfile
image: register-referentie/projection-api:dev
environment:
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
ports:
- "8120:8080"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s
timeout: 3s
retries: 5
start_period: 15s
depends_on:
projection-db:
condition: service_healthy
networks: [cg]
volumes:
oz-db:
nrc-db:
flowable-db:
projection-db:
# Config volumes — created and populated out-of-band by infra/seed-config.sh
# (docker cp), because bind mounts don't reach sibling containers on the CI
# runner. `external` keeps the names deterministic; the seed step manages them.
oz-config:
external: true
name: rr-oz-config
nrc-config:
external: true
name: rr-nrc-config
kc-realms:
external: true
name: rr-kc-realms

39
infra/notification-sink.py Executable file
View File

@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""A throwaway webhook sink for verifying the OpenZaak → NRC notification path.
NRC delivers abonnement callbacks here as POSTs; each body is printed to stdout
(prefixed `NOTIFICATION `) so the verify harness can assert on `docker logs`.
NRC refuses to register an abonnement whose callback is unauthenticated
(`no-auth-on-callback`): when validating it sends a probe and expects the callback
to reject a request without the configured `Authorization` value. So this sink
enforces that header (EXPECTED_AUTH env) — 401 without it, 204 with it.
Stdlib only. Listens on :9000. See infra/verify-notifications.sh / S-01-c (#56).
"""
import http.server
import os
import sys
EXPECTED_AUTH = os.environ.get("EXPECTED_AUTH", "Bearer notification-sink-token")
class Handler(http.server.BaseHTTPRequestHandler):
def do_POST(self):
length = int(self.headers.get("content-length", 0))
body = self.rfile.read(length).decode("utf-8", "replace")
if self.headers.get("Authorization") != EXPECTED_AUTH:
self.send_response(401)
self.end_headers()
return
print("NOTIFICATION " + body, flush=True)
self.send_response(204)
self.end_headers()
def log_message(self, *args): # silence default request logging
pass
if __name__ == "__main__":
http.server.HTTPServer(("0.0.0.0", 9000), Handler).serve_forever()
sys.exit(0)

View File

@@ -52,11 +52,18 @@ services:
OPENNOTIFICATIES_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin
OPENNOTIFICATIES_SUPERUSER_EMAIL: admin@localhost
# Migrations only for now. No setup_configuration steps are enabled yet (the
# OZ<->NRC notification wiring lands in S-06), and NRC's `setup_configuration`
# aborts with "No steps enabled" on the empty data.yaml — so we run `migrate`
# directly instead of /setup_configuration.sh. See data.yaml and ADR-0002.
command: ["sh", "-c", "/wait_for_db.sh && OTEL_SDK_DISABLED=True python src/manage.py migrate"]
RUN_SETUP_CONFIG: "true"
# Delivery cadence: nrc-beat fires `execute_notifications` this often to drain
# scheduled notifications to subscribers. Upstream default is 20s; 5s keeps the
# walking-skeleton + the verify smoke responsive.
NOTIFICATION_SEC_INTERVAL: "5"
# Runs migrations + setup_configuration (S-01-c): the JWT credential, the
# Autorisaties-API delegation, and the `zaken` kanaal that let OpenZaak publish
# notifications. data.yaml is streamed into this external volume by
# infra/seed-config.sh (same pattern as oz-init). See data.yaml + ADR-0006.
command: /setup_configuration.sh
volumes:
- nrc-config:/app/setup_configuration:ro
depends_on:
nrc-db:
condition: service_healthy
@@ -89,8 +96,25 @@ services:
condition: service_completed_successfully
networks: [cg]
# Celery beat: periodically fires `execute_notifications`, which drains the
# ScheduledNotification rows the API creates on publish and hands them to the
# worker for delivery. Without beat, notifications are accepted but never
# delivered to subscribers — so it is required, not optional. See ADR-0007.
nrc-beat:
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: *nrc-env
command: /celery_beat.sh
depends_on:
nrc-init:
condition: service_completed_successfully
networks: [cg]
volumes:
nrc-db:
# populated out-of-band by infra/seed-config.sh (docker cp) — see that script.
nrc-config:
external: true
name: rr-nrc-config
networks:
cg:

View File

@@ -1,5 +1,41 @@
# Open Notificaties setup_configuration.
# Stage 1 (this commit): intentionally minimal — the init container runs
# migrations; no steps enabled yet. The OpenZaak<->NRC notification wiring
# (Services, Authorization, JWT, Kanalen) is added next. See ADR-0002 / S-01-c.
{}
# Open Notificaties (NRC) setup_configuration (S-01-c, #56).
# Wires NRC so OpenZaak can publish notifications:
# - the JWT credential OpenZaak authenticates with,
# - delegation of authorization checks to OpenZaak's Autorisaties API (AC),
# - the `zaken` kanaal OpenZaak publishes zaak events on.
# Dev-only credentials — not for production. Steps from nrc.setup_configuration.
# 1. JWT credential NRC uses to verify the token OpenZaak presents.
vng_api_common_credentials_config_enable: true
vng_api_common_credentials:
items:
- identifier: big-reference-seed
secret: insecure-dev-secret-change-me
# 2. The Autorisaties API (OpenZaak's AC) NRC consults to authorize publishers.
zgw_consumers_config_enable: true
zgw_consumers:
services:
- identifier: openzaak-ac
label: OpenZaak Autorisaties API
api_type: ac
api_root: http://openzaak:8000/autorisaties/api/v1/
auth_type: zgw
client_id: big-reference-seed
secret: insecure-dev-secret-change-me
# 3. Delegate authorization to that AC.
autorisaties_api_config_enable: true
autorisaties_api:
authorizations_api_service_identifier: openzaak-ac
# 4. The kanaal OpenZaak publishes zaak events on.
notifications_kanalen_config_enable: true
notifications_kanalen_config:
items:
- naam: zaken
documentatie_link: https://github.com/VNG-Realisatie/gemma-zaken
filters:
- bronorganisatie
- zaaktype
- vertrouwelijkheidaanduiding

View File

@@ -47,9 +47,12 @@ services:
CELERY_BROKER_URL: redis://oz-redis:6379/1
CELERY_RESULT_BACKEND: redis://oz-redis:6379/1
DISABLE_2FA: "true"
# Notifications go to Open Notificaties (NRC), which arrives in S-01-c.
# Until then, disable outbound notifications so writes don't 500.
NOTIFICATIONS_DISABLED: "true"
# Notifications are OFF by default so OpenZaak-only bring-ups (openzaak-up,
# the ACL integration test) don't 500 trying to reach an absent NRC. When
# OpenZaak runs together with the NRC stack, set OZ_NOTIFICATIONS_DISABLED=false
# (make stack-up does) to publish; the NRC service + notifications_config that
# name it are provisioned by setup_configuration (data.yaml, S-01-c).
NOTIFICATIONS_DISABLED: "${OZ_NOTIFICATIONS_DISABLED:-true}"
OPENZAAK_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin
OPENZAAK_SUPERUSER_EMAIL: admin@localhost

View File

@@ -18,6 +18,16 @@ SECRET = os.environ.get("OZ_SECRET", "insecure-dev-secret-change-me")
ZTC = f"{BASE}/catalogi/api/v1"
RSIN = "517439943" # elfproef-valid test RSIN
# Opt-in: also publish the zaaktype so OpenZaak's Zaken API accepts a zaak against
# it (a concept zaaktype is rejected with `not-published`). Off by default — the
# S-01 compose seed keeps it a concept (ADR-0002). The ACL integration test
# (S-04a, #46) sets OZ_PUBLISH=1. Publishing requires ≥2 statustypen, ≥1 roltype
# and ≥1 resultaattype; the resultaattype is validated against the external
# Selectielijst reference API, so this path needs outbound access to it. See ADR-0006.
PUBLISH = os.environ.get("OZ_PUBLISH", "").lower() in ("1", "true", "yes")
SELECTIELIJST = os.environ.get(
"OZ_SELECTIELIJST", "https://selectielijst.openzaak.nl/api/v1").rstrip("/")
def token():
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=")
@@ -52,6 +62,68 @@ def find(path):
return body.get("results", [])
def selectielijst(path):
"""GET the external Selectielijst reference API (no auth). Used only when publishing."""
req = urllib.request.Request(f"{SELECTIELIJST}{path}", headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())
def publish_zaaktype(zt):
"""Add the relations OpenZaak requires to publish, then publish (idempotent).
Publish validation (verified against OpenZaak 1.28.2) demands: ≥2 statustypen
(begin + eind), ≥1 roltype, ≥1 resultaattype. A resultaattype needs a
Selectielijst `selectielijstklasse` whose procestype matches the zaaktype's
`selectielijstProcestype`, plus a `resultaattypeomschrijving`.
"""
have_st = {s.get("volgnummer") for s in find(f"/statustypen?zaaktype={zt['url']}&status=alles")}
for volgnummer, omschrijving in [(1, "Ontvangen"), (2, "Afgehandeld")]:
if volgnummer not in have_st:
st, body = api("POST", "/statustypen", {
"omschrijving": omschrijving, "zaaktype": zt["url"], "volgnummer": volgnummer})
if st != 201:
sys.exit(f"create statustype {volgnummer} -> {st}: {json.dumps(body, indent=2)}")
print(f"create statustype {volgnummer} ({omschrijving})")
if find(f"/roltypen?zaaktype={zt['url']}&status=alles"):
print("skip roltype Aanvrager")
else:
st, body = api("POST", "/roltypen", {
"zaaktype": zt["url"], "omschrijving": "Aanvrager", "omschrijvingGeneriek": "initiator"})
if st != 201:
sys.exit(f"create roltype -> {st}: {json.dumps(body, indent=2)}")
print("create roltype Aanvrager")
if find(f"/resultaattypen?zaaktype={zt['url']}&status=alles"):
print("skip resultaattype Geregistreerd")
else:
resultaat = selectielijst("/resultaten?pageSize=1")["results"][0]
omschrijvingen = selectielijst("/resultaattypeomschrijvingen")
oms = (omschrijvingen if isinstance(omschrijvingen, list) else omschrijvingen["results"])[0]["url"]
# The selectielijstklasse and the zaaktype must share a procestype.
st, body = api("PATCH", zt["url"], {"selectielijstProcestype": resultaat["procesType"]})
if st != 200:
sys.exit(f"set procestype -> {st}: {json.dumps(body, indent=2)}")
st, body = api("POST", "/resultaattypen", {
"zaaktype": zt["url"], "omschrijving": "Geregistreerd",
"resultaattypeomschrijving": oms, "selectielijstklasse": resultaat["url"],
"archiefnominatie": "blijvend_bewaren",
"brondatumArchiefprocedure": {"afleidingswijze": "afgehandeld"},
})
if st != 201:
sys.exit(f"create resultaattype -> {st}: {json.dumps(body, indent=2)}")
print("create resultaattype Geregistreerd")
if zt.get("concept", True):
st, body = api("POST", f"{zt['url']}/publish")
if st != 200:
sys.exit(f"publish zaaktype -> {st}: {json.dumps(body, indent=2)}")
print(f"publish zaaktype BIG-REGISTRATIE ({zt['url']})")
else:
print("skip publish (already published)")
def main():
# 1. Catalogus
existing = [c for c in find(f"/catalogussen?domein=BIG") if c.get("domein") == "BIG"]
@@ -121,16 +193,24 @@ def main():
else:
print("warn zaaktype already published; cannot add bsn eigenschap")
# Intentionally NOT published. Publishing requires roltypen, resultaattypen
# and statustypen, which go beyond the "lean / schema-mandatory" zaaktype this
# slice asks for; they arrive with the workflow/zaak slices. See ADR-0002.
# 4. Optionally publish. By default the zaaktype stays a concept: publishing
# requires roltypen, resultaattypen and statustypen, beyond the "lean /
# schema-mandatory" zaaktype S-01 asks for (ADR-0002). Set OZ_PUBLISH=1 to add
# those relations and publish — needed so a real zaak POST is accepted, which
# the ACL integration test (S-04a, #46) exercises. See ADR-0006.
if PUBLISH:
# Re-fetch: the bsn-eigenschap branch above may hold a stale concept flag.
zt = next(z for z in find(f"/zaaktypen?catalogus={cat['url']}&status=alles")
if z.get("identificatie") == "BIG-REGISTRATIE")
publish_zaaktype(zt)
# 4. Verify the JWT client can list the zaaktype (concepts included).
# 5. Verify the JWT client can list the zaaktype (concepts included).
zaaktypen = find(f"/zaaktypen?catalogus={cat['url']}&status=alles")
names = [z.get("identificatie") for z in zaaktypen]
print(f"zaaktypen in BIG: {names}")
assert "BIG-REGISTRATIE" in names, "BIG-REGISTRATIE not listed"
print("OK — BIG catalogus seeded (BIG-REGISTRATIE concept + bsn eigenschap)")
state = "published" if PUBLISH else "concept"
print(f"OK — BIG catalogus seeded (BIG-REGISTRATIE {state} + bsn eigenschap)")
if __name__ == "__main__":

View File

@@ -20,3 +20,22 @@ vng_api_common_applicaties:
- big-reference-seed
label: BIG reference seed client
heeft_alle_autorisaties: true
# ── OpenZaak → Open Notificaties (NRC) publishing (S-01-c, #56) ─────────────
# The NRC service OpenZaak posts notifications to, authenticating with the same
# big-reference-seed client (NRC verifies the JWT and authorizes it via the AC).
zgw_consumers_config_enable: true
zgw_consumers:
services:
- identifier: nrc
label: Open Notificaties
api_type: nrc
api_root: http://nrc-web:8000/api/v1/
auth_type: zgw
client_id: big-reference-seed
secret: insecure-dev-secret-change-me
# Point OpenZaak's notifications at that service. Requires NOTIFICATIONS_DISABLED=false.
notifications_config_enable: true
notifications_config:
notifications_api_service_identifier: nrc

37
infra/run-acl-integration.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
#
# Run the ACL integration tests (Category=Integration) against the OpenZaak that is
# ALREADY running — works for any stack: oz-only (`make integration`), the standalone
# oz+nrc stack, or the full compose stack (the CI `verify-stack` job). Seeds a
# published BIG zaaktype (idempotent), then builds + runs the test image on the stack
# network, reaching OpenZaak by container IP (a single-label host isn't URL-valid;
# the runner can't reach published ports — see gitea-actions-gotchas.md §5/§6).
#
# Does NOT manage the stack lifecycle: the caller owns bring-up + teardown. Plain
# docker primitives only (docker/podman-portable). See ADR-0006.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(cd "$here/.." && pwd)"
# The OpenZaak API container, matched across compose projects + docker/podman naming
# (`<project>[-_]openzaak[-_]<n>`); the delimiters exclude oz-db / oz-redis / oz-init.
oz="$(docker ps -q --filter 'name=[-_]openzaak[-_]' | head -1)"
[ -n "$oz" ] || { echo "ERROR: no running OpenZaak container found — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$oz" | head -1)"
oz_ip="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$oz")"
oz_base="http://$oz_ip:8000"
echo ">> OpenZaak at $oz_base on network $net"
echo ">> seeding a published BIG zaaktype (idempotent)"
sid="$(docker create --network "$net" -e "OZ_BASE=$oz_base" -e OZ_PUBLISH=1 \
python:3-slim python /seed.py)"
docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null
docker start -a "$sid"
docker rm -f "$sid" >/dev/null
echo ">> building the integration test image"
docker build -f "$root/services/acl/Dockerfile.integration" -t rr-acl-integration "$root/services/acl"
echo ">> running the ACL integration tests (inside the network)"
docker run --rm --network "$net" -e "OZ_BASE=$oz_base" rr-acl-integration

34
infra/run-integration.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
#
# Local convenience: run the ACL integration test against a throwaway OpenZaak-only
# stack (fast iteration on the ACL gateway). Brings OpenZaak up, runs the shared
# stack-agnostic check (infra/run-acl-integration.sh), then always tears down.
#
# CI does not use this — there the full stack is brought up once and the same runner
# is invoked as a step (see the `verify-stack` job / Makefile `verify-*`). See ADR-0006.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OZ_COMPOSE="$here/openzaak/docker-compose.yml"
cleanup() {
docker compose -f "$OZ_COMPOSE" down --volumes >/dev/null 2>&1 || true
docker volume rm -f rr-oz-config >/dev/null 2>&1 || true
}
trap cleanup EXIT
echo ">> bringing OpenZaak up"
bash "$here/seed-config.sh" oz
docker compose -f "$OZ_COMPOSE" up -d
echo ">> waiting for the OpenZaak API container to be healthy"
for _ in $(seq 1 140); do
oz="$(docker ps -q --filter 'name=[-_]openzaak[-_]' | head -1)"
if [ -n "$oz" ] && [ "$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$oz" 2>/dev/null || true)" = healthy ]; then
break
fi
sleep 3
done
[ -n "${oz:-}" ] || { echo "ERROR: OpenZaak never came up" >&2; exit 1; }
bash "$here/run-acl-integration.sh"

72
infra/run-notification-check.sh Executable file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/env bash
#
# Verify the OpenZaak → NRC notification path against an ALREADY-RUNNING oz+nrc stack
# (the standalone stack via `make verify-notifications`, or the full compose stack in
# the CI `verify-stack` job). Seeds a published BIG zaaktype (idempotent), registers
# an abonnement to a webhook sink, creates a zaak, and asserts the sink receives the
# `zaken`/`create` notification. All in-network, reaching services by container IP
# (single-label hosts aren't URL-valid; the runner can't reach published ports).
#
# Does NOT manage the stack lifecycle (the caller owns bring-up + teardown), but it
# cleans up the throwaway sink/driver it creates. Plain docker primitives only.
# See ADR-0007.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SINK_AUTH="Bearer notification-sink-token"
cleanup() { docker rm -f rr-nsink rr-nverify >/dev/null 2>&1 || true; }
trap cleanup EXIT
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
oz="$(docker ps -q --filter 'name=[-_]openzaak[-_]' | head -1)"
nrc="$(docker ps -q --filter 'name=nrc-web' | head -1)"
[ -n "$oz" ] && [ -n "$nrc" ] || { echo "ERROR: OpenZaak and/or NRC not running — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$oz" | head -1)"
oz_ip="$(ip "$oz")"; nrc_ip="$(ip "$nrc")"
echo ">> network=$net openzaak=$oz_ip nrc=$nrc_ip"
echo ">> seeding a published BIG zaaktype (idempotent)"
sid="$(docker create --network "$net" -e "OZ_BASE=http://$oz_ip:8000" -e OZ_PUBLISH=1 \
python:3-slim python /seed.py)"
docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null
docker start -a "$sid"
docker rm -f "$sid" >/dev/null
echo ">> starting the webhook sink"
docker rm -f rr-nsink >/dev/null 2>&1 || true
sink="$(docker create --network "$net" --name rr-nsink -e "EXPECTED_AUTH=$SINK_AUTH" \
python:3-slim python /sink.py)"
docker cp "$here/notification-sink.py" "$sink:/sink.py" >/dev/null
docker start "$sink" >/dev/null
sleep 1
sink_ip="$(ip rr-nsink)"
echo ">> sink at $sink_ip:9000"
echo ">> registering abonnement + creating a zaak"
docker rm -f rr-nverify >/dev/null 2>&1 || true
drv="$(docker create --network "$net" --name rr-nverify \
-e "OZ_BASE=http://$oz_ip:8000" -e "NRC_BASE=http://$nrc_ip:8000" \
-e "SINK_CALLBACK=http://$sink_ip:9000/" -e "SINK_AUTH=$SINK_AUTH" \
python:3-slim python /driver.py)"
docker cp "$here/verify-notification-driver.py" "$drv:/driver.py" >/dev/null
docker start -a "$drv"
zaak_url="$(docker logs rr-nverify 2>/dev/null | sed -n 's/^ZAAK_CREATED //p' | head -1)"
docker rm -f rr-nverify >/dev/null
[ -n "$zaak_url" ] || { echo "ERROR: driver did not create a zaak" >&2; exit 1; }
zaak_uuid="${zaak_url##*/}"
echo ">> zaak created: $zaak_url"
echo ">> waiting for the notification to reach the sink"
for _ in $(seq 1 30); do
if docker logs rr-nsink 2>&1 | grep -q "$zaak_uuid"; then
echo "OK — NRC delivered the zaken notification for zaak $zaak_uuid to the sink"
docker logs rr-nsink 2>&1 | grep "$zaak_uuid" | tail -1 | cut -c1-300
exit 0
fi
sleep 2
done
echo "FAIL — the sink never received a notification for zaak $zaak_uuid" >&2
echo "--- sink log ---" >&2; docker logs rr-nsink 2>&1 | tail -8 >&2
exit 1

68
infra/run-projection-check.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
#
# Verify the end-to-end read-projection path (S-06) against an ALREADY-RUNNING full stack:
# OpenZaak → NRC → Event Subscriber → projection → projection-api. Seeds a published BIG
# zaaktype (idempotent), registers an abonnement on the `zaken` kanaal pointing at the real
# Event Subscriber's /notifications callback (with the bearer it enforces), creates a zaak,
# and asserts projection-api serves a row for that zaak with status INGEDIEND.
#
# All in-network, reaching services by container IP — single-label hosts aren't URL-valid and
# the runner can't reach published ports (gitea-actions-gotchas.md §5/§6). Reuses the
# notification driver to register the abonnement + create the zaak. Does NOT manage the stack
# lifecycle (the caller owns bring-up + teardown). Plain docker primitives only. See ADR-0007/0008.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WEBHOOK_AUTH="${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}"
cleanup() { docker rm -f rr-pverify rr-pquery >/dev/null 2>&1 || true; }
trap cleanup EXIT
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
oz="$(docker ps -q --filter 'name=[-_]openzaak[-_]' | head -1)"
nrc="$(docker ps -q --filter 'name=nrc-web' | head -1)"
es="$(docker ps -q --filter 'name=event-subscriber' | head -1)"
proj="$(docker ps -q --filter 'name=projection-api' | head -1)"
[ -n "$oz" ] && [ -n "$nrc" ] || { echo "ERROR: OpenZaak and/or NRC not running — bring the stack up first" >&2; exit 1; }
[ -n "$es" ] && [ -n "$proj" ] || { echo "ERROR: event-subscriber and/or projection-api not running — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$oz" | head -1)"
oz_ip="$(ip "$oz")"; nrc_ip="$(ip "$nrc")"; es_ip="$(ip "$es")"; proj_ip="$(ip "$proj")"
echo ">> network=$net openzaak=$oz_ip nrc=$nrc_ip event-subscriber=$es_ip projection-api=$proj_ip"
echo ">> seeding a published BIG zaaktype (idempotent)"
sid="$(docker create --network "$net" -e "OZ_BASE=http://$oz_ip:8000" -e OZ_PUBLISH=1 \
python:3-slim python /seed.py)"
docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null
docker start -a "$sid"
docker rm -f "$sid" >/dev/null
echo ">> registering abonnement at the Event Subscriber + creating a zaak"
docker rm -f rr-pverify >/dev/null 2>&1 || true
drv="$(docker create --network "$net" --name rr-pverify \
-e "OZ_BASE=http://$oz_ip:8000" -e "NRC_BASE=http://$nrc_ip:8000" \
-e "SINK_CALLBACK=http://$es_ip:8080/notifications" -e "SINK_AUTH=$WEBHOOK_AUTH" \
python:3-slim python /driver.py)"
docker cp "$here/verify-notification-driver.py" "$drv:/driver.py" >/dev/null
docker start -a "$drv"
zaak_url="$(docker logs rr-pverify 2>/dev/null | sed -n 's/^ZAAK_CREATED //p' | head -1)"
docker rm -f rr-pverify >/dev/null
[ -n "$zaak_url" ] || { echo "ERROR: driver did not create a zaak" >&2; exit 1; }
zaak_uuid="${zaak_url##*/}"
echo ">> zaak created: $zaak_url"
echo ">> polling projection-api for the projected row (status INGEDIEND)"
for _ in $(seq 1 30); do
body="$(docker run --rm --network "$net" curlimages/curl:latest \
-fsS "http://$proj_ip:8080/register/$zaak_uuid" 2>/dev/null || true)"
if echo "$body" | grep -q '"INGEDIEND"'; then
echo "OK — projection-api serves zaak $zaak_uuid with status INGEDIEND"
echo "$body" | cut -c1-300
exit 0
fi
sleep 2
done
echo "FAIL — projection-api never served an INGEDIEND row for zaak $zaak_uuid" >&2
echo "--- event-subscriber log ---" >&2; docker logs "$es" 2>&1 | tail -10 >&2
echo "--- projection-api log ---" >&2; docker logs "$proj" 2>&1 | tail -10 >&2
exit 1

View File

@@ -33,11 +33,12 @@ populate() { # volume source(file or dir/.)
echo " seeded $vol"
}
[ "$#" -gt 0 ] || { echo "usage: seed-config.sh <oz|kc|fl> ..." >&2; exit 2; }
[ "$#" -gt 0 ] || { echo "usage: seed-config.sh <oz|nrc|kc|fl> ..." >&2; exit 2; }
for key in "$@"; do
case "$key" in
oz) populate rr-oz-config "$here/openzaak/setup_configuration/." ;;
nrc) populate rr-nrc-config "$here/opennotificaties/setup_configuration/." ;;
kc) populate rr-kc-realms "$here/keycloak/realms/." ;;
fl) populate rr-fl-bpmn "$here/../workflows/registratie.bpmn" ;;
*) echo "unknown seed key: $key" >&2; exit 2 ;;

View File

@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Drive the OpenZaak → NRC notification check from *inside* the compose network.
Registers an abonnement on the `zaken` kanaal pointing at a webhook sink, then
creates a zaak against the published BIG zaaktype. OpenZaak publishes a
`zaken`/`create` notification; NRC delivers it to the sink. The host harness
(infra/verify-notifications.sh) then asserts the sink received it.
Reached by container IP, not service name: OpenZaak/NRC validate URLs with Django's
URLValidator, which rejects a single-label host like `openzaak`. Stdlib only.
Env: OZ_BASE, NRC_BASE, SINK_CALLBACK, SINK_AUTH, OZ_CLIENT_ID, OZ_SECRET.
"""
import base64
import hashlib
import hmac
import json
import os
import sys
import time
import urllib.error
import urllib.request
OZ = os.environ["OZ_BASE"].rstrip("/")
NRC = os.environ["NRC_BASE"].rstrip("/")
SINK_CALLBACK = os.environ["SINK_CALLBACK"]
SINK_AUTH = os.environ.get("SINK_AUTH", "Bearer notification-sink-token")
CID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed")
SECRET = os.environ.get("OZ_SECRET", "insecure-dev-secret-change-me")
RSIN = "517439943"
def token():
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=")
seg = (
b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
+ b"."
+ b64(json.dumps(
{"iss": CID, "iat": int(time.time()), "client_id": CID,
"user_id": "verify", "user_representation": "verify"},
separators=(",", ":")).encode())
)
return (seg + b"." + b64(hmac.new(SECRET.encode(), seg, hashlib.sha256).digest())).decode()
def call(method, url, body=None, crs=False):
headers = {"Authorization": "Bearer " + token(),
"Content-Type": "application/json", "Accept": "application/json"}
if crs:
headers["Accept-Crs"] = "EPSG:4326"
headers["Content-Crs"] = "EPSG:4326"
data = json.dumps(body).encode() if body is not None else None
try:
with urllib.request.urlopen(
urllib.request.Request(url, data=data, method=method, headers=headers), timeout=30
) as r:
return r.status, json.loads(r.read() or "null")
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or "null")
def main():
status, ab = call("POST", f"{NRC}/api/v1/abonnement", {
"callbackUrl": SINK_CALLBACK,
"auth": SINK_AUTH,
"kanalen": [{"naam": "zaken", "filters": {}}],
})
if status != 201:
sys.exit(f"create abonnement -> {status}: {json.dumps(ab)}")
print(f"abonnement: {ab['url']}")
status, body = call(
"GET", f"{OZ}/catalogi/api/v1/zaaktypen?identificatie=BIG-REGISTRATIE&status=definitief")
results = body.get("results", []) if status == 200 else []
if not results:
sys.exit("no published BIG-REGISTRATIE zaaktype — seed with OZ_PUBLISH=1 first")
zaaktype = results[0]["url"]
status, zaak = call("POST", f"{OZ}/zaken/api/v1/zaken", {
"bronorganisatie": RSIN, "verantwoordelijkeOrganisatie": RSIN,
"zaaktype": zaaktype, "startdatum": time.strftime("%Y-%m-%d"),
"vertrouwelijkheidaanduiding": "openbaar",
}, crs=True)
if status != 201:
sys.exit(f"create zaak -> {status}: {json.dumps(zaak)}")
# The harness greps the sink for this exact URL.
print(f"ZAAK_CREATED {zaak['url']}")
if __name__ == "__main__":
main()

41
infra/verify-notifications.sh Executable file
View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
#
# Local convenience: verify the OpenZaak → NRC notification path against a throwaway
# oz+nrc stack. Brings both up (notifications enabled), runs the shared stack-agnostic
# check (infra/run-notification-check.sh), then always tears down.
#
# CI does not use this — there the full stack is brought up once and the same runner
# is invoked as a step (see the `verify-stack` job / Makefile `verify-*`). See ADR-0007.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OZ_COMPOSE="$here/openzaak/docker-compose.yml"
NRC_COMPOSE="$here/opennotificaties/docker-compose.yml"
cleanup() {
docker compose -f "$OZ_COMPOSE" -f "$NRC_COMPOSE" down --volumes >/dev/null 2>&1 || true
docker volume rm -f rr-oz-config rr-nrc-config >/dev/null 2>&1 || true
}
trap cleanup EXIT
wait_healthy() { # name-regex
local re="$1" cid
for _ in $(seq 1 140); do
cid="$(docker ps -q --filter "name=$re" | head -1)"
if [ -n "$cid" ] && [ "$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$cid" 2>/dev/null || true)" = healthy ]; then
return 0
fi
sleep 3
done
return 1
}
echo ">> bringing up OpenZaak + Open Notificaties (notifications enabled)"
bash "$here/seed-config.sh" oz nrc
OZ_NOTIFICATIONS_DISABLED=false docker compose -f "$OZ_COMPOSE" -f "$NRC_COMPOSE" up -d
echo ">> waiting for OpenZaak + NRC to be healthy"
wait_healthy '[-_]openzaak[-_]' || { echo "ERROR: OpenZaak not healthy" >&2; exit 1; }
wait_healthy 'nrc-web' || { echo "ERROR: NRC not healthy" >&2; exit 1; }
bash "$here/run-notification-check.sh"

View File

@@ -26,7 +26,12 @@ nav:
- "ADR-0002: Catalogus design": architecture/adr-0002-catalogus-design.md
- "ADR-0003: ACL default-fill": architecture/adr-0003-default-fill.md
- "ADR-0004: BDD framework": architecture/adr-0004-bdd-framework.md
- "ADR-0005: Mutation testing": architecture/adr-0005-mutation-testing.md
- "ADR-0006: ACL integration test provisioning": architecture/adr-0006-integration-test-provisioning.md
- "ADR-0007: OpenZaak → NRC notification wiring": architecture/adr-0007-notification-wiring.md
- "ADR-0008: Read projection store": architecture/adr-0008-read-projection-store.md
- Working in Gitea: gitea-workflow.md
- Demo script: demo-script.md
- Runbooks:
- CI: runbooks/ci.md

View File

@@ -4,12 +4,22 @@
<Project Path="services/acl/Acl.Api/Acl.Api.csproj" />
<Project Path="services/acl/Acl.Application/Acl.Application.csproj" />
<Project Path="services/acl/Acl.Infrastructure/Acl.Infrastructure.csproj" />
<Project Path="services/acl/Acl.IntegrationTests/Acl.IntegrationTests.csproj" />
<Project Path="services/acl/Acl.Tests/Acl.Tests.csproj" />
</Folder>
<Folder Name="/services/bff/">
<Project Path="services/bff/Bff.Api/Bff.Api.csproj" />
<Project Path="services/bff/Bff.Tests/Bff.Tests.csproj" />
</Folder>
<Folder Name="/services/event-subscriber/">
<Project Path="services/event-subscriber/EventSubscriber.Api/EventSubscriber.Api.csproj" />
<Project Path="services/event-subscriber/EventSubscriber.Application/EventSubscriber.Application.csproj" />
<Project Path="services/event-subscriber/EventSubscriber.Tests/EventSubscriber.Tests.csproj" />
</Folder>
<Folder Name="/services/projection-api/">
<Project Path="services/projection-api/Projection.ReadModel/Projection.ReadModel.csproj" />
<Project Path="services/projection-api/ProjectionApi.Api/ProjectionApi.Api.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/acceptance/Acceptance.csproj" />
</Folder>

View File

@@ -27,6 +27,11 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
// ZRC is a geo API; it requires the CRS headers.
message.Headers.Add("Accept-Crs", "EPSG:4326");
message.Content.Headers.Add("Content-Crs", "EPSG:4326");
// OpenZaak runs behind uwsgi, which rejects a chunked request body with 400.
// JsonContent streams without a known length (→ Transfer-Encoding: chunked),
// so buffer it first to send a Content-Length instead. Only a real OpenZaak
// surfaces this — a stubbed HttpMessageHandler accepts either framing.
await message.Content.LoadIntoBufferAsync(ct);
using var response = await http.SendAsync(message, ct);
response.EnsureSuccessStatusCode();

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Integration tests: they talk to a real OpenZaak (the compose stack), so they
are gated behind [Trait("Category","Integration")] and excluded from the fast
`make unit` / mutation lanes. `make integration` brings the stack up, seeds a
published BIG zaaktype (OZ_PUBLISH=1) and runs this project. See ADR-0006. -->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Acl.Application\Acl.Application.csproj" />
<ProjectReference Include="..\Acl.Infrastructure\Acl.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,97 @@
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Acl.Infrastructure;
namespace Acl.IntegrationTests;
/// <summary>
/// Shared connection to the running OpenZaak compose stack (ADR-0006). Reads the
/// same endpoint + JWT-client config the seed uses, and locates the published
/// BIG-REGISTRATIE zaaktype the ACL opens zaken against. Defaults match
/// `infra/openzaak/seed_catalogus.py`; override via OZ_BASE / OZ_CLIENT_ID / OZ_SECRET.
/// </summary>
public sealed class OpenZaakFixture : IDisposable
{
private static string Env(string key, string fallback) =>
Environment.GetEnvironmentVariable(key) is { Length: > 0 } v ? v : fallback;
public Uri BaseUrl { get; } = new(Env("OZ_BASE", "http://localhost:8000"));
public string ClientId { get; } = Env("OZ_CLIENT_ID", "big-reference-seed");
public string Secret { get; } = Env("OZ_SECRET", "insecure-dev-secret-change-me");
public HttpClient Http { get; } = new();
public OpenZaakOptions Options => new()
{
BaseUrl = BaseUrl,
ClientId = ClientId,
Secret = Secret,
};
/// <summary>
/// The URL of the published BIG-REGISTRATIE zaaktype, or null when none is
/// published yet (a concept-only stack). `status=definitief` returns published
/// zaaktypen only — a concept zaaktype is deliberately excluded.
/// </summary>
public async Task<Uri?> FindPublishedBigZaaktypeAsync(CancellationToken ct = default)
{
var query = new Uri(BaseUrl,
"/catalogi/api/v1/zaaktypen?identificatie=BIG-REGISTRATIE&status=definitief");
using var message = new HttpRequestMessage(HttpMethod.Get, query);
message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MintToken());
using var response = await Http.SendAsync(message, ct);
response.EnsureSuccessStatusCode();
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct));
var results = document.RootElement.GetProperty("results");
return results.GetArrayLength() == 0
? null
: new Uri(results[0].GetProperty("url").GetString()!);
}
/// <summary>GETs a previously-created zaak to prove it was really persisted.</summary>
public async Task<JsonElement> GetZaakAsync(Uri zaakUrl, CancellationToken ct = default)
{
using var message = new HttpRequestMessage(HttpMethod.Get, zaakUrl);
message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MintToken());
message.Headers.Add("Accept-Crs", "EPSG:4326");
using var response = await Http.SendAsync(message, ct);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(ct);
return JsonDocument.Parse(json).RootElement.Clone();
}
// 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.
private string MintToken()
{
static string B64(byte[] b) =>
Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_');
var header = B64(JsonSerializer.SerializeToUtf8Bytes(new { alg = "HS256", typ = "JWT" }));
var payload = B64(JsonSerializer.SerializeToUtf8Bytes(new
{
iss = ClientId,
iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
client_id = ClientId,
user_id = "acl-integration-test",
user_representation = "acl-integration-test",
}));
var signingInput = $"{header}.{payload}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(Secret));
var signature = B64(hmac.ComputeHash(Encoding.UTF8.GetBytes(signingInput)));
return $"{signingInput}.{signature}";
}
public void Dispose() => Http.Dispose();
}
[CollectionDefinition(Name)]
public sealed class OpenZaakCollection : ICollectionFixture<OpenZaakFixture>
{
public const string Name = "OpenZaak";
}

View File

@@ -0,0 +1,45 @@
using Acl.Application;
using Acl.Infrastructure;
namespace Acl.IntegrationTests;
/// <summary>
/// S-04a (#46): the deferred S-04 acceptance criterion — the ACL's OpenZaakGateway
/// opening a zaak against a *real* OpenZaak, exercising real ZGW JWT auth and the
/// real POST /zaken/api/v1/zaken contract (CRS headers, default-fill, the created
/// zaak URL) that the stubbed-HttpMessageHandler unit tests cannot. See ADR-0006.
/// </summary>
[Trait("Category", "Integration")]
[Collection(OpenZaakCollection.Name)]
public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
{
[Fact]
public async Task Opens_a_real_zaak_against_the_published_big_zaaktype_and_returns_its_url()
{
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 request = new ZaakRequest(
Bronorganisatie: "517439943",
VerantwoordelijkeOrganisatie: "517439943",
Vertrouwelijkheidaanduiding: "openbaar",
Zaaktype: zaaktype!,
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow));
var zaakUrl = await gateway.OpenZaakAsync(request);
// The gateway returns the canonical zaak URL on OpenZaak's Zaken API...
Assert.StartsWith(
new Uri(stack.BaseUrl, "/zaken/api/v1/zaken/").ToString(),
zaakUrl.ToString());
// ...and that zaak is really persisted with the default-filled fields.
var zaak = await stack.GetZaakAsync(zaakUrl);
Assert.Equal(zaaktype.ToString(), zaak.GetProperty("zaaktype").GetString());
Assert.Equal("517439943", zaak.GetProperty("bronorganisatie").GetString());
Assert.Equal("openbaar", zaak.GetProperty("vertrouwelijkheidaanduiding").GetString());
}
}

View File

@@ -44,4 +44,21 @@ public class AclServiceTests
Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype);
Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum);
}
[Fact]
public async Task Rejects_a_null_registration_without_calling_the_gateway()
{
var gateway = new FakeGateway();
var defaults = new AclDefaults
{
Bronorganisatie = "517439943",
VerantwoordelijkeOrganisatie = "517439943",
Vertrouwelijkheidaanduiding = "openbaar",
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
};
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
await Assert.ThrowsAsync<ArgumentNullException>(() => service.OpenZaakAsync(null!));
Assert.Null(gateway.Captured);
}
}

View File

@@ -1,5 +1,7 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Acl.Application;
using Acl.Infrastructure;
@@ -14,38 +16,139 @@ public class OpenZaakGatewayTests
=> onSend(request);
}
[Fact]
public async Task Posts_zaak_to_openzaak_with_bearer_and_default_fields_and_returns_url()
private static OpenZaakGateway Gateway(StubHandler handler) => new(
new HttpClient(handler),
new OpenZaakOptions { BaseUrl = new("http://openzaak"), ClientId = "cid", Secret = "sec" });
private static ZaakRequest SampleRequest() => new(
"517439943", "517439943", "openbaar",
new("http://openzaak/catalogi/api/v1/zaaktypen/big"), new DateOnly(2026, 6, 4));
private static StubHandler Created(out RequestCapture capture)
{
HttpRequestMessage? seen = null;
string? body = null;
var handler = new StubHandler(async req =>
var c = new RequestCapture();
capture = c;
return new StubHandler(async req =>
{
seen = req;
body = await req.Content!.ReadAsStringAsync();
c.Seen = req;
// Capture the length BEFORE reading the body: ReadAsStringAsync buffers the
// content and would set ContentLength as a side effect, masking the gateway's
// own buffering. Read here to assert the gateway sent a length (not chunked).
c.ContentLength = req.Content?.Headers.ContentLength;
c.Body = req.Content is null ? null : await req.Content.ReadAsStringAsync();
return new HttpResponseMessage(HttpStatusCode.Created)
{
Content = JsonContent.Create(new { url = "http://openzaak/zaken/api/v1/zaken/xyz" }),
};
});
var gateway = new OpenZaakGateway(
new HttpClient(handler),
new OpenZaakOptions { BaseUrl = new("http://openzaak"), ClientId = "cid", Secret = "sec" });
var request = new ZaakRequest(
"517439943", "517439943", "openbaar",
new("http://openzaak/catalogi/api/v1/zaaktypen/big"), new DateOnly(2026, 6, 4));
}
var url = await gateway.OpenZaakAsync(request);
private sealed class RequestCapture
{
public HttpRequestMessage? Seen;
public string? Body;
public long? ContentLength;
}
[Fact]
public async Task Posts_zaak_to_openzaak_with_bearer_and_default_fields_and_returns_url()
{
var handler = Created(out var capture);
var url = await Gateway(handler).OpenZaakAsync(SampleRequest());
Assert.Equal("http://openzaak/zaken/api/v1/zaken/xyz", url.ToString());
Assert.Equal(HttpMethod.Post, seen!.Method);
Assert.Equal("http://openzaak/zaken/api/v1/zaken", seen.RequestUri!.ToString());
Assert.Equal("Bearer", seen.Headers.Authorization!.Scheme);
Assert.False(string.IsNullOrWhiteSpace(seen.Headers.Authorization.Parameter));
Assert.Contains("\"bronorganisatie\":\"517439943\"", body);
Assert.Contains("\"verantwoordelijkeOrganisatie\":\"517439943\"", body);
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", body);
Assert.Contains("\"startdatum\":\"2026-06-04\"", body);
Assert.Contains("\"zaaktype\":\"http://openzaak/catalogi/api/v1/zaaktypen/big\"", body);
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://openzaak/zaken/api/v1/zaken", capture.Seen.RequestUri!.ToString());
Assert.Equal("Bearer", capture.Seen.Headers.Authorization!.Scheme);
Assert.False(string.IsNullOrWhiteSpace(capture.Seen.Headers.Authorization.Parameter));
Assert.Contains("\"bronorganisatie\":\"517439943\"", capture.Body);
Assert.Contains("\"verantwoordelijkeOrganisatie\":\"517439943\"", capture.Body);
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", capture.Body);
Assert.Contains("\"startdatum\":\"2026-06-04\"", capture.Body);
Assert.Contains("\"zaaktype\":\"http://openzaak/catalogi/api/v1/zaaktypen/big\"", capture.Body);
}
[Fact]
public async Task Sends_the_geo_crs_headers_required_by_the_zaken_api()
{
var handler = Created(out var capture);
await Gateway(handler).OpenZaakAsync(SampleRequest());
Assert.Equal("EPSG:4326", Assert.Single(capture.Seen!.Headers.GetValues("Accept-Crs")));
Assert.Equal("EPSG:4326", Assert.Single(capture.Seen.Content!.Headers.GetValues("Content-Crs")));
}
[Fact]
public async Task Sends_the_body_with_a_content_length_so_it_is_not_chunked()
{
// OpenZaak's uwsgi rejects a chunked request body (400). The gateway buffers
// the body so a Content-Length is sent. JsonContent has no length until
// buffered, so this guards the fix the real-OpenZaak integration test found.
var handler = Created(out var capture);
await Gateway(handler).OpenZaakAsync(SampleRequest());
Assert.NotNull(capture.ContentLength);
Assert.True(capture.ContentLength > 0);
}
[Fact]
public async Task Mints_a_hs256_jwt_carrying_the_acl_identity_claims()
{
var handler = Created(out var capture);
await Gateway(handler).OpenZaakAsync(SampleRequest());
var parts = capture.Seen!.Headers.Authorization!.Parameter!.Split('.');
Assert.Equal(3, parts.Length);
using var header = JsonDocument.Parse(DecodeSegment(parts[0]));
Assert.Equal("HS256", header.RootElement.GetProperty("alg").GetString());
Assert.Equal("JWT", header.RootElement.GetProperty("typ").GetString());
using var payload = JsonDocument.Parse(DecodeSegment(parts[1]));
Assert.Equal("cid", payload.RootElement.GetProperty("client_id").GetString());
Assert.Equal("acl", payload.RootElement.GetProperty("user_id").GetString());
Assert.Equal("acl", payload.RootElement.GetProperty("user_representation").GetString());
}
[Fact]
public async Task Throws_when_openzaak_rejects_the_request()
{
var handler = new StubHandler(_ =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)));
await Assert.ThrowsAsync<HttpRequestException>(
() => Gateway(handler).OpenZaakAsync(SampleRequest()));
}
[Fact]
public async Task Throws_when_openzaak_returns_an_empty_body()
{
var handler = new StubHandler(_ =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.Created)
{
Content = new StringContent("null", Encoding.UTF8, "application/json"),
}));
await Assert.ThrowsAsync<InvalidOperationException>(
() => Gateway(handler).OpenZaakAsync(SampleRequest()));
}
[Fact]
public async Task Rejects_a_null_request()
{
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
await Assert.ThrowsAsync<ArgumentNullException>(
() => Gateway(handler).OpenZaakAsync(null!));
}
// ZGW tokens are base64url with padding stripped (ZgwToken.B64Url); restore it to decode.
private static string DecodeSegment(string segment)
{
var b64 = segment.Replace('-', '+').Replace('_', '/');
b64 = (b64.Length % 4) switch { 2 => b64 + "==", 3 => b64 + "=", _ => b64 };
return Encoding.UTF8.GetString(Convert.FromBase64String(b64));
}
}

View File

@@ -2,5 +2,6 @@
<Project Path="Acl.Api/Acl.Api.csproj" />
<Project Path="Acl.Application/Acl.Application.csproj" />
<Project Path="Acl.Infrastructure/Acl.Infrastructure.csproj" />
<Project Path="Acl.IntegrationTests/Acl.IntegrationTests.csproj" />
<Project Path="Acl.Tests/Acl.Tests.csproj" />
</Solution>

View File

@@ -0,0 +1,26 @@
# Runs the ACL integration tests (Category=Integration) from *inside* the compose
# network, so they reach OpenZaak at http://openzaak:8000 by service name. On the
# hosted CI runner a process on the runner can't reach the stack's published ports
# (sibling containers — gitea-actions-gotchas.md §5), so the test runs as a
# container joined to that network instead. See ADR-0006 / #55.
#
# Build context is services/acl (like the service Dockerfile). dotnet lives in this
# image, so the CI `integration` job needs only Docker — no setup-dotnet step.
FROM mcr.microsoft.com/dotnet/sdk:10.0
WORKDIR /src
# Restore first (cached unless the .csproj files change). The integration test
# project pulls in Acl.Application + Acl.Infrastructure via its ProjectReferences.
COPY Acl.Application/Acl.Application.csproj Acl.Application/
COPY Acl.Infrastructure/Acl.Infrastructure.csproj Acl.Infrastructure/
COPY Acl.IntegrationTests/Acl.IntegrationTests.csproj Acl.IntegrationTests/
RUN dotnet restore Acl.IntegrationTests/Acl.IntegrationTests.csproj
COPY Acl.Application/ Acl.Application/
COPY Acl.Infrastructure/ Acl.Infrastructure/
COPY Acl.IntegrationTests/ Acl.IntegrationTests/
# OZ_BASE is supplied at run time (the OpenZaak container IP — see run-integration.sh,
# which passes `-e OZ_BASE=http://<ip>:8000`; a single-label host is not URL-valid).
ENTRYPOINT ["dotnet", "test", "Acl.IntegrationTests/Acl.IntegrationTests.csproj", \
"-c", "Release", "--filter", "Category=Integration"]

View File

@@ -0,0 +1,12 @@
{
"stryker-config": {
"solution": "Acl.slnx",
"test-projects": ["Acl.Tests/Acl.Tests.csproj"],
"reporters": ["progress", "html"],
"thresholds": {
"high": 95,
"low": 90,
"break": 90
}
}
}

View File

@@ -0,0 +1,32 @@
# Multi-stage build for the Event Subscriber service (.NET 10).
# Build context is services/event-subscriber, but it references the shared read model in
# services/projection-api, so the context is the repo root (see infra/docker-compose.yml).
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Restore first (cached unless .csproj files change).
COPY services/event-subscriber/EventSubscriber.Api/EventSubscriber.Api.csproj services/event-subscriber/EventSubscriber.Api/
COPY services/event-subscriber/EventSubscriber.Application/EventSubscriber.Application.csproj services/event-subscriber/EventSubscriber.Application/
COPY services/projection-api/Projection.ReadModel/Projection.ReadModel.csproj services/projection-api/Projection.ReadModel/
RUN dotnet restore services/event-subscriber/EventSubscriber.Api/EventSubscriber.Api.csproj
COPY services/event-subscriber/ services/event-subscriber/
COPY services/projection-api/Projection.ReadModel/ services/projection-api/Projection.ReadModel/
RUN dotnet publish services/event-subscriber/EventSubscriber.Api/EventSubscriber.Api.csproj -c Release -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/publish .
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
HEALTHCHECK --interval=5s --timeout=3s --start-period=15s --retries=5 \
CMD curl -fsS http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "EventSubscriber.Api.dll"]

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\EventSubscriber.Application\EventSubscriber.Application.csproj" />
<ProjectReference Include="..\..\projection-api\Projection.ReadModel\Projection.ReadModel.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,66 @@
using System.Text.Json;
using EventSubscriber.Application;
using Projection.ReadModel;
var builder = WebApplication.CreateBuilder(args);
var connectionString = builder.Configuration.GetConnectionString("Projection")
?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Projection'");
// The exact Authorization header value Open Notificaties sends on each abonnement callback.
// NRC probes the callback during registration and refuses it unless it returns 401 without
// this value (ADR-0007), so the webhook enforces it.
var webhookToken = builder.Configuration["EventSubscriber:Webhook:AuthToken"]
?? throw new InvalidOperationException("Missing configuration 'EventSubscriber:Webhook:AuthToken'");
builder.Services.AddProjectionReadModel(connectionString);
builder.Services.AddProjectionWriteSide();
var app = builder.Build();
// Apply migrations on start so a fresh stack reaches a usable schema unattended.
await app.Services.MigrateProjectionAsync();
app.MapGet("/health", () => "Healthy");
// The NRC abonnement callback. Open Notificaties POSTs a notification here; we project it.
// Auth-on-callback is mandatory: the auth check runs *before* the body is read, so NRC's
// registration probe (a POST without the configured Authorization, and without a valid
// notification body) gets the 401 it requires rather than a 400 (ADR-0007).
app.MapPost("/notifications", async (
HttpRequest request,
NotificationProjector projector,
CancellationToken ct) =>
{
if (request.Headers.Authorization != webhookToken)
return Results.Unauthorized();
var dto = await JsonSerializer.DeserializeAsync<NotificationDto>(
request.Body, SerializerOptions, ct);
if (dto is null)
return Results.BadRequest();
await projector.HandleAsync(dto.ToNotification(), ct);
return Results.NoContent();
});
// Admin: rebuild the projection from the durable notification log (PRD §8.4 — rebuildable).
app.MapPost("/admin/rebuild", async (NotificationProjector projector, CancellationToken ct) =>
{
await projector.RebuildAsync(ct);
return Results.NoContent();
});
await app.RunAsync();
/// <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>
public sealed record NotificationDto(string Kanaal, string Resource, string Actie, Uri ResourceUrl)
{
public Notification ToNotification() => new(Kanaal, Resource, Actie, ResourceUrl);
}
public partial class Program
{
// NRC sends camelCase JSON; match it case-insensitively.
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,30 @@
namespace EventSubscriber.Application;
/// <summary>
/// An inbound NRC (Open Notificaties) notification, as Open Notificaties POSTs it to an
/// abonnement callback. Only the fields the projection needs are modelled; the full ZGW
/// "Notificatie" resource also carries <c>aanmaakdatum</c> and <c>kenmerken</c> which the
/// minimal projection ignores (bsn is deferred — see ADR-0008). For a <c>zaken</c>/<c>zaak</c>/<c>create</c>
/// notification <c>hoofdObject</c> and <c>resourceUrl</c> are both the created zaak's URL.
/// </summary>
public sealed record Notification(
string Kanaal,
string Resource,
string Actie,
Uri ResourceUrl)
{
/// <summary>The only event the walking-skeleton projection reacts to: a zaak being created.</summary>
public bool IsZaakCreated =>
Kanaal == "zaken" && Resource == "zaak" && Actie == "create";
/// <summary>The zaak UUID — the trailing path segment of the resource URL — used as the projection key.</summary>
public string ZaakId => ResourceUrl.Segments[^1].Trim('/');
/// <summary>
/// A deterministic dedup key. Open Notificaties carries no notification id and may
/// redeliver, so the key is derived from the immutable notification content: two
/// deliveries of the same zaak-create collapse to one. (NRC may also deliver
/// out of order; the projector tolerates that — order does not change the outcome.)
/// </summary>
public string IdempotencyKey => $"{Kanaal}:{Resource}:{Actie}:{ResourceUrl}";
}

View File

@@ -0,0 +1,38 @@
namespace EventSubscriber.Application;
/// <summary>
/// Projects inbound NRC notifications into the read projection. Tolerates duplicate and
/// 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.
/// </summary>
public sealed class NotificationProjector(INotificationLog log, IProjectionStore store)
{
/// <summary>Handle one inbound notification. Ignores anything that is not a zaak-created event.</summary>
public async Task HandleAsync(Notification notification, CancellationToken ct = default)
{
if (!notification.IsZaakCreated)
return;
var recorded = new RecordedNotification(notification.IdempotencyKey, notification.Actie, notification.ZaakId);
// 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.
if (!await log.TryRecordAsync(recorded, ct))
return;
await store.UpsertAsync(ToEntry(recorded), ct);
}
/// <summary>Rebuild the projection from the durable notification log (PRD §8.4).</summary>
public async Task RebuildAsync(CancellationToken ct = default)
{
await store.ClearAsync(ct);
foreach (var recorded in await log.AllAsync(ct))
await store.UpsertAsync(ToEntry(recorded), ct);
}
/// <summary>The minimal projection row for an accepted notification. A zaak-create maps to
/// INGEDIEND; bsn/naam are deferred (ADR-0008).</summary>
private static RegisterEntry ToEntry(RecordedNotification recorded)
=> new(recorded.ZaakId, RegistrationStatus.Ingediend);
}

View File

@@ -0,0 +1,37 @@
namespace EventSubscriber.Application;
/// <summary>
/// The durable log of notifications the subscriber has accepted. It is both the idempotency
/// guard (a replayed notification is recognised and dropped) and the rebuild source: the
/// projection is a derived artefact (PRD §8.4) regenerated by replaying this log, so a rebuild
/// needs no access to OpenZaak (CLAUDE.md §8.1). Implemented in Infrastructure over Postgres.
/// </summary>
public interface INotificationLog
{
/// <summary>
/// Record a notification. Returns <c>true</c> if it was newly recorded, <c>false</c> if an
/// entry with the same key already existed (a duplicate delivery). Must be atomic so that
/// concurrent duplicate deliveries cannot both observe <c>true</c>.
/// </summary>
Task<bool> TryRecordAsync(RecordedNotification notification, CancellationToken ct = default);
/// <summary>Every accepted notification, for rebuilding the projection.</summary>
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>
public sealed record RecordedNotification(string Key, string Actie, string ZaakId);
/// <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>
public interface IProjectionStore
{
/// <summary>Insert or update the row for <see cref="RegisterEntry.Id"/>. Idempotent on the id.</summary>
Task UpsertAsync(RegisterEntry entry, CancellationToken ct = default);
/// <summary>Remove every row — the first step of a rebuild.</summary>
Task ClearAsync(CancellationToken ct = default);
/// <summary>Every projection row (used by tests and the rebuild verification).</summary>
Task<IReadOnlyList<RegisterEntry>> AllAsync(CancellationToken ct = default);
}

View File

@@ -0,0 +1,20 @@
namespace EventSubscriber.Application;
/// <summary>
/// A row of the rebuildable read projection (PRD §8.4). For the minimal slice it carries
/// the zaak id and a status; <c>Bsn</c> and <c>NaamPlaceholder</c> are part of the schema but
/// are deferred — the NRC notification does not carry them and the subscriber may not read
/// OpenZaak directly (CLAUDE.md §8.1). Populating them is a follow-up (ADR-0008).
/// </summary>
public sealed record RegisterEntry(
string Id,
string Status,
string? Bsn = null,
string? NaamPlaceholder = null);
/// <summary>The projection statuses the walking skeleton knows about.</summary>
public static class RegistrationStatus
{
/// <summary>A zaak has been created; the registration is submitted.</summary>
public const string Ingediend = "INGEDIEND";
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EventSubscriber.Application\EventSubscriber.Application.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,42 @@
using EventSubscriber.Application;
namespace EventSubscriber.Tests;
/// <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
/// convention — no mocking library).</summary>
internal sealed class InMemoryNotificationLog : INotificationLog
{
private readonly Dictionary<string, RecordedNotification> _byKey = [];
public Task<bool> TryRecordAsync(RecordedNotification notification, CancellationToken ct = default)
=> Task.FromResult(_byKey.TryAdd(notification.Key, notification));
public Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<RecordedNotification>>([.. _byKey.Values]);
}
internal sealed class InMemoryProjectionStore : IProjectionStore
{
private readonly Dictionary<string, RegisterEntry> _byId = [];
/// <summary>How many times a write was attempted — lets a test prove a deduped delivery
/// never reaches the store (an idempotent upsert hides the difference in row count alone).</summary>
public int UpsertCount { get; private set; }
public Task UpsertAsync(RegisterEntry entry, CancellationToken ct = default)
{
UpsertCount++;
_byId[entry.Id] = entry;
return Task.CompletedTask;
}
public Task ClearAsync(CancellationToken ct = default)
{
_byId.Clear();
return Task.CompletedTask;
}
public Task<IReadOnlyList<RegisterEntry>> AllAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<RegisterEntry>>([.. _byId.Values]);
}

View File

@@ -0,0 +1,89 @@
using EventSubscriber.Application;
namespace EventSubscriber.Tests;
/// <summary>Behaviour of the projector that turns NRC notifications into projection rows.
/// The walking skeleton reacts only to a zaak being created (status INGEDIEND) and must
/// tolerate duplicate and out-of-order deliveries (CLAUDE.md §8.6).</summary>
public sealed class NotificationProjectorTests
{
private const string ZaakUrl = "http://openzaak:8000/zaken/api/v1/zaken/11111111-1111-1111-1111-111111111111";
private readonly InMemoryNotificationLog _log = new();
private readonly InMemoryProjectionStore _store = new();
private NotificationProjector Projector() => new(_log, _store);
private static Notification ZaakCreated(string url = ZaakUrl)
=> new("zaken", "zaak", "create", new Uri(url));
[Fact]
public async Task creating_a_zaak_writes_one_row_with_status_ingediend()
{
await Projector().HandleAsync(ZaakCreated());
var entry = Assert.Single(await _store.AllAsync());
Assert.Equal("11111111-1111-1111-1111-111111111111", entry.Id);
Assert.Equal(RegistrationStatus.Ingediend, entry.Status);
}
[Fact]
public async Task replaying_the_same_notification_keeps_a_single_row()
{
var projector = Projector();
await projector.HandleAsync(ZaakCreated());
await projector.HandleAsync(ZaakCreated());
Assert.Single(await _store.AllAsync());
}
[Fact]
public async Task a_replayed_notification_never_reaches_the_projection_store()
{
var projector = Projector();
await projector.HandleAsync(ZaakCreated());
await projector.HandleAsync(ZaakCreated());
// The duplicate is dropped at the log, before the (idempotent) upsert — so the store
// is written exactly once. Row count alone can't see this; the upsert count can.
Assert.Equal(1, _store.UpsertCount);
}
[Fact]
public async Task two_different_zaken_each_get_their_own_row()
{
var projector = Projector();
await projector.HandleAsync(ZaakCreated());
await projector.HandleAsync(ZaakCreated(ZaakUrl[..^1] + "2")); // a distinct zaak url
Assert.Equal(2, (await _store.AllAsync()).Count);
}
[Theory]
[InlineData("documenten", "enkelvoudiginformatieobject", "create")] // wrong kanaal + resource
[InlineData("documenten", "zaak", "create")] // wrong kanaal only
[InlineData("zaken", "status", "create")] // wrong resource only
[InlineData("zaken", "zaak", "update")] // 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)
{
await Projector().HandleAsync(new Notification(kanaal, resource, actie, new Uri(ZaakUrl)));
Assert.Empty(await _store.AllAsync());
}
[Fact]
public async Task rebuild_clears_stale_rows_and_repopulates_from_the_notification_log()
{
var projector = Projector();
await projector.HandleAsync(ZaakCreated());
// A stale row that is not backed by any logged notification must not survive a rebuild.
await _store.UpsertAsync(new RegisterEntry("stale-9999", RegistrationStatus.Ingediend));
await projector.RebuildAsync();
var entry = Assert.Single(await _store.AllAsync());
Assert.Equal("11111111-1111-1111-1111-111111111111", entry.Id);
Assert.Equal(RegistrationStatus.Ingediend, entry.Status);
}
}

View File

@@ -0,0 +1,4 @@
<Solution>
<Project Path="EventSubscriber.Application/EventSubscriber.Application.csproj" />
<Project Path="EventSubscriber.Tests/EventSubscriber.Tests.csproj" />
</Solution>

View File

@@ -0,0 +1,12 @@
{
"stryker-config": {
"solution": "EventSubscriber.slnx",
"test-projects": ["EventSubscriber.Tests/EventSubscriber.Tests.csproj"],
"reporters": ["progress", "html"],
"thresholds": {
"high": 95,
"low": 90,
"break": 90
}
}
}

View File

@@ -0,0 +1,31 @@
# Multi-stage build for the projection-api service (.NET 10).
# Build context is the repo root (it shares Projection.ReadModel — see infra/docker-compose.yml).
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Restore first (cached unless .csproj files change).
COPY services/projection-api/ProjectionApi.Api/ProjectionApi.Api.csproj services/projection-api/ProjectionApi.Api/
COPY services/projection-api/Projection.ReadModel/Projection.ReadModel.csproj services/projection-api/Projection.ReadModel/
COPY services/event-subscriber/EventSubscriber.Application/EventSubscriber.Application.csproj services/event-subscriber/EventSubscriber.Application/
RUN dotnet restore services/projection-api/ProjectionApi.Api/ProjectionApi.Api.csproj
COPY services/projection-api/ services/projection-api/
COPY services/event-subscriber/EventSubscriber.Application/ services/event-subscriber/EventSubscriber.Application/
RUN dotnet publish services/projection-api/ProjectionApi.Api/ProjectionApi.Api.csproj -c Release -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /app/publish .
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
HEALTHCHECK --interval=5s --timeout=3s --start-period=15s --retries=5 \
CMD curl -fsS http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "ProjectionApi.Api.dll"]

View File

@@ -0,0 +1,18 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace Projection.ReadModel;
/// <summary>Lets <c>dotnet ef migrations</c> build the context at design time without a running
/// database or the host's DI. The connection string is a placeholder — migrations only need the
/// provider to emit Postgres-shaped SQL.</summary>
public sealed class DesignTimeProjectionDbContextFactory : IDesignTimeDbContextFactory<ProjectionDbContext>
{
public ProjectionDbContext CreateDbContext(string[] args)
{
var options = new DbContextOptionsBuilder<ProjectionDbContext>()
.UseNpgsql("Host=localhost;Database=projection;Username=projection;Password=projection")
.Options;
return new ProjectionDbContext(options);
}
}

View File

@@ -0,0 +1,39 @@
using EventSubscriber.Application;
using Microsoft.EntityFrameworkCore;
namespace Projection.ReadModel;
/// <summary>EF Core implementation of the notification log. Idempotency is enforced atomically
/// by the primary key on <c>key</c>: a duplicate insert raises a unique violation, which is
/// caught and reported as "already recorded" rather than failing the request.</summary>
public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog
{
public async Task<bool> TryRecordAsync(RecordedNotification notification, CancellationToken ct = default)
{
db.ProcessedNotifications.Add(new ProcessedNotificationRow
{
Key = notification.Key,
Actie = notification.Actie,
ZaakId = notification.ZaakId,
ReceivedAt = DateTimeOffset.UtcNow,
});
try
{
await db.SaveChangesAsync(ct);
return true;
}
catch (DbUpdateException)
{
// Already recorded by an earlier (or concurrent) delivery — drop this duplicate.
db.ChangeTracker.Clear();
return false;
}
}
public async Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default)
=> await db.ProcessedNotifications
.OrderBy(r => r.ReceivedAt)
.Select(r => new RecordedNotification(r.Key, r.Actie, r.ZaakId))
.ToListAsync(ct);
}

View File

@@ -0,0 +1,40 @@
using EventSubscriber.Application;
using Microsoft.EntityFrameworkCore;
namespace Projection.ReadModel;
/// <summary>EF Core implementation of the projection store over <see cref="ProjectionDbContext"/>.</summary>
public sealed class EfProjectionStore(ProjectionDbContext db) : IProjectionStore
{
public async Task UpsertAsync(RegisterEntry entry, CancellationToken ct = default)
{
var row = await db.RegisterEntries.FindAsync([entry.Id], ct);
if (row is null)
{
db.RegisterEntries.Add(new RegisterEntryRow
{
Id = entry.Id,
Status = entry.Status,
Bsn = entry.Bsn,
NaamPlaceholder = entry.NaamPlaceholder,
});
}
else
{
row.Status = entry.Status;
row.Bsn = entry.Bsn;
row.NaamPlaceholder = entry.NaamPlaceholder;
}
await db.SaveChangesAsync(ct);
}
public async Task ClearAsync(CancellationToken ct = default)
=> await db.RegisterEntries.ExecuteDeleteAsync(ct);
public async Task<IReadOnlyList<RegisterEntry>> AllAsync(CancellationToken ct = default)
=> await db.RegisterEntries
.OrderBy(r => r.Id)
.Select(r => new RegisterEntry(r.Id, r.Status, r.Bsn, r.NaamPlaceholder))
.ToListAsync(ct);
}

View File

@@ -0,0 +1,79 @@
// <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("20260630125055_InitialProjection")]
partial class InitialProjection
{
/// <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>("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,53 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Projection.ReadModel.Migrations
{
/// <inheritdoc />
public partial class InitialProjection : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "processed_notifications",
columns: table => new
{
key = table.Column<string>(type: "text", nullable: false),
actie = table.Column<string>(type: "text", nullable: false),
zaak_id = table.Column<string>(type: "text", nullable: false),
received_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_processed_notifications", x => x.key);
});
migrationBuilder.CreateTable(
name: "register_projection",
columns: table => new
{
id = table.Column<string>(type: "text", nullable: false),
status = table.Column<string>(type: "text", nullable: false),
bsn = table.Column<string>(type: "text", nullable: true),
naam_placeholder = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_register_projection", x => x.id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "processed_notifications");
migrationBuilder.DropTable(
name: "register_projection");
}
}
}

View File

@@ -0,0 +1,76 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Projection.ReadModel;
#nullable disable
namespace Projection.ReadModel.Migrations
{
[DbContext(typeof(ProjectionDbContext))]
partial class ProjectionDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(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>("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,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Audit only the packages we choose directly. The one flagged transitive
(System.Security.Cryptography.Xml, NU1903) comes via EF Core's design-time
migration tooling (Microsoft.EntityFrameworkCore.Design, PrivateAssets=all) and
is never shipped at runtime — it is out of our control to upgrade. -->
<NuGetAuditMode>direct</NuGetAuditMode>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\event-subscriber\EventSubscriber.Application\EventSubscriber.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore;
namespace Projection.ReadModel;
/// <summary>
/// The read projection's persistence. This single store is the system's rebuildable read model
/// (PRD §8.4): the Event Subscriber writes to it (projector) and the projection-api reads it
/// (query). Both processes are the one "Read Projection" bounded context and share this schema —
/// not a cross-domain DB share (ADR-0008 reconciles this with CLAUDE.md §8.5).
/// </summary>
public sealed class ProjectionDbContext(DbContextOptions<ProjectionDbContext> options) : DbContext(options)
{
/// <summary>The public-facing register rows (public-safe filtering is tightened in S-09).</summary>
public DbSet<RegisterEntryRow> RegisterEntries => Set<RegisterEntryRow>();
/// <summary>The Event Subscriber's durable log of accepted notifications — idempotency guard and rebuild source.</summary>
public DbSet<ProcessedNotificationRow> ProcessedNotifications => Set<ProcessedNotificationRow>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<RegisterEntryRow>(e =>
{
e.ToTable("register_projection");
e.HasKey(r => r.Id);
e.Property(r => r.Id).HasColumnName("id");
e.Property(r => r.Status).HasColumnName("status").IsRequired();
e.Property(r => r.Bsn).HasColumnName("bsn");
e.Property(r => r.NaamPlaceholder).HasColumnName("naam_placeholder");
});
modelBuilder.Entity<ProcessedNotificationRow>(e =>
{
e.ToTable("processed_notifications");
e.HasKey(r => r.Key);
e.Property(r => r.Key).HasColumnName("key");
e.Property(r => r.Actie).HasColumnName("actie").IsRequired();
e.Property(r => r.ZaakId).HasColumnName("zaak_id").IsRequired();
e.Property(r => r.ReceivedAt).HasColumnName("received_at");
});
}
}
/// <summary>A register projection row. <c>Bsn</c>/<c>NaamPlaceholder</c> are deferred (ADR-0008).</summary>
public sealed class RegisterEntryRow
{
public required string Id { get; set; }
public required string Status { get; set; }
public string? Bsn { get; set; }
public string? NaamPlaceholder { get; set; }
}
/// <summary>An accepted notification, retained so the projection can be rebuilt without OpenZaak (§8.1).</summary>
public sealed class ProcessedNotificationRow
{
public required string Key { get; set; }
public required string Actie { get; set; }
public required string ZaakId { get; set; }
public DateTimeOffset ReceivedAt { get; set; }
}

View File

@@ -0,0 +1,30 @@
using EventSubscriber.Application;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Projection.ReadModel;
public static class ServiceCollectionExtensions
{
/// <summary>Register the projection <see cref="ProjectionDbContext"/> against Postgres.</summary>
public static IServiceCollection AddProjectionReadModel(this IServiceCollection services, string connectionString)
=> services.AddDbContext<ProjectionDbContext>(o => o.UseNpgsql(connectionString));
/// <summary>Register the write-side ports (projector store + notification log) used by the Event Subscriber.</summary>
public static IServiceCollection AddProjectionWriteSide(this IServiceCollection services)
{
services.AddScoped<IProjectionStore, EfProjectionStore>();
services.AddScoped<INotificationLog, EfNotificationLog>();
services.AddScoped<NotificationProjector>();
return services;
}
/// <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>
public static async Task MigrateProjectionAsync(this IServiceProvider services, CancellationToken ct = default)
{
await using var scope = services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<ProjectionDbContext>();
await db.Database.MigrateAsync(ct);
}
}

View File

@@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore;
using Projection.ReadModel;
var builder = WebApplication.CreateBuilder(args);
var connectionString = builder.Configuration.GetConnectionString("Projection")
?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Projection'");
builder.Services.AddProjectionReadModel(connectionString);
var app = builder.Build();
// Ensure the schema exists before serving reads. EF serialises concurrent migrators via the
// migrations-history lock, so it is safe that the Event Subscriber migrates too.
await app.Services.MigrateProjectionAsync();
app.MapGet("/health", () => "Healthy");
// The read side of the projection. Public-safe field filtering is tightened in S-09; for now
// the minimal projection only carries id + status (bsn/naam deferred — ADR-0008).
app.MapGet("/register", async (ProjectionDbContext db, CancellationToken ct) =>
{
var rows = await db.RegisterEntries
.OrderBy(r => r.Id)
.Select(r => new RegisterEntryDto(r.Id, r.Status, r.Bsn, r.NaamPlaceholder))
.ToListAsync(ct);
return Results.Ok(rows);
});
app.MapGet("/register/{id}", async (string id, ProjectionDbContext db, CancellationToken ct) =>
{
var row = await db.RegisterEntries
.Where(r => r.Id == id)
.Select(r => new RegisterEntryDto(r.Id, r.Status, r.Bsn, r.NaamPlaceholder))
.SingleOrDefaultAsync(ct);
return row is null ? Results.NotFound() : Results.Ok(row);
});
await app.RunAsync();
public sealed record RegisterEntryDto(string Id, string Status, string? Bsn, string? NaamPlaceholder);
public partial class Program;

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\Projection.ReadModel\Projection.ReadModel.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -18,6 +18,7 @@
<ItemGroup>
<ProjectReference Include="..\..\services\acl\Acl.Application\Acl.Application.csproj" />
<ProjectReference Include="..\..\services\acl\Acl.Infrastructure\Acl.Infrastructure.csproj" />
<ProjectReference Include="..\..\services\event-subscriber\EventSubscriber.Application\EventSubscriber.Application.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,19 @@
# language: en
# Drives S-06 (#7). On a zaak-created notification from NRC the Event Subscriber writes a
# rebuildable read-projection row (PRD §8.4). This scenario exercises the use case against an
# in-memory stand-in for the projection store and notification log; real OpenZaak → NRC →
# subscriber delivery is verified by the live-stack check (verify-projection, ADR-0007/#58).
Feature: Register-projectie bijwerken op een zaaknotificatie
Als openbaar register wil ik dat een aangemaakte zaak in de projectie verschijnt
zodat het register de ingediende registratie kan tonen.
Scenario: Een zaaknotificatie levert een rij met status INGEDIEND
Given a zaak is created in OpenZaak with id "11111111-1111-1111-1111-111111111111"
When the NRC notification for that zaak is delivered to the event subscriber
Then the register projection contains a row for "11111111-1111-1111-1111-111111111111" with status "INGEDIEND"
Scenario: Dezelfde notificatie tweemaal levert geen duplicaat
Given a zaak is created in OpenZaak with id "22222222-2222-2222-2222-222222222222"
When the NRC notification for that zaak is delivered to the event subscriber
And the same NRC notification is delivered again
Then the register projection contains exactly one row for "22222222-2222-2222-2222-222222222222"

View File

@@ -0,0 +1,48 @@
using Acceptance.Support;
using EventSubscriber.Application;
using Reqnroll;
using Xunit;
namespace Acceptance.Steps;
/// <summary>Bindings for <c>RegisterProjectieBijwerken.feature</c> (S-06). Reqnroll creates
/// one instance per scenario, so instance fields hold scenario-scoped state.</summary>
[Binding]
public sealed class RegisterProjectieBijwerkenSteps
{
private const string ZaakBase = "http://openzaak:8000/zaken/api/v1/zaken/";
private readonly InMemoryNotificationLog _log = new();
private readonly InMemoryProjectionStore _store = new();
private readonly NotificationProjector _projector;
private Notification? _notification;
public RegisterProjectieBijwerkenSteps() => _projector = new NotificationProjector(_log, _store);
[Given("a zaak is created in OpenZaak with id \"(.*)\"")]
public void GivenAZaakIsCreatedInOpenZaakWithId(string id)
=> _notification = new Notification("zaken", "zaak", "create", new Uri(ZaakBase + id));
[When("the NRC notification for that zaak is delivered to the event subscriber")]
public Task WhenTheNotificationIsDelivered()
=> _projector.HandleAsync(_notification!);
[When("the same NRC notification is delivered again")]
public Task WhenTheSameNotificationIsDeliveredAgain()
=> _projector.HandleAsync(_notification!);
[Then("the register projection contains a row for \"(.*)\" with status \"(.*)\"")]
public async Task ThenTheProjectionContainsARowWithStatus(string id, string status)
{
var entry = Assert.Single(await _store.AllAsync());
Assert.Equal(id, entry.Id);
Assert.Equal(status, entry.Status);
}
[Then("the register projection contains exactly one row for \"(.*)\"")]
public async Task ThenTheProjectionContainsExactlyOneRowFor(string id)
{
await _store.AllAsync();
Assert.Single(_store.RowsFor(id));
}
}

View File

@@ -0,0 +1,40 @@
using EventSubscriber.Application;
namespace Acceptance.Support;
/// <summary>In-memory stand-ins for the Event Subscriber's projection store and notification
/// log, so the S-06 acceptance scenario runs without Postgres (real delivery is a live-stack
/// check). Mirrors the <c>InMemoryZaakGateway</c> approach used for the ACL scenario.</summary>
public sealed class InMemoryNotificationLog : INotificationLog
{
private readonly Dictionary<string, RecordedNotification> _byKey = [];
public Task<bool> TryRecordAsync(RecordedNotification notification, CancellationToken ct = default)
=> Task.FromResult(_byKey.TryAdd(notification.Key, notification));
public Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<RecordedNotification>>([.. _byKey.Values]);
}
public sealed class InMemoryProjectionStore : IProjectionStore
{
private readonly Dictionary<string, RegisterEntry> _byId = [];
public Task UpsertAsync(RegisterEntry entry, CancellationToken ct = default)
{
_byId[entry.Id] = entry;
return Task.CompletedTask;
}
public Task ClearAsync(CancellationToken ct = default)
{
_byId.Clear();
return Task.CompletedTask;
}
public Task<IReadOnlyList<RegisterEntry>> AllAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<RegisterEntry>>([.. _byId.Values]);
public IReadOnlyList<RegisterEntry> RowsFor(string id)
=> [.. _byId.Values.Where(e => e.Id == id)];
}