Compare commits

...

20 Commits

Author SHA1 Message Date
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
32 changed files with 1357 additions and 65 deletions

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

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

View File

@@ -43,6 +43,59 @@ jobs:
dotnet-version: '10.0.x' dotnet-version: '10.0.x'
- run: make unit - run: make unit
mutation:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- 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
integration:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
# No setup-dotnet: `make integration` runs the seed and the test as containers
# *inside* the OpenZaak compose network (reaching it by container IP), so dotnet
# lives in the test image and the runner needs only Docker. This sidesteps the
# runner being unable to reach published ports (gitea-actions-gotchas.md §5).
# Needs egress to pull base images + nuget + selectielijst.openzaak.nl.
- run: make integration
- name: dump OpenZaak logs on failure
if: failure()
run: docker compose -f infra/openzaak/docker-compose.yml logs --no-color --tail=80 oz-init openzaak 2>&1 || true
- name: tear down on failure
if: failure()
run: docker compose -f infra/openzaak/docker-compose.yml down --volumes 2>&1 || true
notifications:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
# `make verify-notifications` brings up OpenZaak + NRC, seeds, and asserts a
# zaak-create notification is delivered to a subscriber — all via containers on
# the compose network (the runner can't reach published ports; §5). Needs only
# Docker + egress (base images, selectielijst.openzaak.nl).
- run: make verify-notifications
- name: dump OpenZaak/NRC logs on failure
if: failure()
run: docker compose -f infra/openzaak/docker-compose.yml -f infra/opennotificaties/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat 2>&1 || true
- name: tear down on failure
if: failure()
run: docker compose -f infra/openzaak/docker-compose.yml -f infra/opennotificaties/docker-compose.yml down --volumes 2>&1 || true
compose-smoke: compose-smoke:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

3
.gitignore vendored
View File

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

View File

@@ -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 # volumes are `external`, so compose won't remove them — CFG_VOLS lists them for
# explicit teardown. See docs/runbooks/gitea-actions-gotchas.md. # explicit teardown. See docs/runbooks/gitea-actions-gotchas.md.
SEED := bash infra/seed-config.sh 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 # 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 # 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. # engine. This is the no-make / Windows-friendly path. See that file's header.
@@ -43,10 +43,10 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
endif endif
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-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: run the full pipeline — lint, build, unit, mutation, smoke (mirrors Gitea Actions)
ci: lint build unit smoke ci: lint build unit mutation smoke
## lint: verify formatting (no changes) ## lint: verify formatting (no changes)
lint: lint:
@@ -56,9 +56,18 @@ lint:
build: build:
dotnet build $(SLN) -c Release dotnet build $(SLN) -c Release
## unit: run unit tests ## unit: run unit tests (excludes the container-backed Integration lane)
unit: unit:
dotnet test $(SLN) -c Release dotnet test $(SLN) -c Release --filter "Category!=Integration"
## mutation: run the Stryker.NET ratchet on the ACL (fails below the recorded baseline)
# Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore`
# makes `make mutation` work from a fresh clone. Config + break threshold (the ratchet,
# CLAUDE.md §5) live in services/acl/stryker-config.json. The ACL is the first service
# with branching logic, so it sets the repo-wide baseline; later slices ratchet it up.
mutation:
dotnet tool restore
cd services/acl && dotnet stryker
## smoke: seed config, bring the whole stack up, wait for health-checked services, tear down ## 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; # SEED populates the external config volumes first (upstream images used verbatim;
@@ -68,14 +77,14 @@ unit:
# podman-compose, and needing no `--wait` flag or host port access. The one-shots # 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. # (oz-init, flowable-init) aren't polled; they just need to have run.
smoke: smoke:
$(SEED) oz kc fl $(SEED) oz nrc kc fl
docker compose -f $(COMPOSE) up -d --build 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' 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 ## 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) ## `docker compose up`, which can't self-seed the external config volumes)
up: up:
$(SEED) oz kc fl $(SEED) oz nrc kc fl
docker compose -f $(COMPOSE) up -d --build docker compose -f $(COMPOSE) up -d --build
## down: stop and remove the local stack (incl. the external config volumes) ## down: stop and remove the local stack (incl. the external config volumes)
@@ -97,6 +106,15 @@ local-down:
changelog: changelog:
git-cliff --output CHANGELOG.md git-cliff --output CHANGELOG.md
## integration: ACL integration tests against a real OpenZaak (S-04a, #46). The
## seed and the test run inside the compose network (reaching http://openzaak:8000),
## so this works on the hosted CI runner where a runner process can't reach the
## stack's published ports. Brings the stack up, seeds a PUBLISHED BIG zaaktype,
## runs the Integration-category tests, then always tears down. Kept out of
## `unit`/`mutation` because it needs the live stack. See infra/run-integration.sh + ADR-0006.
integration:
bash infra/run-integration.sh
## openzaak-up: start the OpenZaak stack (migrations run on first start) ## openzaak-up: start the OpenZaak stack (migrations run on first start)
openzaak-up: openzaak-up:
$(SEED) oz $(SEED) oz
@@ -129,10 +147,18 @@ openzaak-down:
docker compose -f $(OZ_COMPOSE) down --volumes docker compose -f $(OZ_COMPOSE) down --volumes
-docker volume rm -f rr-oz-config -docker volume rm -f rr-oz-config
## stack-up: start OpenZaak + Open Notificaties together (shared network) ## verify-notifications: end-to-end check that a zaak created in OpenZaak is
## published to NRC and delivered to a subscriber (S-01-c). Brings the stack up,
## seeds, drives a zaak + sink abonnement inside the network, asserts delivery,
## tears down. Runner-safe (no host-port access). See infra/verify-notifications.sh.
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: stack-up:
$(SEED) oz $(SEED) oz nrc
docker compose $(STACK_FILES) up -d 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: start both, assert OpenZaak (403/302/200) and NRC (302) are reachable
stack-smoke: stack-up stack-smoke: stack-up
@@ -151,7 +177,7 @@ stack-smoke: stack-up
## stack-down: stop and remove both stacks (wipes data) ## stack-down: stop and remove both stacks (wipes data)
stack-down: stack-down:
docker compose $(STACK_FILES) down --volumes 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: start Keycloak with the four imported realms
keycloak-up: 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,91 @@
# 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 new `make integration` target
(`infra/run-integration.sh`) brings the stack up, seeds, runs the lane, and always tears down
— mirrored by a Gitea Actions `integration` job. 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,76 @@
# 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 (`make verify-notifications`,
`infra/verify-notifications.sh` + a `notifications` CI job): it brings the stack up,
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).
## 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

@@ -15,9 +15,17 @@ and CI cannot drift:
|---|---|---| |---|---|---|
| `lint` | `make lint``dotnet format … --verify-no-changes` | .NET 10 SDK | | `lint` | `make lint``dotnet format … --verify-no-changes` | .NET 10 SDK |
| `build` | `make build``dotnet build … -c Release` | .NET 10 SDK | | `build` | `make build``dotnet build … -c Release` | .NET 10 SDK |
| `unit` | `make unit``dotnet test … -c Release` | .NET 10 SDK | | `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 |
| `integration` | `make integration``infra/run-integration.sh`: OpenZaak up → seed a **published** BIG zaaktype + run `Acl.IntegrationTests` **as containers inside the compose network** → tear down | container engine + egress (base images, nuget, `selectielijst.openzaak.nl`) |
| `notifications` | `make verify-notifications` → bring up OpenZaak + NRC → seed → assert a zaak-create notification reaches a subscriber (containers on the network) → tear down | container engine + egress (base images, `selectielijst.openzaak.nl`) |
| `compose-smoke` | `make smoke` → seed config volumes → `up -d` (full stack) → `up --wait` durable services → `down` | container engine + compose v2 | | `compose-smoke` | `make smoke` → seed config volumes → `up -d` (full stack) → `up --wait` durable services → `down` | container engine + compose v2 |
> **The `integration` job needs no `setup-dotnet`.** dotnet runs inside the test
> image, and both the seed and the test join the OpenZaak network and reach it by
> container IP — so the runner never has to reach a published port
> (see [gitea-actions-gotchas.md §5](gitea-actions-gotchas.md)).
All `uses:` references are absolute, tag-pinned URLs (`https://github.com/actions/checkout@v4`, 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 `https://github.com/actions/setup-dotnet@v4`) per CLAUDE.md §8.7 and §15 — Gitea
Actions resolves them from GitHub. Actions resolves them from GitHub.
@@ -30,6 +38,38 @@ Actions resolves them from GitHub.
> bare `docker compose up` no longer self-seeds; use `make up`. See > bare `docker compose up` no longer self-seeds; use `make up`. See
> [gitea-actions-gotchas.md](gitea-actions-gotchas.md). > [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) ## 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 `make` and the bash helpers assume a Unix shell. To bring the whole stack up on a
@@ -55,11 +95,17 @@ the containerized CI runner). Keep the two files in sync.
Until the runner exists, run the full pipeline yourself before pushing: Until the runner exists, run the full pipeline yourself before pushing:
```bash ```bash
make ci # lint + build + unit + smoke — what the pipeline runs make ci # lint + build + unit + mutation + smoke — the fast pipeline lanes
make lint # or a single stage make lint # or a single stage
make mutation # Stryker.NET ratchet on the ACL
make smoke # compose up --wait, curl /health, tear down make smoke # compose up --wait, curl /health, tear down
make integration # ACL ↔ real OpenZaak (its own CI job; not part of `make ci`)
``` ```
> `make integration` is a separate, heavier lane (it stands the OpenZaak stack up and
> seeds a published zaaktype), so it is **not** folded into `make ci`. Run it before
> pushing changes that touch the ACL gateway or the OpenZaak seed. See ADR-0006.
**Prerequisites:** .NET 10 SDK, a container engine with Compose v2, and `curl`. **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 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` | | 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` | | `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 | | `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 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 the db healthchecks add a `SELECT PostGIS_Version()` probe, making dependents wait
for the extension, not just the port. 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-integration.sh`, `infra/verify-notifications.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_BROKER_URL: redis://oz-redis:6379/1
CELERY_RESULT_BACKEND: redis://oz-redis:6379/1 CELERY_RESULT_BACKEND: redis://oz-redis:6379/1
DISABLE_2FA: "true" 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 OPENZAAK_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin DJANGO_SUPERUSER_PASSWORD: admin
OPENZAAK_SUPERUSER_EMAIL: admin@localhost OPENZAAK_SUPERUSER_EMAIL: admin@localhost
@@ -122,7 +123,9 @@ services:
networks: [cg] networks: [cg]
nrc-init: 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} image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: &nrc-env environment: &nrc-env
DJANGO_SETTINGS_MODULE: nrc.conf.docker DJANGO_SETTINGS_MODULE: nrc.conf.docker
@@ -141,7 +144,11 @@ services:
OPENNOTIFICATIES_SUPERUSER_USERNAME: admin OPENNOTIFICATIES_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin DJANGO_SUPERUSER_PASSWORD: admin
OPENNOTIFICATIES_SUPERUSER_EMAIL: admin@localhost 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: depends_on:
nrc-db: nrc-db:
condition: service_healthy condition: service_healthy
@@ -176,6 +183,17 @@ services:
condition: service_completed_successfully condition: service_completed_successfully
networks: [cg] 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 (S-02) ──────────────────────────────────────────────────────
keycloak: keycloak:
image: quay.io/keycloak/keycloak:26.1 image: quay.io/keycloak/keycloak:26.1

View File

@@ -62,7 +62,10 @@ services:
CELERY_BROKER_URL: redis://oz-redis:6379/1 CELERY_BROKER_URL: redis://oz-redis:6379/1
CELERY_RESULT_BACKEND: redis://oz-redis:6379/1 CELERY_RESULT_BACKEND: redis://oz-redis:6379/1
DISABLE_2FA: "true" 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 OPENZAAK_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin DJANGO_SUPERUSER_PASSWORD: admin
OPENZAAK_SUPERUSER_EMAIL: admin@localhost OPENZAAK_SUPERUSER_EMAIL: admin@localhost
@@ -146,11 +149,17 @@ services:
OPENNOTIFICATIES_SUPERUSER_USERNAME: admin OPENNOTIFICATIES_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin DJANGO_SUPERUSER_PASSWORD: admin
OPENNOTIFICATIES_SUPERUSER_EMAIL: admin@localhost OPENNOTIFICATIES_SUPERUSER_EMAIL: admin@localhost
# Migrations only for now. No setup_configuration steps are enabled yet (the RUN_SETUP_CONFIG: "true"
# OZ<->NRC notification wiring lands in S-06), and NRC's `setup_configuration` # nrc-beat fires `execute_notifications` this often to drain scheduled
# aborts with "No steps enabled" on the empty data.yaml — so we run `migrate` # notifications to subscribers (upstream default 20s). See ADR-0007.
# directly instead of /setup_configuration.sh. See data.yaml and ADR-0002. NOTIFICATION_SEC_INTERVAL: "5"
command: ["sh", "-c", "/wait_for_db.sh && OTEL_SDK_DISABLED=True python src/manage.py migrate"] # 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: depends_on:
nrc-db: nrc-db:
condition: service_healthy condition: service_healthy
@@ -185,6 +194,18 @@ services:
condition: service_completed_successfully condition: service_completed_successfully
networks: [cg] 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 (S-02) ──────────────────────────────────────────────────────
keycloak: keycloak:
image: quay.io/keycloak/keycloak:26.1 image: quay.io/keycloak/keycloak:26.1
@@ -309,6 +330,9 @@ volumes:
oz-config: oz-config:
external: true external: true
name: rr-oz-config name: rr-oz-config
nrc-config:
external: true
name: rr-nrc-config
kc-realms: kc-realms:
external: true external: true
name: rr-kc-realms 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 OPENNOTIFICATIES_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin DJANGO_SUPERUSER_PASSWORD: admin
OPENNOTIFICATIES_SUPERUSER_EMAIL: admin@localhost OPENNOTIFICATIES_SUPERUSER_EMAIL: admin@localhost
# Migrations only for now. No setup_configuration steps are enabled yet (the RUN_SETUP_CONFIG: "true"
# OZ<->NRC notification wiring lands in S-06), and NRC's `setup_configuration` # Delivery cadence: nrc-beat fires `execute_notifications` this often to drain
# aborts with "No steps enabled" on the empty data.yaml — so we run `migrate` # scheduled notifications to subscribers. Upstream default is 20s; 5s keeps the
# directly instead of /setup_configuration.sh. See data.yaml and ADR-0002. # walking-skeleton + the verify smoke responsive.
command: ["sh", "-c", "/wait_for_db.sh && OTEL_SDK_DISABLED=True python src/manage.py migrate"] 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: depends_on:
nrc-db: nrc-db:
condition: service_healthy condition: service_healthy
@@ -89,8 +96,25 @@ services:
condition: service_completed_successfully condition: service_completed_successfully
networks: [cg] 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: volumes:
nrc-db: 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: networks:
cg: cg:

View File

@@ -1,5 +1,41 @@
# Open Notificaties setup_configuration. # Open Notificaties (NRC) setup_configuration (S-01-c, #56).
# Stage 1 (this commit): intentionally minimal — the init container runs # Wires NRC so OpenZaak can publish notifications:
# migrations; no steps enabled yet. The OpenZaak<->NRC notification wiring # - the JWT credential OpenZaak authenticates with,
# (Services, Authorization, JWT, Kanalen) is added next. See ADR-0002 / S-01-c. # - 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_BROKER_URL: redis://oz-redis:6379/1
CELERY_RESULT_BACKEND: redis://oz-redis:6379/1 CELERY_RESULT_BACKEND: redis://oz-redis:6379/1
DISABLE_2FA: "true" DISABLE_2FA: "true"
# Notifications go to Open Notificaties (NRC), which arrives in S-01-c. # Notifications are OFF by default so OpenZaak-only bring-ups (openzaak-up,
# Until then, disable outbound notifications so writes don't 500. # the ACL integration test) don't 500 trying to reach an absent NRC. When
NOTIFICATIONS_DISABLED: "true" # 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 OPENZAAK_SUPERUSER_USERNAME: admin
DJANGO_SUPERUSER_PASSWORD: admin DJANGO_SUPERUSER_PASSWORD: admin
OPENZAAK_SUPERUSER_EMAIL: admin@localhost 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" ZTC = f"{BASE}/catalogi/api/v1"
RSIN = "517439943" # elfproef-valid test RSIN 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(): def token():
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=") b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=")
@@ -52,6 +62,68 @@ def find(path):
return body.get("results", []) 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(): def main():
# 1. Catalogus # 1. Catalogus
existing = [c for c in find(f"/catalogussen?domein=BIG") if c.get("domein") == "BIG"] existing = [c for c in find(f"/catalogussen?domein=BIG") if c.get("domein") == "BIG"]
@@ -121,16 +193,24 @@ def main():
else: else:
print("warn zaaktype already published; cannot add bsn eigenschap") print("warn zaaktype already published; cannot add bsn eigenschap")
# Intentionally NOT published. Publishing requires roltypen, resultaattypen # 4. Optionally publish. By default the zaaktype stays a concept: publishing
# and statustypen, which go beyond the "lean / schema-mandatory" zaaktype this # requires roltypen, resultaattypen and statustypen, beyond the "lean /
# slice asks for; they arrive with the workflow/zaak slices. See ADR-0002. # 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") zaaktypen = find(f"/zaaktypen?catalogus={cat['url']}&status=alles")
names = [z.get("identificatie") for z in zaaktypen] names = [z.get("identificatie") for z in zaaktypen]
print(f"zaaktypen in BIG: {names}") print(f"zaaktypen in BIG: {names}")
assert "BIG-REGISTRATIE" in names, "BIG-REGISTRATIE not listed" 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__": if __name__ == "__main__":

View File

@@ -20,3 +20,22 @@ vng_api_common_applicaties:
- big-reference-seed - big-reference-seed
label: BIG reference seed client label: BIG reference seed client
heeft_alle_autorisaties: true 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

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

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
#
# Run the ACL ↔ real-OpenZaak integration test (S-04a / #46) end to end.
#
# Everything that talks to OpenZaak runs *inside* the compose network and reaches
# it by service name (http://openzaak:8000) — the hosted CI runner can't reach the
# stack's published ports (sibling containers) and bind mounts don't reach the
# daemon either (gitea-actions-gotchas.md §1/§5). So we use only plain docker
# primitives (run / create / cp / build) — portable across docker compose (CI) and
# podman-compose (local), exactly like infra/seed-config.sh. See ADR-0006.
#
# Steps: bring OpenZaak up → wait for it healthy → seed a PUBLISHED BIG zaaktype
# (a seed container on the network) → build + run the test container on the network
# → always tear down.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(cd "$here/.." && 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"
# Match the API container under both docker compose (openzaak-openzaak-1) and
# podman-compose (openzaak_openzaak_1) naming; the regex excludes oz-db / oz-redis.
api=""
for _ in $(seq 1 140); do
api="$(docker ps -q --filter 'name=openzaak[-_]openzaak' | head -1)"
if [ -n "$api" ]; then
status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$api" 2>/dev/null || true)"
[ "$status" = "healthy" ] && break
fi
sleep 3
done
[ -n "$api" ] || { echo "ERROR: OpenZaak API container never appeared" >&2; exit 1; }
[ "${status:-}" = "healthy" ] || { echo "ERROR: OpenZaak not healthy (status=${status:-none})" >&2; exit 1; }
# The network the API container is attached to — joined by the seed + test below.
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$api" | head -1)"
# Reach OpenZaak by container IP, not by the service name. OpenZaak echoes its
# request Host into the self-referential URLs it returns, then validates those URLs
# with Django's URLValidator — which rejects a single-label host like `openzaak`
# ("Voer een geldige URL in") while accepting an IPv4 literal (and `localhost`).
oz_ip="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$api")"
oz_base="http://${oz_ip}:8000"
echo ">> OpenZaak healthy on network $net at $oz_base"
echo ">> seeding a published BIG zaaktype (OZ_PUBLISH=1, inside the network)"
sid="$(docker create --network "$net" \
-e "OZ_BASE=$oz_base" -e OZ_PUBLISH=1 \
python:3-slim python /seed_catalogus.py)"
docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed_catalogus.py"
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 integration tests (inside the network)"
docker run --rm --network "$net" -e "OZ_BASE=$oz_base" rr-acl-integration

View File

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

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

@@ -0,0 +1,93 @@
#!/usr/bin/env bash
#
# Verify the OpenZaak → Open Notificaties (NRC) notification path end to end (S-01-c,
# #56): a zaak created in OpenZaak is published to NRC and delivered to a subscriber.
#
# Everything that talks to OpenZaak/NRC runs *inside* the compose network and reaches
# them by container IP — the hosted CI runner can't reach published ports (sibling
# containers, gitea-actions-gotchas.md §5) and OpenZaak/NRC reject a single-label host
# in URLs (Django URLValidator). Plain docker primitives only (docker/podman-portable),
# like infra/seed-config.sh and infra/run-integration.sh. 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"
SINK_AUTH="Bearer notification-sink-token"
cleanup() {
docker rm -f rr-nsink rr-nverify >/dev/null 2>&1 || true
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
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
wait_healthy() { # name-regex -> echoes container id
local re="$1" cid status
for _ in $(seq 1 140); do
cid="$(docker ps -q --filter "name=$re" | head -1)"
if [ -n "$cid" ]; then
status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$cid" 2>/dev/null || true)"
[ "$status" = healthy ] && { echo "$cid"; 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"
oz="$(wait_healthy 'openzaak[-_]openzaak')" || { echo "ERROR: OpenZaak not healthy" >&2; exit 1; }
nrc="$(wait_healthy 'nrc-web')" || { echo "ERROR: NRC not healthy" >&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"
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

View File

@@ -26,6 +26,9 @@ nav:
- "ADR-0002: Catalogus design": architecture/adr-0002-catalogus-design.md - "ADR-0002: Catalogus design": architecture/adr-0002-catalogus-design.md
- "ADR-0003: ACL default-fill": architecture/adr-0003-default-fill.md - "ADR-0003: ACL default-fill": architecture/adr-0003-default-fill.md
- "ADR-0004: BDD framework": architecture/adr-0004-bdd-framework.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
- Working in Gitea: gitea-workflow.md - Working in Gitea: gitea-workflow.md
- Runbooks: - Runbooks:
- CI: runbooks/ci.md - CI: runbooks/ci.md

View File

@@ -4,6 +4,7 @@
<Project Path="services/acl/Acl.Api/Acl.Api.csproj" /> <Project Path="services/acl/Acl.Api/Acl.Api.csproj" />
<Project Path="services/acl/Acl.Application/Acl.Application.csproj" /> <Project Path="services/acl/Acl.Application/Acl.Application.csproj" />
<Project Path="services/acl/Acl.Infrastructure/Acl.Infrastructure.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" /> <Project Path="services/acl/Acl.Tests/Acl.Tests.csproj" />
</Folder> </Folder>
<Folder Name="/services/bff/"> <Folder Name="/services/bff/">

View File

@@ -27,6 +27,11 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
// ZRC is a geo API; it requires the CRS headers. // ZRC is a geo API; it requires the CRS headers.
message.Headers.Add("Accept-Crs", "EPSG:4326"); message.Headers.Add("Accept-Crs", "EPSG:4326");
message.Content.Headers.Add("Content-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); using var response = await http.SendAsync(message, ct);
response.EnsureSuccessStatusCode(); 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(defaults.ZaaktypeUrl, req.Zaaktype);
Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum); 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;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Acl.Application; using Acl.Application;
using Acl.Infrastructure; using Acl.Infrastructure;
@@ -14,38 +16,139 @@ public class OpenZaakGatewayTests
=> onSend(request); => onSend(request);
} }
[Fact] private static OpenZaakGateway Gateway(StubHandler handler) => new(
public async Task Posts_zaak_to_openzaak_with_bearer_and_default_fields_and_returns_url() 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; var c = new RequestCapture();
string? body = null; capture = c;
var handler = new StubHandler(async req => return new StubHandler(async req =>
{ {
seen = req; c.Seen = req;
body = await req.Content!.ReadAsStringAsync(); // 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) return new HttpResponseMessage(HttpStatusCode.Created)
{ {
Content = JsonContent.Create(new { url = "http://openzaak/zaken/api/v1/zaken/xyz" }), 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("http://openzaak/zaken/api/v1/zaken/xyz", url.ToString());
Assert.Equal(HttpMethod.Post, seen!.Method); Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://openzaak/zaken/api/v1/zaken", seen.RequestUri!.ToString()); Assert.Equal("http://openzaak/zaken/api/v1/zaken", capture.Seen.RequestUri!.ToString());
Assert.Equal("Bearer", seen.Headers.Authorization!.Scheme); Assert.Equal("Bearer", capture.Seen.Headers.Authorization!.Scheme);
Assert.False(string.IsNullOrWhiteSpace(seen.Headers.Authorization.Parameter)); Assert.False(string.IsNullOrWhiteSpace(capture.Seen.Headers.Authorization.Parameter));
Assert.Contains("\"bronorganisatie\":\"517439943\"", body); Assert.Contains("\"bronorganisatie\":\"517439943\"", capture.Body);
Assert.Contains("\"verantwoordelijkeOrganisatie\":\"517439943\"", body); Assert.Contains("\"verantwoordelijkeOrganisatie\":\"517439943\"", capture.Body);
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", body); Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", capture.Body);
Assert.Contains("\"startdatum\":\"2026-06-04\"", body); Assert.Contains("\"startdatum\":\"2026-06-04\"", capture.Body);
Assert.Contains("\"zaaktype\":\"http://openzaak/catalogi/api/v1/zaaktypen/big\"", 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.Api/Acl.Api.csproj" />
<Project Path="Acl.Application/Acl.Application.csproj" /> <Project Path="Acl.Application/Acl.Application.csproj" />
<Project Path="Acl.Infrastructure/Acl.Infrastructure.csproj" /> <Project Path="Acl.Infrastructure/Acl.Infrastructure.csproj" />
<Project Path="Acl.IntegrationTests/Acl.IntegrationTests.csproj" />
<Project Path="Acl.Tests/Acl.Tests.csproj" /> <Project Path="Acl.Tests/Acl.Tests.csproj" />
</Solution> </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
}
}
}