Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96d447832f | ||
|
|
a07d8277d6 | ||
|
|
69d6e80378 | ||
|
|
5d32d4f15e | ||
|
|
d767430ad7 | ||
|
|
751ca006a7 | ||
|
|
fea806848b | ||
|
|
2f5d656b54 | ||
|
|
72efab3ae0 | ||
|
|
1edd34e2db | ||
|
|
f885e0a3be | ||
|
|
ac874bf746 | ||
|
|
67f0ffb88d | ||
|
|
5a3f28ac6d | ||
|
|
e9a873c152 | ||
|
|
79dcd8f14b | ||
|
|
22ab38f328 | ||
|
|
0d34d60797 | ||
|
|
6d4adaf957 | ||
|
|
39b2388a9d | ||
|
|
8d176c2603 | ||
|
|
53751fd1bc | ||
|
|
cc9e7852e1 | ||
|
|
c9edf27a48 | ||
|
|
c3ccffe417 | ||
|
|
0d0778036e | ||
|
|
fa8382fc02 | ||
|
|
06d8d13e19 | ||
|
|
a111e5cc20 | ||
|
|
7ef63c7ae9 | ||
|
|
017cd5e66b | ||
|
|
c70840e5b7 | ||
|
|
32c98f00db | ||
|
|
d49443353e | ||
|
|
a256db1a23 | ||
|
|
4d07285dcd | ||
|
|
f3e9db7147 | ||
|
|
86cc65f4d9 | ||
|
|
4474585606 | ||
|
|
3829cb0b68 | ||
|
|
855a5565fe | ||
|
|
09de500fb8 | ||
|
|
4322c607cb | ||
|
|
d0582cef65 | ||
|
|
f2e575b427 | ||
|
|
fd5fa5ac3c | ||
|
|
5f3dd31925 | ||
|
|
347713766e | ||
|
|
7ecc184111 | ||
|
|
e8510bf9c3 | ||
|
|
6ac2fca384 | ||
|
|
10816f5303 | ||
|
|
89b097d015 | ||
|
|
5a83216395 | ||
|
|
f9e123dfcb | ||
|
|
e87113da24 | ||
|
|
dda4c58e1c | ||
|
|
b349dff496 | ||
|
|
6d8e1d0830 | ||
|
|
a0aa22c80b | ||
|
|
12049a0f35 | ||
|
|
9ff7937055 | ||
|
|
88de47d1bb | ||
|
|
8528664660 | ||
|
|
f32fc4e8c0 | ||
|
|
eaca611842 |
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-stryker": {
|
||||
"version": "4.15.0",
|
||||
"commands": [
|
||||
"dotnet-stryker"
|
||||
],
|
||||
"rollForward": false
|
||||
},
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.0",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: respellion-linux
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
- uses: https://github.com/actions/setup-dotnet@v4
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
- run: make lint
|
||||
|
||||
build:
|
||||
runs-on: respellion-linux
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
- uses: https://github.com/actions/setup-dotnet@v4
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
- run: make build
|
||||
|
||||
unit:
|
||||
runs-on: respellion-linux
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
- uses: https://github.com/actions/setup-dotnet@v4
|
||||
@@ -43,8 +43,77 @@ jobs:
|
||||
dotnet-version: '10.0.x'
|
||||
- run: make unit
|
||||
|
||||
compose-smoke:
|
||||
runs-on: respellion-linux
|
||||
mutation:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
- run: make smoke
|
||||
- uses: https://github.com/actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
- run: make mutation
|
||||
# Publish the Stryker HTML reports. `if: always()` uploads them even when the
|
||||
# ratchet fails — that is exactly when you want to inspect the survivors.
|
||||
# `continue-on-error` keeps the upload best-effort: the mutation *gate* is the
|
||||
# ratchet (make mutation's exit code), not the report, so a Gitea artifact-backend
|
||||
# 500 must not fail the job (gitea-actions-gotchas.md §4). Glob handles Stryker's
|
||||
# non-deterministic StrykerOutput/<timestamp>/ dir. Pinned @v3: @v4's bundled
|
||||
# @actions/artifact hard-aborts on non-github.com (GHES guard) — see the runbook.
|
||||
- uses: https://github.com/actions/upload-artifact@v3
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: acl-mutation-report
|
||||
path: services/acl/StrykerOutput/**/reports/mutation-report.html
|
||||
if-no-files-found: warn
|
||||
- uses: https://github.com/actions/upload-artifact@v3
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: event-subscriber-mutation-report
|
||||
path: services/event-subscriber/StrykerOutput/**/reports/mutation-report.html
|
||||
if-no-files-found: warn
|
||||
- uses: https://github.com/actions/upload-artifact@v3
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: domain-mutation-report
|
||||
path: services/domain/StrykerOutput/**/reports/mutation-report.html
|
||||
if-no-files-found: warn
|
||||
- uses: https://github.com/actions/upload-artifact@v3
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: bff-mutation-report
|
||||
path: services/bff/StrykerOutput/**/reports/mutation-report.html
|
||||
if-no-files-found: warn
|
||||
|
||||
# One stage for every check that needs the live stack. On the single self-hosted
|
||||
# runner jobs run sequentially, so booting OpenZaak once (instead of once per job)
|
||||
# is the cheapest layout (issue #58). No setup-dotnet: the ACL test runs in a built
|
||||
# image and everything reaches services by container IP. Needs Docker + egress
|
||||
# (base images, nuget, selectielijst.openzaak.nl).
|
||||
verify-stack:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
# Bring the full stack up + wait for health — this also is the DoD "compose up
|
||||
# reaches green health" smoke (it replaces the old compose-smoke job).
|
||||
- name: Bring up the full stack & wait for health
|
||||
run: make verify-up
|
||||
- name: ACL ↔ OpenZaak integration tests
|
||||
run: make verify-acl
|
||||
- name: OpenZaak → NRC notification delivery
|
||||
run: make verify-nrc
|
||||
- name: OpenZaak → NRC → Event Subscriber → projection-api
|
||||
run: make verify-projection
|
||||
- name: Domain → Flowable → ACL → OpenZaak
|
||||
run: make verify-domain
|
||||
- name: BFF → Keycloak + domain + projection
|
||||
run: make verify-bff
|
||||
# Log dump must precede teardown (which removes the containers).
|
||||
- name: Dump container logs on failure
|
||||
if: failure()
|
||||
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api 2>&1 || true
|
||||
- name: Tear down
|
||||
if: always()
|
||||
run: make down
|
||||
|
||||
@@ -15,6 +15,9 @@ coverage*.json
|
||||
coverage*.xml
|
||||
*.coverage
|
||||
|
||||
# Stryker.NET mutation-testing reports (regenerated by `make mutation`)
|
||||
StrykerOutput/
|
||||
|
||||
# Rider / VS / VS Code
|
||||
.idea/
|
||||
.vs/
|
||||
|
||||
@@ -7,7 +7,22 @@
|
||||
|
||||
SLN := register-referentie.slnx
|
||||
COMPOSE := infra/docker-compose.yml
|
||||
HEALTH_URL := http://localhost:8080/health
|
||||
# Long-running services with a healthcheck — the smoke polls these for readiness
|
||||
# (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init)
|
||||
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
|
||||
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api
|
||||
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
|
||||
# into external named volumes via `docker cp` (infra/seed-config.sh) instead of
|
||||
# bind-mounted, because bind mounts don't reach sibling containers on the
|
||||
# containerized CI runner. SEED populates them; run it before every `up`. The
|
||||
# volumes are `external`, so compose won't remove them — CFG_VOLS lists them for
|
||||
# explicit teardown. See docs/runbooks/gitea-actions-gotchas.md.
|
||||
SEED := bash infra/seed-config.sh
|
||||
CFG_VOLS := rr-oz-config rr-nrc-config rr-kc-realms rr-fl-bpmn
|
||||
# Local-only stack: same services but config is bind-mounted (no seed step), so a
|
||||
# plain `docker compose -f infra/docker-compose.local.yml up` works on any local
|
||||
# engine. This is the no-make / Windows-friendly path. See that file's header.
|
||||
LOCAL_COMPOSE := infra/docker-compose.local.yml
|
||||
OZ_COMPOSE := infra/openzaak/docker-compose.yml
|
||||
OZ_BASE := http://localhost:8000
|
||||
NRC_COMPOSE := infra/opennotificaties/docker-compose.yml
|
||||
@@ -28,10 +43,11 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
|
||||
endif
|
||||
endif
|
||||
|
||||
.PHONY: ci lint build unit smoke down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
|
||||
.PHONY: ci lint build unit mutation integration verify verify-up verify-acl verify-nrc verify-projection verify-notifications smoke up down local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
|
||||
|
||||
## ci: run the full pipeline — lint, build, unit, smoke (mirrors Gitea Actions)
|
||||
ci: lint build unit smoke
|
||||
## ci: run the full pipeline — lint, build, unit, mutation, verify (mirrors Gitea Actions)
|
||||
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
|
||||
ci: lint build unit mutation verify
|
||||
|
||||
## lint: verify formatting (no changes)
|
||||
lint:
|
||||
@@ -41,30 +57,126 @@ lint:
|
||||
build:
|
||||
dotnet build $(SLN) -c Release
|
||||
|
||||
## unit: run unit tests
|
||||
## unit: run unit tests (excludes the container-backed Integration lane)
|
||||
unit:
|
||||
dotnet test $(SLN) -c Release
|
||||
dotnet test $(SLN) -c Release --filter "Category!=Integration"
|
||||
|
||||
## smoke: compose up (wait for healthy), curl /health, then tear down
|
||||
## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline)
|
||||
# Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore`
|
||||
# makes `make mutation` work from a fresh clone. Each service owns its config + break
|
||||
# threshold (the ratchet, CLAUDE.md §5): each services/<svc>/stryker-config.json.
|
||||
# Scores never regress below baseline.
|
||||
mutation:
|
||||
dotnet tool restore
|
||||
cd services/acl && dotnet stryker
|
||||
cd services/event-subscriber && dotnet stryker
|
||||
cd services/domain && dotnet stryker
|
||||
cd services/bff && dotnet stryker
|
||||
|
||||
## smoke: seed config, bring the whole stack up, wait for health-checked services, tear down
|
||||
# SEED populates the external config volumes first (upstream images used verbatim;
|
||||
# only our acl/bff are built). `up -d --build` starts EVERYTHING. Readiness is
|
||||
# checked by infra/wait-healthy.sh polling the durable, health-checked services
|
||||
# ($(WAIT_SVCS)) via `docker inspect` — portable across docker compose and
|
||||
# podman-compose, and needing no `--wait` flag or host port access. The one-shots
|
||||
# (oz-init, flowable-init) aren't polled; they just need to have run.
|
||||
smoke:
|
||||
docker compose -f $(COMPOSE) up -d --build --wait
|
||||
bash -c 'curl -fsS $(HEALTH_URL); rc=$$?; docker compose -f $(COMPOSE) down --volumes; exit $$rc'
|
||||
$(SEED) oz nrc kc fl
|
||||
docker compose -f $(COMPOSE) up -d --build
|
||||
bash -c 'WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS); rc=$$?; docker compose -f $(COMPOSE) down --volumes; docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; exit $$rc'
|
||||
|
||||
## down: stop and remove the local stack
|
||||
## up: seed config volumes and start the full stack (use instead of bare
|
||||
## `docker compose up`, which can't self-seed the external config volumes)
|
||||
up:
|
||||
$(SEED) oz nrc kc fl
|
||||
docker compose -f $(COMPOSE) up -d --build
|
||||
|
||||
## down: stop and remove the local stack (incl. the external config volumes)
|
||||
down:
|
||||
docker compose -f $(COMPOSE) down --volumes
|
||||
-docker volume rm -f $(CFG_VOLS)
|
||||
|
||||
## local: bring up the bind-mount stack (no seed step) and wait for health
|
||||
## (Windows / no-make users: run `docker compose -f infra/docker-compose.local.yml up -d --build` directly)
|
||||
local:
|
||||
docker compose -f $(LOCAL_COMPOSE) up -d --build
|
||||
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
|
||||
|
||||
## local-down: stop and remove the bind-mount stack
|
||||
local-down:
|
||||
docker compose -f $(LOCAL_COMPOSE) down --volumes
|
||||
|
||||
## changelog: regenerate CHANGELOG.md from Conventional Commits (git-cliff)
|
||||
changelog:
|
||||
git-cliff --output CHANGELOG.md
|
||||
|
||||
# ── ZGW verification ───────────────────────────────────────────────────────
|
||||
# On the single runner CI jobs run sequentially, so the OpenZaak-dependent checks
|
||||
# share ONE full-stack bring-up: the `verify-stack` CI job runs `verify-up` then
|
||||
# `verify-acl` + `verify-nrc` as steps against the same stack (issue #58). The
|
||||
# check logic lives in stack-agnostic runners that reach services by container IP
|
||||
# (gitea-actions-gotchas.md §5/§6); `integration` / `verify-notifications` are local
|
||||
# convenience wrappers that bring up a lighter stack and call the same runners.
|
||||
|
||||
## verify-up: bring the FULL stack up and wait for health (CI verify-stack step 1;
|
||||
## subsumes the old compose-smoke health gate — the DoD "up reaches green" check).
|
||||
verify-up:
|
||||
$(SEED) oz nrc kc fl
|
||||
docker compose -f $(COMPOSE) up -d --build
|
||||
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
|
||||
|
||||
## verify-acl: ACL ↔ OpenZaak integration tests against the already-running stack.
|
||||
verify-acl:
|
||||
bash infra/run-acl-integration.sh
|
||||
|
||||
## verify-nrc: OpenZaak → NRC notification delivery against the already-running stack.
|
||||
verify-nrc:
|
||||
bash infra/run-notification-check.sh
|
||||
|
||||
## verify-projection: OpenZaak → NRC → Event Subscriber → projection-api end-to-end (S-06),
|
||||
## against the already-running stack.
|
||||
verify-projection:
|
||||
bash infra/run-projection-check.sh
|
||||
|
||||
## verify-domain: domain → Flowable → ACL → OpenZaak end-to-end (S-05), against the
|
||||
## already-running stack. Recreates the acl service to inject the seeded zaaktype URL.
|
||||
verify-domain:
|
||||
bash infra/run-domain-check.sh
|
||||
|
||||
## verify-bff: BFF end-to-end (S-07) against the up stack — token validation on self-service
|
||||
## + anonymous public-safe openbaar register (ADR-0010).
|
||||
verify-bff:
|
||||
bash infra/run-bff-check.sh
|
||||
|
||||
## verify: local mirror of the CI verify-stack job — full stack up once, all checks,
|
||||
## tear down (always). For fast single-concern local iteration use `integration`
|
||||
## (oz-only) or `verify-notifications` (oz+nrc) instead.
|
||||
verify:
|
||||
$(SEED) oz nrc kc fl
|
||||
docker compose -f $(COMPOSE) up -d --build
|
||||
@bash -c 'set -e; rc=0; \
|
||||
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS) \
|
||||
&& bash infra/run-acl-integration.sh \
|
||||
&& bash infra/run-notification-check.sh \
|
||||
&& bash infra/run-projection-check.sh \
|
||||
&& bash infra/run-domain-check.sh \
|
||||
&& bash infra/run-bff-check.sh || rc=$$?; \
|
||||
docker compose -f $(COMPOSE) down --volumes >/dev/null 2>&1; \
|
||||
docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; \
|
||||
exit $$rc'
|
||||
|
||||
## integration: local convenience — ACL integration test against a throwaway
|
||||
## OpenZaak-only stack (fast iteration). CI uses verify-acl on the shared stack.
|
||||
integration:
|
||||
bash infra/run-integration.sh
|
||||
|
||||
## openzaak-up: start the OpenZaak stack (migrations run on first start)
|
||||
openzaak-up:
|
||||
$(SEED) oz
|
||||
docker compose -f $(OZ_COMPOSE) up -d
|
||||
|
||||
## openzaak-smoke: start OpenZaak, then assert it is up with auth enforced
|
||||
openzaak-smoke:
|
||||
docker compose -f $(OZ_COMPOSE) up -d
|
||||
openzaak-smoke: openzaak-up
|
||||
@bash -c 'set -e; \
|
||||
echo "waiting for OpenZaak to respond..."; \
|
||||
for i in $$(seq 1 60); do \
|
||||
@@ -88,10 +200,18 @@ openzaak-seed: openzaak-up
|
||||
## openzaak-down: stop and remove the OpenZaak stack (wipes data)
|
||||
openzaak-down:
|
||||
docker compose -f $(OZ_COMPOSE) down --volumes
|
||||
-docker volume rm -f rr-oz-config
|
||||
|
||||
## stack-up: start OpenZaak + Open Notificaties together (shared network)
|
||||
## verify-notifications: local convenience — OpenZaak → NRC notification delivery
|
||||
## against a throwaway oz+nrc stack (S-01-c). CI uses verify-nrc on the shared stack.
|
||||
verify-notifications:
|
||||
bash infra/verify-notifications.sh
|
||||
|
||||
## stack-up: start OpenZaak + Open Notificaties together (shared network), with
|
||||
## OpenZaak publishing notifications to NRC (S-01-c).
|
||||
stack-up:
|
||||
docker compose $(STACK_FILES) up -d
|
||||
$(SEED) oz nrc
|
||||
OZ_NOTIFICATIONS_DISABLED=false docker compose $(STACK_FILES) up -d
|
||||
|
||||
## stack-smoke: start both, assert OpenZaak (403/302/200) and NRC (302) are reachable
|
||||
stack-smoke: stack-up
|
||||
@@ -110,9 +230,11 @@ stack-smoke: stack-up
|
||||
## stack-down: stop and remove both stacks (wipes data)
|
||||
stack-down:
|
||||
docker compose $(STACK_FILES) down --volumes
|
||||
-docker volume rm -f rr-oz-config rr-nrc-config
|
||||
|
||||
## keycloak-up: start Keycloak with the four imported realms
|
||||
keycloak-up:
|
||||
$(SEED) kc
|
||||
docker compose -f $(KC_COMPOSE) up -d
|
||||
|
||||
## keycloak-smoke: start Keycloak, then verify each realm logs in + returns its claim
|
||||
@@ -125,9 +247,11 @@ keycloak-smoke: keycloak-up
|
||||
## keycloak-down: stop and remove Keycloak
|
||||
keycloak-down:
|
||||
docker compose -f $(KC_COMPOSE) down --volumes
|
||||
-docker volume rm -f rr-kc-realms
|
||||
|
||||
## flowable-up: start Flowable (deploys registratie.bpmn on boot)
|
||||
flowable-up:
|
||||
$(SEED) fl
|
||||
docker compose -f $(FL_COMPOSE) up -d
|
||||
|
||||
## flowable-smoke: start Flowable, then verify a started instance waits on the external task
|
||||
@@ -140,6 +264,7 @@ flowable-smoke: flowable-up
|
||||
## flowable-down: stop and remove Flowable
|
||||
flowable-down:
|
||||
docker compose -f $(FL_COMPOSE) down --volumes
|
||||
-docker volume rm -f rr-fl-bpmn
|
||||
|
||||
## help: list available targets
|
||||
help:
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,92 @@
|
||||
# ADR-0006: Provision the ACL integration test against the compose stack
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-06-29
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-04a (#46); proposed in #53; builds on ADR-0001 (loose coupling), ADR-0002 (catalogus design), ADR-0003 (default-fill); supports CLAUDE.md §11 (integration tests via real containers)
|
||||
|
||||
## Context
|
||||
|
||||
S-04 delivered the ACL's one operation — `OpenZaakGateway.OpenZaakAsync` — with unit
|
||||
tests against a stubbed `HttpMessageHandler` and a Reqnroll scenario over an in-memory
|
||||
stand-in. The deferred S-04 acceptance criterion (S-04a) is the one a stub cannot meet:
|
||||
|
||||
> Integration test using Testcontainers against real OpenZaak passes.
|
||||
|
||||
The test must drive the gateway against a **real** OpenZaak — real ZGW JWT auth, the real
|
||||
`POST /zaken/api/v1/zaken` contract, real CRS handling — and assert a zaak comes back.
|
||||
|
||||
Two ways to stand OpenZaak up were considered (the issue's open question): (a) a full
|
||||
**Testcontainers** graph started by the test, or (b) target the **running compose stack**
|
||||
the repo already defines (`infra/openzaak/docker-compose.yml`, `make openzaak-up`).
|
||||
|
||||
Investigation reversed the initially-favoured Testcontainers option:
|
||||
|
||||
1. **Testcontainers .NET has no docker-compose support.** OpenZaak needs PostGIS + Redis +
|
||||
a `setup_configuration` one-shot (the JWT client) + the API. Honouring "full graph" would
|
||||
mean re-implementing that five-service stack — init ordering, the config volume, health
|
||||
gating — by hand in C#, duplicating the maintained compose file and rotting with it. That
|
||||
rubs against CLAUDE.md §13 ("if a test is hard to write, the design is wrong").
|
||||
2. **The test cannot be hermetic anyway.** OpenZaak's Zaken API rejects a zaak against a
|
||||
*concept* zaaktype (`not-published`), and a *published* zaaktype requires ≥1 resultaattype,
|
||||
which OpenZaak validates by fetching the external **Selectielijst** reference API
|
||||
(`selectielijst.openzaak.nl`). So a real zaak POST already depends on outbound internet
|
||||
from the OpenZaak container — the self-containment that motivated Testcontainers is lost
|
||||
regardless of how the containers are started.
|
||||
|
||||
## Decision
|
||||
|
||||
**The ACL integration test targets the running compose stack; it does not start containers
|
||||
itself. No new test dependency is added.**
|
||||
|
||||
- A gated test project `Acl.IntegrationTests` (`[Trait("Category","Integration")]`) talks to
|
||||
OpenZaak with a plain `HttpClient`, reusing the same endpoint + JWT-client config the seed
|
||||
uses (`OZ_BASE` / `OZ_CLIENT_ID` / `OZ_SECRET`, defaulting to the local stack). It locates
|
||||
the published `BIG-REGISTRATIE` zaaktype via the Catalogi API and exercises the real
|
||||
`OpenZaakGateway` against it.
|
||||
- **The lane is kept out of the fast checks.** `make unit` runs with
|
||||
`--filter "Category!=Integration"`; Stryker is pinned to `Acl.Tests` (`test-projects`), so
|
||||
neither the unit nor the mutation lane needs a live stack. A `make integration` target
|
||||
(`infra/run-integration.sh`) brings up a throwaway OpenZaak and runs the lane locally.
|
||||
In CI the check runs as the `verify-acl` step of the consolidated `verify-stack` job
|
||||
(issue #58) — one shared full-stack bring-up. This matches `make` being the single
|
||||
source of truth (ADR-0005).
|
||||
- **Publishing is opt-in in the seed.** `infra/openzaak/seed_catalogus.py` gains an
|
||||
`OZ_PUBLISH=1` path that adds the relations OpenZaak's publish requires — two statustypen
|
||||
(begin/eind), a roltype, and a resultaattype whose Selectielijst procestype is matched onto
|
||||
the zaaktype — then publishes. The default seed (S-01 / ADR-0002) still leaves the zaaktype
|
||||
a concept; only `make integration` flips the switch.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Positive:** a small, honest test over the real ZGW contract with no bespoke orchestration
|
||||
to maintain; the compose stack is exercised exactly as operators run it; no new dependency.
|
||||
- **It caught a real bug.** The gateway sent the zaak body via `JsonContent` without a
|
||||
`Content-Length`, so .NET framed it as `Transfer-Encoding: chunked`, which OpenZaak's uwsgi
|
||||
rejects with 400. A stubbed handler accepts either framing, so only a real OpenZaak surfaced
|
||||
it. Fixed by buffering the body (`LoadIntoBufferAsync`); guarded in the fast lane by a unit
|
||||
test asserting a `Content-Length` is set. This is the concrete justification for §11's
|
||||
integration tier.
|
||||
- **External dependency:** the integration job needs the OpenZaak container to reach
|
||||
`selectielijst.openzaak.nl`. It is a stable public reference API (the same one OpenZaak uses
|
||||
in production) but it is a network touchpoint, and a CI environment without egress would need
|
||||
a local Selectielijst service or a recorded fixture. `OZ_SELECTIELIJST` overrides the base URL.
|
||||
- **Cost:** the lane needs the stack up first, so it is separate from the fast lanes.
|
||||
- **Runs on the hosted runner.** A process *on* the runner can't reach the stack's published
|
||||
ports (Compose starts sibling containers via the host daemon — gitea-actions-gotchas.md §5,
|
||||
same split as §1), so `infra/run-integration.sh` runs both the seed and the test as containers
|
||||
*joined to the OpenZaak network*, reaching it by **container IP** (a single-label host like
|
||||
`openzaak` isn't URL-valid for OpenZaak's own `URLValidator`; an IPv4 literal is). Code is
|
||||
delivered by image build / `docker cp`, never bind mounts. The CI job therefore needs only
|
||||
Docker — no `setup-dotnet`. (This closed the follow-up that was originally split out as #55.)
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Full Testcontainers graph** — rejected: re-implements the compose stack in C# (brittle,
|
||||
duplicative) for no hermeticity gain, since the Selectielijst dependency remains.
|
||||
- **Single OpenZaak container (sqlite/locmem)** — rejected: diverges from the real
|
||||
PostGIS-backed, Redis-cached deployment; the Zaken API is a geo API and the divergence would
|
||||
undermine the contract the test exists to verify.
|
||||
- **Mock OpenZaak / record-replay** — rejected: that is what the existing stubbed-handler unit
|
||||
tests already do; it cannot exercise the real contract, and would not have caught the chunked
|
||||
body bug.
|
||||
@@ -0,0 +1,77 @@
|
||||
# ADR-0007: Wiring OpenZaak → Open Notificaties (NRC) for notifications
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-06-29
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-01-c (#56); completes S-01 (#2); unblocks the Event Subscriber (#7); builds on ADR-0002 (catalogus/seed) and ADR-0006 (runner-safe container harnesses)
|
||||
|
||||
## Context
|
||||
|
||||
S-01 brought OpenZaak + Open Notificaties (NRC) up in compose but **deferred the
|
||||
notification wiring**: OpenZaak ran with `NOTIFICATIONS_DISABLED=true` and NRC's
|
||||
`setup_configuration` was empty. The walking skeleton (PRD §12) needs the upstream
|
||||
event path — a zaak created in OpenZaak must publish a notification NRC fans out to
|
||||
subscribers — before the Event Subscriber (#7) can consume it.
|
||||
|
||||
The OpenZaak↔NRC handshake is intricate and several details are non-obvious; they
|
||||
were nailed down by iterating `setup_configuration` against the running stack.
|
||||
|
||||
## Decision
|
||||
|
||||
**Provision both sides declaratively via `setup_configuration`, authenticate with the
|
||||
existing `big-reference-seed` client, and run NRC's celery-beat so deliveries happen.**
|
||||
|
||||
- **OpenZaak** (`infra/openzaak/setup_configuration/data.yaml`): a `zgw_consumers`
|
||||
service `nrc` (api_type `nrc`, the NRC API root) plus `notifications_config` naming
|
||||
it. `NOTIFICATIONS_DISABLED` is flipped to `false` **only when NRC is present** —
|
||||
the full stack and the local twin set it; OpenZaak-only bring-ups (`openzaak-up`,
|
||||
the ACL integration test) default it back to `true` via `OZ_NOTIFICATIONS_DISABLED`
|
||||
so they don't 500 publishing to an absent NRC.
|
||||
- **NRC** (`infra/opennotificaties/setup_configuration/data.yaml`): the
|
||||
`big-reference-seed` JWT credential (to verify OpenZaak's token), a `zgw_consumers`
|
||||
`ac` service pointing at **OpenZaak's Autorisaties API**, the `autorisaties_api`
|
||||
step delegating authorization to that AC, and the `zaken` kanaal. NRC's init
|
||||
container switches from `migrate` to `/setup_configuration.sh`; its data.yaml is
|
||||
delivered through the `rr-nrc-config` external volume by `infra/seed-config.sh`
|
||||
(the same `docker cp` pattern as OpenZaak — bind mounts don't reach the CI runner's
|
||||
daemon).
|
||||
- **celery-beat is required.** NRC accepts a notification and writes a
|
||||
`ScheduledNotification`; a periodic `execute_notifications` task (celery-beat,
|
||||
every `NOTIFICATION_SEC_INTERVAL`s) drains it to the worker for delivery. The lean
|
||||
S-01 stack dropped beat — so notifications were accepted but never delivered. An
|
||||
`nrc-beat` service is added to every compose; the interval is lowered to 5s.
|
||||
|
||||
Verification is a runner-safe smoke (`infra/run-notification-check.sh`): it seeds a
|
||||
published BIG zaaktype, registers an abonnement to a webhook sink, creates a zaak, and
|
||||
asserts the sink receives the `zaken`/`create` notification — all from containers
|
||||
**inside** the compose network (ADR-0006). Locally it runs via `make verify-notifications`
|
||||
(a throwaway oz+nrc stack); in CI it runs as the `verify-nrc` step of the consolidated
|
||||
`verify-stack` job (one shared full-stack bring-up — issue #58).
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Positive:** the walking-skeleton event path works end to end; #7 can consume real
|
||||
notifications; the wiring is declarative and reproducible from a fresh `make`.
|
||||
- **Gotchas captured (see gitea-actions-gotchas.md):**
|
||||
- **Single-label hosts aren't URL-valid.** OpenZaak/NRC reject `http://openzaak…`
|
||||
/`http://nrc-web…` in URLs they validate (Django `URLValidator`); the verify
|
||||
harness reaches services and registers the sink callback **by container IP**.
|
||||
- **Abonnement callbacks must enforce auth.** NRC probes the callback during
|
||||
registration and refuses it (`no-auth-on-callback-url`) unless it returns 401
|
||||
without the configured `Authorization`; the sink enforces a bearer token.
|
||||
- **Cost:** an extra long-running service (`nrc-beat`) per stack, and the verify job
|
||||
needs egress (base images + `selectielijst.openzaak.nl`, since the published
|
||||
zaaktype the check creates a zaak against depends on it — ADR-0006).
|
||||
- **Dev-only credentials** reused (`big-reference-seed` / its secret) across publish,
|
||||
AC lookup, and seeding — acceptable for the reference app, not production.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **NRC with its own (non-AC) authorization** — rejected: delegating to OpenZaak's
|
||||
Autorisaties API is the upstream-intended model and reuses the applicatie that
|
||||
already grants `heeft_alle_autorisaties`.
|
||||
- **Keep beat out, deliver synchronously** — not an option: Open Notificaties 1.16
|
||||
delivers via scheduled notifications drained by beat; there is no sync path.
|
||||
- **A persistent abonnement in `setup_configuration`** instead of registering one in
|
||||
the verify harness — deferred: the real subscriber is #7; the harness's sink
|
||||
abonnement is throwaway and IP-specific.
|
||||
@@ -0,0 +1,89 @@
|
||||
# ADR-0008: The read projection — a shared, rebuildable store with a writer and a reader
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-06-30
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-06 (#7); builds on ADR-0001 (loose coupling), ADR-0007 (#56, OZ→NRC wiring); first EF Core usage in the repo
|
||||
|
||||
## Context
|
||||
|
||||
S-06 (#7) adds the upstream event path's destination: an **Event Subscriber** that consumes
|
||||
NRC notifications and a **read projection** the openbaar register reads. The walking-skeleton
|
||||
projection (PRD §8.4) holds one row per zaak — `id`, `bsn`, `naam_placeholder`, `status` —
|
||||
and must be **idempotent** (NRC redelivers and reorders, CLAUDE.md §8.6) and **rebuildable**
|
||||
(a derived artefact, never a write-only source of truth).
|
||||
|
||||
Two design questions had no obvious answer:
|
||||
|
||||
1. **Where does `bsn` come from?** The NRC `zaken`/`zaak`/`create` notification carries only the
|
||||
zaak URL plus the fixed `kenmerken` (`bronorganisatie`, `zaaktype`, `vertrouwelijkheidaanduiding`).
|
||||
It does **not** carry the bsn. Reading it means calling a ZGW API — which **only the ACL** may
|
||||
do (CLAUDE.md §8.1). The issue's "Touches" lists only `event-subscriber` + `projection-api`,
|
||||
not the ACL.
|
||||
2. **Who owns the projection schema?** The subscriber writes the projection; the projection-api
|
||||
reads it. CLAUDE.md §8.5 says "no direct DB access across services; each service owns its
|
||||
schema." Two deployables on one table looks like a violation.
|
||||
|
||||
## Decision
|
||||
|
||||
**One Postgres database is the read projection. The Event Subscriber writes it (projector) and
|
||||
the projection-api reads it (query); both are processes of the single "Read Projection" bounded
|
||||
context and share one schema, defined in a shared `Projection.ReadModel` library. `bsn` is
|
||||
deferred.**
|
||||
|
||||
- **Schema ownership.** The read model — `register_projection` plus the subscriber's
|
||||
`processed_notifications` log — lives in `services/projection-api/Projection.ReadModel`
|
||||
(EF Core + Npgsql). Both services reference it. This is the textbook CQRS read-model split
|
||||
(one writer, one reader over one derived store), **not** the cross-*domain* DB reach §8.5
|
||||
forbids: no domain owns write-state here; the projection is rebuildable (§8.4). §8.5 still
|
||||
holds for every domain database.
|
||||
- **Idempotency** is the primary key on `processed_notifications.key` (a deterministic key
|
||||
derived from the immutable notification content). A duplicate insert raises a unique violation,
|
||||
caught and reported as "already recorded", so the duplicate never reaches the projection. The
|
||||
projection upsert is itself idempotent on the zaak id, a second line of defence.
|
||||
- **Rebuild replays the log, not OpenZaak.** `POST /admin/rebuild` clears `register_projection`
|
||||
and reprojects every row in `processed_notifications`. So "rebuildable" needs **no** ZGW access
|
||||
(§8.1) and no ACL dependency — keeping S-06 within its stated scope.
|
||||
- **`bsn` and `naam_placeholder` are deferred.** They are columns (nullable) but the minimal slice
|
||||
populates only `id` + `status` (`INGEDIEND`) from the notification. Populating personal data
|
||||
requires reading the zaak **through the ACL** (§8.1) and is its own follow-up; the column shape
|
||||
is in place so that change is additive.
|
||||
- **New dependency: EF Core 10 + `Npgsql.EntityFrameworkCore.PostgreSQL`.** What it gives us: a
|
||||
migrated relational schema, LINQ queries, and a clean port implementation. What we'd write
|
||||
instead: hand-rolled SQL + a migration runner. Risk: ORM complexity and an extra dependency
|
||||
graph — bounded here to a tiny two-table read model. `dotnet-ef` is pinned as a local tool for
|
||||
migrations; `NuGetAuditMode=direct` keeps EF's design-time-only tooling transitive out of the
|
||||
audited, shipped graph.
|
||||
|
||||
The end-to-end path is verified by a runner-safe live-stack smoke (`infra/run-projection-check.sh`,
|
||||
the `verify-projection` step of the `verify-stack` job, #58): register an abonnement at the real
|
||||
Event Subscriber's callback, create a zaak, assert projection-api serves an `INGEDIEND` row — all
|
||||
in-network, reaching services by container IP (ADR-0006/0007).
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Positive:** the upstream event path reaches a queryable projection; idempotent and rebuildable
|
||||
without OpenZaak; S-06 stays inside its stated touch-set (no ACL change); the projection-api is
|
||||
ready for S-09 to tighten public-safe field filtering.
|
||||
- **Negative / deferred:**
|
||||
- `bsn`/`naam_placeholder` stay empty until a follow-up wires zaak reads via the ACL.
|
||||
- The abonnement is registered by the verify harness (by container IP), not provisioned
|
||||
persistently — ADR-0007 already deferred a persistent abonnement, and a single-label service
|
||||
host is not URL-valid for NRC, so persistent registration needs a dotted network alias. Tracked
|
||||
as a follow-up; a plain `make up` therefore needs the abonnement registered before the event
|
||||
path flows.
|
||||
- Two services share one database. Acceptable for a derived read model; revisit if the read and
|
||||
write sides ever need independent scaling or storage.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Subscriber reads OpenZaak directly to fill `bsn`** — rejected: breaks §8.1 (only the ACL talks
|
||||
to ZGW) and would need its own ADR to bend the rule.
|
||||
- **Extend the ACL with a zaak-read operation, consumed as a library** — viable and §8.1-clean, but
|
||||
it grows S-06 beyond its stated scope (touches the ACL) and pulls personal-data handling forward;
|
||||
deferred to a follow-up.
|
||||
- **projection-api owns the DB and exposes an internal write endpoint the subscriber calls** —
|
||||
rejected for the walking skeleton: adds an HTTP hop and a write surface on a read service for no
|
||||
current benefit over a shared, rebuildable read model.
|
||||
- **Separate databases for the log and the projection** — rejected as premature: both are the read
|
||||
side's private, rebuildable state; one DB is simpler and still honours §8.5's intent.
|
||||
@@ -0,0 +1,88 @@
|
||||
# ADR-0009: The Domain Service drives Flowable as an external-task job worker
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-06-30
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-05 (#6); proposal #60; builds on ADR-0001 (loose coupling, §8.1/§8.2), S-03 (#4, the `registratie` BPMN), S-04 (#5, the ACL `OpenZaak` operation)
|
||||
|
||||
## Context
|
||||
|
||||
S-05 (#6) adds the **BIG Domain Service**. Submitting a registration must: create a
|
||||
`Registration` aggregate, **start the Flowable `registratie` process** (S-03), have the
|
||||
`OpenZaakAanmaken` task **open a zaak via the ACL** (S-04), and store the resulting zaak URL
|
||||
back on the aggregate.
|
||||
|
||||
`OpenZaakAanmaken` is a Flowable **external-worker** service task (`flowable:type="external-worker"`,
|
||||
topic `OpenZaakAanmaken`). Flowable does not push it anywhere — it parks the job and waits for a
|
||||
worker to **acquire and lock** it, do the work, and **complete** it. Two coupling rules constrain
|
||||
who may do what:
|
||||
|
||||
- **§8.2 — the Workflow Client is the only code that talks to Flowable.** BPMN models never embed
|
||||
OpenZaak knowledge; they ask the Workflow Client to execute external tasks.
|
||||
- **§8.1 — the ACL is the only code that talks to ZGW.** The worker opens the zaak *through the ACL*,
|
||||
never by constructing ZGW URLs itself.
|
||||
|
||||
This is an ADR-worthy moment (§14): a service boundary is defined and both coupling rules are
|
||||
exercised. The open question is *how* the external task is driven.
|
||||
|
||||
## Decision
|
||||
|
||||
**The Domain Service drives the `OpenZaakAanmaken` task as a hosted external-task job worker
|
||||
(PRD §36). Orchestration is eventually consistent, not request-synchronous.**
|
||||
|
||||
- **`POST /registrations` is fast and side-effecting only on the domain side.** It creates the
|
||||
`Registration` aggregate in state `INGEDIEND`, persists it, and asks the Workflow Client to start
|
||||
one `registratie` process instance, recording the process-instance id on the aggregate. It returns
|
||||
immediately; it does **not** wait for the zaak to be opened.
|
||||
- **A hosted worker polls Flowable for `OpenZaakAanmaken` jobs.** It acquires and locks a job, calls
|
||||
the ACL `OpenZaak` operation (§8.1), attaches the returned zaak URL to the matching aggregate
|
||||
(`Registration.AttachZaak`), and completes the job in Flowable. The process then runs to its end
|
||||
event.
|
||||
- **The Workflow Client is the only Flowable client (§8.2).** It lives in the Domain Service's
|
||||
`Infrastructure` layer and speaks Flowable's REST API (start process-instance; acquire/lock/complete
|
||||
external-worker jobs). No other code — not the Application layer, not the BPMN — knows Flowable
|
||||
exists.
|
||||
- **The worker *logic* is an Application service over ports**, not Flowable-aware code. `OpenZaakWorker`
|
||||
takes an acquired job (topic + the registration id it carries), calls `IAclClient` and
|
||||
`IRegistrationStore`, and returns the zaak URL to complete with. The **polling loop** is a thin
|
||||
`BackgroundService` in `Infrastructure` that fetches jobs via the Workflow Client and feeds them to
|
||||
the worker. So the orchestration is covered by fast unit tests against fakes; only the REST framing
|
||||
needs a container integration test.
|
||||
|
||||
## Scope decisions for the minimal slice
|
||||
|
||||
- **Registration persistence is in-memory.** The walking skeleton's *read* path is fed by
|
||||
NRC → Event Subscriber → projection (S-06, #7), not by the domain database. An EF-backed domain
|
||||
store buys nothing the demo needs yet, so it is a documented follow-up; the `IRegistrationStore`
|
||||
port keeps that change additive. (PRD §88 envisions EF Core for the domain DB eventually.)
|
||||
- **The aggregate's state machine is minimal:** `INGEDIEND` on submission. Later flows (withdrawal,
|
||||
beoordeling, herregistratie) add states in their own slices — they are out of scope here.
|
||||
- **No bsn flows to ZGW yet.** The ACL `OpenZaak` operation already default-fills the ZGW-mandatory
|
||||
fields (ADR-0003) and takes the bsn as its domain payload; the domain hands it through unchanged.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Positive:** the submit request is decoupled from ACL/OpenZaak latency; the documented Common
|
||||
Ground pattern (external-task worker) is realised; both coupling rules (§8.1, §8.2) hold with the
|
||||
Flowable knowledge isolated to one Infrastructure class; the orchestration is unit-testable.
|
||||
- **Negative / deferred:**
|
||||
- Eventual consistency: immediately after `POST /registrations` the aggregate has no zaak URL yet.
|
||||
Acceptable — the read side is the projection, not the domain store.
|
||||
- In-memory registration state is lost on restart; fine for the skeleton, replaced by an EF store
|
||||
in a follow-up.
|
||||
- The worker polls (no push); poll interval is a tuning knob, not a correctness concern, since
|
||||
Flowable holds the job until completed.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Synchronous acquire+complete inside the `POST /registrations` request** — rejected: simpler and
|
||||
deterministic, but couples the submit request to ACL/OpenZaak latency and failure, and is not the
|
||||
external-task worker pattern PRD §36 mandates. It would also make the request fail if OpenZaak is
|
||||
briefly down, instead of the job simply staying parked for the worker to retry.
|
||||
- **A standalone Workflow Client service, separate from the Domain Service** — rejected for this
|
||||
slice: the worker needs the domain's aggregate store and the ACL client anyway, and PRD §9 places
|
||||
the Workflow Client inside the Domain Service deployment. A separate process adds a hop and a
|
||||
shared store for no current benefit.
|
||||
- **Flowable pushes to a webhook instead of being polled** — rejected: Flowable's external-worker
|
||||
model is pull-based (acquire/lock/complete); a push shim would re-implement it with weaker
|
||||
delivery guarantees.
|
||||
@@ -0,0 +1,74 @@
|
||||
# ADR-0010: The BFF validates Keycloak tokens and is the portals' only backend
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-01
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-07 (#8); proposal #63; builds on ADR-0001 (loose coupling, §8.3), S-02 (#3, Keycloak realms), S-05 (#6, Domain Service), S-06 (#7, read projection)
|
||||
|
||||
## Context
|
||||
|
||||
S-07 (#8) adds the **BFF (Backend-for-Frontend)** — the single backend the Angular portals talk
|
||||
to (CLAUDE.md §8.3). For the walking skeleton it exposes two endpoints and fans out to services
|
||||
already built:
|
||||
|
||||
- `POST /self-service/registrations` → Domain Service `POST /registrations` (S-05).
|
||||
- `GET /openbaar/register?q=…` → projection-api `GET /register` (S-06).
|
||||
|
||||
It must validate tokens issued by Keycloak (S-02). This is an ADR-worthy moment (§14): a new
|
||||
dependency (JWT bearer authentication) and two new service boundaries (BFF→domain, BFF→projection).
|
||||
|
||||
## Decision
|
||||
|
||||
**The BFF is the portals' only backend; it validates Keycloak `digid`-realm JWTs on the
|
||||
self-service endpoint, leaves the openbaar lookup anonymous, and fans out to the domain and
|
||||
projection over typed HTTP clients.**
|
||||
|
||||
- **Auth model.** `POST /self-service/registrations` requires a valid `digid`-realm bearer token;
|
||||
the BFF reads the `bsn` claim and forwards it to the domain. Missing / invalid / expired token →
|
||||
**401**. `GET /openbaar/register` is **anonymous** — the openbaar register is a public lookup
|
||||
(S-09), so no token is required.
|
||||
- **Portals talk only to the BFF (§8.3).** They never call the Domain Service, ACL, projection, or
|
||||
OpenZaak directly. The BFF orchestrates via typed `HttpClient`s whose base URLs come from config.
|
||||
Downstream calls are unauthenticated on the internal network for the walking skeleton; a
|
||||
service-to-service auth story (e.g. client-credentials) is a later slice, not this one.
|
||||
- **Validation is `Microsoft.AspNetCore.Authentication.JwtBearer`** pointed at the Keycloak `digid`
|
||||
realm authority. **New dependency justification:** it gives us standards-based OIDC/JWT validation
|
||||
(signature, issuer, expiry, audience) maintained by the framework; rolling our own JWT validation
|
||||
would be error-prone security code; the risk is a first-party ASP.NET Core package — minimal.
|
||||
- **Tests mint their own tokens.** `WebApplicationFactory` tests override the bearer options with a
|
||||
**test signing key**, so valid / invalid / expired tokens are minted in-process without a live
|
||||
Keycloak. Real Keycloak validation is exercised by a live-stack `verify-bff` check.
|
||||
- **OpenAPI is generated and committed** (`services/bff/openapi.json`) from .NET's built-in OpenAPI,
|
||||
so S-08's Angular client is generated from the spec, never hand-written (§10).
|
||||
|
||||
## Known wrinkle — container OIDC issuer mismatch
|
||||
|
||||
Keycloak stamps tokens with an `iss` equal to its **browser-facing** URL (what the portal used to
|
||||
log in), which differs from the BFF's **in-container** authority (`http://keycloak:8080/realms/digid`).
|
||||
Strict issuer validation then rejects otherwise-valid tokens. Unit tests avoid this (test key).
|
||||
`verify-bff` handles it by aligning the configured authority/issuer with the token's `iss` (and, if
|
||||
needed, disabling metadata address rewriting). Recorded so it is not rediscovered each time.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Positive:** the walking skeleton gains its front door; §8.3 holds with all portal traffic going
|
||||
through one backend; token validation is standard and testable without infra; the committed
|
||||
OpenAPI unblocks S-08.
|
||||
- **Negative / deferred:**
|
||||
- Downstream service-to-service auth is deferred (internal-network trust for now).
|
||||
- The openbaar endpoint is anonymous; when public-safe field filtering tightens (S-09) it stays
|
||||
anonymous but the projection query narrows.
|
||||
- The issuer-mismatch handling is dev-oriented; a production reverse-proxy setup would align the
|
||||
browser and internal issuer URLs instead.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Token-gate the openbaar endpoint too** — rejected: the openbaar register is public by design
|
||||
(S-09); requiring a login would contradict the slice's intent.
|
||||
- **Validate tokens by calling Keycloak's introspection endpoint per request** — rejected: adds a
|
||||
network hop per call and a Keycloak dependency on the hot path; local JWT signature validation via
|
||||
the realm's JWKS is the standard, faster choice.
|
||||
- **Hand-written JWT parsing** — rejected: security-sensitive code we shouldn't own when a
|
||||
first-party validator exists.
|
||||
- **Generate the OpenAPI client by hand / keep the spec uncommitted** — rejected: §10 requires a
|
||||
generated client from a committed spec.
|
||||
@@ -1,46 +0,0 @@
|
||||
# FDS-architectuur — Open Register
|
||||
|
||||
Deze map bevat de architectuurbesluiten en de engineer-documentatie voor de FDS-kant van deze
|
||||
referentie-applicatie: deelnemen aan het Federatief Datastelsel als **afnemer**.
|
||||
|
||||
De strategische inzet, de slices en de portfoliostatus staan in het Innovation Lab-repo,
|
||||
`Respellion/innovation-lab`, onder `projects/open-register-fd/`. Daar staan ook de
|
||||
architectuurblauwdruk, de FDS gap-analyse en de privacy-views.
|
||||
|
||||
## Documenten
|
||||
|
||||
| Document | Waarvoor |
|
||||
|---|---|
|
||||
| [`c4-component-view.md`](c4-component-view.md) | Componentview op niveau 3: ports en adapters, en welke views nog waarde toevoegen |
|
||||
| [`slice-1-proposal.md`](slice-1-proposal.md) | Het bouwbare eerste increment; plak dit in een `poc-voorstel`-issue |
|
||||
| `adr/` | De geaccepteerde architectuurbesluiten, ADR-0001 tot en met ADR-0006. Zie de tabel hieronder. |
|
||||
|
||||
## Architecture Decision Records
|
||||
|
||||
Een ADR legt een besluit vast dat **vaststaat**, met de context en de gevolgen, zodat het niet stil
|
||||
opnieuw wordt uitgevochten. Statuswaarden: `proposed` → `accepted` → (`vervangen door ADR-NNNN` |
|
||||
`deprecated`).
|
||||
|
||||
Een geaccepteerde ADR wijzigen betekent een nieuwe ADR schrijven die de oude vervangt. Wij
|
||||
herschrijven de historie nooit.
|
||||
|
||||
ADRs liggen naast governance. Acceptatie volgt de asynchrone bezwaarronde uit
|
||||
`Respellion/innovation-lab`, `operating-model/operating-model.md`, sectie *Besluitvorming*.
|
||||
|
||||
| ADR | Besluit | Status |
|
||||
|---|---|---|
|
||||
| [0001](adr/0001-acl-at-every-register-boundary.md) | Anti-Corruption Layer op elke registergrens | accepted |
|
||||
| [0002](adr/0002-fsc-for-connectivity.md) | FSC voor connectiviteit tussen organisaties, geen ruwe REST | accepted |
|
||||
| [0003](adr/0003-pbac-via-opa.md) | Policy-based access control via OPA, FTV-klaar | accepted |
|
||||
| [0004](adr/0004-bounded-cache.md) | Begrensde cache; registers blijven systeem van registratie | accepted |
|
||||
| [0005](adr/0005-ldv-verwerkingenlog.md) | Verwerkingenlog via event-emissie, in lijn met LDV | accepted |
|
||||
| [0006](adr/0006-module-boundary-and-reuse.md) | Modulegrens en hergebruikstrategie: in-process → .NET-module → OpenMetadata-feed → gateway op verzoek | accepted |
|
||||
|
||||
## Nummering
|
||||
|
||||
Deze reeks staat los van de ADR-reeks over de referentie-applicatie zelf, die begint bij
|
||||
[`adr-0001-loose-coupling.md`](../adr-0001-loose-coupling.md). Vandaar de eigen map `fds/`: beide
|
||||
reeksen beginnen bij 0001, en de nummers zouden anders botsen.
|
||||
|
||||
Nieuwe FDS-ADR: kopieer [`adr/template.md`](adr/template.md), neem het volgende nummer, en open een
|
||||
pull request.
|
||||
@@ -1,42 +0,0 @@
|
||||
# ADR-0001: Anti-Corruption Layer op elke registergrens
|
||||
|
||||
- **Status:** accepted
|
||||
- **Datum:** 2026-06-13
|
||||
- **Deciders:** Lab Circle (Build, Lead Link)
|
||||
- **Vervangt / vervangen door:** —
|
||||
|
||||
## Context
|
||||
|
||||
De applicatie bevraagt meerdere registers: BRP, NHR/KVK, en ZGW via OpenZaak. Hun vocabulaires en
|
||||
schema's verschillen van elkaar en van ons domein. Zij veranderen ook zelf mee met de FDS-standaarden.
|
||||
|
||||
Lekt registervocabulaire het domeinmodel in, dan werkt elke wijziging aan de registerzijde door in de
|
||||
bedrijfslogica. Het domein wordt dan een lappendeken van vreemde begrippen in plaats van ubiquitous
|
||||
language.
|
||||
|
||||
## Besluit
|
||||
|
||||
Elk register is bereikbaar via een Anti-Corruption Layer: **één adapter per register**, die een
|
||||
**port** vervult die het domein definieert.
|
||||
|
||||
Adapters doen alleen vertalen en velden versmallen. Zij bevatten geen bedrijfslogica. Het domein
|
||||
spreekt `Persoon` en `Organisatie`, en nooit veldnamen uit BRP of NHR.
|
||||
|
||||
## Gevolgen
|
||||
|
||||
**Positief:** verloop in registers en FDS-standaarden blijft bij de adapter. Het domein blijft stabiel
|
||||
en testbaar. Adapters zijn onafhankelijk vervangbaar, en dat is precies wat de FSC-wissel uit
|
||||
ADR-0002 goedkoop maakt. Het patroon generaliseert naar een herbruikbare ACL-template per register,
|
||||
een Foundations-kandidaat.
|
||||
|
||||
**Negatief en kosten:** één vertaalmap per register om te schrijven en te onderhouden, plus een extra
|
||||
indirectie die engineers moeten respecteren in plaats van omzeilen.
|
||||
|
||||
**Vervolgwerk:** extraheer de ACL-template zodra de tweede adapter bestaat (slice 3).
|
||||
|
||||
## Overwogen alternatieven
|
||||
|
||||
- **Registers direct aanroepen uit de applicatieservices** — afgewezen: dit koppelt bedrijfscode aan
|
||||
registerschema's en aan versies van FDS-standaarden.
|
||||
- **Eén generieke registeradapter** — afgewezen: registers verschillen genoeg dat een generieke
|
||||
abstractie zou gaan lekken of opzwellen. Adapters per register zijn duidelijker.
|
||||
@@ -1,44 +0,0 @@
|
||||
# ADR-0002: FSC voor connectiviteit tussen organisaties, geen ruwe REST
|
||||
|
||||
- **Status:** accepted
|
||||
- **Datum:** 2026-06-13
|
||||
- **Deciders:** Lab Circle, Upstream Liaison
|
||||
- **Vervangt / vervangen door:** —
|
||||
|
||||
## Context
|
||||
|
||||
Registerbevragingen kruisen een organisatiegrens naar systemen van bronhouders met
|
||||
persoonsgegevens. Het FDS noemt Federatieve Service Connectiviteit (FSC, de opvolger van NLX) als de
|
||||
richting voor connectiviteit: wederzijdse authenticatie op organisatieniveau, autorisatie
|
||||
gecontroleerd tegen een contract en gehandhaafd bij de bron, en symmetrische transactielogging.
|
||||
|
||||
Een ruwe REST-client met mTLS geeft ons geen van de contractadministratie, delegatie of onafhankelijke
|
||||
tweezijdige verantwoording die een FG of auditor nodig heeft.
|
||||
|
||||
## Besluit
|
||||
|
||||
Het FSC Client-component stuurt alle registerbevragingen via een **FSC outway**, de
|
||||
EUPL-referentie-implementatie. De ACL-adapter hangt af van de FSC Client, en niet van een HTTP-client.
|
||||
|
||||
FSC-zaken — contracten, identiteiten, delegatie — leven in dit component, achter de Register Port.
|
||||
|
||||
## Gevolgen
|
||||
|
||||
**Positief:** de autorisatie wordt bij de bron gehandhaafd, en niet op gezag van de aanroeper
|
||||
vertrouwd. Onweerlegbaar loggen aan beide uiteinden maakt onafhankelijke afstemming tegen ons LDV-log
|
||||
mogelijk. Delegatie wordt expliciet meegedragen. Wij lopen in lijn met de FDS-richting, vóór er een
|
||||
verplichting is.
|
||||
|
||||
**Negatief en kosten:** FSC is operationeel zwaarder dan een REST-aanroep — beheer van certificaten en
|
||||
identiteiten, plus een outway die op De Werf moet draaien. De vergelijking FSC tegenover DSP loopt
|
||||
binnen het FDS nog, dus sommige details kunnen schuiven.
|
||||
|
||||
**Vervolgwerk:** valideer het contract- en logginggedrag van de huidige fsc-nlx-implementatie
|
||||
(slice 2). Herzie dit als het FDS voor DSP kiest; ADR-0001 houdt die wissel beperkt tot één component.
|
||||
|
||||
## Overwogen alternatieven
|
||||
|
||||
- **Ruwe REST met mTLS** — afgewezen: geen contractlaag, geen tweezijdig log, en het wijkt af van het
|
||||
FDS.
|
||||
- **Wachten tot het FDS FSC tegenover DSP heeft beslist** — afgewezen: de naad uit ADR-0001 laat ons nu
|
||||
adopteren en later aanpassen. Wachten geeft het voordeel van vroege expertise weg.
|
||||
@@ -1,44 +0,0 @@
|
||||
# ADR-0003: Policy-based access control via OPA, FTV-klaar
|
||||
|
||||
- **Status:** accepted
|
||||
- **Datum:** 2026-06-13
|
||||
- **Deciders:** Lab Circle, FG (geconsulteerd)
|
||||
- **Vervangt / vervangen door:** —
|
||||
|
||||
## Context
|
||||
|
||||
Elke bevraging van persoonsgegevens uit BRP of NHR is een verwerking die een grondslag en een
|
||||
begrensde doelbinding nodig heeft. Toegangsregels moeten handhaafbaar en auditeerbaar zijn, en
|
||||
wijzigbaar zonder de bedrijfscode opnieuw uit te rollen.
|
||||
|
||||
De Federatieve Toegangsverlening (FTV) van het FDS beweegt naar policy-based access control, maar is
|
||||
nog geen afgeronde standaard.
|
||||
|
||||
## Besluit
|
||||
|
||||
Introduceer een Policy Decision Point met Open Policy Agent (OPA). De applicatieservices roepen de
|
||||
PDP aan — via een Authorisation Port en een PDP Client — **vóór elke registerbevraging**, en geven
|
||||
rol, doel en grondslag mee.
|
||||
|
||||
Policies schrijven wij als code, **geversioneerd in Gitea**, en zij gaan via review naar productie. De
|
||||
PDP staat zo gepositioneerd dat wij bij de komst van FTV alleen het policy-dialect opnieuw uitdrukken,
|
||||
zonder de architectuurgrens te verplaatsen.
|
||||
|
||||
## Gevolgen
|
||||
|
||||
**Positief:** doelbinding en grondslag worden gehandhaafd, en niet alleen gedocumenteerd. De FG kan de
|
||||
werkelijke regels in versiebeheer lezen, waardoor het verwerkingenregister en de gehandhaafde policy
|
||||
naar elkaar toe groeien. Toegangswijzigingen zijn reviewbaar en gedateerd.
|
||||
|
||||
**Negatief en kosten:** BRP-autorisatiebesluiten correct modelleren is juridisch werk, geen
|
||||
engineering. De PDP maakt de handhaving betrouwbaar, niet de policy juist. Daarnaast komt er een
|
||||
component bij om te exploiteren.
|
||||
|
||||
**Vervolgwerk:** een promotiepijplijn voor policies in Gitea Actions. Policies opnieuw uitdrukken zodra
|
||||
FTV stabiliseert. Een FG-review van de policy-set vóórdat er echte persoonsgegevens in komen.
|
||||
|
||||
## Overwogen alternatieven
|
||||
|
||||
- **Rolcontroles in de applicatiecode** — afgewezen: niet auditeerbaar, niet wijzigbaar zonder deploy,
|
||||
en het verspreidt toegangslogica over de codebase.
|
||||
- **Wachten op FTV** — afgewezen: de PBAC-vorm is al duidelijk. Nu OPA, later het FTV-dialect.
|
||||
@@ -1,48 +0,0 @@
|
||||
# ADR-0004: Begrensde cache; registers blijven systeem van registratie
|
||||
|
||||
- **Status:** accepted
|
||||
- **Datum:** 2026-06-13
|
||||
- **Deciders:** Lab Circle, FG (geconsulteerd)
|
||||
- **Vervangt / vervangen door:** —
|
||||
|
||||
## Context
|
||||
|
||||
*Data bij de bron* verbiedt het behandelen van registerdata als lokale bron van waarheid. Maar BRP of
|
||||
NHR bij elke interactie bevragen is onpraktisch en vergroot de blootstelling.
|
||||
|
||||
Persoonsgegevens zijn de data die wij het minst willen opbouwen. Een onbegrensde cache wordt stil een
|
||||
schaduwregister, met een onbeheerde bewaarverplichting als gevolg.
|
||||
|
||||
## Besluit
|
||||
|
||||
Een **begrensde cache** staat achter een Cache Port, beheerd door een Cache Manager. Vier grenzen
|
||||
gelden.
|
||||
|
||||
| Grens | Wat die betekent |
|
||||
|---|---|
|
||||
| **Tijd** | Een TTL die aan het doel hangt |
|
||||
| **Omvang** | Alleen de werkset van een actieve zaak |
|
||||
| **Gezag** | Antwoordt nooit wat de bron niet zou antwoorden; geen systeem van registratie |
|
||||
| **Adresseerbaarheid** | Gesleuteld op subject, zodat verwijderen op verzoek kan |
|
||||
|
||||
Purge-triggers: het verstrijken van de TTL, het sluiten van de zaak, en een verwijderingsverzoek.
|
||||
|
||||
## Gevolgen
|
||||
|
||||
**Positief:** de prestaties van een lokale kopie, zonder een onbevoegd register te worden. Bewaartermijn
|
||||
en het recht op verwijdering zijn echte operaties, geen hoop. Dit is consistent met zowel
|
||||
AVG-dataminimalisatie als FDS-data-bij-de-bron.
|
||||
|
||||
**Negatief en kosten:** de mapping van doel naar TTL is een beleidsbesluit, samen met de FG en de
|
||||
autorisatievoorwaarden, en geen engineeringconstante. Die is dus makkelijk fout te krijgen. Daarnaast
|
||||
komt de complexiteit van cache-invalidatie erbij.
|
||||
|
||||
**Vervolgwerk:** definieer het beleid voor doel naar TTL met de FG. Maak een toestandsdiagram voor de
|
||||
levensloop van een cache-entry. Documenteer de aanvaardbare veroudering per register.
|
||||
|
||||
## Overwogen alternatieven
|
||||
|
||||
- **Geen cache; altijd de bron bevragen** — afgewezen: onpraktische latency en belasting, en meer
|
||||
blootstelling per aanroep.
|
||||
- **Een onbegrensde of algemene cache** — afgewezen: die wordt een schaduwregister, precies de
|
||||
faalvorm waar de AVG en het FDS beide tegen duwen.
|
||||
@@ -1,42 +0,0 @@
|
||||
# ADR-0005: Verwerkingenlog via event-emissie, in lijn met LDV
|
||||
|
||||
- **Status:** accepted
|
||||
- **Datum:** 2026-06-13
|
||||
- **Deciders:** Lab Circle, FG (geconsulteerd)
|
||||
- **Vervangt / vervangen door:** —
|
||||
|
||||
## Context
|
||||
|
||||
AVG art. 30 vereist een register van verwerkingsactiviteiten. De FDS-bouwsteen Logboek
|
||||
Dataverwerkingen (LDV) wijst naar een gestandaardiseerd verwerkingslog dat de burger kan bevragen.
|
||||
|
||||
Database-CDC met Debezium legt *datawijzigingen* vast, en niet *verwerkingsgebeurtenissen met
|
||||
doelbinding*. Het is dus geen verwerkingenlog.
|
||||
|
||||
## Besluit
|
||||
|
||||
Elke registeradapter stuurt een **verwerkingsactiviteit-event** naar een eigen Redpanda-topic, via een
|
||||
Verwerking Port en een LDV Emitter. Het event bevat: subjectcategorie, register, velden, doel en
|
||||
doelbinding, grondslag, bevragende rol, en tijdstempel. **Nooit de opgehaalde waarden.**
|
||||
|
||||
Een projectie maakt het log bevraagbaar. De emissie is asynchroon, maar niet over te slaan: de adapter
|
||||
die de Register Port vervult, is dezelfde code die het event uitstuurt.
|
||||
|
||||
## Gevolgen
|
||||
|
||||
**Positief:** het spoor voor art. 30 en LDV ontstaat als neveneffect van de bevraging, dus het kan niet
|
||||
uit de pas lopen met de werkelijkheid. Het is af te stemmen tegen de tweezijdige logs van FSC
|
||||
(ADR-0002). Het is onderscheidend in een tender.
|
||||
|
||||
**Negatief en kosten:** een topic en een projectie om te exploiteren. Het ontsluiten van het log naar
|
||||
de burger valt buiten de huidige scope; wij produceren het log. Het eventschema vraagt governance.
|
||||
|
||||
**Vervolgwerk:** definieer het schema van het verwerkingsevent. Bouw de bevraagbare projectie. Sluit
|
||||
aan op de LDV-standaard zodra die volwassen wordt; dit is een upstream-kandidaat.
|
||||
|
||||
## Overwogen alternatieven
|
||||
|
||||
- **Debezium-CDC hergebruiken als log** — afgewezen: dat legt datawijzigingen vast, en geen verwerking
|
||||
met doelbinding. Verkeerde semantiek.
|
||||
- **Synchroon loggen in het aanroeppad** — afgewezen: dat koppelt de latency van de bevraging aan het
|
||||
log. Asynchroon maar niet over te slaan geeft zowel snelheid als garantie.
|
||||
@@ -1,68 +0,0 @@
|
||||
# ADR-0006: Modulegrens en hergebruikstrategie voor de governed-access spine
|
||||
|
||||
- **Status:** accepted
|
||||
- **Datum:** 2026-06-13
|
||||
- **Deciders:** Lab Circle (Lead Link, Build, Upstream Liaison)
|
||||
- **Vervangt / vervangen door:** —
|
||||
|
||||
## Context
|
||||
|
||||
De compliance-spine uit slice 1 bestaat uit de PDP-controle (ADR-0003), gegoverneerd uitgaand verkeer
|
||||
via FSC (ADR-0002), emissie van het verwerkingenlog (ADR-0005), en de begrensde cache (ADR-0004),
|
||||
allemaal achter ports (ADR-0001). Die spine is mogelijk breder herbruikbaar dan alleen in de
|
||||
referentie-applicatie.
|
||||
|
||||
Er spelen twee hergebruikvragen: welke verpakkingsvorm kiezen wij, en hoe verhoudt de spine zich tot
|
||||
andere omgevingen zoals het OpenMetadata-datagovernanceproject?
|
||||
|
||||
Twee verduidelijkingen bepalen het besluit.
|
||||
|
||||
1. **OpenMetadata is geen afnemer.** In het datagovernanceproject is het de catalogus- en
|
||||
lineage-laag over (synthetische) data. Het bevraagt geen BRP of NHR. FSC of de begrensde cache
|
||||
daarin inbouwen zou zinloos zijn. De juiste aansluiting is **integratie van de output van de
|
||||
spine**, en niet het inbouwen van de spine.
|
||||
2. **FSC en de begrensde cache zijn zaken die alleen een afnemer aangaan.** "Maak het herbruikbaar"
|
||||
mag deze niet uitsmeren over componenten die geen registerdata bevragen.
|
||||
|
||||
Nu al een taalonafhankelijke gateway bouwen — vóórdat er een tweede, niet-.NET afnemer bestaat — zou
|
||||
de valkuil van speculatieve architectuur herhalen, die wij voor de capability-laag al hebben
|
||||
afgewezen.
|
||||
|
||||
## Besluit
|
||||
|
||||
Wij nemen een **vraaggestuurde reeks van vier stappen** aan. Elke stap hangt af van echte behoefte, en
|
||||
niet van verwachte behoefte.
|
||||
|
||||
| Stap | Wat | Wanneer |
|
||||
|---|---|---|
|
||||
| 1 | **In-process bewijzen.** Bouw de spine als gewone componenten achter ports, binnen de .NET register-applicatie. Nog geen extractie. Doel: de compliance-invarianten één keer echt valideren. | Slice 1 |
|
||||
| 2 | **Extraheren als .NET-module.** Zodra een tweede .NET-afnemer in zicht is, haal de spine eruit als een geversioneerde .NET-library of SDK. Dit is de ACL-template-extractie die het charter al plant. Herbruikbaar voor .NET-afnemers, en dat is genoeg voor register-reference en zijn broertjes. | Slice 3 |
|
||||
| 3 | **De feed LDV naar OpenMetadata aansluiten.** Route verwerkingsevents uit de LDV-emitter naar OpenMetadata als access- en usage-metadata bij het geclassificeerde asset: wie las welk persoonsgegevensveld, met welk doel, hoe vaak. Optioneel laten classificatietags uit OpenMetadata terugstromen om veldminimalisatie in de ACL aan te sturen. Dit is de concrete brug tussen beide anchor-projecten: integratie, geen inbouw. | Na stap 2 |
|
||||
| 4 | **Alleen op verzoek een taalonafhankelijke gateway bouwen.** Heeft een echte niet-.NET afnemer gegoverneerde registertoegang nodig, verpak de spine dan als zelfstandige sidecar of proxy met een dunne lokale API, met PDP, FSC-egress en LDV erachter. Niet eerder. | Op verzoek |
|
||||
|
||||
## Gevolgen
|
||||
|
||||
**Positief:** eigen software blijft minimaal. Hergebruik volgt op validatie in plaats van eraan vooraf
|
||||
te gaan. Beide anchor-projecten krijgen een concreet, benoemd integratiepunt (stap 3). Zaken die
|
||||
alleen een afnemer aangaan, blijven ingesloten.
|
||||
|
||||
**Negatief en kosten:** de .NET-module uit stap 2 dient geen niet-.NET afnemers. Dat aanvaarden wij,
|
||||
omdat stap 4 dat geval dekt zodra het echt is. Stap 3 vraagt een afgesproken schema voor het
|
||||
verwerkingsevent, stabiel genoeg voor OpenMetadata om te consumeren.
|
||||
|
||||
**Vervolgwerk:**
|
||||
|
||||
1. Neem stap 3 als expliciet integratiepunt op in beide projectpagina's in het Innovation Lab-repo:
|
||||
`projects/open-register-fd/README.md` en `projects/openmetadata/README.md`.
|
||||
2. Herzie de trigger van stap 4 bij elke portfolio-review. Bouw niet vooruit.
|
||||
3. Regel governance op het schema van het verwerkingsevent; dat is een gedeelde afhankelijkheid van
|
||||
stap 1 en stap 3.
|
||||
|
||||
## Overwogen alternatieven
|
||||
|
||||
- **De taalonafhankelijke gateway vooraf bouwen** — afgewezen: speculatieve architectuur voordat er een
|
||||
tweede afnemer bestaat. De latency en de operationele kosten zijn niet te rechtvaardigen.
|
||||
- **De spine in OpenMetadata inbouwen** — afgewezen: OpenMetadata is geen afnemer. Dit is een
|
||||
categoriefout.
|
||||
- **De spine permanent in-process houden, zonder extractie** — afgewezen: dat geeft het hergebruik
|
||||
tussen projecten en applicaties weg, en dat is een kerndoel van de Open Register-inzet.
|
||||
@@ -1,27 +0,0 @@
|
||||
# ADR-NNNN: <titel>
|
||||
|
||||
- **Status:** proposed
|
||||
- **Datum:** JJJJ-MM-DD
|
||||
- **Deciders:** <rollen>
|
||||
- **Vervangt / vervangen door:** —
|
||||
|
||||
## Context
|
||||
|
||||
<De krachten die spelen: het probleem, de beperkingen, de FDS- en AVG-drijfveren. Waarom er nu een
|
||||
besluit nodig is.>
|
||||
|
||||
## Besluit
|
||||
|
||||
<De keuze, eenvoudig gesteld.>
|
||||
|
||||
## Gevolgen
|
||||
|
||||
**Positief:** <wat dit oplevert>
|
||||
|
||||
**Negatief en kosten:** <wat het kost, en wat wij aanvaarden>
|
||||
|
||||
**Vervolgwerk:** <welk werk dit oproept>
|
||||
|
||||
## Overwogen alternatieven
|
||||
|
||||
<De afgewezen opties, en waarom.>
|
||||
@@ -1,127 +0,0 @@
|
||||
# C4-componentview — register-applicatie en capability-laag
|
||||
|
||||
> Niveau 3, de componentview. Deze view zoomt in op de container van de .NET register-applicatie uit
|
||||
> het L2-containerdiagram. Zij verbindt het geheel op componentniveau — domein, ports, adapters en de
|
||||
> FDS-capability-componenten — en toont waar elk onderdeel externe tooling raakt.
|
||||
>
|
||||
> De hexagonale structuur is expliciet: het domein hangt alleen af van **ports** (interfaces). Elke
|
||||
> concrete capability is een **adapter** die aan een port is gebonden.
|
||||
>
|
||||
> De containerview (L2), de blauwdruk en de privacy-datastroomviews staan in het Innovation Lab-repo,
|
||||
> `Respellion/innovation-lab`, onder `projects/open-register-fd/`.
|
||||
|
||||
```mermaid
|
||||
C4Component
|
||||
title Componentview — register-applicatie (.NET) en de FDS-capability-laag
|
||||
|
||||
Person(user, "Behandelaar", "Behandelt zaken")
|
||||
Container(spa, "Frontend", "Angular + NL Design System", "Zaakinterface")
|
||||
|
||||
Container_Boundary(app, "Register-applicatie (.NET, hexagonaal)") {
|
||||
Component(api, "API / application services", ".NET", "Orkestreert use cases; verklaart doelbinding per vraag")
|
||||
Component(domain, "Domeinmodel", ".NET / DDD", "Ubiquitous language; geen registervocabulaire")
|
||||
|
||||
Component(portReg, "Register Port", "interface", "De vraag van het domein: Personen / Organisaties")
|
||||
Component(portPol, "Authorisation Port", "interface", "mag-deze-verwerking-doorgaan?")
|
||||
Component(portLog, "Verwerking Port", "interface", "leg de verwerkingsgebeurtenis vast")
|
||||
Component(portTm, "Terugmelding Port", "interface", "meld een vermoedelijke fout")
|
||||
Component(portCache, "Cache Port", "interface", "doelgebonden lezen, schrijven en verwijderen")
|
||||
|
||||
Component(aclBrp, "BRP-adapter", ".NET", "Vertaalt domein<->BRP; minimale velden")
|
||||
Component(aclKvk, "NHR/KVK-adapter", ".NET", "Vertaalt domein<->NHR; UBO-bewust")
|
||||
Component(pdpClient, "PDP Client", ".NET -> OPA", "Roept de policy engine; geeft doel en grondslag mee")
|
||||
Component(ldvEmit, "LDV Emitter", ".NET", "Bouwt het verwerkingsevent; publiceert naar Redpanda")
|
||||
Component(fscClient, "FSC Client", ".NET", "Stuurt contractuele aanroepen via de outway")
|
||||
Component(cacheMgr, "Cache Manager", ".NET", "TTL en verwijderen op subjectsleutel")
|
||||
Component(tmHandler, "Terugmelding Handler", ".NET -> Flowable", "Start het terugmeldproces")
|
||||
Component(procClient, "Process Client", ".NET -> Flowable", "Uitvoering van BPMN en DMN")
|
||||
}
|
||||
|
||||
System_Ext(opa, "OPA (PDP)", "Policies geversioneerd in Gitea")
|
||||
System_Ext(fsc, "FSC Outway", "EUPL-referentie-implementatie")
|
||||
System_Ext(flowable, "Flowable", "BPMN + DMN")
|
||||
ContainerDb_Ext(cache, "Begrensde cache", "PostgreSQL")
|
||||
System_Ext(redpanda, "Redpanda", "LDV-topic + CDC")
|
||||
System_Ext(brp, "BRP", "via FSC inway")
|
||||
System_Ext(kvk, "NHR / KVK", "via FSC inway")
|
||||
System_Ext(kanidm, "Kanidm", "OIDC")
|
||||
|
||||
Rel(user, spa, "Gebruikt")
|
||||
Rel(spa, api, "REST/JSON")
|
||||
Rel(kanidm, api, "OIDC", "authenticatie")
|
||||
Rel(api, domain, "Roept aan")
|
||||
Rel(api, portPol, "Controleert vóór de bevraging")
|
||||
Rel(api, portReg, "Vraagt data")
|
||||
Rel(api, portTm, "Dient melding in")
|
||||
Rel(api, procClient, "Voert proces uit")
|
||||
|
||||
Rel(portPol, pdpClient, "gebonden aan")
|
||||
Rel(pdpClient, opa, "besluitverzoek")
|
||||
|
||||
Rel(portReg, aclBrp, "gebonden aan")
|
||||
Rel(portReg, aclKvk, "gebonden aan")
|
||||
Rel(aclBrp, fscClient, "via")
|
||||
Rel(aclKvk, fscClient, "via")
|
||||
Rel(aclBrp, portLog, "stuurt event")
|
||||
Rel(aclKvk, portLog, "stuurt event")
|
||||
Rel(aclBrp, portCache, "leest en schrijft")
|
||||
Rel(aclKvk, portCache, "leest en schrijft")
|
||||
Rel(fscClient, fsc, "contractuele aanroep")
|
||||
Rel(fsc, brp, "mTLS + contract")
|
||||
Rel(fsc, kvk, "mTLS + contract")
|
||||
|
||||
Rel(portLog, ldvEmit, "gebonden aan")
|
||||
Rel(ldvEmit, redpanda, "publiceert")
|
||||
Rel(portCache, cacheMgr, "gebonden aan")
|
||||
Rel(cacheMgr, cache, "slaat op")
|
||||
Rel(portTm, tmHandler, "gebonden aan")
|
||||
Rel(tmHandler, flowable, "start proces")
|
||||
Rel(procClient, flowable, "voert uit")
|
||||
```
|
||||
|
||||
## Hoe je dit leest
|
||||
|
||||
1. **De ports zijn de naad.** Het domein en de application services hangen af van de vijf interfaces,
|
||||
en nooit van adapters. FSC wisselen voor DSP, of OPA voor de latere FTV-client, verandert een
|
||||
adapter — geen port, en niet het domein. Dit is de clock-speed boundary, concreet gemaakt.
|
||||
2. **De compliance-componenten zijn adapters, geen domeinlogica.** De PDP-client, de LDV-emitter, de
|
||||
FSC-client en de cache manager staan allemaal aan de adapterzijde. Een bevraging kan er fysiek niet
|
||||
langs, omdat de adapter die de Register Port vervult dezelfde code is die het LDV-event uitstuurt
|
||||
en via FSC routeert.
|
||||
3. **Slechts twee componenten raken de registers**: de BRP-adapter en de NHR/KVK-adapter. Beide
|
||||
bereiken ze uitsluitend via de FSC-client. Er is geen vierde pad.
|
||||
|
||||
## Componenten tegenover verplichtingen
|
||||
|
||||
| Component | Omvang eigen bouw | Verplichting die het afdekt |
|
||||
|---|---|---|
|
||||
| Domeinmodel | het product | correctheid van de bedrijfsregels |
|
||||
| BRP- en NHR-adapters | dun | dataminimalisatie: vertalen en velden versmallen |
|
||||
| PDP Client | klein | handhaven van grondslag en doelbinding |
|
||||
| LDV Emitter | klein | verwerkingenlog (AVG art. 30 en LDV) |
|
||||
| FSC Client | klein | geautoriseerde, gelogde connectiviteit |
|
||||
| Cache Manager | klein | grenzen aan bewaring, en verwijdering |
|
||||
| Terugmelding Handler | klein | de terugmeldplicht van de afnemer |
|
||||
|
||||
---
|
||||
|
||||
## Aanvullende views die voor engineers waarde hebben
|
||||
|
||||
De diagrammen tot hier verklaren *structuur* en *compliance-intentie*. Engineers die dit bouwen,
|
||||
hebben er nog een aantal nodig. Wij tekenen geen view voordat er iets echt is om te beschrijven, dus
|
||||
elke regel noemt de trigger.
|
||||
|
||||
| # | View | Wat het toevoegt | Trigger |
|
||||
|---|---|---|---|
|
||||
| 1 | **Deploymentview** (C4 deployment, topologie) | Waar elke container op De Werf draait: k3s-namespaces, welke services sidecar zijn en welke een eigen pod (is OPA een sidecar of centraal? waar eindigt de FSC outway?), netwerkpolicies tussen de vlakken van de vertrouwensgrens, en beheer van secrets en mTLS-certificaten voor FSC. Hier worden de privacy*grenzen* echte firewall- en netwerkregels. | Vóór de eerste deploy met meerdere services. **Hoogste waarde als volgende.** |
|
||||
| 2 | **Sequences voor de niet-gelukkige paden** | Wij hebben het gelukkige pad. Engineers hebben de lastige nodig: PDP-*deny* midden in een transactie, een verlopen of ingetrokken FSC-contract, een register-timeout terwijl er een verouderde cache-entry ligt, en een gedeeltelijk NHR-antwoord waarbij een UBO-veld is achtergehouden. Dit bepaalt de foutafhandeling, en hier verstoppen de compliance-randgevallen zich. | Direct na slice 1. |
|
||||
| 3 | **Domeinmodel en ERD** | De bounded contexts en aggregates in het domein, plus het cacheschema: welke persoonsgegevens blijven staan, op welke sleutel, en met welke purge-kolom. Dit is tegelijk het artefact dat de FG beoordeelt voor bewaartermijnen. | Zodra het domein in slice 1 stabiliseert. |
|
||||
| 4 | **Dataclassificatie- en catalogusview** | Elk veld dat een grens kruist, getagd — persoonsgegeven? bijzondere categorie? UBO-beperkt? — en gemapt op zijn classificatie in OpenMetadata. Dit stuurt de GDPR-scrubbingregels en de lineage-tags. | Beter *uit* OpenMetadata gegenereerd zodra die gevuld is, dan met de hand getekend. |
|
||||
| 5 | **Toestandsdiagram: levensloop van een cache-entry** | `fetched` → `valid` (binnen TTL) → `stale` → `purged` (TTL verstreken \| zaak gesloten \| verwijderingsverzoek). Klein, maar het pint de bewaarsemantiek vast die "begrensde cache" nu alleen in prose beschrijft. | Samen met ADR-0004-vervolgwerk. |
|
||||
| 6 | **BPMN-view: de terugmelding-workflow** | Het Flowable-proces zelf: ingediend → verstuurd naar bronhouder → bevestigd → opgelost of afgewezen. Dit is uitvoerbaar BPMN, dus het diagram en de implementatie zijn hetzelfde artefact. | Wanneer de terugmelding-slice start. |
|
||||
| 7 | **Threat model en vertrouwensgrensview** (STRIDE-stijl) | Dreigingen over de vertrouwensgrens leggen: tokendiefstal, cache poisoning, replay tegen FSC, policy bypass, en manipulatie van logs. Past natuurlijk bij de FSC-zoom, en is het anker van het securitygesprek. | Vóór het verwerken van echte persoonsgegevens. |
|
||||
| 8 | **CI/CD- en policy-promotieview** | Hoe OPA-policies en BPMN/DMN-modellen van een pull request naar draaiende configuratie gaan. "Toegangsbeheer is configuratie in Gitea" geldt alleen als er een pijplijn is die review en promotie handhaaft. | Samen met het vervolgwerk uit ADR-0003. |
|
||||
|
||||
**Voorstel voor de volgende twee.** De **deploymentview**, omdat die de privacygrenzen omzet in
|
||||
handhaafbare netwerkpolicy. En de **sequences voor de niet-gelukkige paden**, omdat compliance daar
|
||||
werkelijk breekt.
|
||||
@@ -1,103 +0,0 @@
|
||||
# POC-voorstel — slice 1: walking skeleton (één register, gegoverneerde bevraging)
|
||||
|
||||
> Klaar om in een `poc-voorstel`-issue te plakken, met de labels `build` en `poc`. Dit is het bouwbare
|
||||
> eerste increment dat de architectuurdocumenten beschrijven. Het bewijst met opzet de
|
||||
> *compliance-spine* end-to-end op de dunst mogelijke functionaliteit.
|
||||
|
||||
## Probleem en strategische vraag
|
||||
|
||||
Kunnen wij een registerbevraging demonstreren die *structureel* gegoverneerd is — onmogelijk uit te
|
||||
voeren zonder gehandhaafde grondslag en een automatische regel in het verwerkingenlog — op onze
|
||||
soevereine stack?
|
||||
|
||||
Dit is de geloofwaardigheidstoets achter de hele Open Register-inzet (slice 1 van het charter) en
|
||||
achter de FDS gap-analyse.
|
||||
|
||||
## Hypothese
|
||||
|
||||
Wij verwachten dat het doorverbinden van één registerbevraging door de volledige capability-spine —
|
||||
Register Port → ACL-adapter → PDP-controle → FSC-aanroep → LDV-emissie → begrensde cache — de claim
|
||||
"compliance is structureel" bewijst.
|
||||
|
||||
Wij weten dat wij het goed hebben als een geautomatiseerde test aantoont dat een bevraging **niet** kan
|
||||
voltooien als de PDP weigert, en **altijd** een LDV-event oplevert als de PDP toestaat.
|
||||
|
||||
## Scope ter grootte van één blok
|
||||
|
||||
**Wel in scope**
|
||||
|
||||
| Onderdeel | Wat |
|
||||
|---|---|
|
||||
| Register | **NHR/KVK**, basisgegevens over onderneming en bestuurder. Gekozen boven BRP; zie de slotnotitie. |
|
||||
| Use case | Geef bij een KVK-nummer de geregistreerde organisatie terug aan het domein, voor één verklaard doel. |
|
||||
| Ports | De vijf ports als interface. Concrete adapters: NHR-ACL, PDP-client (OPA), FSC-client met sandbox- of test-outway, LDV-emitter (Redpanda-topic), en cache manager (PostgreSQL met TTL). |
|
||||
| Policy | OPA draait met één handgeschreven voorbeeldpolicy in Gitea: één allow-regel en één deny-geval. |
|
||||
| Log | Verwerkingsevent-schema v0 plus een minimale bevraagbare projectie; een tabelweergave is genoeg. |
|
||||
| Tests | Tests die de twee compliance-invarianten vastleggen: deny blokkeert, allow logt. |
|
||||
|
||||
**Niet in scope** — even belangrijk om op te schrijven.
|
||||
|
||||
1. Afgewerkte interface of NL Design System-schermen, verder dan een dev-harness.
|
||||
2. BRP en paden met veel persoonsgegevens. Die gaan naar slice 2, met een door de FG beoordeelde
|
||||
policy.
|
||||
3. UBO-data. Het regime van beperkte toegankelijkheid valt buiten deze slice.
|
||||
4. De terugmelding-workflow (latere slice), DCAT-export, en Superset-dashboards.
|
||||
5. Echte register-endpoints. Alleen sandbox en stubs.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- [ ] Een bevraging op KVK-nummer geeft een domein-`Organisatie` terug via de NHR-ACL-adapter, zonder
|
||||
registervocabulaire in het domein (ADR-0001).
|
||||
- [ ] De aanroep loopt via de FSC-client naar een sandbox-outway, en niet via een ruwe HTTP-client
|
||||
(ADR-0002).
|
||||
- [ ] Er vindt geen bevraging plaats tenzij de PDP allow teruggeeft voor de combinatie rol, doel en
|
||||
grondslag (ADR-0003).
|
||||
- [ ] Elke toegestane bevraging stuurt precies één verwerkingsevent naar Redpanda, bevraagbaar in de
|
||||
projectie, zonder opgehaalde waarden (ADR-0005).
|
||||
- [ ] Cache-entries dragen een TTL en een subjectsleutel; een purge-aanroep verwijdert ze (ADR-0004).
|
||||
- [ ] **De tests op de compliance-invarianten slagen in CI:** (a) PDP-deny betekent geen FSC-aanroep;
|
||||
(b) PDP-allow betekent precies één LDV-event; (c) te ruim gevraagde velden bereiken het domein
|
||||
nooit.
|
||||
- [ ] Het geheel draait lokaal uit een gedocumenteerd `compose`- of k3s-manifest met stubs, zonder
|
||||
echte registertoegang.
|
||||
- [ ] ADR-0001 tot en met ADR-0005 zijn vanuit de code gelinkt. Eén nieuwe ADR als er in slice 1 een
|
||||
besluit ontstaat.
|
||||
|
||||
## Acceptatiedemo (bewijs voor de week-3-toets)
|
||||
|
||||
Live: een geslaagde bevraging plus de bijbehorende LDV-regel. Zet daarna de policy op deny en toon
|
||||
dezelfde bevraging geweigerd, zonder registeraanroep en zonder data.
|
||||
|
||||
Dat contrast *is* de demo.
|
||||
|
||||
## Ontvangende Delivery Circle (voorlopig)
|
||||
|
||||
De register-reference Delivery Circle. De Handoff-ontvanger krijgt bij de kickoff een naam.
|
||||
|
||||
Waarschijnlijke adoptie: de capability-spine wordt het herbruikbare substraat voor de
|
||||
register-reference-applicatie.
|
||||
|
||||
## Upstream-kandidaten
|
||||
|
||||
| Project | Wat wij kunnen bijdragen |
|
||||
|---|---|
|
||||
| fsc-nlx | Ergonomie van de sandbox en testomgeving, plus documentatie |
|
||||
| OPA | Policy-patronen voor het modelleren van Nederlandse grondslagen |
|
||||
| OpenMetadata | Later een DCAT-AP-NL exporter; dit verbindt het OpenMetadata-project |
|
||||
|
||||
## AVG- en soevereiniteitsoverwegingen
|
||||
|
||||
Alleen NHR-basisgegevens, over onderneming en bestuurder, en in slice 1 **gestubd**. Er worden geen
|
||||
echte persoonsgegevens verwerkt.
|
||||
|
||||
Een FG-review is een voorwaarde voor slice 2, met echte data en BRP. Alle componenten draaien
|
||||
zelfgehost op De Werf; OPA-policies en BPMN staan in Gitea.
|
||||
|
||||
## Slotnotitie: waarom NHR vóór BRP voor het skeleton
|
||||
|
||||
Beide registers bevatten persoonsgegevens, dus geen van beide is "gratis". NHR-basisgegevens over
|
||||
onderneming en bestuurder zijn echter minder gevoelig dan BRP-gegevens over inwoners, en er is een
|
||||
duidelijker verhaal rond een publieke sandbox.
|
||||
|
||||
Zo bewijst slice 1 het *mechanisme*, voordat slice 2 BRP oppakt onder een door de FG beoordeelde
|
||||
policy. UBO-data blijft buiten scope tot het toegangsregime is gemodelleerd.
|
||||
@@ -0,0 +1,104 @@
|
||||
# Demo script
|
||||
|
||||
A running log of demoable outcomes, one section per slice. Each entry is a short,
|
||||
copy-pasteable walkthrough against a local `make up` stack.
|
||||
|
||||
---
|
||||
|
||||
## S-07 — BFF: the portals' single backend
|
||||
|
||||
**Outcome:** the BFF validates Keycloak `digid` tokens on the self-service submit (forwarding the
|
||||
bsn to the domain) and serves the openbaar register anonymously with only public-safe fields — the
|
||||
front door the portals (S-08/S-09) will talk to.
|
||||
|
||||
**The path:** portal → BFF `POST /self-service/registrations` (token-gated) → domain; and
|
||||
BFF `GET /openbaar/register` (anonymous) → projection-api. See ADR-0010.
|
||||
|
||||
```bash
|
||||
# 1. Bring the full stack up.
|
||||
make up
|
||||
|
||||
# 2. Drive the BFF end-to-end (401 without a token, 202 with a real digid token, anonymous openbaar).
|
||||
make verify-bff # → "OK — BFF: 401 without token, 202 with a digid token, anonymous ..."
|
||||
|
||||
# 3. Try it by hand (BFF on host port 8080).
|
||||
# a) A digid access token for the mock user jan-burger (bsn 123456782):
|
||||
tok=$(curl -s -X POST http://localhost:8180/realms/digid/protocol/openid-connect/token \
|
||||
-d grant_type=password -d client_id=big-portal -d username=jan-burger -d password=test123 \
|
||||
| python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
|
||||
|
||||
# b) Submit — without the token it is 401; with it, 202:
|
||||
curl -s -o /dev/null -w "no token -> %{http_code}\n" -X POST http://localhost:8080/self-service/registrations
|
||||
curl -s -o /dev/null -w "with token-> %{http_code}\n" -X POST http://localhost:8080/self-service/registrations \
|
||||
-H "Authorization: Bearer $tok"
|
||||
|
||||
# c) The openbaar register is anonymous and exposes only id + status (never the bsn):
|
||||
curl -fsS http://localhost:8080/openbaar/register | jq
|
||||
```
|
||||
|
||||
> The self-service token is validated against Keycloak's `digid` realm; the openbaar lookup needs no
|
||||
> token (S-09). The generated contract lives at `services/bff/openapi.json` — S-08's client is built
|
||||
> from it.
|
||||
|
||||
---
|
||||
|
||||
## S-05 — BIG Domain Service: submit a registration
|
||||
|
||||
**Outcome:** submitting a registration starts a Flowable process; the external-task worker
|
||||
opens a zaak via the ACL and records it on the aggregate — the upstream half of the skeleton
|
||||
that produces the zaak S-06 then projects.
|
||||
|
||||
**The path:** domain `POST /registrations` → Flowable `registratie` process → `OpenZaakAanmaken`
|
||||
worker → ACL → OpenZaak; `GET /registrations/{id}` shows the opened zaak (ADR-0009).
|
||||
|
||||
```bash
|
||||
# 1. Bring the full stack up (seeds config, builds our services, waits for health).
|
||||
make up
|
||||
|
||||
# 2. Drive the full path end-to-end. This also seeds a published BIG zaaktype and points the
|
||||
# ACL at it (the zaak's zaaktype URL is server-assigned, so it isn't known at bring-up).
|
||||
make verify-domain # → "OK — the domain opened a zaak and recorded it on the registration"
|
||||
|
||||
# 3. Submit one yourself (domain on host port 8130). Returns 202 + a Location to read back.
|
||||
loc=$(curl -fsS -D - -o /dev/null -X POST http://localhost:8130/registrations \
|
||||
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' | sed -n 's/\r$//; s/^[Ll]ocation: //p')
|
||||
|
||||
# 4. The worker opens the zaak off the request path (eventual consistency, ADR-0009); poll
|
||||
# until zaakUrl is filled. (Step 2 must have run first, so the ACL knows the zaaktype.)
|
||||
curl -fsS "http://localhost:8130$loc" | jq
|
||||
# → { "registrationId": "...", "status": "Ingediend", "zaakUrl": "http://.../zaken/api/v1/zaken/<uuid>" }
|
||||
```
|
||||
|
||||
> Registration state is in-memory for this slice (ADR-0009); the rebuildable read model is the
|
||||
> projection (S-06), fed by the very zaak this flow opens.
|
||||
|
||||
---
|
||||
|
||||
## S-06 — Event Subscriber + read projection
|
||||
|
||||
**Outcome:** a zaak created in OpenZaak flows through NRC to the Event Subscriber, which
|
||||
projects it into a rebuildable read projection the projection-api serves.
|
||||
|
||||
**The path:** OpenZaak → (notification) NRC → (abonnement callback) Event Subscriber →
|
||||
`register_projection` → projection-api `GET /register`.
|
||||
|
||||
```bash
|
||||
# 1. Bring the full stack up (seeds config, builds our services, waits for health).
|
||||
make up
|
||||
|
||||
# 2. Register the Event Subscriber's abonnement and create a zaak, then read it back.
|
||||
# (The verify-projection check does exactly this end-to-end and asserts the result.)
|
||||
make verify-projection # → "OK — projection-api serves zaak <uuid> with status INGEDIEND"
|
||||
|
||||
# 3. Observe the projection directly via the read API (host port 8120).
|
||||
curl -fsS http://localhost:8120/register | jq
|
||||
# → [ { "id": "<zaak-uuid>", "status": "INGEDIEND", "bsn": null, "naamPlaceholder": null } ]
|
||||
|
||||
# 4. Idempotency + rebuild: replays don't duplicate; a rebuild repopulates from the
|
||||
# notification log (no OpenZaak access needed — ADR-0008).
|
||||
curl -fsS -X POST http://localhost:8110/admin/rebuild # Event Subscriber, host port 8110
|
||||
curl -fsS http://localhost:8120/register | jq 'length' # → unchanged
|
||||
```
|
||||
|
||||
> `bsn` / `naam_placeholder` are deferred (ADR-0008) — the notification doesn't carry them and
|
||||
> the subscriber may not read OpenZaak directly (§8.1). They surface in a later slice.
|
||||
@@ -9,9 +9,6 @@ should teach.
|
||||
- **[Product Requirements](PRD.md)** — what we're building and why.
|
||||
- **[ADR-0001: Loose coupling](architecture/adr-0001-loose-coupling.md)** — the
|
||||
non-negotiable integration stance; the template for future ADRs.
|
||||
- **[FDS architecture](architecture/fds/README.md)** — participating in the Federatief
|
||||
Datastelsel as an afnemer: ADR-0001…0006, the L3 component view, the slice-1 proposal.
|
||||
In Dutch; the strategic framing lives in `Respellion/innovation-lab`.
|
||||
- **[Working in Gitea](gitea-workflow.md)** — issues, milestones, branches, PRs.
|
||||
- **[CI runbook](runbooks/ci.md)** — the pipeline and the `make ci` local gate.
|
||||
|
||||
|
||||
+97
-50
@@ -1,11 +1,9 @@
|
||||
# CI runbook — Gitea Actions
|
||||
|
||||
> **Status: no runner yet → run CI locally with `make ci`.** The workflow
|
||||
> `.gitea/workflows/ci.yaml` is in place, but the pipeline cannot go green until a
|
||||
> self-hosted `respellion-linux` runner is registered against the Gitea instance.
|
||||
> Until then, **`make ci` is the gate** — it runs the exact same checks locally
|
||||
> (the workflow calls the same `make` targets). Issue **#30 (S-00-c)** stays open
|
||||
> until CI is verified green on a runner.
|
||||
> **Status: active.** The workflow `.gitea/workflows/ci.yaml` runs on Gitea's
|
||||
> hosted `ubuntu-latest` runner — no self-hosted runner required.
|
||||
> **`make ci` is still the local gate** — it runs the exact same checks
|
||||
> (the workflow calls the same `make` targets).
|
||||
|
||||
## The pipeline
|
||||
|
||||
@@ -17,23 +15,102 @@ and CI cannot drift:
|
||||
|---|---|---|
|
||||
| `lint` | `make lint` → `dotnet format … --verify-no-changes` | .NET 10 SDK |
|
||||
| `build` | `make build` → `dotnet build … -c Release` | .NET 10 SDK |
|
||||
| `unit` | `make unit` → `dotnet test … -c Release` | .NET 10 SDK |
|
||||
| `compose-smoke` | `make smoke` → compose up `--wait` → `curl /health` → `down` | container engine + compose v2 |
|
||||
| `unit` | `make unit` → `dotnet test … -c Release --filter "Category!=Integration"` | .NET 10 SDK |
|
||||
| `mutation` | `make mutation` → `dotnet tool restore` → `dotnet stryker` (ACL); uploads the HTML report as an artifact | .NET 10 SDK |
|
||||
| `verify-stack` | the single live-stack stage — steps: `make verify-up` (full stack up + health, the DoD smoke) → `make verify-acl` (ACL ↔ OpenZaak) → `make verify-nrc` (OpenZaak → NRC delivery) → `make down` | container engine + egress (base images, nuget, `selectielijst.openzaak.nl`) |
|
||||
|
||||
> **Why one `verify-stack` job, not three.** The single self-hosted runner runs jobs
|
||||
> **sequentially**, so booting OpenZaak once (instead of once per check) is the
|
||||
> cheapest layout (issue #58). It subsumes the old `integration`, `notifications`, and
|
||||
> `compose-smoke` jobs — the bring-up step *is* the "compose up reaches green health"
|
||||
> gate. No `setup-dotnet`: the ACL test runs in a built image and every check reaches
|
||||
> services by **container IP** (the runner can't reach published ports — see
|
||||
> [gitea-actions-gotchas.md §5/§6](gitea-actions-gotchas.md)).
|
||||
|
||||
All `uses:` references are absolute, tag-pinned URLs (`https://github.com/actions/checkout@v4`,
|
||||
`https://github.com/actions/setup-dotnet@v4`) per CLAUDE.md §8.7 and §15 — Gitea
|
||||
Actions resolves them from GitHub.
|
||||
|
||||
## Running CI locally (`make ci`)
|
||||
> **`verify-stack` runs on a containerized runner.** Workspace bind mounts do
|
||||
> **not** reach the sibling containers Compose starts, so config/assets are
|
||||
> streamed into external named volumes via `docker cp` (`infra/seed-config.sh`),
|
||||
> and the upstream images are used verbatim (no build). If you add a service that
|
||||
> needs a repo file at runtime, seed it the same way — don't bind-mount it. Note:
|
||||
> bare `docker compose up` no longer self-seeds; use `make up`. See
|
||||
> [gitea-actions-gotchas.md](gitea-actions-gotchas.md).
|
||||
|
||||
Until the runner exists, run the full pipeline yourself before pushing:
|
||||
## 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 ci # lint + build + unit + smoke — what the pipeline runs
|
||||
make lint # or a single stage
|
||||
make smoke # compose up --wait, curl /health, tear down
|
||||
make mutation # dotnet tool restore + dotnet stryker on the ACL
|
||||
```
|
||||
|
||||
Config lives in [`services/acl/stryker-config.json`](../../services/acl/stryker-config.json).
|
||||
It runs in **solution mode** against `Acl.slnx`, mutating the two projects under test
|
||||
(`Acl.Application`, `Acl.Infrastructure`); `Acl.Api` has no tests and is skipped.
|
||||
|
||||
**Baseline (the ratchet):** the ACL is the first service with branching logic, so it
|
||||
sets the repo-wide baseline. Observed score **95%**; enforced `break` threshold **90%**
|
||||
(one-mutant headroom over the ~20-mutant surface). Stryker exits non-zero — failing the
|
||||
job — when the score drops below `break`. Per §5 the baseline only moves **up**, and only
|
||||
as a slice's stated outcome; never lower it. New services add their own mutation run as
|
||||
they gain logic.
|
||||
|
||||
The HTML report is written to `services/acl/StrykerOutput/<timestamp>/reports/` (git-ignored);
|
||||
open it to see survived vs. killed mutants.
|
||||
|
||||
In CI the `mutation` job publishes that report as the **`acl-mutation-report`** artifact
|
||||
(download it from the run's summary page). The upload step uses `if: always()`, so the
|
||||
report is available even when the ratchet *fails* — which is exactly when you want to inspect
|
||||
the survivors. It is the repo's first use of `actions/upload-artifact`, pinned to **`@v3`**:
|
||||
`@v4` refuses to run on Gitea (its `@actions/artifact` v2 library blocks any non-github.com
|
||||
server as "GHES"), while `@v3` speaks the artifact protocol Gitea implements. See
|
||||
[gitea-actions-gotchas.md §4](gitea-actions-gotchas.md) (§15).
|
||||
|
||||
## Running the stack locally without `make` (Windows / Docker Desktop)
|
||||
|
||||
`make` and the bash helpers assume a Unix shell. To bring the whole stack up on a
|
||||
machine without them (e.g. Windows + Docker Desktop), use the **local compose
|
||||
file**, which bind-mounts the config instead of seeding volumes — so it needs no
|
||||
`make`, no seed step, and no bash:
|
||||
|
||||
```bash
|
||||
docker compose -f infra/docker-compose.local.yml up -d --build # any engine
|
||||
docker compose -f infra/docker-compose.local.yml up -d --build --wait # Docker Desktop (Compose v2)
|
||||
docker compose -f infra/docker-compose.local.yml down --volumes
|
||||
```
|
||||
|
||||
On Linux/macOS the same thing is wrapped as `make local` / `make local-down`.
|
||||
|
||||
`infra/docker-compose.local.yml` mirrors the canonical `infra/docker-compose.yml`
|
||||
but swaps the external config volumes for bind mounts — valid locally because a
|
||||
local daemon can see the working directory (the seed/volume dance only exists for
|
||||
the containerized CI runner). Keep the two files in sync.
|
||||
|
||||
## Running CI locally (`make ci`)
|
||||
|
||||
`make ci` runs the exact same checks as the pipeline — handy to run before pushing:
|
||||
|
||||
```bash
|
||||
make ci # lint + build + unit + mutation + verify — mirrors the pipeline
|
||||
make lint # or a single stage
|
||||
make mutation # Stryker.NET ratchet on the ACL
|
||||
make verify # the live-stack stage: full stack up once → ACL + NRC checks → down
|
||||
```
|
||||
|
||||
> **`make verify`** mirrors the CI `verify-stack` job: it boots the full stack once and
|
||||
> runs both the ACL ↔ OpenZaak and OpenZaak → NRC checks against it. For fast,
|
||||
> single-concern local iteration use a lighter throwaway stack instead:
|
||||
>
|
||||
> ```bash
|
||||
> make integration # ACL ↔ OpenZaak only (no NRC)
|
||||
> make verify-notifications # OpenZaak → NRC delivery only
|
||||
> ```
|
||||
|
||||
**Prerequisites:** .NET 10 SDK, a container engine with Compose v2, and `curl`.
|
||||
|
||||
On a **rootless Podman** box (the default dev setup here), the `smoke` target needs
|
||||
@@ -49,56 +126,26 @@ The Makefile auto-points `DOCKER_HOST` at `/run/user/$(id -u)/podman/podman.sock
|
||||
when that socket exists and `DOCKER_HOST` is unset, so `make smoke` "just works"
|
||||
locally while leaving real Docker hosts / CI runners untouched.
|
||||
|
||||
## Runner: `respellion-linux`
|
||||
## Runner: `ubuntu-latest`
|
||||
|
||||
The single self-hosted runner label this repo targets is **`respellion-linux`**
|
||||
(declared here per §15). It is intended to run **co-located on the Gitea server**
|
||||
(`git.labs.respellion.tech` / `46.224.220.37`) so CI is durable and independent of
|
||||
any developer machine.
|
||||
All jobs run on Gitea's hosted **`ubuntu-latest`** runner — no self-hosted runner
|
||||
setup is required. The hosted runner ships with Docker and Docker Compose v2, so
|
||||
`make smoke` (`docker compose … up --wait`) works without extra configuration.
|
||||
|
||||
### Host prerequisites
|
||||
|
||||
The runner executes jobs in **host mode** (see registration below), so the host
|
||||
must have, on `PATH`:
|
||||
|
||||
- .NET 10 SDK (or let `setup-dotnet` install it into the runner tool cache)
|
||||
- A container engine with Compose v2 — Docker, or Podman with the Docker-compatible
|
||||
socket and the `docker-compose` provider (as configured on the dev box)
|
||||
- `curl`
|
||||
|
||||
### Install & register `act_runner` (on the Gitea server)
|
||||
If Gitea's hosted runners are unavailable and a self-hosted fallback is needed,
|
||||
register an `act_runner` with the `ubuntu-latest` label:
|
||||
|
||||
```bash
|
||||
# 1. Install the binary (pick the version matching the Gitea release line)
|
||||
VER=0.2.11
|
||||
curl -fsSL -o /usr/local/bin/act_runner \
|
||||
"https://dl.gitea.com/act_runner/${VER}/act_runner-${VER}-linux-amd64"
|
||||
chmod +x /usr/local/bin/act_runner
|
||||
|
||||
# 2. Obtain a registration token from the Gitea UI:
|
||||
# Site Administration → Actions → Runners → "Create new Runner" (instance-level)
|
||||
# (or Repo → Settings → Actions → Runners for a repo-scoped runner)
|
||||
|
||||
# 3. Register with the respellion-linux label in HOST execution mode.
|
||||
# The ":host" suffix means jobs run directly on the host shell, so
|
||||
# `docker compose` in compose-smoke uses the host engine (no docker-in-docker).
|
||||
act_runner register --no-interactive \
|
||||
--instance https://git.labs.respellion.tech \
|
||||
--token <REGISTRATION_TOKEN> \
|
||||
--name respellion-ci-1 \
|
||||
--labels "respellion-linux:host"
|
||||
--labels "ubuntu-latest:docker://node:20-bookworm"
|
||||
|
||||
# 4. Run it (foreground to verify, then install as a systemd service)
|
||||
act_runner daemon
|
||||
```
|
||||
|
||||
Verify in the Gitea UI (Actions → Runners) that `respellion-ci-1` shows **Idle**,
|
||||
then re-run the `CI` workflow; all four jobs should pass.
|
||||
|
||||
## Security note
|
||||
|
||||
A self-hosted runner in **host mode** executes workflow code directly on the Gitea
|
||||
server host. Anyone who can push a workflow can run code there. This is acceptable
|
||||
for a **private lab** instance with trusted contributors. For anything
|
||||
internet-facing, switch to container/VM isolation (`--labels "respellion-linux:docker://..."`)
|
||||
or a dedicated runner host, and gate workflow runs on approval for outside PRs.
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
# Gitea Actions gotchas
|
||||
|
||||
How our CI (Gitea Actions on the hosted **`ubuntu-latest`** runner) differs from a
|
||||
local run, and the workarounds in this repo. Referenced by `CLAUDE.md` §8.7/§15.
|
||||
|
||||
**One root cause sits under most of this:** the runner executes the job **inside a
|
||||
container**, so when a step runs `docker compose up`, Compose starts the stack as
|
||||
**sibling containers** on the host's daemon. Anything that assumes the job and
|
||||
those containers share a filesystem — or a `localhost` — breaks.
|
||||
|
||||
| Gotcha | Fix | Lives in |
|
||||
|---|---|---|
|
||||
| Bind-mounted config arrives empty | `docker cp` config into external volumes | `infra/seed-config.sh` |
|
||||
| `docker compose up --wait` is unsupported / flaky | poll health with `docker inspect` | `infra/wait-healthy.sh` |
|
||||
| `pg_isready` passes before PostGIS is ready | add a `PostGIS_Version()` probe | the db healthchecks |
|
||||
| `upload-artifact@v4` fails ("not supported on GHES") | pin `@v3` | `.gitea/workflows/ci.yaml` (`mutation` job) |
|
||||
| `upload-artifact@v3` fails with "Artifact service responded with 500" | mark the upload `continue-on-error: true` (server-side; issue #62) | `.gitea/workflows/ci.yaml` (`mutation` job) |
|
||||
|
||||
---
|
||||
|
||||
## 1. Bind mounts don't reach the containers
|
||||
|
||||
**Symptom** — green locally, but `compose-smoke` fails with:
|
||||
|
||||
```
|
||||
oz-init-1 | CommandError: Yaml file `/app/setup_configuration/data.yaml` does not exist.
|
||||
```
|
||||
|
||||
Migrations run fine; only the step that reads a *mounted* file fails. The same
|
||||
trap hits `nrc-init`, `flowable-init`, and `keycloak`.
|
||||
|
||||
**Why** — a relative bind mount like `./openzaak/setup_configuration:/app/...` is
|
||||
resolved by Compose to a path *inside the job container*
|
||||
(`/workspace/.../setup_configuration`). The daemon then looks for that path on
|
||||
*its own host*, doesn't find it, and mounts an **empty directory**. (It works on a
|
||||
runner that executes jobs on the host — which is why moving to `ubuntu-latest`
|
||||
exposed it.)
|
||||
|
||||
**Fix** — use the upstream images verbatim (no build) and stream config into
|
||||
**external named volumes** with `docker cp`, which copies over the Docker API and
|
||||
so works wherever the daemon runs. `infra/seed-config.sh` creates each volume,
|
||||
mounts it in a throwaway helper, and copies the files in:
|
||||
|
||||
| Asset | Volume | Mounted at |
|
||||
|---|---|---|
|
||||
| OpenZaak `data.yaml` | `rr-oz-config` | `oz-init:/app/setup_configuration` |
|
||||
| Keycloak realms | `rr-kc-realms` | `keycloak:/opt/keycloak/data/import` |
|
||||
| `registratie.bpmn` | `rr-fl-bpmn` | `flowable-init:/work` |
|
||||
|
||||
The volumes are `external: true` with fixed names, so they resolve identically
|
||||
under docker compose and podman-compose. `make` seeds before every `up`; `make
|
||||
down` removes them. (Open Notificaties needs nothing — `nrc-init` migrates only.)
|
||||
|
||||
**Consequence — bare `docker compose up` can't self-seed external volumes:**
|
||||
|
||||
- **CI / Linux / macOS:** `make up` or `make smoke` (seed, then start).
|
||||
- **No-make / Windows:** `infra/docker-compose.local.yml` — a twin stack that
|
||||
**bind-mounts** the config instead. Bind mounts are fine *locally* because a
|
||||
local daemon can see your working directory, so
|
||||
`docker compose -f infra/docker-compose.local.yml up -d` just works.
|
||||
|
||||
**Why not the obvious alternatives**
|
||||
|
||||
- *Bake config into an image* (incl. an inline Dockerfile) — `docker compose up`
|
||||
would then work unaided, but it's a build; we wanted the upstream images as-is.
|
||||
- *Compose `configs:` with inline `content`* — Compose writes a client-side temp
|
||||
file and bind-mounts it, hitting the exact same problem.
|
||||
- *A host-executing runner* — bind mounts would work with zero seeding, but it
|
||||
reintroduces a self-hosted runner and undoes the move to `ubuntu-latest`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Readiness: poll health, don't use `--wait`
|
||||
|
||||
`docker compose up --wait` looks ideal but fails us three ways:
|
||||
|
||||
- **podman-compose doesn't implement it** (`unrecognized arguments: --wait`) — so
|
||||
it would break local dev.
|
||||
- A project-wide `--wait` **treats a one-shot exiting `0` as a failure** unless
|
||||
something `depends_on` it with `service_completed_successfully`. `flowable-init`
|
||||
deploys the BPMN and exits with no dependant, so `--wait` fails the moment it
|
||||
does — last line `container infra-flowable-init-1 exited (0)`.
|
||||
- The containerized runner **can't reach published host ports**, so an external
|
||||
`curl localhost:8080/health` can't work either.
|
||||
|
||||
**Fix** — `infra/wait-healthy.sh` polls each durable service (`openzaak nrc-web
|
||||
acl bff`, listed as `WAIT_SVCS` in the `Makefile`) with `docker ps` + `docker
|
||||
inspect '{{.State.Health.Status}}'` until it reports `healthy`. It uses only
|
||||
primitives both runtimes support, reads the **in-container** healthcheck (no host
|
||||
port needed), and ignores the one-shots (they only need to have run).
|
||||
`WAIT_TIMEOUT` defaults to 420 s — enough for the cold OpenZaak migrate (~90 s)
|
||||
plus app start.
|
||||
|
||||
---
|
||||
|
||||
## 3. `pg_isready` passes before PostGIS is ready
|
||||
|
||||
`pg_isready` succeeds as soon as the TCP port is open — *before* the
|
||||
`postgis/postgis` image has finished running `CREATE EXTENSION postgis`. An init
|
||||
container that starts migrating in that window can fail on a missing PostGIS. So
|
||||
the db healthchecks add a `SELECT PostGIS_Version()` probe, making dependents wait
|
||||
for the extension, not just the port.
|
||||
|
||||
---
|
||||
|
||||
## 4. `actions/upload-artifact@v4` refuses to run on Gitea
|
||||
|
||||
**Symptom** — the `mutation` job's `make mutation` step passes (95% score), but the
|
||||
upload step right after it fails the job:
|
||||
|
||||
```
|
||||
::error::@actions/artifact v2.0.0+, upload-artifact@v4+ and download-artifact@v4+
|
||||
are not currently supported on GHES.
|
||||
❌ Failure - Main https://github.com/actions/upload-artifact@v4
|
||||
```
|
||||
|
||||
**Why** — `upload-artifact@v4` bundles `@actions/artifact` v2, which inspects the
|
||||
server URL and **hard-aborts on anything that isn't `github.com`**, treating Gitea
|
||||
as an unsupported GitHub Enterprise Server. The check fires regardless of whether
|
||||
the Gitea server can actually store artifacts (1.24+ can). It is the *action*, not
|
||||
the server, that refuses.
|
||||
|
||||
**Fix** — pin **`actions/upload-artifact@v3`** (and `download-artifact@v3` if ever
|
||||
needed). v3 uses the older artifact protocol that Gitea implements, and has no GHES
|
||||
guard. Inputs are the same (`name`, `path`, `if-no-files-found`), so it is a drop-in
|
||||
swap. Do **not** bump to `@v4` until act_runner advertises github.com-compatible
|
||||
artifact support.
|
||||
|
||||
**Second failure mode — the server's artifact backend returns 500.** Even on the
|
||||
correctly-pinned `@v3`, uploads can fail with:
|
||||
|
||||
```
|
||||
Create Artifact Container - Attempt 5 of 5 failed with error: Artifact service responded with 500
|
||||
::error::Create Artifact Container failed: Artifact service responded with 500
|
||||
```
|
||||
|
||||
This is the **Gitea server's** artifact storage failing (not the action's GHES guard),
|
||||
so it is outside the repo's control. Because the `mutation` job's upload steps run with
|
||||
`if: always()`, that 500 would fail the job even though the ratchet passed. **Fix:** mark
|
||||
the uploads `continue-on-error: true` (issue #62). The mutation *gate* is the Stryker
|
||||
ratchet — `make mutation`'s exit code fails the job on a real regression — so the report
|
||||
upload is best-effort: when the server's artifact storage is restored, reports publish
|
||||
again with no workflow change.
|
||||
|
||||
---
|
||||
|
||||
## 5. A runner process can't reach a service container's published port
|
||||
|
||||
**Symptom** — green locally, but a CI step that runs *on the runner* and talks to a
|
||||
compose service over `localhost` fails. The ACL integration test's seed died with:
|
||||
|
||||
```
|
||||
OpenZaak ready (000)
|
||||
urllib.error.URLError: <urlopen error [Errno 111] Connection refused>
|
||||
make: *** [Makefile:114: integration] Error 1
|
||||
```
|
||||
|
||||
OpenZaak was demonstrably up — uwsgi had been serving for ~2 minutes — yet
|
||||
`curl`/`urllib` to `localhost:8000` from the runner were refused the whole time.
|
||||
|
||||
**Why** — the same sibling-container split as §1. Compose starts the stack via the
|
||||
host daemon, so `ports: ["8000:8000"]` publishes to the *daemon host*, not to the job
|
||||
container. From the runner, `localhost:8000` has nothing listening. (`make smoke`
|
||||
sidesteps this by polling readiness via `docker inspect` (§2), never a service port.)
|
||||
|
||||
**Fix** — don't talk to service ports from the runner. Either check state via `docker
|
||||
inspect` (health), or run the client **inside the compose network** so it reaches the
|
||||
service by name (`http://openzaak:8000`). For a test/seed that needs the repo's own
|
||||
code, deliver it via a **built image** (not a bind mount — §1), then
|
||||
`docker run --network <stack>_cg …`.
|
||||
|
||||
**Applied** — `make integration` (ADR-0006) and `make verify-notifications` (ADR-0007)
|
||||
do exactly this: they run the seed/test/driver as containers on the stack network and
|
||||
reach services by **container IP** (see §6).
|
||||
|
||||
---
|
||||
|
||||
## 6. OpenZaak / NRC reject single-label hosts in URLs
|
||||
|
||||
**Symptom** — talking to OpenZaak or NRC by compose **service name** fails where a URL
|
||||
is validated: catalogus/zaaktype filters, the zaak `zaaktype` URL, and abonnement
|
||||
`callbackUrl` come back `400 "Voer een geldige URL in."` — even though the host
|
||||
resolves and is reachable.
|
||||
|
||||
**Why** — these apps validate URLs with Django's `URLValidator`, which rejects a
|
||||
**single-label** host like `openzaak` or `nrc-web` (no dot, and not `localhost`).
|
||||
`localhost` passes (so it's invisible in host-port-based local runs); in-network the
|
||||
reality is a service name or an IPv4 literal — and only the IP passes.
|
||||
|
||||
**Fix** — in-network tooling reaches OpenZaak/NRC by **container IP**
|
||||
(`docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}'`), not
|
||||
service name; the notif verify harness also registers the sink callback by IP.
|
||||
(`infra/run-acl-integration.sh`, `infra/run-notification-check.sh`.)
|
||||
|
||||
**Related — abonnement callbacks must enforce auth.** NRC probes a callback when an
|
||||
abonnement is registered and refuses it (`no-auth-on-callback-url`) unless it returns
|
||||
**401** without the configured `Authorization`. The verify sink
|
||||
(`infra/notification-sink.py`) enforces a bearer token for exactly this reason.
|
||||
@@ -71,5 +71,12 @@ The Makefile auto-points `DOCKER_HOST` at the Podman socket when it exists, so t
|
||||
but OZ→NRC delivery wiring + re-enabling lands with **S-06**.
|
||||
- **Zaaktype is a concept**, not published (publishing needs roltypen/statustypen/
|
||||
resultaattypen — beyond the lean seed). List with `?status=alles`.
|
||||
- **Image tag.** Currently `openzaak/open-zaak:latest` via `${OPENZAAK_TAG}`; pin to
|
||||
a known-good tag (ADR-0002 follow-up).
|
||||
- **Image tag.** Pinned to `openzaak/open-zaak:1.28.2` via `${OPENZAAK_TAG}` (bump
|
||||
deliberately, not via `:latest`).
|
||||
- **Config arrives via a volume, not a bind mount.** `setup_configuration/data.yaml`
|
||||
is streamed into the external `rr-oz-config` volume by `infra/seed-config.sh`
|
||||
(`docker cp`) and mounted at `/app/setup_configuration`, so the init container
|
||||
finds it on Gitea's containerized CI runner too (bind mounts don't reach sibling
|
||||
containers there). The image is the upstream `openzaak/open-zaak` verbatim — no
|
||||
build. Run via `make openzaak-up` (seeds first). See
|
||||
[gitea-actions-gotchas.md](gitea-actions-gotchas.md).
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
# LOCAL development stack — runs with a plain `docker compose up`, no make / no
|
||||
# seed step / no bash. Use this on a local engine (Docker Desktop on Windows or
|
||||
# macOS, or rootless Podman on Linux).
|
||||
#
|
||||
# docker compose -f infra/docker-compose.local.yml up -d --build # podman
|
||||
# docker compose -f infra/docker-compose.local.yml up -d --build --wait # Docker Desktop
|
||||
# docker compose -f infra/docker-compose.local.yml down --volumes
|
||||
#
|
||||
# It is identical to infra/docker-compose.yml EXCEPT that the three config inputs
|
||||
# (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are **bind-mounted** from
|
||||
# the repo instead of being streamed into external volumes by infra/seed-config.sh.
|
||||
# Bind mounts work here because a local daemon can see your working directory —
|
||||
# the seed dance only exists for the containerized CI runner, where it can't. See
|
||||
# docs/runbooks/gitea-actions-gotchas.md.
|
||||
#
|
||||
# `infra/docker-compose.yml` remains the CI-canonical stack; keep the two in sync.
|
||||
#
|
||||
# Port map (host):
|
||||
# 8000 OpenZaak · 8001 Open Notificaties · 8080 BFF · 8090 Flowable REST
|
||||
# 8100 ACL · 8180 Keycloak (all admin: admin / admin — dev only)
|
||||
|
||||
services:
|
||||
|
||||
# ── OpenZaak (S-01) ──────────────────────────────────────────────────────
|
||||
oz-db:
|
||||
image: docker.io/postgis/postgis:17-3.5
|
||||
environment:
|
||||
POSTGRES_USER: openzaak
|
||||
POSTGRES_PASSWORD: openzaak
|
||||
POSTGRES_DB: openzaak
|
||||
command: postgres -c max_connections=300
|
||||
volumes:
|
||||
- oz-db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U openzaak -d openzaak && psql -U openzaak -d openzaak -c 'SELECT PostGIS_Version();' -q 2>/dev/null"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 15s
|
||||
networks: [cg]
|
||||
|
||||
oz-redis:
|
||||
image: docker.io/library/redis:7
|
||||
networks: [cg]
|
||||
|
||||
oz-init:
|
||||
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
|
||||
environment: &oz-env
|
||||
DJANGO_SETTINGS_MODULE: openzaak.conf.docker
|
||||
SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production}
|
||||
DB_HOST: oz-db
|
||||
DB_NAME: openzaak
|
||||
DB_USER: openzaak
|
||||
DB_PASSWORD: openzaak
|
||||
IS_HTTPS: "no"
|
||||
ALLOWED_HOSTS: "*"
|
||||
CACHE_DEFAULT: oz-redis:6379/0
|
||||
CACHE_AXES: oz-redis:6379/0
|
||||
CELERY_BROKER_URL: redis://oz-redis:6379/1
|
||||
CELERY_RESULT_BACKEND: redis://oz-redis:6379/1
|
||||
DISABLE_2FA: "true"
|
||||
# Publish notifications to NRC (always present in this twin). See ADR-0007.
|
||||
NOTIFICATIONS_DISABLED: "false"
|
||||
OPENZAAK_SUPERUSER_USERNAME: admin
|
||||
DJANGO_SUPERUSER_PASSWORD: admin
|
||||
OPENZAAK_SUPERUSER_EMAIL: admin@localhost
|
||||
RUN_SETUP_CONFIG: "true"
|
||||
command: /setup_configuration.sh
|
||||
# Bind mount (`:z` relabels for SELinux on Linux; a no-op on Docker Desktop).
|
||||
volumes:
|
||||
- ./openzaak/setup_configuration:/app/setup_configuration:ro,z
|
||||
depends_on:
|
||||
oz-db:
|
||||
condition: service_healthy
|
||||
oz-redis:
|
||||
condition: service_started
|
||||
networks: [cg]
|
||||
|
||||
openzaak:
|
||||
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
|
||||
environment: *oz-env
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
oz-init:
|
||||
condition: service_completed_successfully
|
||||
networks: [cg]
|
||||
|
||||
oz-celery:
|
||||
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
|
||||
environment: *oz-env
|
||||
command: /celery_worker.sh
|
||||
depends_on:
|
||||
oz-init:
|
||||
condition: service_completed_successfully
|
||||
networks: [cg]
|
||||
|
||||
# ── Open Notificaties / NRC (S-01-c) ─────────────────────────────────────
|
||||
nrc-db:
|
||||
image: docker.io/postgis/postgis:17-3.5
|
||||
environment:
|
||||
POSTGRES_USER: opennotificaties
|
||||
POSTGRES_PASSWORD: opennotificaties
|
||||
POSTGRES_DB: opennotificaties
|
||||
command: postgres -c max_connections=300
|
||||
volumes:
|
||||
- nrc-db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U opennotificaties -d opennotificaties"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
networks: [cg]
|
||||
|
||||
nrc-redis:
|
||||
image: docker.io/library/redis:7
|
||||
networks: [cg]
|
||||
|
||||
nrc-init:
|
||||
# Migrations + setup_configuration (S-01-c): the JWT credential, Autorisaties-API
|
||||
# delegation, and the `zaken` kanaal that let OpenZaak publish. Config is
|
||||
# bind-mounted here (this twin is the local/no-make path). See ADR-0007.
|
||||
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
|
||||
environment: &nrc-env
|
||||
DJANGO_SETTINGS_MODULE: nrc.conf.docker
|
||||
SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production}
|
||||
DB_HOST: nrc-db
|
||||
DB_NAME: opennotificaties
|
||||
DB_USER: opennotificaties
|
||||
DB_PASSWORD: opennotificaties
|
||||
IS_HTTPS: "no"
|
||||
ALLOWED_HOSTS: "*"
|
||||
CACHE_DEFAULT: nrc-redis:6379/0
|
||||
CACHE_AXES: nrc-redis:6379/0
|
||||
CELERY_BROKER_URL: redis://nrc-redis:6379/1
|
||||
CELERY_RESULT_BACKEND: redis://nrc-redis:6379/1
|
||||
DISABLE_2FA: "true"
|
||||
OPENNOTIFICATIES_SUPERUSER_USERNAME: admin
|
||||
DJANGO_SUPERUSER_PASSWORD: admin
|
||||
OPENNOTIFICATIES_SUPERUSER_EMAIL: admin@localhost
|
||||
RUN_SETUP_CONFIG: "true"
|
||||
NOTIFICATION_SEC_INTERVAL: "5"
|
||||
command: /setup_configuration.sh
|
||||
volumes:
|
||||
- ./opennotificaties/setup_configuration:/app/setup_configuration:ro,z
|
||||
depends_on:
|
||||
nrc-db:
|
||||
condition: service_healthy
|
||||
nrc-redis:
|
||||
condition: service_started
|
||||
openzaak:
|
||||
condition: service_healthy
|
||||
networks: [cg]
|
||||
|
||||
nrc-web:
|
||||
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
|
||||
environment: *nrc-env
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
ports:
|
||||
- "8001:8000"
|
||||
depends_on:
|
||||
nrc-init:
|
||||
condition: service_completed_successfully
|
||||
networks: [cg]
|
||||
|
||||
nrc-celery:
|
||||
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
|
||||
environment: *nrc-env
|
||||
command: /celery_worker.sh
|
||||
depends_on:
|
||||
nrc-init:
|
||||
condition: service_completed_successfully
|
||||
networks: [cg]
|
||||
|
||||
# Celery beat drains scheduled notifications to subscribers — required for
|
||||
# delivery, not optional. See ADR-0007.
|
||||
nrc-beat:
|
||||
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
|
||||
environment: *nrc-env
|
||||
command: /celery_beat.sh
|
||||
depends_on:
|
||||
nrc-init:
|
||||
condition: service_completed_successfully
|
||||
networks: [cg]
|
||||
|
||||
# ── Keycloak (S-02) ──────────────────────────────────────────────────────
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.1
|
||||
command: ["start-dev", "--import-realm"]
|
||||
environment:
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: admin
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
KC_HEALTH_ENABLED: "true"
|
||||
KC_HTTP_ENABLED: "true"
|
||||
ports:
|
||||
- "8180:8080"
|
||||
volumes:
|
||||
- ./keycloak/realms:/opt/keycloak/data/import:ro,z
|
||||
networks: [cg]
|
||||
|
||||
# ── Flowable (S-03) ──────────────────────────────────────────────────────
|
||||
flowable-db:
|
||||
image: docker.io/library/postgres:16
|
||||
environment:
|
||||
POSTGRES_USER: flowable
|
||||
POSTGRES_PASSWORD: flowable
|
||||
POSTGRES_DB: flowable
|
||||
volumes:
|
||||
- flowable-db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U flowable -d flowable"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
networks: [cg]
|
||||
|
||||
flowable-rest:
|
||||
image: docker.io/flowable/flowable-rest:latest
|
||||
environment:
|
||||
SPRING_DATASOURCE_DRIVER-CLASS-NAME: org.postgresql.Driver
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://flowable-db:5432/flowable
|
||||
SPRING_DATASOURCE_USERNAME: flowable
|
||||
SPRING_DATASOURCE_PASSWORD: flowable
|
||||
ports:
|
||||
- "8090:8080"
|
||||
depends_on:
|
||||
flowable-db:
|
||||
condition: service_healthy
|
||||
networks: [cg]
|
||||
|
||||
flowable-init:
|
||||
image: docker.io/curlimages/curl:latest
|
||||
restart: "no"
|
||||
volumes:
|
||||
- ../workflows/registratie.bpmn:/work/registratie.bpmn:ro,z
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
base=http://flowable-rest:8080/flowable-rest/service/repository/deployments
|
||||
until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
|
||||
if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then
|
||||
echo "registratie already deployed; skip"
|
||||
else
|
||||
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie"
|
||||
fi
|
||||
depends_on:
|
||||
flowable-rest:
|
||||
condition: service_started
|
||||
networks: [cg]
|
||||
|
||||
# ── ACL ──────────────────────────────────────────────────────────────────
|
||||
acl:
|
||||
build:
|
||||
context: ../services/acl
|
||||
dockerfile: Dockerfile
|
||||
image: register-referentie/acl:dev
|
||||
environment:
|
||||
Acl__OpenZaak__BaseUrl: http://openzaak:8000/
|
||||
Acl__OpenZaak__ClientId: big-reference-seed
|
||||
Acl__OpenZaak__Secret: insecure-dev-secret-change-me
|
||||
Acl__Defaults__Bronorganisatie: "517439943"
|
||||
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
|
||||
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
|
||||
Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000}
|
||||
ports:
|
||||
- "8100:8080"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
depends_on:
|
||||
openzaak:
|
||||
condition: service_healthy
|
||||
networks: [cg]
|
||||
|
||||
# ── BFF ──────────────────────────────────────────────────────────────────
|
||||
bff:
|
||||
build:
|
||||
context: ../services/bff
|
||||
dockerfile: Dockerfile
|
||||
image: register-referentie/bff:dev
|
||||
ports:
|
||||
- "8080:8080"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
networks: [cg]
|
||||
|
||||
# ── Read projection (S-06) ────────────────────────────────────────────────
|
||||
projection-db:
|
||||
image: docker.io/library/postgres:16
|
||||
environment:
|
||||
POSTGRES_USER: projection
|
||||
POSTGRES_PASSWORD: projection
|
||||
POSTGRES_DB: projection
|
||||
volumes:
|
||||
- projection-db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U projection -d projection"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
networks: [cg]
|
||||
|
||||
event-subscriber:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: services/event-subscriber/Dockerfile
|
||||
image: register-referentie/event-subscriber:dev
|
||||
environment:
|
||||
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
|
||||
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
|
||||
ports:
|
||||
- "8110:8080"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
depends_on:
|
||||
projection-db:
|
||||
condition: service_healthy
|
||||
networks: [cg]
|
||||
|
||||
projection-api:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: services/projection-api/Dockerfile
|
||||
image: register-referentie/projection-api:dev
|
||||
environment:
|
||||
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
|
||||
ports:
|
||||
- "8120:8080"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
depends_on:
|
||||
projection-db:
|
||||
condition: service_healthy
|
||||
networks: [cg]
|
||||
|
||||
volumes:
|
||||
oz-db:
|
||||
nrc-db:
|
||||
flowable-db:
|
||||
projection-db:
|
||||
|
||||
networks:
|
||||
cg:
|
||||
+442
-3
@@ -1,14 +1,356 @@
|
||||
# Local development stack. Grows service-by-service with each slice.
|
||||
# S-00-b: the placeholder BFF with a /health check.
|
||||
# Development stack — boots all infra services plus the ACL and BFF.
|
||||
#
|
||||
# Consolidates infra/openzaak/, infra/opennotificaties/, infra/keycloak/,
|
||||
# and infra/flowable/ and adds the ACL and BFF services.
|
||||
#
|
||||
# Port map (host):
|
||||
# 8000 OpenZaak ZGW API (admin: admin / admin)
|
||||
# 8001 Open Notificaties (admin: admin / admin)
|
||||
# 8080 BFF GET /health → Healthy
|
||||
# 8090 Flowable REST http://localhost:8090/flowable-rest/service/
|
||||
# 8100 ACL GET /health → Healthy POST /zaken
|
||||
# 8110 Event Subscriber GET /health → Healthy POST /notifications
|
||||
# 8120 projection-api GET /health → Healthy GET /register
|
||||
# 8180 Keycloak (admin: admin / admin)
|
||||
#
|
||||
# docker compose -f infra/docker-compose.yml up -d --build --wait
|
||||
# curl http://localhost:8080/health # -> Healthy
|
||||
#
|
||||
# After first boot, seed the BIG catalogus and note the zaaktype URL:
|
||||
# python infra/openzaak/seed_catalogus.py
|
||||
# Then set ACL_ZAAKTYPE_URL in a .env file or your shell and re-up the acl
|
||||
# service:
|
||||
# export ACL_ZAAKTYPE_URL=http://openzaak:8000/catalogi/api/v1/zaaktypen/<uuid>
|
||||
# docker compose -f infra/docker-compose.yml up -d acl
|
||||
|
||||
services:
|
||||
|
||||
# ── OpenZaak (S-01) ──────────────────────────────────────────────────────
|
||||
oz-db:
|
||||
image: docker.io/postgis/postgis:17-3.5
|
||||
environment:
|
||||
POSTGRES_USER: openzaak
|
||||
POSTGRES_PASSWORD: openzaak
|
||||
POSTGRES_DB: openzaak
|
||||
command: postgres -c max_connections=300
|
||||
volumes:
|
||||
- oz-db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
# pg_isready only checks TCP; the second clause verifies PostGIS is installed
|
||||
# so oz-init migrations can safely start (avoids race on cold container start).
|
||||
test: ["CMD-SHELL", "pg_isready -U openzaak -d openzaak && psql -U openzaak -d openzaak -c 'SELECT PostGIS_Version();' -q 2>/dev/null"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 15s
|
||||
networks: [cg]
|
||||
|
||||
oz-redis:
|
||||
image: docker.io/library/redis:7
|
||||
networks: [cg]
|
||||
|
||||
oz-init:
|
||||
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
|
||||
environment: &oz-env
|
||||
DJANGO_SETTINGS_MODULE: openzaak.conf.docker
|
||||
SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production}
|
||||
DB_HOST: oz-db
|
||||
DB_NAME: openzaak
|
||||
DB_USER: openzaak
|
||||
DB_PASSWORD: openzaak
|
||||
IS_HTTPS: "no"
|
||||
ALLOWED_HOSTS: "*"
|
||||
CACHE_DEFAULT: oz-redis:6379/0
|
||||
CACHE_AXES: oz-redis:6379/0
|
||||
CELERY_BROKER_URL: redis://oz-redis:6379/1
|
||||
CELERY_RESULT_BACKEND: redis://oz-redis:6379/1
|
||||
DISABLE_2FA: "true"
|
||||
# Publish notifications to NRC (always present in this full stack). The NRC
|
||||
# service + notifications_config are provisioned by setup_configuration
|
||||
# (infra/openzaak/setup_configuration/data.yaml). See ADR-0007 / S-01-c.
|
||||
NOTIFICATIONS_DISABLED: "false"
|
||||
OPENZAAK_SUPERUSER_USERNAME: admin
|
||||
DJANGO_SUPERUSER_PASSWORD: admin
|
||||
OPENZAAK_SUPERUSER_EMAIL: admin@localhost
|
||||
RUN_SETUP_CONFIG: "true"
|
||||
command: /setup_configuration.sh
|
||||
# data.yaml is streamed into this external volume by infra/seed-config.sh
|
||||
# before start (bind mounts don't reach sibling containers on the CI runner).
|
||||
volumes:
|
||||
- oz-config:/app/setup_configuration:ro
|
||||
depends_on:
|
||||
oz-db:
|
||||
condition: service_healthy
|
||||
oz-redis:
|
||||
condition: service_started
|
||||
networks: [cg]
|
||||
|
||||
openzaak:
|
||||
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
|
||||
environment: *oz-env
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
oz-init:
|
||||
condition: service_completed_successfully
|
||||
networks: [cg]
|
||||
|
||||
oz-celery:
|
||||
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
|
||||
environment: *oz-env
|
||||
command: /celery_worker.sh
|
||||
depends_on:
|
||||
oz-init:
|
||||
condition: service_completed_successfully
|
||||
networks: [cg]
|
||||
|
||||
# ── Open Notificaties / NRC (S-01-c) ─────────────────────────────────────
|
||||
nrc-db:
|
||||
image: docker.io/postgis/postgis:17-3.5
|
||||
environment:
|
||||
POSTGRES_USER: opennotificaties
|
||||
POSTGRES_PASSWORD: opennotificaties
|
||||
POSTGRES_DB: opennotificaties
|
||||
command: postgres -c max_connections=300
|
||||
volumes:
|
||||
- nrc-db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U opennotificaties -d opennotificaties"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
networks: [cg]
|
||||
|
||||
nrc-redis:
|
||||
image: docker.io/library/redis:7
|
||||
networks: [cg]
|
||||
|
||||
nrc-init:
|
||||
# Plain base image — nrc-init runs migrations only (see command below), so it
|
||||
# needs no baked config.
|
||||
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
|
||||
environment: &nrc-env
|
||||
DJANGO_SETTINGS_MODULE: nrc.conf.docker
|
||||
SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production}
|
||||
DB_HOST: nrc-db
|
||||
DB_NAME: opennotificaties
|
||||
DB_USER: opennotificaties
|
||||
DB_PASSWORD: opennotificaties
|
||||
IS_HTTPS: "no"
|
||||
ALLOWED_HOSTS: "*"
|
||||
CACHE_DEFAULT: nrc-redis:6379/0
|
||||
CACHE_AXES: nrc-redis:6379/0
|
||||
CELERY_BROKER_URL: redis://nrc-redis:6379/1
|
||||
CELERY_RESULT_BACKEND: redis://nrc-redis:6379/1
|
||||
DISABLE_2FA: "true"
|
||||
OPENNOTIFICATIES_SUPERUSER_USERNAME: admin
|
||||
DJANGO_SUPERUSER_PASSWORD: admin
|
||||
OPENNOTIFICATIES_SUPERUSER_EMAIL: admin@localhost
|
||||
RUN_SETUP_CONFIG: "true"
|
||||
# nrc-beat fires `execute_notifications` this often to drain scheduled
|
||||
# notifications to subscribers (upstream default 20s). See ADR-0007.
|
||||
NOTIFICATION_SEC_INTERVAL: "5"
|
||||
# Runs migrations + setup_configuration (S-01-c): the JWT credential, the
|
||||
# Autorisaties-API delegation, and the `zaken` kanaal that let OpenZaak publish.
|
||||
# data.yaml is streamed into rr-nrc-config by infra/seed-config.sh (bind mounts
|
||||
# don't reach sibling containers on the CI runner). See data.yaml + ADR-0007.
|
||||
command: /setup_configuration.sh
|
||||
volumes:
|
||||
- nrc-config:/app/setup_configuration:ro
|
||||
depends_on:
|
||||
nrc-db:
|
||||
condition: service_healthy
|
||||
nrc-redis:
|
||||
condition: service_started
|
||||
openzaak:
|
||||
condition: service_healthy
|
||||
networks: [cg]
|
||||
|
||||
nrc-web:
|
||||
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
|
||||
environment: *nrc-env
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
ports:
|
||||
- "8001:8000"
|
||||
depends_on:
|
||||
nrc-init:
|
||||
condition: service_completed_successfully
|
||||
networks: [cg]
|
||||
|
||||
nrc-celery:
|
||||
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
|
||||
environment: *nrc-env
|
||||
command: /celery_worker.sh
|
||||
depends_on:
|
||||
nrc-init:
|
||||
condition: service_completed_successfully
|
||||
networks: [cg]
|
||||
|
||||
# Celery beat drains the ScheduledNotification rows the API creates on publish
|
||||
# and hands them to the worker. Without it, notifications are accepted but never
|
||||
# delivered to subscribers — required, not optional. See ADR-0007.
|
||||
nrc-beat:
|
||||
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
|
||||
environment: *nrc-env
|
||||
command: /celery_beat.sh
|
||||
depends_on:
|
||||
nrc-init:
|
||||
condition: service_completed_successfully
|
||||
networks: [cg]
|
||||
|
||||
# ── Keycloak (S-02) ──────────────────────────────────────────────────────
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.1
|
||||
command: ["start-dev", "--import-realm"]
|
||||
environment:
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: admin
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
KC_HEALTH_ENABLED: "true"
|
||||
KC_HTTP_ENABLED: "true"
|
||||
ports:
|
||||
- "8180:8080"
|
||||
# realm exports are streamed into this external volume by infra/seed-config.sh.
|
||||
volumes:
|
||||
- kc-realms:/opt/keycloak/data/import:ro
|
||||
networks: [cg]
|
||||
|
||||
# ── Flowable (S-03) ──────────────────────────────────────────────────────
|
||||
flowable-db:
|
||||
image: docker.io/library/postgres:16
|
||||
environment:
|
||||
POSTGRES_USER: flowable
|
||||
POSTGRES_PASSWORD: flowable
|
||||
POSTGRES_DB: flowable
|
||||
volumes:
|
||||
- flowable-db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U flowable -d flowable"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
networks: [cg]
|
||||
|
||||
flowable-rest:
|
||||
image: docker.io/flowable/flowable-rest:latest
|
||||
environment:
|
||||
SPRING_DATASOURCE_DRIVER-CLASS-NAME: org.postgresql.Driver
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://flowable-db:5432/flowable
|
||||
SPRING_DATASOURCE_USERNAME: flowable
|
||||
SPRING_DATASOURCE_PASSWORD: flowable
|
||||
ports:
|
||||
- "8090:8080"
|
||||
depends_on:
|
||||
flowable-db:
|
||||
condition: service_healthy
|
||||
networks: [cg]
|
||||
|
||||
flowable-init:
|
||||
image: docker.io/curlimages/curl:latest
|
||||
restart: "no"
|
||||
# registratie.bpmn is streamed into this external volume by infra/seed-config.sh.
|
||||
volumes:
|
||||
- fl-bpmn:/work:ro
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
base=http://flowable-rest:8080/flowable-rest/service/repository/deployments
|
||||
until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
|
||||
if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then
|
||||
echo "registratie already deployed; skip"
|
||||
else
|
||||
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie"
|
||||
fi
|
||||
depends_on:
|
||||
flowable-rest:
|
||||
condition: service_started
|
||||
networks: [cg]
|
||||
|
||||
# ── ACL ──────────────────────────────────────────────────────────────────
|
||||
acl:
|
||||
build:
|
||||
context: ../services/acl
|
||||
dockerfile: Dockerfile
|
||||
image: register-referentie/acl:dev
|
||||
environment:
|
||||
# Overridable so verify-domain can point the ACL at the same OpenZaak host that
|
||||
# owns the seeded zaaktype URL (host-consistent zaak creation, ADR-0009).
|
||||
Acl__OpenZaak__BaseUrl: ${ACL_OPENZAAK_BASEURL:-http://openzaak:8000/}
|
||||
Acl__OpenZaak__ClientId: big-reference-seed
|
||||
Acl__OpenZaak__Secret: insecure-dev-secret-change-me
|
||||
Acl__Defaults__Bronorganisatie: "517439943"
|
||||
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
|
||||
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
|
||||
# Override with the real zaaktype URL after running seed_catalogus.py.
|
||||
Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000}
|
||||
ports:
|
||||
- "8100:8080"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
depends_on:
|
||||
openzaak:
|
||||
condition: service_healthy
|
||||
networks: [cg]
|
||||
|
||||
# ── BIG Domain Service (S-05) ──────────────────────────────────────────────
|
||||
# Orchestrates a registration: POST /registrations creates the aggregate and
|
||||
# starts the registratie Flowable process; a hosted worker acquires the
|
||||
# OpenZaakAanmaken job, opens a zaak via the ACL and completes it (ADR-0009).
|
||||
# Talks only to Flowable (Workflow Client, §8.2) and the ACL (§8.1).
|
||||
domain:
|
||||
build:
|
||||
context: ../services/domain
|
||||
dockerfile: Dockerfile
|
||||
image: register-referentie/domain:dev
|
||||
environment:
|
||||
Flowable__BaseUrl: http://flowable-rest:8080/flowable-rest/
|
||||
Flowable__Username: rest-admin
|
||||
Flowable__Password: test
|
||||
Acl__BaseUrl: http://acl:8080/
|
||||
ports:
|
||||
- "8130:8080"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
depends_on:
|
||||
acl:
|
||||
condition: service_healthy
|
||||
flowable-init:
|
||||
condition: service_completed_successfully
|
||||
networks: [cg]
|
||||
|
||||
# ── BFF ──────────────────────────────────────────────────────────────────
|
||||
bff:
|
||||
build:
|
||||
context: ../services/bff
|
||||
dockerfile: Dockerfile
|
||||
image: register-referentie/bff:dev
|
||||
environment:
|
||||
# The BFF is the portals' only backend; it validates digid tokens and fans out (ADR-0010).
|
||||
# Keycloak (start-dev) derives the issuer from the request host, so the BFF authority and the
|
||||
# verify token request both use keycloak:8080 to keep the issuer consistent.
|
||||
Keycloak__Authority: http://keycloak:8080/realms/digid
|
||||
Downstream__Domain__BaseUrl: http://domain:8080/
|
||||
Downstream__Projection__BaseUrl: http://projection-api:8080/
|
||||
ports:
|
||||
- "8080:8080"
|
||||
healthcheck:
|
||||
@@ -17,3 +359,100 @@ services:
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
depends_on:
|
||||
domain:
|
||||
condition: service_healthy
|
||||
projection-api:
|
||||
condition: service_healthy
|
||||
keycloak:
|
||||
condition: service_started
|
||||
networks: [cg]
|
||||
|
||||
# ── Read projection (S-06) ────────────────────────────────────────────────
|
||||
# One Postgres DB backing the rebuildable read projection (PRD §8.4): the Event
|
||||
# Subscriber writes it, projection-api reads it. See ADR-0008.
|
||||
projection-db:
|
||||
image: docker.io/library/postgres:16
|
||||
environment:
|
||||
POSTGRES_USER: projection
|
||||
POSTGRES_PASSWORD: projection
|
||||
POSTGRES_DB: projection
|
||||
volumes:
|
||||
- projection-db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U projection -d projection"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
networks: [cg]
|
||||
|
||||
# Consumes NRC notifications (abonnement callback) and projects zaak-created events
|
||||
# into register_projection. Build context is the repo root: it shares the read model
|
||||
# in services/projection-api/Projection.ReadModel.
|
||||
event-subscriber:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: services/event-subscriber/Dockerfile
|
||||
image: register-referentie/event-subscriber:dev
|
||||
environment:
|
||||
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
|
||||
# The bearer Open Notificaties must present on the abonnement callback. NRC's
|
||||
# registration probe expects a 401 without it (ADR-0007). Dev-only token.
|
||||
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
|
||||
ports:
|
||||
- "8110:8080"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
depends_on:
|
||||
projection-db:
|
||||
condition: service_healthy
|
||||
networks: [cg]
|
||||
|
||||
# The read side of the projection. Shares Projection.ReadModel, so build context is root.
|
||||
projection-api:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: services/projection-api/Dockerfile
|
||||
image: register-referentie/projection-api:dev
|
||||
environment:
|
||||
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
|
||||
ports:
|
||||
- "8120:8080"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
depends_on:
|
||||
projection-db:
|
||||
condition: service_healthy
|
||||
networks: [cg]
|
||||
|
||||
volumes:
|
||||
oz-db:
|
||||
nrc-db:
|
||||
flowable-db:
|
||||
projection-db:
|
||||
# Config volumes — created and populated out-of-band by infra/seed-config.sh
|
||||
# (docker cp), because bind mounts don't reach sibling containers on the CI
|
||||
# runner. `external` keeps the names deterministic; the seed step manages them.
|
||||
oz-config:
|
||||
external: true
|
||||
name: rr-oz-config
|
||||
nrc-config:
|
||||
external: true
|
||||
name: rr-nrc-config
|
||||
kc-realms:
|
||||
external: true
|
||||
name: rr-kc-realms
|
||||
fl-bpmn:
|
||||
external: true
|
||||
name: rr-fl-bpmn
|
||||
|
||||
networks:
|
||||
cg:
|
||||
|
||||
@@ -40,8 +40,9 @@ services:
|
||||
flowable-init:
|
||||
image: docker.io/curlimages/curl:latest
|
||||
restart: "no"
|
||||
# registratie.bpmn is streamed into this external volume by infra/seed-config.sh.
|
||||
volumes:
|
||||
- ../../workflows/registratie.bpmn:/work/registratie.bpmn:ro,z
|
||||
- fl-bpmn:/work:ro
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
@@ -60,6 +61,10 @@ services:
|
||||
|
||||
volumes:
|
||||
flowable-db:
|
||||
# populated out-of-band by infra/seed-config.sh (docker cp) — see that script.
|
||||
fl-bpmn:
|
||||
external: true
|
||||
name: rr-fl-bpmn
|
||||
|
||||
networks:
|
||||
cg:
|
||||
|
||||
@@ -21,9 +21,15 @@ services:
|
||||
KC_HTTP_ENABLED: "true"
|
||||
ports:
|
||||
- "8180:8080"
|
||||
# realm exports are streamed into this external volume by infra/seed-config.sh.
|
||||
volumes:
|
||||
- ./realms:/opt/keycloak/data/import:ro,z
|
||||
- kc-realms:/opt/keycloak/data/import:ro
|
||||
networks: [cg]
|
||||
|
||||
volumes:
|
||||
kc-realms:
|
||||
external: true
|
||||
name: rr-kc-realms
|
||||
|
||||
networks:
|
||||
cg:
|
||||
|
||||
Executable
+39
@@ -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)
|
||||
@@ -19,10 +19,13 @@ services:
|
||||
volumes:
|
||||
- nrc-db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U opennotificaties -d opennotificaties"]
|
||||
# pg_isready only checks TCP; the second clause verifies PostGIS is installed
|
||||
# so nrc-init migrations can safely start (avoids race on cold container start).
|
||||
test: ["CMD-SHELL", "pg_isready -U opennotificaties -d opennotificaties && psql -U opennotificaties -d opennotificaties -c 'SELECT PostGIS_Version();' -q 2>/dev/null"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 15s
|
||||
networks: [cg]
|
||||
|
||||
nrc-redis:
|
||||
@@ -30,7 +33,8 @@ services:
|
||||
networks: [cg]
|
||||
|
||||
nrc-init:
|
||||
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-latest}
|
||||
# Plain base image — nrc-init runs migrations only (see command below).
|
||||
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
|
||||
environment: &nrc-env
|
||||
DJANGO_SETTINGS_MODULE: nrc.conf.docker
|
||||
SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production}
|
||||
@@ -49,9 +53,17 @@ services:
|
||||
DJANGO_SUPERUSER_PASSWORD: admin
|
||||
OPENNOTIFICATIES_SUPERUSER_EMAIL: admin@localhost
|
||||
RUN_SETUP_CONFIG: "true"
|
||||
# Delivery cadence: nrc-beat fires `execute_notifications` this often to drain
|
||||
# scheduled notifications to subscribers. Upstream default is 20s; 5s keeps the
|
||||
# walking-skeleton + the verify smoke responsive.
|
||||
NOTIFICATION_SEC_INTERVAL: "5"
|
||||
# Runs migrations + setup_configuration (S-01-c): the JWT credential, the
|
||||
# Autorisaties-API delegation, and the `zaken` kanaal that let OpenZaak publish
|
||||
# notifications. data.yaml is streamed into this external volume by
|
||||
# infra/seed-config.sh (same pattern as oz-init). See data.yaml + ADR-0006.
|
||||
command: /setup_configuration.sh
|
||||
volumes:
|
||||
- ./setup_configuration:/app/setup_configuration:ro,z
|
||||
- nrc-config:/app/setup_configuration:ro
|
||||
depends_on:
|
||||
nrc-db:
|
||||
condition: service_healthy
|
||||
@@ -60,7 +72,7 @@ services:
|
||||
networks: [cg]
|
||||
|
||||
nrc-web:
|
||||
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-latest}
|
||||
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
|
||||
environment: *nrc-env
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"]
|
||||
@@ -76,7 +88,7 @@ services:
|
||||
networks: [cg]
|
||||
|
||||
nrc-celery:
|
||||
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-latest}
|
||||
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
|
||||
environment: *nrc-env
|
||||
command: /celery_worker.sh
|
||||
depends_on:
|
||||
@@ -84,8 +96,25 @@ services:
|
||||
condition: service_completed_successfully
|
||||
networks: [cg]
|
||||
|
||||
# Celery beat: periodically fires `execute_notifications`, which drains the
|
||||
# ScheduledNotification rows the API creates on publish and hands them to the
|
||||
# worker for delivery. Without beat, notifications are accepted but never
|
||||
# delivered to subscribers — so it is required, not optional. See ADR-0007.
|
||||
nrc-beat:
|
||||
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
|
||||
environment: *nrc-env
|
||||
command: /celery_beat.sh
|
||||
depends_on:
|
||||
nrc-init:
|
||||
condition: service_completed_successfully
|
||||
networks: [cg]
|
||||
|
||||
volumes:
|
||||
nrc-db:
|
||||
# populated out-of-band by infra/seed-config.sh (docker cp) — see that script.
|
||||
nrc-config:
|
||||
external: true
|
||||
name: rr-nrc-config
|
||||
|
||||
networks:
|
||||
cg:
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
# Open Notificaties setup_configuration.
|
||||
# Stage 1 (this commit): intentionally minimal — the init container runs
|
||||
# migrations; no steps enabled yet. The OpenZaak<->NRC notification wiring
|
||||
# (Services, Authorization, JWT, Kanalen) is added next. See ADR-0002 / S-01-c.
|
||||
{}
|
||||
# Open Notificaties (NRC) setup_configuration (S-01-c, #56).
|
||||
# Wires NRC so OpenZaak can publish notifications:
|
||||
# - the JWT credential OpenZaak authenticates with,
|
||||
# - delegation of authorization checks to OpenZaak's Autorisaties API (AC),
|
||||
# - the `zaken` kanaal OpenZaak publishes zaak events on.
|
||||
# Dev-only credentials — not for production. Steps from nrc.setup_configuration.
|
||||
|
||||
# 1. JWT credential NRC uses to verify the token OpenZaak presents.
|
||||
vng_api_common_credentials_config_enable: true
|
||||
vng_api_common_credentials:
|
||||
items:
|
||||
- identifier: big-reference-seed
|
||||
secret: insecure-dev-secret-change-me
|
||||
|
||||
# 2. The Autorisaties API (OpenZaak's AC) NRC consults to authorize publishers.
|
||||
zgw_consumers_config_enable: true
|
||||
zgw_consumers:
|
||||
services:
|
||||
- identifier: openzaak-ac
|
||||
label: OpenZaak Autorisaties API
|
||||
api_type: ac
|
||||
api_root: http://openzaak:8000/autorisaties/api/v1/
|
||||
auth_type: zgw
|
||||
client_id: big-reference-seed
|
||||
secret: insecure-dev-secret-change-me
|
||||
|
||||
# 3. Delegate authorization to that AC.
|
||||
autorisaties_api_config_enable: true
|
||||
autorisaties_api:
|
||||
authorizations_api_service_identifier: openzaak-ac
|
||||
|
||||
# 4. The kanaal OpenZaak publishes zaak events on.
|
||||
notifications_kanalen_config_enable: true
|
||||
notifications_kanalen_config:
|
||||
items:
|
||||
- naam: zaken
|
||||
documentatie_link: https://github.com/VNG-Realisatie/gemma-zaken
|
||||
filters:
|
||||
- bronorganisatie
|
||||
- zaaktype
|
||||
- vertrouwelijkheidaanduiding
|
||||
|
||||
@@ -18,10 +18,13 @@ services:
|
||||
volumes:
|
||||
- oz-db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U openzaak -d openzaak"]
|
||||
# pg_isready only checks TCP; the second clause verifies PostGIS is installed
|
||||
# so oz-init migrations can safely start (avoids race on cold container start).
|
||||
test: ["CMD-SHELL", "pg_isready -U openzaak -d openzaak && psql -U openzaak -d openzaak -c 'SELECT PostGIS_Version();' -q 2>/dev/null"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
start_period: 15s
|
||||
networks: [cg]
|
||||
|
||||
oz-redis:
|
||||
@@ -29,7 +32,7 @@ services:
|
||||
networks: [cg]
|
||||
|
||||
oz-init:
|
||||
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-latest}
|
||||
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
|
||||
environment: &oz-env
|
||||
DJANGO_SETTINGS_MODULE: openzaak.conf.docker
|
||||
SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production}
|
||||
@@ -44,18 +47,20 @@ services:
|
||||
CELERY_BROKER_URL: redis://oz-redis:6379/1
|
||||
CELERY_RESULT_BACKEND: redis://oz-redis:6379/1
|
||||
DISABLE_2FA: "true"
|
||||
# Notifications go to Open Notificaties (NRC), which arrives in S-01-c.
|
||||
# Until then, disable outbound notifications so writes don't 500.
|
||||
NOTIFICATIONS_DISABLED: "true"
|
||||
# Notifications are OFF by default so OpenZaak-only bring-ups (openzaak-up,
|
||||
# the ACL integration test) don't 500 trying to reach an absent NRC. When
|
||||
# OpenZaak runs together with the NRC stack, set OZ_NOTIFICATIONS_DISABLED=false
|
||||
# (make stack-up does) to publish; the NRC service + notifications_config that
|
||||
# name it are provisioned by setup_configuration (data.yaml, S-01-c).
|
||||
NOTIFICATIONS_DISABLED: "${OZ_NOTIFICATIONS_DISABLED:-true}"
|
||||
OPENZAAK_SUPERUSER_USERNAME: admin
|
||||
DJANGO_SUPERUSER_PASSWORD: admin
|
||||
OPENZAAK_SUPERUSER_EMAIL: admin@localhost
|
||||
RUN_SETUP_CONFIG: "true"
|
||||
command: /setup_configuration.sh
|
||||
# data.yaml is streamed into this external volume by infra/seed-config.sh.
|
||||
volumes:
|
||||
# :z relabels for SELinux; the dir/file must be world-readable for the
|
||||
# container user (rootless Podman uid mapping). See docs/runbooks/openzaak.md.
|
||||
- ./setup_configuration:/app/setup_configuration:ro,z
|
||||
- oz-config:/app/setup_configuration:ro
|
||||
depends_on:
|
||||
oz-db:
|
||||
condition: service_healthy
|
||||
@@ -64,7 +69,7 @@ services:
|
||||
networks: [cg]
|
||||
|
||||
openzaak:
|
||||
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-latest}
|
||||
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
|
||||
environment: *oz-env
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"]
|
||||
@@ -80,7 +85,7 @@ services:
|
||||
networks: [cg]
|
||||
|
||||
oz-celery:
|
||||
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-latest}
|
||||
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
|
||||
environment: *oz-env
|
||||
command: /celery_worker.sh
|
||||
depends_on:
|
||||
@@ -90,6 +95,10 @@ services:
|
||||
|
||||
volumes:
|
||||
oz-db:
|
||||
# populated out-of-band by infra/seed-config.sh (docker cp) — see that script.
|
||||
oz-config:
|
||||
external: true
|
||||
name: rr-oz-config
|
||||
|
||||
networks:
|
||||
cg:
|
||||
|
||||
@@ -18,6 +18,16 @@ SECRET = os.environ.get("OZ_SECRET", "insecure-dev-secret-change-me")
|
||||
ZTC = f"{BASE}/catalogi/api/v1"
|
||||
RSIN = "517439943" # elfproef-valid test RSIN
|
||||
|
||||
# Opt-in: also publish the zaaktype so OpenZaak's Zaken API accepts a zaak against
|
||||
# it (a concept zaaktype is rejected with `not-published`). Off by default — the
|
||||
# S-01 compose seed keeps it a concept (ADR-0002). The ACL integration test
|
||||
# (S-04a, #46) sets OZ_PUBLISH=1. Publishing requires ≥2 statustypen, ≥1 roltype
|
||||
# and ≥1 resultaattype; the resultaattype is validated against the external
|
||||
# Selectielijst reference API, so this path needs outbound access to it. See ADR-0006.
|
||||
PUBLISH = os.environ.get("OZ_PUBLISH", "").lower() in ("1", "true", "yes")
|
||||
SELECTIELIJST = os.environ.get(
|
||||
"OZ_SELECTIELIJST", "https://selectielijst.openzaak.nl/api/v1").rstrip("/")
|
||||
|
||||
|
||||
def token():
|
||||
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=")
|
||||
@@ -52,6 +62,68 @@ def find(path):
|
||||
return body.get("results", [])
|
||||
|
||||
|
||||
def selectielijst(path):
|
||||
"""GET the external Selectielijst reference API (no auth). Used only when publishing."""
|
||||
req = urllib.request.Request(f"{SELECTIELIJST}{path}", headers={"Accept": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def publish_zaaktype(zt):
|
||||
"""Add the relations OpenZaak requires to publish, then publish (idempotent).
|
||||
|
||||
Publish validation (verified against OpenZaak 1.28.2) demands: ≥2 statustypen
|
||||
(begin + eind), ≥1 roltype, ≥1 resultaattype. A resultaattype needs a
|
||||
Selectielijst `selectielijstklasse` whose procestype matches the zaaktype's
|
||||
`selectielijstProcestype`, plus a `resultaattypeomschrijving`.
|
||||
"""
|
||||
have_st = {s.get("volgnummer") for s in find(f"/statustypen?zaaktype={zt['url']}&status=alles")}
|
||||
for volgnummer, omschrijving in [(1, "Ontvangen"), (2, "Afgehandeld")]:
|
||||
if volgnummer not in have_st:
|
||||
st, body = api("POST", "/statustypen", {
|
||||
"omschrijving": omschrijving, "zaaktype": zt["url"], "volgnummer": volgnummer})
|
||||
if st != 201:
|
||||
sys.exit(f"create statustype {volgnummer} -> {st}: {json.dumps(body, indent=2)}")
|
||||
print(f"create statustype {volgnummer} ({omschrijving})")
|
||||
|
||||
if find(f"/roltypen?zaaktype={zt['url']}&status=alles"):
|
||||
print("skip roltype Aanvrager")
|
||||
else:
|
||||
st, body = api("POST", "/roltypen", {
|
||||
"zaaktype": zt["url"], "omschrijving": "Aanvrager", "omschrijvingGeneriek": "initiator"})
|
||||
if st != 201:
|
||||
sys.exit(f"create roltype -> {st}: {json.dumps(body, indent=2)}")
|
||||
print("create roltype Aanvrager")
|
||||
|
||||
if find(f"/resultaattypen?zaaktype={zt['url']}&status=alles"):
|
||||
print("skip resultaattype Geregistreerd")
|
||||
else:
|
||||
resultaat = selectielijst("/resultaten?pageSize=1")["results"][0]
|
||||
omschrijvingen = selectielijst("/resultaattypeomschrijvingen")
|
||||
oms = (omschrijvingen if isinstance(omschrijvingen, list) else omschrijvingen["results"])[0]["url"]
|
||||
# The selectielijstklasse and the zaaktype must share a procestype.
|
||||
st, body = api("PATCH", zt["url"], {"selectielijstProcestype": resultaat["procesType"]})
|
||||
if st != 200:
|
||||
sys.exit(f"set procestype -> {st}: {json.dumps(body, indent=2)}")
|
||||
st, body = api("POST", "/resultaattypen", {
|
||||
"zaaktype": zt["url"], "omschrijving": "Geregistreerd",
|
||||
"resultaattypeomschrijving": oms, "selectielijstklasse": resultaat["url"],
|
||||
"archiefnominatie": "blijvend_bewaren",
|
||||
"brondatumArchiefprocedure": {"afleidingswijze": "afgehandeld"},
|
||||
})
|
||||
if st != 201:
|
||||
sys.exit(f"create resultaattype -> {st}: {json.dumps(body, indent=2)}")
|
||||
print("create resultaattype Geregistreerd")
|
||||
|
||||
if zt.get("concept", True):
|
||||
st, body = api("POST", f"{zt['url']}/publish")
|
||||
if st != 200:
|
||||
sys.exit(f"publish zaaktype -> {st}: {json.dumps(body, indent=2)}")
|
||||
print(f"publish zaaktype BIG-REGISTRATIE ({zt['url']})")
|
||||
else:
|
||||
print("skip publish (already published)")
|
||||
|
||||
|
||||
def main():
|
||||
# 1. Catalogus
|
||||
existing = [c for c in find(f"/catalogussen?domein=BIG") if c.get("domein") == "BIG"]
|
||||
@@ -121,16 +193,28 @@ def main():
|
||||
else:
|
||||
print("warn zaaktype already published; cannot add bsn eigenschap")
|
||||
|
||||
# Intentionally NOT published. Publishing requires roltypen, resultaattypen
|
||||
# and statustypen, which go beyond the "lean / schema-mandatory" zaaktype this
|
||||
# slice asks for; they arrive with the workflow/zaak slices. See ADR-0002.
|
||||
# 4. Optionally publish. By default the zaaktype stays a concept: publishing
|
||||
# requires roltypen, resultaattypen and statustypen, beyond the "lean /
|
||||
# schema-mandatory" zaaktype S-01 asks for (ADR-0002). Set OZ_PUBLISH=1 to add
|
||||
# those relations and publish — needed so a real zaak POST is accepted, which
|
||||
# the ACL integration test (S-04a, #46) exercises. See ADR-0006.
|
||||
if PUBLISH:
|
||||
# Re-fetch: the bsn-eigenschap branch above may hold a stale concept flag.
|
||||
zt = next(z for z in find(f"/zaaktypen?catalogus={cat['url']}&status=alles")
|
||||
if z.get("identificatie") == "BIG-REGISTRATIE")
|
||||
publish_zaaktype(zt)
|
||||
|
||||
# 4. Verify the JWT client can list the zaaktype (concepts included).
|
||||
# 5. Verify the JWT client can list the zaaktype (concepts included).
|
||||
zaaktypen = find(f"/zaaktypen?catalogus={cat['url']}&status=alles")
|
||||
names = [z.get("identificatie") for z in zaaktypen]
|
||||
print(f"zaaktypen in BIG: {names}")
|
||||
assert "BIG-REGISTRATIE" in names, "BIG-REGISTRATIE not listed"
|
||||
print("OK — BIG catalogus seeded (BIG-REGISTRATIE concept + bsn eigenschap)")
|
||||
state = "published" if PUBLISH else "concept"
|
||||
# Machine-readable line so callers (e.g. infra/run-domain-check.sh) can capture the
|
||||
# zaaktype URL to configure the ACL's default-fill (ADR-0003/0009).
|
||||
zt_url = next(z["url"] for z in zaaktypen if z.get("identificatie") == "BIG-REGISTRATIE")
|
||||
print(f"ZAAKTYPE_URL {zt_url}")
|
||||
print(f"OK — BIG catalogus seeded (BIG-REGISTRATIE {state} + bsn eigenschap)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -20,3 +20,22 @@ vng_api_common_applicaties:
|
||||
- big-reference-seed
|
||||
label: BIG reference seed client
|
||||
heeft_alle_autorisaties: true
|
||||
|
||||
# ── OpenZaak → Open Notificaties (NRC) publishing (S-01-c, #56) ─────────────
|
||||
# The NRC service OpenZaak posts notifications to, authenticating with the same
|
||||
# big-reference-seed client (NRC verifies the JWT and authorizes it via the AC).
|
||||
zgw_consumers_config_enable: true
|
||||
zgw_consumers:
|
||||
services:
|
||||
- identifier: nrc
|
||||
label: Open Notificaties
|
||||
api_type: nrc
|
||||
api_root: http://nrc-web:8000/api/v1/
|
||||
auth_type: zgw
|
||||
client_id: big-reference-seed
|
||||
secret: insecure-dev-secret-change-me
|
||||
|
||||
# Point OpenZaak's notifications at that service. Requires NOTIFICATIONS_DISABLED=false.
|
||||
notifications_config_enable: true
|
||||
notifications_config:
|
||||
notifications_api_service_identifier: nrc
|
||||
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Run the ACL integration tests (Category=Integration) against the OpenZaak that is
|
||||
# ALREADY running — works for any stack: oz-only (`make integration`), the standalone
|
||||
# oz+nrc stack, or the full compose stack (the CI `verify-stack` job). Seeds a
|
||||
# published BIG zaaktype (idempotent), then builds + runs the test image on the stack
|
||||
# network, reaching OpenZaak by container IP (a single-label host isn't URL-valid;
|
||||
# the runner can't reach published ports — see gitea-actions-gotchas.md §5/§6).
|
||||
#
|
||||
# Does NOT manage the stack lifecycle: the caller owns bring-up + teardown. Plain
|
||||
# docker primitives only (docker/podman-portable). See ADR-0006.
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
root="$(cd "$here/.." && pwd)"
|
||||
|
||||
# The OpenZaak API container, matched across compose projects + docker/podman naming
|
||||
# (`<project>[-_]openzaak[-_]<n>`); the delimiters exclude oz-db / oz-redis / oz-init.
|
||||
oz="$(docker ps -q --filter 'name=[-_]openzaak[-_]' | head -1)"
|
||||
[ -n "$oz" ] || { echo "ERROR: no running OpenZaak container found — bring the stack up first" >&2; exit 1; }
|
||||
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$oz" | head -1)"
|
||||
oz_ip="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$oz")"
|
||||
oz_base="http://$oz_ip:8000"
|
||||
echo ">> OpenZaak at $oz_base on network $net"
|
||||
|
||||
echo ">> seeding a published BIG zaaktype (idempotent)"
|
||||
sid="$(docker create --network "$net" -e "OZ_BASE=$oz_base" -e OZ_PUBLISH=1 \
|
||||
python:3-slim python /seed.py)"
|
||||
docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null
|
||||
docker start -a "$sid"
|
||||
docker rm -f "$sid" >/dev/null
|
||||
|
||||
echo ">> building the integration test image"
|
||||
docker build -f "$root/services/acl/Dockerfile.integration" -t rr-acl-integration "$root/services/acl"
|
||||
|
||||
echo ">> running the ACL integration tests (inside the network)"
|
||||
docker run --rm --network "$net" -e "OZ_BASE=$oz_base" rr-acl-integration
|
||||
Executable
+58
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Verify the BFF end-to-end (S-07) against an ALREADY-RUNNING full stack. The BFF is the portals'
|
||||
# only backend (§8.3): it validates Keycloak digid tokens on the self-service submit and serves the
|
||||
# openbaar register anonymously with only public-safe fields (ADR-0010).
|
||||
#
|
||||
# Checks, in-network (services reached by container IP; Keycloak by its service name so the token's
|
||||
# host-derived issuer matches the BFF's authority — see the compose bff env and ADR-0010):
|
||||
# 1. POST /self-service/registrations without a token -> 401
|
||||
# 2. mint a real digid access token (direct grant) and POST it -> 202 (forwarded to the domain)
|
||||
# 3. GET /openbaar/register (anonymous) -> 200 JSON array, never a bsn
|
||||
#
|
||||
# Does NOT manage the stack lifecycle (the caller owns bring-up + teardown). Plain docker primitives.
|
||||
set -euo pipefail
|
||||
|
||||
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
|
||||
|
||||
bff="$(docker ps -q --filter 'name=[-_]bff[-_]' | head -1)"
|
||||
kc="$(docker ps -q --filter 'name=keycloak' | head -1)"
|
||||
[ -n "$bff" ] || { echo "ERROR: no running bff container — bring the stack up first" >&2; exit 1; }
|
||||
[ -n "$kc" ] || { echo "ERROR: no running keycloak container — bring the stack up first" >&2; exit 1; }
|
||||
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$bff" | head -1)"
|
||||
bff_ip="$(ip "$bff")"
|
||||
echo ">> bff=$bff_ip network=$net"
|
||||
|
||||
# Helper: run curl inside a throwaway container on the stack network (reaches services by name/IP).
|
||||
net_curl() { docker run --rm --network "$net" curlimages/curl:latest "$@"; }
|
||||
|
||||
echo ">> 1. self-service submit without a token must be 401"
|
||||
code="$(net_curl -s -o /dev/null -w '%{http_code}' -X POST "http://$bff_ip:8080/self-service/registrations" \
|
||||
-H 'Content-Type: application/json' -d '{}')"
|
||||
echo " -> $code"; [ "$code" = "401" ] || { echo "FAIL: expected 401, got $code" >&2; exit 1; }
|
||||
|
||||
echo ">> 2. minting a digid token (direct grant) via keycloak:8080 (host-consistent issuer)"
|
||||
token=""
|
||||
for _ in $(seq 1 20); do
|
||||
token="$(net_curl -s -X POST "http://keycloak:8080/realms/digid/protocol/openid-connect/token" \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data-urlencode 'grant_type=password' --data-urlencode 'client_id=big-portal' \
|
||||
--data-urlencode 'username=jan-burger' --data-urlencode 'password=test123' \
|
||||
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p')"
|
||||
[ -n "$token" ] && break
|
||||
sleep 3
|
||||
done
|
||||
[ -n "$token" ] || { echo "FAIL: could not obtain a digid access token" >&2; exit 1; }
|
||||
echo " -> got a token"
|
||||
|
||||
echo ">> 2b. self-service submit with the token must be 202"
|
||||
code="$(net_curl -s -o /dev/null -w '%{http_code}' -X POST "http://$bff_ip:8080/self-service/registrations" \
|
||||
-H "Authorization: Bearer $token" -H 'Content-Type: application/json' -d '{}')"
|
||||
echo " -> $code"; [ "$code" = "202" ] || { echo "FAIL: expected 202, got $code" >&2; docker logs "$bff" 2>&1 | tail -15 >&2; exit 1; }
|
||||
|
||||
echo ">> 3. openbaar register (anonymous) must be 200 JSON, never a bsn"
|
||||
body="$(net_curl -s "http://$bff_ip:8080/openbaar/register")"
|
||||
echo "$body" | grep -q '^\[' || { echo "FAIL: openbaar did not return a JSON array: $body" >&2; exit 1; }
|
||||
if echo "$body" | grep -q '"bsn"'; then echo "FAIL: openbaar leaked a bsn field" >&2; exit 1; fi
|
||||
|
||||
echo "OK — BFF: 401 without token, 202 with a digid token, anonymous public-safe openbaar register"
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Verify the BIG Domain Service end-to-end (S-05) against an ALREADY-RUNNING full stack:
|
||||
# domain → Flowable (start the registratie process + external-task worker) → ACL → OpenZaak.
|
||||
# Submits a registration to the domain and asserts the worker opens a zaak in OpenZaak and
|
||||
# records its URL on the aggregate (ADR-0009).
|
||||
#
|
||||
# The seeded zaaktype URL is server-assigned, so it isn't knowable at initial bring-up. This
|
||||
# script therefore seeds a published BIG zaaktype and recreates the `acl` service configured to
|
||||
# default-fill it — pointing the ACL at the SAME OpenZaak host that owns the URL, so zaak creation
|
||||
# is host-consistent (exactly the configuration the ACL integration test proves, ADR-0006). That
|
||||
# one recreate aside, the caller owns stack bring-up + teardown.
|
||||
#
|
||||
# All in-network, reaching services by container IP (a single-label host isn't URL-valid; the
|
||||
# runner can't reach published ports — gitea-actions-gotchas.md §5/§6). Plain docker primitives.
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
root="$(cd "$here/.." && pwd)"
|
||||
compose="$root/infra/docker-compose.yml"
|
||||
|
||||
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
|
||||
|
||||
oz="$(docker ps -q --filter 'name=[-_]openzaak[-_]' | head -1)"
|
||||
dom="$(docker ps -q --filter 'name=domain' | head -1)"
|
||||
[ -n "$oz" ] || { echo "ERROR: no running OpenZaak container — bring the stack up first" >&2; exit 1; }
|
||||
[ -n "$dom" ] || { echo "ERROR: no running domain container — bring the stack up first" >&2; exit 1; }
|
||||
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$oz" | head -1)"
|
||||
oz_ip="$(ip "$oz")"; dom_ip="$(ip "$dom")"
|
||||
oz_base="http://$oz_ip:8000"
|
||||
echo ">> openzaak=$oz_ip domain=$dom_ip network=$net"
|
||||
|
||||
echo ">> seeding a published BIG zaaktype (idempotent) and capturing its URL"
|
||||
sid="$(docker create --network "$net" -e "OZ_BASE=$oz_base" -e OZ_PUBLISH=1 python:3-slim python /seed.py)"
|
||||
docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null
|
||||
zt_url="$(docker start -a "$sid" | sed -n 's/^ZAAKTYPE_URL //p' | head -1)"
|
||||
docker rm -f "$sid" >/dev/null
|
||||
[ -n "$zt_url" ] || { echo "ERROR: seed did not report a ZAAKTYPE_URL" >&2; exit 1; }
|
||||
echo ">> zaaktype: $zt_url"
|
||||
|
||||
echo ">> recreating the acl service pointed at the seeded zaaktype (host-consistent)"
|
||||
ACL_ZAAKTYPE_URL="$zt_url" ACL_OPENZAAK_BASEURL="$oz_base/" docker compose -f "$compose" up -d acl
|
||||
WAIT_TIMEOUT="${WAIT_TIMEOUT:-120}" bash "$here/wait-healthy.sh" acl
|
||||
|
||||
echo ">> submitting a registration to the domain"
|
||||
loc="$(docker run --rm --network "$net" curlimages/curl:latest \
|
||||
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
|
||||
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' \
|
||||
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
|
||||
[ -n "$loc" ] || { echo "ERROR: POST /registrations returned no Location" >&2; exit 1; }
|
||||
echo ">> registration accepted at $loc"
|
||||
|
||||
echo ">> polling the domain until the worker records the opened zaak"
|
||||
for _ in $(seq 1 30); do
|
||||
body="$(docker run --rm --network "$net" curlimages/curl:latest \
|
||||
-fsS "http://$dom_ip:8080$loc" 2>/dev/null || true)"
|
||||
if echo "$body" | grep -q '/zaken/api/v1/zaken/'; then
|
||||
echo "OK — the domain opened a zaak and recorded it on the registration:"
|
||||
echo "$body" | cut -c1-300
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "FAIL — the registration never received a zaak URL" >&2
|
||||
echo "--- domain log ---" >&2; docker logs "$dom" 2>&1 | tail -15 >&2
|
||||
acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)"
|
||||
[ -n "$acl" ] && { echo "--- acl log ---" >&2; docker logs "$acl" 2>&1 | tail -15 >&2; }
|
||||
exit 1
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Local convenience: run the ACL integration test against a throwaway OpenZaak-only
|
||||
# stack (fast iteration on the ACL gateway). Brings OpenZaak up, runs the shared
|
||||
# stack-agnostic check (infra/run-acl-integration.sh), then always tears down.
|
||||
#
|
||||
# CI does not use this — there the full stack is brought up once and the same runner
|
||||
# is invoked as a step (see the `verify-stack` job / Makefile `verify-*`). See ADR-0006.
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
OZ_COMPOSE="$here/openzaak/docker-compose.yml"
|
||||
|
||||
cleanup() {
|
||||
docker compose -f "$OZ_COMPOSE" down --volumes >/dev/null 2>&1 || true
|
||||
docker volume rm -f rr-oz-config >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo ">> bringing OpenZaak up"
|
||||
bash "$here/seed-config.sh" oz
|
||||
docker compose -f "$OZ_COMPOSE" up -d
|
||||
|
||||
echo ">> waiting for the OpenZaak API container to be healthy"
|
||||
for _ in $(seq 1 140); do
|
||||
oz="$(docker ps -q --filter 'name=[-_]openzaak[-_]' | head -1)"
|
||||
if [ -n "$oz" ] && [ "$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$oz" 2>/dev/null || true)" = healthy ]; then
|
||||
break
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
[ -n "${oz:-}" ] || { echo "ERROR: OpenZaak never came up" >&2; exit 1; }
|
||||
|
||||
bash "$here/run-acl-integration.sh"
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Verify the OpenZaak → NRC notification path against an ALREADY-RUNNING oz+nrc stack
|
||||
# (the standalone stack via `make verify-notifications`, or the full compose stack in
|
||||
# the CI `verify-stack` job). Seeds a published BIG zaaktype (idempotent), registers
|
||||
# an abonnement to a webhook sink, creates a zaak, and asserts the sink receives the
|
||||
# `zaken`/`create` notification. All in-network, reaching services by container IP
|
||||
# (single-label hosts aren't URL-valid; the runner can't reach published ports).
|
||||
#
|
||||
# Does NOT manage the stack lifecycle (the caller owns bring-up + teardown), but it
|
||||
# cleans up the throwaway sink/driver it creates. Plain docker primitives only.
|
||||
# See ADR-0007.
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SINK_AUTH="Bearer notification-sink-token"
|
||||
|
||||
cleanup() { docker rm -f rr-nsink rr-nverify >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
|
||||
|
||||
oz="$(docker ps -q --filter 'name=[-_]openzaak[-_]' | head -1)"
|
||||
nrc="$(docker ps -q --filter 'name=nrc-web' | head -1)"
|
||||
[ -n "$oz" ] && [ -n "$nrc" ] || { echo "ERROR: OpenZaak and/or NRC not running — bring the stack up first" >&2; exit 1; }
|
||||
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$oz" | head -1)"
|
||||
oz_ip="$(ip "$oz")"; nrc_ip="$(ip "$nrc")"
|
||||
echo ">> network=$net openzaak=$oz_ip nrc=$nrc_ip"
|
||||
|
||||
echo ">> seeding a published BIG zaaktype (idempotent)"
|
||||
sid="$(docker create --network "$net" -e "OZ_BASE=http://$oz_ip:8000" -e OZ_PUBLISH=1 \
|
||||
python:3-slim python /seed.py)"
|
||||
docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null
|
||||
docker start -a "$sid"
|
||||
docker rm -f "$sid" >/dev/null
|
||||
|
||||
echo ">> starting the webhook sink"
|
||||
docker rm -f rr-nsink >/dev/null 2>&1 || true
|
||||
sink="$(docker create --network "$net" --name rr-nsink -e "EXPECTED_AUTH=$SINK_AUTH" \
|
||||
python:3-slim python /sink.py)"
|
||||
docker cp "$here/notification-sink.py" "$sink:/sink.py" >/dev/null
|
||||
docker start "$sink" >/dev/null
|
||||
sleep 1
|
||||
sink_ip="$(ip rr-nsink)"
|
||||
echo ">> sink at $sink_ip:9000"
|
||||
|
||||
echo ">> registering abonnement + creating a zaak"
|
||||
docker rm -f rr-nverify >/dev/null 2>&1 || true
|
||||
drv="$(docker create --network "$net" --name rr-nverify \
|
||||
-e "OZ_BASE=http://$oz_ip:8000" -e "NRC_BASE=http://$nrc_ip:8000" \
|
||||
-e "SINK_CALLBACK=http://$sink_ip:9000/" -e "SINK_AUTH=$SINK_AUTH" \
|
||||
python:3-slim python /driver.py)"
|
||||
docker cp "$here/verify-notification-driver.py" "$drv:/driver.py" >/dev/null
|
||||
docker start -a "$drv"
|
||||
zaak_url="$(docker logs rr-nverify 2>/dev/null | sed -n 's/^ZAAK_CREATED //p' | head -1)"
|
||||
docker rm -f rr-nverify >/dev/null
|
||||
[ -n "$zaak_url" ] || { echo "ERROR: driver did not create a zaak" >&2; exit 1; }
|
||||
zaak_uuid="${zaak_url##*/}"
|
||||
echo ">> zaak created: $zaak_url"
|
||||
|
||||
echo ">> waiting for the notification to reach the sink"
|
||||
for _ in $(seq 1 30); do
|
||||
if docker logs rr-nsink 2>&1 | grep -q "$zaak_uuid"; then
|
||||
echo "OK — NRC delivered the zaken notification for zaak $zaak_uuid to the sink"
|
||||
docker logs rr-nsink 2>&1 | grep "$zaak_uuid" | tail -1 | cut -c1-300
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "FAIL — the sink never received a notification for zaak $zaak_uuid" >&2
|
||||
echo "--- sink log ---" >&2; docker logs rr-nsink 2>&1 | tail -8 >&2
|
||||
exit 1
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Verify the end-to-end read-projection path (S-06) against an ALREADY-RUNNING full stack:
|
||||
# OpenZaak → NRC → Event Subscriber → projection → projection-api. Seeds a published BIG
|
||||
# zaaktype (idempotent), registers an abonnement on the `zaken` kanaal pointing at the real
|
||||
# Event Subscriber's /notifications callback (with the bearer it enforces), creates a zaak,
|
||||
# and asserts projection-api serves a row for that zaak with status INGEDIEND.
|
||||
#
|
||||
# All in-network, reaching services by container IP — single-label hosts aren't URL-valid and
|
||||
# the runner can't reach published ports (gitea-actions-gotchas.md §5/§6). Reuses the
|
||||
# notification driver to register the abonnement + create the zaak. Does NOT manage the stack
|
||||
# lifecycle (the caller owns bring-up + teardown). Plain docker primitives only. See ADR-0007/0008.
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WEBHOOK_AUTH="${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}"
|
||||
|
||||
cleanup() { docker rm -f rr-pverify rr-pquery >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
|
||||
|
||||
oz="$(docker ps -q --filter 'name=[-_]openzaak[-_]' | head -1)"
|
||||
nrc="$(docker ps -q --filter 'name=nrc-web' | head -1)"
|
||||
es="$(docker ps -q --filter 'name=event-subscriber' | head -1)"
|
||||
proj="$(docker ps -q --filter 'name=projection-api' | head -1)"
|
||||
[ -n "$oz" ] && [ -n "$nrc" ] || { echo "ERROR: OpenZaak and/or NRC not running — bring the stack up first" >&2; exit 1; }
|
||||
[ -n "$es" ] && [ -n "$proj" ] || { echo "ERROR: event-subscriber and/or projection-api not running — bring the stack up first" >&2; exit 1; }
|
||||
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$oz" | head -1)"
|
||||
oz_ip="$(ip "$oz")"; nrc_ip="$(ip "$nrc")"; es_ip="$(ip "$es")"; proj_ip="$(ip "$proj")"
|
||||
echo ">> network=$net openzaak=$oz_ip nrc=$nrc_ip event-subscriber=$es_ip projection-api=$proj_ip"
|
||||
|
||||
echo ">> seeding a published BIG zaaktype (idempotent)"
|
||||
sid="$(docker create --network "$net" -e "OZ_BASE=http://$oz_ip:8000" -e OZ_PUBLISH=1 \
|
||||
python:3-slim python /seed.py)"
|
||||
docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null
|
||||
docker start -a "$sid"
|
||||
docker rm -f "$sid" >/dev/null
|
||||
|
||||
echo ">> registering abonnement at the Event Subscriber + creating a zaak"
|
||||
docker rm -f rr-pverify >/dev/null 2>&1 || true
|
||||
drv="$(docker create --network "$net" --name rr-pverify \
|
||||
-e "OZ_BASE=http://$oz_ip:8000" -e "NRC_BASE=http://$nrc_ip:8000" \
|
||||
-e "SINK_CALLBACK=http://$es_ip:8080/notifications" -e "SINK_AUTH=$WEBHOOK_AUTH" \
|
||||
python:3-slim python /driver.py)"
|
||||
docker cp "$here/verify-notification-driver.py" "$drv:/driver.py" >/dev/null
|
||||
docker start -a "$drv"
|
||||
zaak_url="$(docker logs rr-pverify 2>/dev/null | sed -n 's/^ZAAK_CREATED //p' | head -1)"
|
||||
docker rm -f rr-pverify >/dev/null
|
||||
[ -n "$zaak_url" ] || { echo "ERROR: driver did not create a zaak" >&2; exit 1; }
|
||||
zaak_uuid="${zaak_url##*/}"
|
||||
echo ">> zaak created: $zaak_url"
|
||||
|
||||
echo ">> polling projection-api for the projected row (status INGEDIEND)"
|
||||
for _ in $(seq 1 30); do
|
||||
body="$(docker run --rm --network "$net" curlimages/curl:latest \
|
||||
-fsS "http://$proj_ip:8080/register/$zaak_uuid" 2>/dev/null || true)"
|
||||
if echo "$body" | grep -q '"INGEDIEND"'; then
|
||||
echo "OK — projection-api serves zaak $zaak_uuid with status INGEDIEND"
|
||||
echo "$body" | cut -c1-300
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "FAIL — projection-api never served an INGEDIEND row for zaak $zaak_uuid" >&2
|
||||
echo "--- event-subscriber log ---" >&2; docker logs "$es" 2>&1 | tail -10 >&2
|
||||
echo "--- projection-api log ---" >&2; docker logs "$proj" 2>&1 | tail -10 >&2
|
||||
exit 1
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Populate the external named *config* volumes that the upstream services mount,
|
||||
# by `docker cp`-ing files into a throwaway helper container that mounts each one.
|
||||
#
|
||||
# Why: the compose stack uses the upstream images verbatim (no build). On Gitea's
|
||||
# containerized runner, `docker compose` starts the stack as SIBLING containers
|
||||
# via the host daemon, so a workspace bind mount resolves to a path the daemon
|
||||
# can't see and is mounted empty. `docker cp` instead streams bytes over the
|
||||
# Docker API, so the files reach the volume regardless of where the daemon runs.
|
||||
# We use plain docker primitives (volume create / run / cp / rm) rather than
|
||||
# `docker compose create`, because podman-compose (local dev) lacks that
|
||||
# subcommand. Fixed-name `external` volumes keep the names deterministic across
|
||||
# both runtimes. See docs/runbooks/gitea-actions-gotchas.md.
|
||||
#
|
||||
# Usage: seed-config.sh <key> [<key> ...] where key ∈ { oz, kc, fl }
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
HELPER="${SEED_HELPER_IMAGE:-docker.io/library/busybox:stable}"
|
||||
|
||||
populate() { # volume source(file or dir/.)
|
||||
local vol="$1" src="$2" cid
|
||||
docker volume rm -f "$vol" >/dev/null 2>&1 || true
|
||||
docker volume create "$vol" >/dev/null
|
||||
# A *created* (never started) helper is enough: the volume is attached at create
|
||||
# time, `docker cp` writes through to it, and `docker rm` is instant (nothing to
|
||||
# stop). `docker create` is a container subcommand both docker and podman have —
|
||||
# unlike `docker compose create`, which podman-compose lacks.
|
||||
cid="$(docker create -v "$vol:/dest" "$HELPER" true)"
|
||||
docker cp "$src" "$cid:/dest/"
|
||||
docker rm "$cid" >/dev/null
|
||||
echo " seeded $vol"
|
||||
}
|
||||
|
||||
[ "$#" -gt 0 ] || { echo "usage: seed-config.sh <oz|nrc|kc|fl> ..." >&2; exit 2; }
|
||||
|
||||
for key in "$@"; do
|
||||
case "$key" in
|
||||
oz) populate rr-oz-config "$here/openzaak/setup_configuration/." ;;
|
||||
nrc) populate rr-nrc-config "$here/opennotificaties/setup_configuration/." ;;
|
||||
kc) populate rr-kc-realms "$here/keycloak/realms/." ;;
|
||||
fl) populate rr-fl-bpmn "$here/../workflows/registratie.bpmn" ;;
|
||||
*) echo "unknown seed key: $key" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
Executable
+90
@@ -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()
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Local convenience: verify the OpenZaak → NRC notification path against a throwaway
|
||||
# oz+nrc stack. Brings both up (notifications enabled), runs the shared stack-agnostic
|
||||
# check (infra/run-notification-check.sh), then always tears down.
|
||||
#
|
||||
# CI does not use this — there the full stack is brought up once and the same runner
|
||||
# is invoked as a step (see the `verify-stack` job / Makefile `verify-*`). See ADR-0007.
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
OZ_COMPOSE="$here/openzaak/docker-compose.yml"
|
||||
NRC_COMPOSE="$here/opennotificaties/docker-compose.yml"
|
||||
|
||||
cleanup() {
|
||||
docker compose -f "$OZ_COMPOSE" -f "$NRC_COMPOSE" down --volumes >/dev/null 2>&1 || true
|
||||
docker volume rm -f rr-oz-config rr-nrc-config >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
wait_healthy() { # name-regex
|
||||
local re="$1" cid
|
||||
for _ in $(seq 1 140); do
|
||||
cid="$(docker ps -q --filter "name=$re" | head -1)"
|
||||
if [ -n "$cid" ] && [ "$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$cid" 2>/dev/null || true)" = healthy ]; then
|
||||
return 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
echo ">> bringing up OpenZaak + Open Notificaties (notifications enabled)"
|
||||
bash "$here/seed-config.sh" oz nrc
|
||||
OZ_NOTIFICATIONS_DISABLED=false docker compose -f "$OZ_COMPOSE" -f "$NRC_COMPOSE" up -d
|
||||
|
||||
echo ">> waiting for OpenZaak + NRC to be healthy"
|
||||
wait_healthy '[-_]openzaak[-_]' || { echo "ERROR: OpenZaak not healthy" >&2; exit 1; }
|
||||
wait_healthy 'nrc-web' || { echo "ERROR: NRC not healthy" >&2; exit 1; }
|
||||
|
||||
bash "$here/run-notification-check.sh"
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Wait until the named compose services report a healthy healthcheck.
|
||||
#
|
||||
# Portable across `docker compose` (CI) and `podman-compose` (local dev): it uses
|
||||
# plain `docker ps` + `docker inspect`, so it needs neither `docker compose
|
||||
# up --wait` (podman-compose doesn't implement that flag) nor host port access
|
||||
# (the containerized CI runner can't reach published ports). It also sidesteps the
|
||||
# `--wait`-fails-when-a-one-shot-exits issue, since we only poll long-running
|
||||
# services that declare a healthcheck. See docs/runbooks/gitea-actions-gotchas.md.
|
||||
#
|
||||
# Usage: WAIT_TIMEOUT=420 wait-healthy.sh <service> [<service> ...]
|
||||
set -euo pipefail
|
||||
|
||||
timeout="${WAIT_TIMEOUT:-420}"
|
||||
deadline=$(( $(date +%s) + timeout ))
|
||||
|
||||
# compose service name -> container id. The name filter matches both docker
|
||||
# compose ("infra-openzaak-1") and podman-compose ("infra_openzaak_1") naming.
|
||||
cid_for() { docker ps -aq --filter "name=$1" | head -1; }
|
||||
|
||||
for svc in "$@"; do
|
||||
echo "waiting for '$svc' to be healthy (timeout ${timeout}s)..."
|
||||
while :; do
|
||||
cid="$(cid_for "$svc")"
|
||||
status=""
|
||||
[ -n "$cid" ] && status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$cid" 2>/dev/null || true)"
|
||||
[ "$status" = "healthy" ] && { echo " '$svc' is healthy"; break; }
|
||||
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||||
echo "TIMEOUT: '$svc' not healthy (status=${status:-no-container})" >&2
|
||||
docker ps -a --filter "name=$svc" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
done
|
||||
+8
-16
@@ -26,18 +26,14 @@ nav:
|
||||
- "ADR-0002: Catalogus design": architecture/adr-0002-catalogus-design.md
|
||||
- "ADR-0003: ACL default-fill": architecture/adr-0003-default-fill.md
|
||||
- "ADR-0004: BDD framework": architecture/adr-0004-bdd-framework.md
|
||||
- FDS-architectuur:
|
||||
- Overzicht: architecture/fds/README.md
|
||||
- Componentview (L3): architecture/fds/c4-component-view.md
|
||||
- "Slice 1: walking skeleton": architecture/fds/slice-1-proposal.md
|
||||
- "ADR-0001: ACL op elke registergrens": architecture/fds/adr/0001-acl-at-every-register-boundary.md
|
||||
- "ADR-0002: FSC voor connectiviteit": architecture/fds/adr/0002-fsc-for-connectivity.md
|
||||
- "ADR-0003: PBAC via OPA": architecture/fds/adr/0003-pbac-via-opa.md
|
||||
- "ADR-0004: Begrensde cache": architecture/fds/adr/0004-bounded-cache.md
|
||||
- "ADR-0005: Verwerkingenlog via events": architecture/fds/adr/0005-ldv-verwerkingenlog.md
|
||||
- "ADR-0006: Modulegrens en hergebruik": architecture/fds/adr/0006-module-boundary-and-reuse.md
|
||||
- "ADR-template (FDS)": architecture/fds/adr/template.md
|
||||
- "ADR-0005: Mutation testing": architecture/adr-0005-mutation-testing.md
|
||||
- "ADR-0006: ACL integration test provisioning": architecture/adr-0006-integration-test-provisioning.md
|
||||
- "ADR-0007: OpenZaak → NRC notification wiring": architecture/adr-0007-notification-wiring.md
|
||||
- "ADR-0008: Read projection store": architecture/adr-0008-read-projection-store.md
|
||||
- "ADR-0009: External-task job worker": architecture/adr-0009-external-task-job-worker.md
|
||||
- "ADR-0010: BFF OIDC validation": architecture/adr-0010-bff-oidc.md
|
||||
- Working in Gitea: gitea-workflow.md
|
||||
- Demo script: demo-script.md
|
||||
- Runbooks:
|
||||
- CI: runbooks/ci.md
|
||||
|
||||
@@ -45,11 +41,7 @@ markdown_extensions:
|
||||
- admonition
|
||||
- toc:
|
||||
permalink: true
|
||||
- pymdownx.superfences:
|
||||
custom_fences:
|
||||
- name: mermaid
|
||||
class: mermaid
|
||||
format: !!python/name:pymdownx.superfences.fence_code_format
|
||||
- pymdownx.superfences
|
||||
|
||||
# Many docs referenced by PRD.md land in later slices; don't fail the build on them.
|
||||
validation:
|
||||
|
||||
@@ -4,12 +4,29 @@
|
||||
<Project Path="services/acl/Acl.Api/Acl.Api.csproj" />
|
||||
<Project Path="services/acl/Acl.Application/Acl.Application.csproj" />
|
||||
<Project Path="services/acl/Acl.Infrastructure/Acl.Infrastructure.csproj" />
|
||||
<Project Path="services/acl/Acl.IntegrationTests/Acl.IntegrationTests.csproj" />
|
||||
<Project Path="services/acl/Acl.Tests/Acl.Tests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/services/domain/">
|
||||
<Project Path="services/domain/Big.Domain/Big.Domain.csproj" />
|
||||
<Project Path="services/domain/Big.Application/Big.Application.csproj" />
|
||||
<Project Path="services/domain/Big.Infrastructure/Big.Infrastructure.csproj" />
|
||||
<Project Path="services/domain/Big.Api/Big.Api.csproj" />
|
||||
<Project Path="services/domain/Big.Tests/Big.Tests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/services/bff/">
|
||||
<Project Path="services/bff/Bff.Api/Bff.Api.csproj" />
|
||||
<Project Path="services/bff/Bff.Tests/Bff.Tests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/services/event-subscriber/">
|
||||
<Project Path="services/event-subscriber/EventSubscriber.Api/EventSubscriber.Api.csproj" />
|
||||
<Project Path="services/event-subscriber/EventSubscriber.Application/EventSubscriber.Application.csproj" />
|
||||
<Project Path="services/event-subscriber/EventSubscriber.Tests/EventSubscriber.Tests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/services/projection-api/">
|
||||
<Project Path="services/projection-api/Projection.ReadModel/Projection.ReadModel.csproj" />
|
||||
<Project Path="services/projection-api/ProjectionApi.Api/ProjectionApi.Api.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/acceptance/Acceptance.csproj" />
|
||||
</Folder>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
**/bin
|
||||
**/obj
|
||||
**/*.user
|
||||
@@ -27,6 +27,11 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
||||
// ZRC is a geo API; it requires the CRS headers.
|
||||
message.Headers.Add("Accept-Crs", "EPSG:4326");
|
||||
message.Content.Headers.Add("Content-Crs", "EPSG:4326");
|
||||
// OpenZaak runs behind uwsgi, which rejects a chunked request body with 400.
|
||||
// JsonContent streams without a known length (→ Transfer-Encoding: chunked),
|
||||
// so buffer it first to send a Content-Length instead. Only a real OpenZaak
|
||||
// surfaces this — a stubbed HttpMessageHandler accepts either framing.
|
||||
await message.Content.LoadIntoBufferAsync(ct);
|
||||
|
||||
using var response = await http.SendAsync(message, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -44,4 +44,21 @@ public class AclServiceTests
|
||||
Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype);
|
||||
Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_a_null_registration_without_calling_the_gateway()
|
||||
{
|
||||
var gateway = new FakeGateway();
|
||||
var defaults = new AclDefaults
|
||||
{
|
||||
Bronorganisatie = "517439943",
|
||||
VerantwoordelijkeOrganisatie = "517439943",
|
||||
Vertrouwelijkheidaanduiding = "openbaar",
|
||||
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
|
||||
};
|
||||
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.OpenZaakAsync(null!));
|
||||
Assert.Null(gateway.Captured);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Acl.Application;
|
||||
using Acl.Infrastructure;
|
||||
|
||||
@@ -14,38 +16,139 @@ public class OpenZaakGatewayTests
|
||||
=> onSend(request);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Posts_zaak_to_openzaak_with_bearer_and_default_fields_and_returns_url()
|
||||
private static OpenZaakGateway Gateway(StubHandler handler) => new(
|
||||
new HttpClient(handler),
|
||||
new OpenZaakOptions { BaseUrl = new("http://openzaak"), ClientId = "cid", Secret = "sec" });
|
||||
|
||||
private static ZaakRequest SampleRequest() => new(
|
||||
"517439943", "517439943", "openbaar",
|
||||
new("http://openzaak/catalogi/api/v1/zaaktypen/big"), new DateOnly(2026, 6, 4));
|
||||
|
||||
private static StubHandler Created(out RequestCapture capture)
|
||||
{
|
||||
HttpRequestMessage? seen = null;
|
||||
string? body = null;
|
||||
var handler = new StubHandler(async req =>
|
||||
var c = new RequestCapture();
|
||||
capture = c;
|
||||
return new StubHandler(async req =>
|
||||
{
|
||||
seen = req;
|
||||
body = await req.Content!.ReadAsStringAsync();
|
||||
c.Seen = req;
|
||||
// Capture the length BEFORE reading the body: ReadAsStringAsync buffers the
|
||||
// content and would set ContentLength as a side effect, masking the gateway's
|
||||
// own buffering. Read here to assert the gateway sent a length (not chunked).
|
||||
c.ContentLength = req.Content?.Headers.ContentLength;
|
||||
c.Body = req.Content is null ? null : await req.Content.ReadAsStringAsync();
|
||||
return new HttpResponseMessage(HttpStatusCode.Created)
|
||||
{
|
||||
Content = JsonContent.Create(new { url = "http://openzaak/zaken/api/v1/zaken/xyz" }),
|
||||
};
|
||||
});
|
||||
var gateway = new OpenZaakGateway(
|
||||
new HttpClient(handler),
|
||||
new OpenZaakOptions { BaseUrl = new("http://openzaak"), ClientId = "cid", Secret = "sec" });
|
||||
var request = new ZaakRequest(
|
||||
"517439943", "517439943", "openbaar",
|
||||
new("http://openzaak/catalogi/api/v1/zaaktypen/big"), new DateOnly(2026, 6, 4));
|
||||
}
|
||||
|
||||
var url = await gateway.OpenZaakAsync(request);
|
||||
private sealed class RequestCapture
|
||||
{
|
||||
public HttpRequestMessage? Seen;
|
||||
public string? Body;
|
||||
public long? ContentLength;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Posts_zaak_to_openzaak_with_bearer_and_default_fields_and_returns_url()
|
||||
{
|
||||
var handler = Created(out var capture);
|
||||
|
||||
var url = await Gateway(handler).OpenZaakAsync(SampleRequest());
|
||||
|
||||
Assert.Equal("http://openzaak/zaken/api/v1/zaken/xyz", url.ToString());
|
||||
Assert.Equal(HttpMethod.Post, seen!.Method);
|
||||
Assert.Equal("http://openzaak/zaken/api/v1/zaken", seen.RequestUri!.ToString());
|
||||
Assert.Equal("Bearer", seen.Headers.Authorization!.Scheme);
|
||||
Assert.False(string.IsNullOrWhiteSpace(seen.Headers.Authorization.Parameter));
|
||||
Assert.Contains("\"bronorganisatie\":\"517439943\"", body);
|
||||
Assert.Contains("\"verantwoordelijkeOrganisatie\":\"517439943\"", body);
|
||||
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", body);
|
||||
Assert.Contains("\"startdatum\":\"2026-06-04\"", body);
|
||||
Assert.Contains("\"zaaktype\":\"http://openzaak/catalogi/api/v1/zaaktypen/big\"", body);
|
||||
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
|
||||
Assert.Equal("http://openzaak/zaken/api/v1/zaken", capture.Seen.RequestUri!.ToString());
|
||||
Assert.Equal("Bearer", capture.Seen.Headers.Authorization!.Scheme);
|
||||
Assert.False(string.IsNullOrWhiteSpace(capture.Seen.Headers.Authorization.Parameter));
|
||||
Assert.Contains("\"bronorganisatie\":\"517439943\"", capture.Body);
|
||||
Assert.Contains("\"verantwoordelijkeOrganisatie\":\"517439943\"", capture.Body);
|
||||
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", capture.Body);
|
||||
Assert.Contains("\"startdatum\":\"2026-06-04\"", capture.Body);
|
||||
Assert.Contains("\"zaaktype\":\"http://openzaak/catalogi/api/v1/zaaktypen/big\"", capture.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sends_the_geo_crs_headers_required_by_the_zaken_api()
|
||||
{
|
||||
var handler = Created(out var capture);
|
||||
|
||||
await Gateway(handler).OpenZaakAsync(SampleRequest());
|
||||
|
||||
Assert.Equal("EPSG:4326", Assert.Single(capture.Seen!.Headers.GetValues("Accept-Crs")));
|
||||
Assert.Equal("EPSG:4326", Assert.Single(capture.Seen.Content!.Headers.GetValues("Content-Crs")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sends_the_body_with_a_content_length_so_it_is_not_chunked()
|
||||
{
|
||||
// OpenZaak's uwsgi rejects a chunked request body (400). The gateway buffers
|
||||
// the body so a Content-Length is sent. JsonContent has no length until
|
||||
// buffered, so this guards the fix the real-OpenZaak integration test found.
|
||||
var handler = Created(out var capture);
|
||||
|
||||
await Gateway(handler).OpenZaakAsync(SampleRequest());
|
||||
|
||||
Assert.NotNull(capture.ContentLength);
|
||||
Assert.True(capture.ContentLength > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mints_a_hs256_jwt_carrying_the_acl_identity_claims()
|
||||
{
|
||||
var handler = Created(out var capture);
|
||||
|
||||
await Gateway(handler).OpenZaakAsync(SampleRequest());
|
||||
|
||||
var parts = capture.Seen!.Headers.Authorization!.Parameter!.Split('.');
|
||||
Assert.Equal(3, parts.Length);
|
||||
using var header = JsonDocument.Parse(DecodeSegment(parts[0]));
|
||||
Assert.Equal("HS256", header.RootElement.GetProperty("alg").GetString());
|
||||
Assert.Equal("JWT", header.RootElement.GetProperty("typ").GetString());
|
||||
using var payload = JsonDocument.Parse(DecodeSegment(parts[1]));
|
||||
Assert.Equal("cid", payload.RootElement.GetProperty("client_id").GetString());
|
||||
Assert.Equal("acl", payload.RootElement.GetProperty("user_id").GetString());
|
||||
Assert.Equal("acl", payload.RootElement.GetProperty("user_representation").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Throws_when_openzaak_rejects_the_request()
|
||||
{
|
||||
var handler = new StubHandler(_ =>
|
||||
Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)));
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => Gateway(handler).OpenZaakAsync(SampleRequest()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Throws_when_openzaak_returns_an_empty_body()
|
||||
{
|
||||
var handler = new StubHandler(_ =>
|
||||
Task.FromResult(new HttpResponseMessage(HttpStatusCode.Created)
|
||||
{
|
||||
Content = new StringContent("null", Encoding.UTF8, "application/json"),
|
||||
}));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => Gateway(handler).OpenZaakAsync(SampleRequest()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_a_null_request()
|
||||
{
|
||||
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(
|
||||
() => Gateway(handler).OpenZaakAsync(null!));
|
||||
}
|
||||
|
||||
// ZGW tokens are base64url with padding stripped (ZgwToken.B64Url); restore it to decode.
|
||||
private static string DecodeSegment(string segment)
|
||||
{
|
||||
var b64 = segment.Replace('-', '+').Replace('_', '/');
|
||||
b64 = (b64.Length % 4) switch { 2 => b64 + "==", 3 => b64 + "=", _ => b64 };
|
||||
return Encoding.UTF8.GetString(Convert.FromBase64String(b64));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
<Project Path="Acl.Api/Acl.Api.csproj" />
|
||||
<Project Path="Acl.Application/Acl.Application.csproj" />
|
||||
<Project Path="Acl.Infrastructure/Acl.Infrastructure.csproj" />
|
||||
<Project Path="Acl.IntegrationTests/Acl.IntegrationTests.csproj" />
|
||||
<Project Path="Acl.Tests/Acl.Tests.csproj" />
|
||||
</Solution>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Multi-stage build for the ACL service (.NET 10).
|
||||
# Build context is services/acl (see infra/docker-compose.yml).
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Restore first (cached unless .csproj files change).
|
||||
COPY Acl.Api/Acl.Api.csproj Acl.Api/
|
||||
COPY Acl.Application/Acl.Application.csproj Acl.Application/
|
||||
COPY Acl.Infrastructure/Acl.Infrastructure.csproj Acl.Infrastructure/
|
||||
RUN dotnet restore Acl.Api/Acl.Api.csproj
|
||||
|
||||
COPY Acl.Api/ Acl.Api/
|
||||
COPY Acl.Application/ Acl.Application/
|
||||
COPY Acl.Infrastructure/ Acl.Infrastructure/
|
||||
RUN dotnet publish Acl.Api/Acl.Api.csproj -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=5s --timeout=3s --start-period=10s --retries=5 \
|
||||
CMD curl -fsS http://localhost:8080/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dotnet", "Acl.Api.dll"]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,4 +6,10 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- OIDC/JWT validation of Keycloak-issued tokens (ADR-0010) and OpenAPI generation. -->
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace Bff.Api;
|
||||
|
||||
/// <summary>What the self-service submit returns to the portal (the domain's registration id + status).</summary>
|
||||
public sealed record SubmitAccepted(string RegistrationId, string Status);
|
||||
|
||||
/// <summary>A projection row as the projection-api serves it. <c>Bsn</c>/<c>NaamPlaceholder</c> are
|
||||
/// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09).</summary>
|
||||
public sealed record ProjectionEntry(string Id, string Status, string? Bsn, string? NaamPlaceholder);
|
||||
|
||||
/// <summary>A public-safe openbaar register row — only non-sensitive fields leave the BFF.</summary>
|
||||
public sealed record OpenbaarEntry(string Id, string Status);
|
||||
|
||||
/// <summary>Port to the Domain Service (§8.3: the BFF is the portals' only backend; it fans out).</summary>
|
||||
public interface IDomainClient
|
||||
{
|
||||
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Port to the read projection.</summary>
|
||||
public interface IProjectionClient
|
||||
{
|
||||
Task<IReadOnlyList<ProjectionEntry>> GetRegisterAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Calls the Domain Service's <c>POST /registrations</c>.</summary>
|
||||
public sealed class DomainClient(HttpClient http) : IDomainClient
|
||||
{
|
||||
public async Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default)
|
||||
{
|
||||
using var response = await http.PostAsJsonAsync("registrations", new { bsn }, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var dto = await response.Content.ReadFromJsonAsync<DomainResponse>(ct)
|
||||
?? throw new InvalidOperationException("The Domain Service returned an empty registration response.");
|
||||
return new SubmitAccepted(dto.RegistrationId, dto.Status);
|
||||
}
|
||||
|
||||
private sealed record DomainResponse(string RegistrationId, string Status, string? ZaakUrl);
|
||||
}
|
||||
|
||||
/// <summary>Calls the projection-api's <c>GET /register</c>.</summary>
|
||||
public sealed class ProjectionClient(HttpClient http) : IProjectionClient
|
||||
{
|
||||
public async Task<IReadOnlyList<ProjectionEntry>> GetRegisterAsync(CancellationToken ct = default)
|
||||
=> await http.GetFromJsonAsync<List<ProjectionEntry>>("register", ct) ?? [];
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Bff.Api;
|
||||
|
||||
/// <summary>
|
||||
/// The public view of the read projection: filters rows by the openbaar search term and maps each to
|
||||
/// a public-safe <see cref="OpenbaarEntry"/> (only <c>id</c> + <c>status</c> — bsn/naam never leave the
|
||||
/// BFF). Pure so it is unit- and mutation-tested directly (ADR-0010).
|
||||
/// </summary>
|
||||
public static class OpenbaarProjection
|
||||
{
|
||||
public static IReadOnlyList<OpenbaarEntry> PublicView(IReadOnlyList<ProjectionEntry> entries, string? q)
|
||||
{
|
||||
var filtered = string.IsNullOrWhiteSpace(q)
|
||||
? entries
|
||||
: entries.Where(e => e.Id.Contains(q, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return [.. filtered.Select(e => new OpenbaarEntry(e.Id, e.Status))];
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,72 @@
|
||||
using System.Security.Claims;
|
||||
using Bff.Api;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
var keycloakAuthority = builder.Configuration["Keycloak:Authority"]
|
||||
?? throw new InvalidOperationException("Missing configuration 'Keycloak:Authority'");
|
||||
var domainBaseUrl = builder.Configuration["Downstream:Domain:BaseUrl"]
|
||||
?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'");
|
||||
var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"]
|
||||
?? throw new InvalidOperationException("Missing configuration 'Downstream:Projection:BaseUrl'");
|
||||
|
||||
// Validate Keycloak-issued tokens (ADR-0010). Audience validation is off for the walking skeleton —
|
||||
// Keycloak's audience mapping is a later hardening; signature/issuer/expiry are validated.
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.Authority = keycloakAuthority;
|
||||
options.RequireHttpsMetadata = false;
|
||||
options.TokenValidationParameters.ValidateAudience = false;
|
||||
});
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
// The BFF is the portals' only backend; it fans out to the domain and projection (§8.3).
|
||||
builder.Services.AddHttpClient<IDomainClient, DomainClient>(c => c.BaseAddress = new Uri(domainBaseUrl));
|
||||
builder.Services.AddHttpClient<IProjectionClient, ProjectionClient>(c => c.BaseAddress = new Uri(projectionBaseUrl));
|
||||
|
||||
builder.Services.AddHealthChecks();
|
||||
// Clear the auto-populated `servers` block so the committed spec is stable regardless of the host
|
||||
// the doc was generated from (the client sets its own base URL). Keeps the drift guard deterministic.
|
||||
builder.Services.AddOpenApi(options =>
|
||||
options.AddDocumentTransformer((document, _, _) =>
|
||||
{
|
||||
document.Servers?.Clear();
|
||||
return Task.CompletedTask;
|
||||
}));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapGet("/", () => "BFF placeholder");
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapHealthChecks("/health");
|
||||
app.MapOpenApi();
|
||||
|
||||
// Self-service submit: requires a valid digid token; the bsn comes from the token, not the body,
|
||||
// and is forwarded to the domain (ADR-0010). Returns 202 — the zaak is opened asynchronously (S-05).
|
||||
app.MapPost("/self-service/registrations", async (ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) =>
|
||||
{
|
||||
var bsn = user.FindFirstValue("bsn");
|
||||
if (string.IsNullOrWhiteSpace(bsn))
|
||||
return Results.BadRequest("The token carries no bsn claim.");
|
||||
|
||||
var accepted = await domain.SubmitRegistrationAsync(bsn, ct);
|
||||
return Results.Accepted($"/self-service/registrations/{accepted.RegistrationId}", accepted);
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.Produces<SubmitAccepted>(StatusCodes.Status202Accepted)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized);
|
||||
|
||||
// Openbaar register: an anonymous public lookup that exposes only public-safe fields (S-09).
|
||||
app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) =>
|
||||
{
|
||||
var entries = await projection.GetRegisterAsync(ct);
|
||||
return Results.Ok(OpenbaarProjection.PublicView(entries, q));
|
||||
})
|
||||
.Produces<IReadOnlyList<OpenbaarEntry>>(StatusCodes.Status200OK);
|
||||
|
||||
app.Run();
|
||||
|
||||
|
||||
@@ -5,5 +5,12 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
"AllowedHosts": "*",
|
||||
"Keycloak": {
|
||||
"Authority": "http://localhost:8180/realms/digid"
|
||||
},
|
||||
"Downstream": {
|
||||
"Domain": { "BaseUrl": "http://localhost:8130/" },
|
||||
"Projection": { "BaseUrl": "http://localhost:8120/" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Text;
|
||||
using Bff.Api;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Bff.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Test host for the BFF. It swaps the downstream clients for in-memory fakes and reconfigures the
|
||||
/// JWT bearer to validate against a local test key (no live Keycloak) — so token validation is
|
||||
/// exercised in-process with tokens the tests mint (ADR-0010).
|
||||
/// </summary>
|
||||
internal sealed class BffFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
public static readonly SymmetricSecurityKey TestSigningKey =
|
||||
new(Encoding.UTF8.GetBytes("bff-test-signing-key-that-is-at-least-256-bits-long!"));
|
||||
|
||||
public FakeDomainClient Domain { get; } = new();
|
||||
public FakeProjectionClient Projection { get; } = new();
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseSetting("Keycloak:Authority", "https://keycloak.invalid/realms/digid");
|
||||
builder.UseSetting("Downstream:Domain:BaseUrl", "http://domain.invalid/");
|
||||
builder.UseSetting("Downstream:Projection:BaseUrl", "http://projection.invalid/");
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.AddSingleton<IDomainClient>(Domain);
|
||||
services.AddSingleton<IProjectionClient>(Projection);
|
||||
|
||||
services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
// Validate locally against the test key; never reach out for OIDC metadata.
|
||||
options.Authority = null;
|
||||
options.MetadataAddress = null!;
|
||||
options.RequireHttpsMetadata = false;
|
||||
options.Configuration = new OpenIdConnectConfiguration();
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = TestSigningKey,
|
||||
ClockSkew = TimeSpan.Zero,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Captures the bsn the BFF forwarded and returns a canned acceptance.</summary>
|
||||
internal sealed class FakeDomainClient : IDomainClient
|
||||
{
|
||||
public string? SubmittedBsn { get; private set; }
|
||||
public SubmitAccepted Result { get; set; } = new("reg-123", "Ingediend");
|
||||
|
||||
public Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default)
|
||||
{
|
||||
SubmittedBsn = bsn;
|
||||
return Task.FromResult(Result);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serves a configurable set of projection rows.</summary>
|
||||
internal sealed class FakeProjectionClient : IProjectionClient
|
||||
{
|
||||
public List<ProjectionEntry> Entries { get; } = [];
|
||||
|
||||
public Task<IReadOnlyList<ProjectionEntry>> GetRegisterAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<ProjectionEntry>>(Entries);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Bff.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the committed OpenAPI contract (<c>services/bff/openapi.json</c>) against drift: it must
|
||||
/// equal the document the running BFF serves. S-08's Angular client is generated from this file, so a
|
||||
/// stale spec is a bug. To regenerate: run the BFF and save <c>/openapi/v1.json</c> over the file.
|
||||
/// </summary>
|
||||
public class OpenApiSpecTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Committed_openapi_spec_matches_the_served_document()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
|
||||
var served = await factory.CreateClient().GetStringAsync("/openapi/v1.json");
|
||||
var committed = await File.ReadAllTextAsync(SpecPath());
|
||||
|
||||
Assert.Equal(Canonical(committed), Canonical(served));
|
||||
}
|
||||
|
||||
// Reformat both sides identically so the comparison is about content, not whitespace.
|
||||
private static string Canonical(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
|
||||
// The committed spec sits at services/bff/openapi.json — one level up from this test file.
|
||||
private static string SpecPath([CallerFilePath] string thisFile = "")
|
||||
=> Path.Combine(Path.GetDirectoryName(thisFile)!, "..", "openapi.json");
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Bff.Api;
|
||||
|
||||
namespace Bff.Tests;
|
||||
|
||||
public class OpenbaarEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Serves_public_safe_rows_anonymously()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", "123456782", "Jan"));
|
||||
|
||||
// No Authorization header — the openbaar register is a public lookup (ADR-0010/S-09).
|
||||
var response = await factory.CreateClient().GetAsync("/openbaar/register");
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("abc-111", body);
|
||||
Assert.Contains("INGEDIEND", body);
|
||||
// The bsn must never appear in a public response.
|
||||
Assert.DoesNotContain("123456782", body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Filters_by_the_query_parameter()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", null, null));
|
||||
factory.Projection.Entries.Add(new ProjectionEntry("def-222", "INGEDIEND", null, null));
|
||||
|
||||
var rows = await factory.CreateClient()
|
||||
.GetFromJsonAsync<List<OpenbaarEntry>>("/openbaar/register?q=abc");
|
||||
|
||||
Assert.Equal("abc-111", Assert.Single(rows!).Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Bff.Api;
|
||||
|
||||
namespace Bff.Tests;
|
||||
|
||||
public class OpenbaarProjectionTests
|
||||
{
|
||||
private static readonly ProjectionEntry[] Sample =
|
||||
[
|
||||
new("abc-111", "INGEDIEND", "123456782", "Jan"),
|
||||
new("def-222", "INGEDIEND", "987654321", "Piet"),
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void Maps_every_row_to_public_safe_fields_when_no_query()
|
||||
{
|
||||
var view = OpenbaarProjection.PublicView(Sample, null);
|
||||
|
||||
Assert.Equal(2, view.Count);
|
||||
Assert.Equal("abc-111", view[0].Id);
|
||||
Assert.Equal("INGEDIEND", view[0].Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Public_view_exposes_only_id_and_status()
|
||||
{
|
||||
// OpenbaarEntry structurally carries only Id + Status — bsn/naam can never leak.
|
||||
var props = typeof(OpenbaarEntry).GetProperties().Select(p => p.Name).ToArray();
|
||||
Assert.Equal(["Id", "Status"], props);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("abc", 1)]
|
||||
[InlineData("ABC", 1)]
|
||||
[InlineData("2", 1)]
|
||||
[InlineData("zzz", 0)]
|
||||
public void Filters_by_id_containing_the_query_case_insensitively(string q, int expected)
|
||||
{
|
||||
var view = OpenbaarProjection.PublicView(Sample, q);
|
||||
|
||||
Assert.Equal(expected, view.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blank_query_is_treated_as_no_filter()
|
||||
{
|
||||
Assert.Equal(2, OpenbaarProjection.PublicView(Sample, " ").Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
|
||||
namespace Bff.Tests;
|
||||
|
||||
public class SelfServiceEndpointTests
|
||||
{
|
||||
private static HttpRequestMessage Submit(string? bearer)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "/self-service/registrations")
|
||||
{
|
||||
Content = JsonContent.Create(new { }),
|
||||
};
|
||||
if (bearer is not null)
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
|
||||
return request;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_a_request_without_a_token()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var response = await client.SendAsync(Submit(bearer: null));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
Assert.Null(factory.Domain.SubmittedBsn);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-a-jwt")]
|
||||
public async Task Rejects_a_malformed_token(string bearer)
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
var response = await factory.CreateClient().SendAsync(Submit(bearer));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_a_token_signed_with_the_wrong_key()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
var response = await factory.CreateClient().SendAsync(Submit(TestTokens.WrongKey("123456782")));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_an_expired_token()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
var response = await factory.CreateClient().SendAsync(Submit(TestTokens.Expired("123456782")));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Accepts_a_valid_token_and_forwards_the_bsn_to_the_domain()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var response = await client.SendAsync(Submit(TestTokens.Valid("123456782")));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
|
||||
Assert.Equal("123456782", factory.Domain.SubmittedBsn);
|
||||
var body = await response.Content.ReadFromJsonAsync<SubmitAcceptedDto>();
|
||||
Assert.Equal("reg-123", body!.RegistrationId);
|
||||
}
|
||||
|
||||
private sealed record SubmitAcceptedDto(string RegistrationId, string Status);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Text;
|
||||
using Microsoft.IdentityModel.JsonWebTokens;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace Bff.Tests;
|
||||
|
||||
/// <summary>Mints JWTs for the BFF tests — signed with the factory's test key (valid) or otherwise
|
||||
/// (a wrong key / expired) to exercise the bearer validation the BFF configures.</summary>
|
||||
internal static class TestTokens
|
||||
{
|
||||
public static string Valid(string bsn) => Create(bsn, BffFactory.TestSigningKey, expired: false);
|
||||
|
||||
public static string Expired(string bsn) => Create(bsn, BffFactory.TestSigningKey, expired: true);
|
||||
|
||||
public static string WrongKey(string bsn) => Create(
|
||||
bsn,
|
||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes("a-different-signing-key-256-bits-long-indeed-yes!")),
|
||||
expired: false);
|
||||
|
||||
private static string Create(string bsn, SymmetricSecurityKey key, bool expired)
|
||||
{
|
||||
var handler = new JsonWebTokenHandler();
|
||||
return handler.CreateToken(new SecurityTokenDescriptor
|
||||
{
|
||||
Claims = new Dictionary<string, object> { ["bsn"] = bsn },
|
||||
Expires = DateTime.UtcNow.AddMinutes(expired ? -5 : 30),
|
||||
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"openapi": "3.1.1",
|
||||
"info": {
|
||||
"title": "Bff.Api | v1",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"paths": {
|
||||
"/self-service/registrations": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Bff.Api"
|
||||
],
|
||||
"responses": {
|
||||
"202": {
|
||||
"description": "Accepted",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SubmitAccepted"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/openbaar/register": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Bff.Api"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "q",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/OpenbaarEntry"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"OpenbaarEntry": {
|
||||
"required": [
|
||||
"id",
|
||||
"status"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SubmitAccepted": {
|
||||
"required": [
|
||||
"registrationId",
|
||||
"status"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"registrationId": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "Bff.Api"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"stryker-config": {
|
||||
"solution": "Bff.slnx",
|
||||
"test-projects": ["Bff.Tests/Bff.Tests.csproj"],
|
||||
"reporters": ["progress", "html"],
|
||||
"mutate": [
|
||||
"!**/Program.cs",
|
||||
"!**/DownstreamClients.cs"
|
||||
],
|
||||
"thresholds": {
|
||||
"high": 95,
|
||||
"low": 90,
|
||||
"break": 90
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
**/bin
|
||||
**/obj
|
||||
**/*.user
|
||||
StrykerOutput
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Big.Application\Big.Application.csproj" />
|
||||
<ProjectReference Include="..\Big.Infrastructure\Big.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,64 @@
|
||||
using Big.Application;
|
||||
using Big.Domain;
|
||||
using Big.Infrastructure;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Options bound from configuration (compose sets Flowable__* and Acl__* env vars).
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
|
||||
.GetSection("Flowable").Get<FlowableOptions>()
|
||||
?? throw new InvalidOperationException("Missing configuration section 'Flowable'"));
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
|
||||
.GetSection("Acl").Get<AclOptions>()
|
||||
?? throw new InvalidOperationException("Missing configuration section 'Acl'"));
|
||||
|
||||
// The in-memory registration store is shared between the submit endpoint and the worker (ADR-0009).
|
||||
builder.Services.AddSingleton<IRegistrationStore, InMemoryRegistrationStore>();
|
||||
|
||||
// The Workflow Client is one type behind two ports (start side + worker side); both resolve to the
|
||||
// same HttpClient-backed implementation — the only code that talks to Flowable (§8.2).
|
||||
builder.Services.AddHttpClient<FlowableWorkflowClient>();
|
||||
builder.Services.AddTransient<IWorkflowClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
||||
builder.Services.AddTransient<IExternalWorkerClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
||||
builder.Services.AddHttpClient<IAclClient, AclHttpClient>();
|
||||
|
||||
builder.Services.AddScoped<SubmitRegistration>();
|
||||
builder.Services.AddScoped<OpenZaakWorker>();
|
||||
builder.Services.AddScoped<OpenZaakJobProcessor>();
|
||||
|
||||
// The hosted external-task job worker polls Flowable and drives OpenZaakAanmaken to completion.
|
||||
builder.Services.AddHostedService<OpenZaakJobPump>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapGet("/health", () => "Healthy");
|
||||
|
||||
// Submit a registration. The aggregate is created (INGEDIEND) and the registratie process started;
|
||||
// the zaak is opened later, off the request path, by the worker — so this returns 202 Accepted with
|
||||
// a location to read the registration's progress (ADR-0009, eventual consistency).
|
||||
app.MapPost("/registrations", async (SubmitRegistrationRequest body, SubmitRegistration submit, CancellationToken ct) =>
|
||||
{
|
||||
var id = await submit.HandleAsync(new SubmitRegistrationCommand(body.Bsn), ct);
|
||||
return Results.Accepted($"/registrations/{id}", new RegistrationResponse(id.ToString(), RegistrationStatus.Ingediend.ToString(), null));
|
||||
});
|
||||
|
||||
// Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually).
|
||||
app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) =>
|
||||
{
|
||||
if (!Guid.TryParse(id, out var guid))
|
||||
return Results.NotFound();
|
||||
|
||||
var registration = await store.GetAsync(new RegistrationId(guid), ct);
|
||||
return registration is null
|
||||
? Results.NotFound()
|
||||
: Results.Ok(new RegistrationResponse(
|
||||
registration.Id.ToString(), registration.Status.ToString(), registration.ZaakUrl?.ToString()));
|
||||
});
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
public sealed record SubmitRegistrationRequest(string Bsn);
|
||||
|
||||
public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl);
|
||||
|
||||
public partial class Program;
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!-- The application layer (CLAUDE.md §9): use cases over ports. Depends on Domain only;
|
||||
Infrastructure implements the ports. -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Big.Domain\Big.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,36 @@
|
||||
using Big.Domain;
|
||||
|
||||
namespace Big.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Handles one acquired <c>OpenZaakAanmaken</c> external-worker job (ADR-0009): load the registration
|
||||
/// the job correlates to, open a zaak for it via the ACL (§8.1), attach the zaak to the aggregate, and
|
||||
/// return the zaak URL so the caller can complete the Flowable job. Pure application logic over ports —
|
||||
/// it knows nothing of Flowable; the polling loop that feeds it jobs lives in Infrastructure.
|
||||
/// </summary>
|
||||
public sealed class OpenZaakWorker(IRegistrationStore store, IAclClient acl)
|
||||
{
|
||||
/// <summary>
|
||||
/// Process the job and return the URL of the (existing or newly opened) zaak. Idempotent: if the
|
||||
/// registration already has a zaak — a job redelivered after its completion was lost — it returns
|
||||
/// that zaak without opening a second one (§8.6, at-least-once delivery). An unknown registration
|
||||
/// is an error: it throws, leaving the job un-completed for Flowable to redeliver.
|
||||
/// </summary>
|
||||
public async Task<Uri> HandleAsync(OpenZaakJob job, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
|
||||
var registration = await store.GetAsync(job.RegistrationId, ct)
|
||||
?? throw new InvalidOperationException(
|
||||
$"No registration {job.RegistrationId} for OpenZaakAanmaken job {job.JobId}.");
|
||||
|
||||
// A redelivered job whose zaak was already opened completes without opening a second one.
|
||||
if (registration.ZaakUrl is not null)
|
||||
return registration.ZaakUrl;
|
||||
|
||||
var zaakUrl = await acl.OpenZaakAsync(registration.Bsn, ct);
|
||||
registration.AttachZaak(zaakUrl);
|
||||
await store.SaveAsync(registration, ct);
|
||||
return zaakUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Big.Domain;
|
||||
|
||||
namespace Big.Application;
|
||||
|
||||
/// <summary>
|
||||
/// The port to the workflow engine. Implemented by the Workflow Client in Infrastructure — the
|
||||
/// <em>only</em> code that talks to Flowable (CLAUDE.md §8.2). The application asks it to start the
|
||||
/// registratie process; it never knows Flowable exists.
|
||||
/// </summary>
|
||||
public interface IWorkflowClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Start one <c>registratie</c> process instance for the given registration, carrying the
|
||||
/// registration id so the <c>OpenZaakAanmaken</c> external task can be correlated back to its
|
||||
/// aggregate. Returns the process instance id.
|
||||
/// </summary>
|
||||
Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The port to the Anti-Corruption Layer. Implemented in Infrastructure by an HTTP client to the
|
||||
/// ACL service — the <em>only</em> code that talks to ZGW (CLAUDE.md §8.1). The domain hands over a
|
||||
/// bsn; the ACL default-fills the ZGW-mandatory fields (ADR-0003) and returns the created zaak URL.
|
||||
/// </summary>
|
||||
public interface IAclClient
|
||||
{
|
||||
Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persistence port for the <see cref="Registration"/> aggregate. In-memory for the minimal slice
|
||||
/// (ADR-0009); an EF-backed store is a documented follow-up, and this port keeps that change additive.
|
||||
/// </summary>
|
||||
public interface IRegistrationStore
|
||||
{
|
||||
/// <summary>Insert or update the registration, keyed on its id.</summary>
|
||||
Task SaveAsync(Registration registration, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Load a registration by id, or <c>null</c> if none exists.</summary>
|
||||
Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An acquired <c>OpenZaakAanmaken</c> external-worker job: the Flowable job id (needed to complete
|
||||
/// it) and the registration id it carries as a process variable.
|
||||
/// </summary>
|
||||
public sealed record OpenZaakJob(string JobId, RegistrationId RegistrationId);
|
||||
@@ -0,0 +1,33 @@
|
||||
using Big.Domain;
|
||||
|
||||
namespace Big.Application;
|
||||
|
||||
/// <summary>A zorgprofessional's request to register, in domain language. No ZGW concepts.</summary>
|
||||
public sealed record SubmitRegistrationCommand(string Bsn);
|
||||
|
||||
/// <summary>
|
||||
/// The submit use case: create the <see cref="Registration"/> aggregate (INGEDIEND), persist it,
|
||||
/// then start the registratie workflow process and record its instance id. It returns as soon as
|
||||
/// the process is started — opening the zaak happens later, off the request path, in the external-task
|
||||
/// worker (ADR-0009). Persisting <em>before</em> starting the process closes the race where the worker
|
||||
/// acquires the OpenZaakAanmaken job before the aggregate it correlates to exists.
|
||||
/// </summary>
|
||||
public sealed class SubmitRegistration(IRegistrationStore store, IWorkflowClient workflow)
|
||||
{
|
||||
public async Task<RegistrationId> HandleAsync(SubmitRegistrationCommand command, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
|
||||
var registration = Registration.Submit(command.Bsn);
|
||||
|
||||
// Persist before starting the process so the worker can correlate the OpenZaakAanmaken
|
||||
// job back to an aggregate that already exists (ADR-0009).
|
||||
await store.SaveAsync(registration, ct);
|
||||
|
||||
var processInstanceId = await workflow.StartRegistrationProcessAsync(registration.Id, ct);
|
||||
registration.RecordProcessStarted(processInstanceId);
|
||||
await store.SaveAsync(registration, ct);
|
||||
|
||||
return registration.Id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!-- The domain layer (CLAUDE.md §9): aggregates, value objects, invariants.
|
||||
Pure C# — no external dependencies, no infrastructure concerns. -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,67 @@
|
||||
namespace Big.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// The Registration aggregate root (CLAUDE.md §2.2): a zorgprofessional's submission to the BIG
|
||||
/// register. It owns its lifecycle invariants — it starts <see cref="RegistrationStatus.Ingediend"/>
|
||||
/// on submission, remembers the Flowable process that drives it, and records the zaak the ACL opens.
|
||||
/// </summary>
|
||||
public sealed class Registration
|
||||
{
|
||||
private Registration(RegistrationId id, string bsn)
|
||||
{
|
||||
Id = id;
|
||||
Bsn = bsn;
|
||||
Status = RegistrationStatus.Ingediend;
|
||||
}
|
||||
|
||||
public RegistrationId Id { get; }
|
||||
|
||||
/// <summary>The citizen-service number of the submitting zorgprofessional. Handed to the ACL
|
||||
/// as the domain payload; the domain never constructs ZGW concepts from it (§8.1).</summary>
|
||||
public string Bsn { get; }
|
||||
|
||||
public RegistrationStatus Status { get; private set; }
|
||||
|
||||
/// <summary>The Flowable process instance driving this registration, once started.</summary>
|
||||
public string? ProcessInstanceId { get; private set; }
|
||||
|
||||
/// <summary>The zaak the ACL opened for this registration, once the external task has run.</summary>
|
||||
public Uri? ZaakUrl { get; private set; }
|
||||
|
||||
/// <summary>Submit a new registration. It begins in <see cref="RegistrationStatus.Ingediend"/>.</summary>
|
||||
public static Registration Submit(string bsn)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bsn);
|
||||
return new Registration(RegistrationId.New(), bsn);
|
||||
}
|
||||
|
||||
/// <summary>Record that the registratie workflow process has been started for this registration.</summary>
|
||||
public void RecordProcessStarted(string processInstanceId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(processInstanceId);
|
||||
ProcessInstanceId = processInstanceId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attach the zaak the ACL opened. The external-task worker may deliver the same job more than
|
||||
/// once (at-least-once), so re-attaching the identical URL is a no-op; a different URL signals a
|
||||
/// genuine conflict and is rejected. The status stays <see cref="RegistrationStatus.Ingediend"/>:
|
||||
/// opening the zaak does not advance the registration's lifecycle in this slice.
|
||||
/// </summary>
|
||||
public void AttachZaak(Uri zaakUrl)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||
|
||||
if (ZaakUrl is not null)
|
||||
{
|
||||
if (ZaakUrl != zaakUrl)
|
||||
throw new InvalidOperationException(
|
||||
$"Registration {Id} already has zaak {ZaakUrl}; cannot attach a different zaak {zaakUrl}.");
|
||||
// Stryker disable once Statement : equivalent — re-assigning the identical URL below is a
|
||||
// no-op, so removing this early return is behaviourally indistinguishable.
|
||||
return;
|
||||
}
|
||||
|
||||
ZaakUrl = zaakUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Big.Domain;
|
||||
|
||||
/// <summary>The identity of a <see cref="Registration"/> aggregate — opaque, server-assigned,
|
||||
/// and distinct from the OpenZaak zaak id (which the projection keys on). A value object so the
|
||||
/// id can travel as a Flowable process variable and back without ever being a bare string.</summary>
|
||||
public readonly record struct RegistrationId(Guid Value)
|
||||
{
|
||||
/// <summary>Mint a fresh identity for a newly submitted registration.</summary>
|
||||
public static RegistrationId New() => new(Guid.NewGuid());
|
||||
|
||||
/// <summary>Rehydrate an id carried as a string (e.g. a Flowable process variable).</summary>
|
||||
public static RegistrationId Parse(string value) => new(Guid.Parse(value));
|
||||
|
||||
public override string ToString() => Value.ToString();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Big.Domain;
|
||||
|
||||
/// <summary>The lifecycle states a <see cref="Registration"/> moves through. The walking
|
||||
/// skeleton knows only <see cref="Ingediend"/>; withdrawal, beoordeling and herregistratie
|
||||
/// states arrive in their own slices (Iteration 2+).</summary>
|
||||
public enum RegistrationStatus
|
||||
{
|
||||
/// <summary>Submitted by the zorgprofessional; the registratie process has been started.</summary>
|
||||
Ingediend,
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Big.Application;
|
||||
|
||||
namespace Big.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client to the ACL service — the boundary the domain crosses to open a zaak (§8.1). It POSTs
|
||||
/// the bsn to the ACL's <c>/zaken</c> endpoint and returns the created zaak URL; it never constructs
|
||||
/// ZGW URLs or talks to OpenZaak itself.
|
||||
/// </summary>
|
||||
public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclClient
|
||||
{
|
||||
public async Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default)
|
||||
{
|
||||
using var response = await http.PostAsJsonAsync(
|
||||
new Uri(options.BaseUrl, "zaken"), new OpenZaakRequest(bsn), ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var opened = await response.Content.ReadFromJsonAsync<OpenZaakResponse>(ct)
|
||||
?? throw new InvalidOperationException("The ACL returned an empty zaak response.");
|
||||
return new Uri(opened.ZaakUrl);
|
||||
}
|
||||
|
||||
private sealed record OpenZaakRequest([property: JsonPropertyName("bsn")] string Bsn);
|
||||
|
||||
private sealed record OpenZaakResponse([property: JsonPropertyName("zaakUrl")] string ZaakUrl);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!-- The infrastructure layer (CLAUDE.md §9): adapters implementing the Application ports.
|
||||
The Workflow Client (Flowable REST) and the ACL HTTP client live here — the only code
|
||||
that talks to Flowable (§8.2) and to the ACL respectively. -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Big.Application\Big.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- The external-task job worker is a hosted BackgroundService (ADR-0009); it logs and
|
||||
resolves a per-tick scope. Abstractions only — the host (Api) brings the implementations. -->
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Big.Application;
|
||||
using Big.Domain;
|
||||
|
||||
namespace Big.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// The Workflow Client — the only code that talks to Flowable (§8.2). It starts registratie process
|
||||
/// instances and drives the <c>OpenZaakAanmaken</c> external-worker jobs over Flowable's REST API
|
||||
/// (start: <c>service/runtime/process-instances</c>; acquire/complete: <c>external-job-api/…</c>).
|
||||
/// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009).
|
||||
/// </summary>
|
||||
public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options)
|
||||
: IWorkflowClient, IExternalWorkerClient
|
||||
{
|
||||
private const string Topic = "OpenZaakAanmaken";
|
||||
private const string ProcessDefinitionKey = "registratie";
|
||||
private const string RegistrationIdVariable = "registrationId";
|
||||
private const string ZaakUrlVariable = "zaakUrl";
|
||||
|
||||
public async Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
|
||||
{
|
||||
var request = new StartProcessRequest(
|
||||
ProcessDefinitionKey,
|
||||
[new Variable(RegistrationIdVariable, "string", registrationId.ToString())]);
|
||||
|
||||
var created = await PostAsync<StartProcessRequest, ProcessInstance>(
|
||||
"service/runtime/process-instances", request, ct)
|
||||
?? throw new InvalidOperationException("Flowable returned an empty process-instance response.");
|
||||
return created.Id;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<OpenZaakJob>> AcquireOpenZaakJobsAsync(int maxJobs, CancellationToken ct = default)
|
||||
{
|
||||
var request = new AcquireJobsRequest(Topic, options.LockDuration, maxJobs, options.WorkerId);
|
||||
|
||||
var jobs = await PostAsync<AcquireJobsRequest, List<AcquiredJob>>(
|
||||
"external-job-api/acquire/jobs", request, ct) ?? [];
|
||||
|
||||
return [.. jobs.Select(job => new OpenZaakJob(job.Id, RegistrationId.Parse(job.RegistrationId())))];
|
||||
}
|
||||
|
||||
public async Task CompleteOpenZaakJobAsync(string jobId, Uri zaakUrl, CancellationToken ct = default)
|
||||
{
|
||||
var request = new CompleteJobRequest(
|
||||
options.WorkerId,
|
||||
[new Variable(ZaakUrlVariable, "string", zaakUrl.ToString())]);
|
||||
|
||||
using var response = await SendAsync(
|
||||
$"external-job-api/acquire/jobs/{jobId}/complete", request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct)
|
||||
{
|
||||
using var response = await SendAsync(path, body, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<TResponse>(ct);
|
||||
}
|
||||
|
||||
private Task<HttpResponseMessage> SendAsync<TRequest>(string path, TRequest body, CancellationToken ct)
|
||||
{
|
||||
var message = new HttpRequestMessage(HttpMethod.Post, new Uri(options.BaseUrl, path))
|
||||
{
|
||||
Content = JsonContent.Create(body),
|
||||
};
|
||||
message.Headers.Authorization = new AuthenticationHeaderValue("Basic", BasicCredentials());
|
||||
return http.SendAsync(message, ct);
|
||||
}
|
||||
|
||||
private string BasicCredentials()
|
||||
=> Convert.ToBase64String(Encoding.ASCII.GetBytes($"{options.Username}:{options.Password}"));
|
||||
|
||||
private sealed record StartProcessRequest(
|
||||
[property: JsonPropertyName("processDefinitionKey")] string ProcessDefinitionKey,
|
||||
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables);
|
||||
|
||||
private sealed record AcquireJobsRequest(
|
||||
[property: JsonPropertyName("topic")] string Topic,
|
||||
[property: JsonPropertyName("lockDuration")] string LockDuration,
|
||||
[property: JsonPropertyName("numberOfTasks")] int NumberOfTasks,
|
||||
[property: JsonPropertyName("workerId")] string WorkerId);
|
||||
|
||||
private sealed record CompleteJobRequest(
|
||||
[property: JsonPropertyName("workerId")] string WorkerId,
|
||||
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables);
|
||||
|
||||
private sealed record Variable(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("type")] string Type,
|
||||
[property: JsonPropertyName("value")] string Value);
|
||||
|
||||
private sealed record ProcessInstance([property: JsonPropertyName("id")] string Id);
|
||||
|
||||
private sealed record AcquiredJob(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables)
|
||||
{
|
||||
/// <summary>The registration id this job carries as a process variable.</summary>
|
||||
public string RegistrationId() =>
|
||||
// Stryker disable once Linq : equivalent — Single and SingleOrDefault both throw on a job
|
||||
// with no registrationId variable (the only untested branch), so the mutant is indistinguishable.
|
||||
Variables.SingleOrDefault(v => v.Name == "registrationId")?.Value
|
||||
?? throw new InvalidOperationException($"OpenZaakAanmaken job {Id} carries no registrationId variable.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Big.Application;
|
||||
|
||||
namespace Big.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// The worker-facing side of the Workflow Client: acquiring and completing Flowable external-worker
|
||||
/// jobs (ADR-0009). Kept separate from the Application's <see cref="IWorkflowClient"/> because job
|
||||
/// acquisition is a Flowable-specific polling mechanic the application never needs to know about.
|
||||
/// Implemented by <see cref="FlowableWorkflowClient"/> — the only code that talks to Flowable (§8.2).
|
||||
/// </summary>
|
||||
public interface IExternalWorkerClient
|
||||
{
|
||||
/// <summary>Acquire and lock up to <paramref name="maxJobs"/> <c>OpenZaakAanmaken</c> jobs.</summary>
|
||||
Task<IReadOnlyList<OpenZaakJob>> AcquireOpenZaakJobsAsync(int maxJobs, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Complete an acquired job, passing the opened zaak URL back into the process.</summary>
|
||||
Task CompleteOpenZaakJobAsync(string jobId, Uri zaakUrl, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Big.Application;
|
||||
using Big.Domain;
|
||||
|
||||
namespace Big.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory <see cref="IRegistrationStore"/> for the minimal slice (ADR-0009). The walking
|
||||
/// skeleton's read path is the projection (S-06), not this store, so durable domain persistence is a
|
||||
/// documented follow-up. Registered as a singleton so the submit endpoint and the worker share it.
|
||||
/// </summary>
|
||||
public sealed class InMemoryRegistrationStore : IRegistrationStore
|
||||
{
|
||||
private readonly ConcurrentDictionary<RegistrationId, Registration> _byId = new();
|
||||
|
||||
public Task SaveAsync(Registration registration, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registration);
|
||||
_byId[registration.Id] = registration;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
|
||||
=> Task.FromResult(_byId.GetValueOrDefault(id));
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Big.Application;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Big.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// One poll tick of the external-task worker (ADR-0009): acquire the parked <c>OpenZaakAanmaken</c>
|
||||
/// jobs, hand each to the <see cref="OpenZaakWorker"/> to open a zaak via the ACL, and complete the
|
||||
/// Flowable job with the resulting zaak URL. A job that fails is logged and left un-completed so
|
||||
/// Flowable redelivers it (§8.6). Split out from the hosted <see cref="OpenZaakJobPump"/> so the
|
||||
/// acquire→process→complete logic is unit-testable without a running host.
|
||||
/// </summary>
|
||||
public sealed class OpenZaakJobProcessor(
|
||||
IExternalWorkerClient client,
|
||||
OpenZaakWorker worker,
|
||||
ILogger<OpenZaakJobProcessor> logger)
|
||||
{
|
||||
/// <summary>Acquire and process up to <paramref name="maxJobs"/> jobs. Returns the number acquired.</summary>
|
||||
public async Task<int> PumpOnceAsync(int maxJobs, CancellationToken ct = default)
|
||||
{
|
||||
var jobs = await client.AcquireOpenZaakJobsAsync(maxJobs, ct);
|
||||
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
try
|
||||
{
|
||||
var zaakUrl = await worker.HandleAsync(job, ct);
|
||||
await client.CompleteOpenZaakJobAsync(job.JobId, zaakUrl, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Leave the job un-completed: its lock expires and Flowable redelivers it (§8.6).
|
||||
logger.LogError(ex, "OpenZaakAanmaken job {JobId} failed; leaving it for redelivery.", job.JobId);
|
||||
}
|
||||
}
|
||||
|
||||
return jobs.Count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Big.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// The hosted polling loop of the external-task job worker (ADR-0009): on an interval it resolves a
|
||||
/// scoped <see cref="OpenZaakJobProcessor"/> and asks it to drain the parked <c>OpenZaakAanmaken</c>
|
||||
/// jobs. A deliberately thin shell — all acquire/process/complete logic lives in the processor, which
|
||||
/// is unit-tested; this class only owns the timer, the per-tick scope, and loop resilience.
|
||||
/// </summary>
|
||||
public sealed class OpenZaakJobPump(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
FlowableOptions options,
|
||||
ILogger<OpenZaakJobPump> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var processor = scope.ServiceProvider.GetRequiredService<OpenZaakJobProcessor>();
|
||||
await processor.PumpOnceAsync(options.MaxJobsPerPoll, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A transient fault (e.g. Flowable briefly unreachable) must not kill the loop.
|
||||
logger.LogError(ex, "OpenZaakAanmaken job poll failed; retrying after the poll interval.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(options.PollInterval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace Big.Infrastructure;
|
||||
|
||||
/// <summary>Configuration for the Flowable Workflow Client. <see cref="BaseUrl"/> is the flowable-rest
|
||||
/// root and must end with a slash (e.g. <c>http://flowable-rest:8080/flowable-rest/</c>) so the
|
||||
/// <c>service/…</c> and <c>external-job-api/…</c> sub-paths resolve correctly.</summary>
|
||||
public sealed class FlowableOptions
|
||||
{
|
||||
public Uri BaseUrl { get; set; } = null!;
|
||||
public string Username { get; set; } = "";
|
||||
public string Password { get; set; } = "";
|
||||
|
||||
/// <summary>The id this worker locks jobs under, so two workers don't process the same job.</summary>
|
||||
public string WorkerId { get; set; } = "big-domain-worker";
|
||||
|
||||
/// <summary>How long an acquired job stays locked to this worker (ISO-8601 duration).</summary>
|
||||
public string LockDuration { get; set; } = "PT5M";
|
||||
|
||||
/// <summary>How many OpenZaakAanmaken jobs to acquire per poll.</summary>
|
||||
public int MaxJobsPerPoll { get; set; } = 5;
|
||||
|
||||
/// <summary>How often the worker polls Flowable for new jobs.</summary>
|
||||
public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(2);
|
||||
}
|
||||
|
||||
/// <summary>Configuration for the ACL HTTP client. <see cref="BaseUrl"/> is the ACL service root.</summary>
|
||||
public sealed class AclOptions
|
||||
{
|
||||
public Uri BaseUrl { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Net;
|
||||
using Big.Infrastructure;
|
||||
|
||||
namespace Big.Tests;
|
||||
|
||||
public class AclHttpClientTests
|
||||
{
|
||||
private static AclHttpClient Client(StubHandler handler) => new(
|
||||
new HttpClient(handler), new AclOptions { BaseUrl = new("http://acl/") });
|
||||
|
||||
[Fact]
|
||||
public async Task Opens_a_zaak_by_posting_the_bsn_and_returns_the_zaak_url()
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.OK,
|
||||
"""{"zaakUrl":"http://openzaak/zaken/api/v1/zaken/abc"}"""));
|
||||
|
||||
var url = await client.OpenZaakAsync("123456782");
|
||||
|
||||
Assert.Equal("http://openzaak/zaken/api/v1/zaken/abc", url.ToString());
|
||||
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
|
||||
Assert.Equal("http://acl/zaken", capture.Seen.RequestUri!.ToString());
|
||||
Assert.Contains("\"bsn\":\"123456782\"", capture.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Throws_when_the_acl_rejects_the_request()
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.BadGateway));
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => client.OpenZaakAsync("123456782"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Throws_when_the_acl_returns_an_empty_body()
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.OK, "null"));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => client.OpenZaakAsync("123456782"));
|
||||
Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Big.Domain\Big.Domain.csproj" />
|
||||
<ProjectReference Include="..\Big.Application\Big.Application.csproj" />
|
||||
<ProjectReference Include="..\Big.Infrastructure\Big.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,24 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Big.Tests;
|
||||
|
||||
/// <summary>A minimal <see cref="ILogger{T}"/> that records the level and rendered message of each
|
||||
/// entry, so tests can assert that (for example) a failing job was logged.</summary>
|
||||
internal sealed class CapturingLogger<T> : ILogger<T>
|
||||
{
|
||||
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
=> Entries.Add((logLevel, formatter(state, exception)));
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Big.Application;
|
||||
using Big.Domain;
|
||||
|
||||
namespace Big.Tests;
|
||||
|
||||
/// <summary>An in-memory <see cref="IRegistrationStore"/> for the application-layer tests. Upserts
|
||||
/// keyed on the registration id, mirroring the production store (kept distinct from the production
|
||||
/// <c>InMemoryRegistrationStore</c> so tests can seed and inspect save counts).</summary>
|
||||
internal sealed class FakeRegistrationStore : IRegistrationStore
|
||||
{
|
||||
private readonly Dictionary<RegistrationId, Registration> _byId = [];
|
||||
|
||||
public int SaveCount { get; private set; }
|
||||
|
||||
public Task SaveAsync(Registration registration, CancellationToken ct = default)
|
||||
{
|
||||
SaveCount++;
|
||||
_byId[registration.Id] = registration;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
|
||||
=> Task.FromResult(_byId.GetValueOrDefault(id));
|
||||
|
||||
public void Seed(Registration registration) => _byId[registration.Id] = registration;
|
||||
}
|
||||
|
||||
/// <summary>A fake Workflow Client that records the registration it was asked to start a process for
|
||||
/// and returns a fixed process-instance id. An optional callback runs at start time, letting a test
|
||||
/// assert ordering (e.g. that the registration was persisted before the process started).</summary>
|
||||
internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Action<RegistrationId>? onStart = null)
|
||||
: IWorkflowClient
|
||||
{
|
||||
public RegistrationId? StartedFor { get; private set; }
|
||||
|
||||
public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
|
||||
{
|
||||
onStart?.Invoke(registrationId);
|
||||
StartedFor = registrationId;
|
||||
return Task.FromResult(processInstanceId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A fake ACL client that records the bsn it was asked to open a zaak for and returns a
|
||||
/// fixed zaak URL.</summary>
|
||||
internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient
|
||||
{
|
||||
public static readonly Uri DefaultZaakUrl = new("http://openzaak/zaken/api/v1/zaken/abc");
|
||||
|
||||
private readonly Uri _zaakUrl = zaakUrl ?? DefaultZaakUrl;
|
||||
|
||||
public string? OpenedForBsn { get; private set; }
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default)
|
||||
{
|
||||
CallCount++;
|
||||
OpenedForBsn = bsn;
|
||||
return Task.FromResult(_zaakUrl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Big.Domain;
|
||||
using Big.Infrastructure;
|
||||
|
||||
namespace Big.Tests;
|
||||
|
||||
public class FlowableWorkflowClientTests
|
||||
{
|
||||
private static readonly Uri Base = new("http://flowable/flowable-rest/");
|
||||
|
||||
private static FlowableWorkflowClient Client(StubHandler handler) => new(
|
||||
new HttpClient(handler),
|
||||
new FlowableOptions { BaseUrl = Base, Username = "rest-admin", Password = "test", WorkerId = "worker-x" });
|
||||
|
||||
private static string DecodeBasic(HttpRequestMessage request)
|
||||
=> Encoding.ASCII.GetString(Convert.FromBase64String(request.Headers.Authorization!.Parameter!));
|
||||
|
||||
[Fact]
|
||||
public async Task Start_posts_the_registration_id_variable_and_returns_the_instance_id()
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.Created, """{"id":"pi-1"}"""));
|
||||
var rid = RegistrationId.New();
|
||||
|
||||
var pid = await client.StartRegistrationProcessAsync(rid);
|
||||
|
||||
Assert.Equal("pi-1", pid);
|
||||
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
|
||||
Assert.Equal("http://flowable/flowable-rest/service/runtime/process-instances",
|
||||
capture.Seen.RequestUri!.ToString());
|
||||
Assert.Equal("Basic", capture.Seen.Headers.Authorization!.Scheme);
|
||||
Assert.Equal("rest-admin:test", DecodeBasic(capture.Seen));
|
||||
Assert.Contains("\"processDefinitionKey\":\"registratie\"", capture.Body);
|
||||
Assert.Contains("\"name\":\"registrationId\"", capture.Body);
|
||||
Assert.Contains("\"type\":\"string\"", capture.Body);
|
||||
Assert.Contains($"\"value\":\"{rid}\"", capture.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Start_uses_the_configured_worker_credentials_and_defaults()
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
// Only BaseUrl set — the worker id and (empty) credentials must come from the defaults.
|
||||
var client = new FlowableWorkflowClient(
|
||||
new HttpClient(capture.Responds(HttpStatusCode.OK, "[]")),
|
||||
new FlowableOptions { BaseUrl = Base });
|
||||
|
||||
await client.AcquireOpenZaakJobsAsync(1);
|
||||
|
||||
Assert.Equal(":", DecodeBasic(capture.Seen!));
|
||||
Assert.Contains("\"workerId\":\"big-domain-worker\"", capture.Body);
|
||||
Assert.Contains("\"lockDuration\":\"PT5M\"", capture.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Acquire_posts_the_topic_and_parses_jobs_with_their_registration_id()
|
||||
{
|
||||
var rid = RegistrationId.New();
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.OK,
|
||||
$$"""[{"id":"job-1","variables":[{"name":"registrationId","type":"string","value":"{{rid}}"}]}]"""));
|
||||
|
||||
var jobs = await client.AcquireOpenZaakJobsAsync(3);
|
||||
|
||||
var job = Assert.Single(jobs);
|
||||
Assert.Equal("job-1", job.JobId);
|
||||
Assert.Equal(rid, job.RegistrationId);
|
||||
Assert.Equal("http://flowable/flowable-rest/external-job-api/acquire/jobs",
|
||||
capture.Seen!.RequestUri!.ToString());
|
||||
Assert.Contains("\"topic\":\"OpenZaakAanmaken\"", capture.Body);
|
||||
Assert.Contains("\"numberOfTasks\":3", capture.Body);
|
||||
Assert.Contains("\"workerId\":\"worker-x\"", capture.Body);
|
||||
Assert.Contains("\"lockDuration\":\"PT5M\"", capture.Body);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("[]")]
|
||||
[InlineData("null")]
|
||||
public async Task Acquire_returns_empty_when_flowable_has_no_parked_jobs(string body)
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.OK, body));
|
||||
|
||||
var jobs = await client.AcquireOpenZaakJobsAsync(1);
|
||||
|
||||
Assert.NotNull(capture.Seen);
|
||||
Assert.Empty(jobs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Acquire_throws_when_a_job_carries_no_registration_id()
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.OK, """[{"id":"job-1","variables":[]}]"""));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => client.AcquireOpenZaakJobsAsync(1));
|
||||
Assert.Contains("job-1", ex.Message);
|
||||
Assert.Contains("registrationId", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Complete_posts_the_zaak_url_variable_to_the_job_complete_endpoint()
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.NoContent));
|
||||
|
||||
await client.CompleteOpenZaakJobAsync("job-1", new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
||||
|
||||
Assert.NotNull(capture.Seen);
|
||||
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
|
||||
Assert.Equal("http://flowable/flowable-rest/external-job-api/acquire/jobs/job-1/complete",
|
||||
capture.Seen.RequestUri!.ToString());
|
||||
Assert.Contains("\"workerId\":\"worker-x\"", capture.Body);
|
||||
Assert.Contains("\"name\":\"zaakUrl\"", capture.Body);
|
||||
Assert.Contains("\"type\":\"string\"", capture.Body);
|
||||
Assert.Contains("\"value\":\"http://openzaak/zaken/api/v1/zaken/abc\"", capture.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Start_throws_when_flowable_rejects_the_request()
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.InternalServerError));
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => client.StartRegistrationProcessAsync(RegistrationId.New()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Complete_throws_when_flowable_rejects_the_request()
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.InternalServerError));
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => client.CompleteOpenZaakJobAsync("job-1", new Uri("http://openzaak/zaken/api/v1/zaken/abc")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Net;
|
||||
|
||||
namespace Big.Tests;
|
||||
|
||||
/// <summary>A test double for <see cref="HttpMessageHandler"/> that records the last request and
|
||||
/// returns a scripted response — the same approach the ACL gateway tests use.</summary>
|
||||
internal sealed class StubHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> onSend) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
=> onSend(request);
|
||||
}
|
||||
|
||||
/// <summary>Captures the request a client sent, including the (buffered) body.</summary>
|
||||
internal sealed class RequestCapture
|
||||
{
|
||||
public HttpRequestMessage? Seen { get; private set; }
|
||||
public string? Body { get; private set; }
|
||||
|
||||
/// <summary>A handler that records the request, then replies with <paramref name="status"/> and
|
||||
/// <paramref name="json"/>.</summary>
|
||||
public StubHandler Responds(HttpStatusCode status, string? json = null) => new(async req =>
|
||||
{
|
||||
Seen = req;
|
||||
Body = req.Content is null ? null : await req.Content.ReadAsStringAsync();
|
||||
var response = new HttpResponseMessage(status);
|
||||
if (json is not null)
|
||||
response.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||
return response;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Big.Domain;
|
||||
using Big.Infrastructure;
|
||||
|
||||
namespace Big.Tests;
|
||||
|
||||
public class InMemoryRegistrationStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Saves_and_reads_back_a_registration_by_id()
|
||||
{
|
||||
var store = new InMemoryRegistrationStore();
|
||||
var registration = Registration.Submit("123456782");
|
||||
|
||||
await store.SaveAsync(registration);
|
||||
|
||||
var loaded = await store.GetAsync(registration.Id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(registration.Id, loaded.Id);
|
||||
Assert.Null(await store.GetAsync(RegistrationId.New()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Saving_the_same_id_upserts()
|
||||
{
|
||||
var store = new InMemoryRegistrationStore();
|
||||
var registration = Registration.Submit("123456782");
|
||||
await store.SaveAsync(registration);
|
||||
|
||||
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
||||
await store.SaveAsync(registration);
|
||||
|
||||
var loaded = await store.GetAsync(registration.Id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal("http://openzaak/zaken/api/v1/zaken/abc", loaded.ZaakUrl!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Saving_a_null_registration_is_rejected()
|
||||
{
|
||||
var store = new InMemoryRegistrationStore();
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => store.SaveAsync(null!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Big.Application;
|
||||
using Big.Domain;
|
||||
using Big.Infrastructure;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace Big.Tests;
|
||||
|
||||
public class OpenZaakJobProcessorTests
|
||||
{
|
||||
/// <summary>A fake external-worker client: scripts the jobs to acquire and records completions.</summary>
|
||||
private sealed class FakeExternalWorkerClient(params OpenZaakJob[] jobs) : IExternalWorkerClient
|
||||
{
|
||||
public int AcquireCount { get; private set; }
|
||||
public List<(string JobId, Uri ZaakUrl)> Completed { get; } = [];
|
||||
|
||||
public Task<IReadOnlyList<OpenZaakJob>> AcquireOpenZaakJobsAsync(int maxJobs, CancellationToken ct = default)
|
||||
{
|
||||
AcquireCount++;
|
||||
return Task.FromResult<IReadOnlyList<OpenZaakJob>>(jobs.Take(maxJobs).ToList());
|
||||
}
|
||||
|
||||
public Task CompleteOpenZaakJobAsync(string jobId, Uri zaakUrl, CancellationToken ct = default)
|
||||
{
|
||||
Completed.Add((jobId, zaakUrl));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private static OpenZaakJobProcessor Processor(IExternalWorkerClient client, IRegistrationStore store, FakeAclClient acl)
|
||||
=> new(client, new OpenZaakWorker(store, acl), NullLogger<OpenZaakJobProcessor>.Instance);
|
||||
|
||||
[Fact]
|
||||
public async Task Acquires_a_job_opens_the_zaak_and_completes_it()
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
var store = new FakeRegistrationStore();
|
||||
store.Seed(registration);
|
||||
var client = new FakeExternalWorkerClient(new OpenZaakJob("job-1", registration.Id));
|
||||
var acl = new FakeAclClient();
|
||||
|
||||
var acquired = await Processor(client, store, acl).PumpOnceAsync(5);
|
||||
|
||||
Assert.Equal(1, acquired);
|
||||
var completed = Assert.Single(client.Completed);
|
||||
Assert.Equal("job-1", completed.JobId);
|
||||
Assert.Equal(FakeAclClient.DefaultZaakUrl, completed.ZaakUrl);
|
||||
Assert.Equal(FakeAclClient.DefaultZaakUrl, (await store.GetAsync(registration.Id))!.ZaakUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_failing_job_is_left_uncompleted_for_flowable_to_redeliver()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
// The job correlates to a registration that is not in the store, so the worker throws.
|
||||
var client = new FakeExternalWorkerClient(new OpenZaakJob("job-1", RegistrationId.New()));
|
||||
var logger = new CapturingLogger<OpenZaakJobProcessor>();
|
||||
var processor = new OpenZaakJobProcessor(client, new OpenZaakWorker(store, acl), logger);
|
||||
|
||||
var acquired = await processor.PumpOnceAsync(5);
|
||||
|
||||
Assert.Equal(1, acquired);
|
||||
Assert.Empty(client.Completed);
|
||||
// The failure is logged (and the job left for redelivery), not swallowed silently.
|
||||
var error = Assert.Single(logger.Entries, e => e.Level == LogLevel.Error);
|
||||
Assert.Contains("job-1", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Does_nothing_but_poll_when_there_are_no_jobs()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var client = new FakeExternalWorkerClient();
|
||||
|
||||
var acquired = await Processor(client, store, acl).PumpOnceAsync(5);
|
||||
|
||||
Assert.Equal(0, acquired);
|
||||
Assert.Equal(1, client.AcquireCount);
|
||||
Assert.Empty(client.Completed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Big.Application;
|
||||
using Big.Domain;
|
||||
|
||||
namespace Big.Tests;
|
||||
|
||||
public class OpenZaakWorkerTests
|
||||
{
|
||||
private static Registration Submitted(string bsn = "123456782")
|
||||
{
|
||||
var registration = Registration.Submit(bsn);
|
||||
registration.RecordProcessStarted("proc-1");
|
||||
return registration;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handling_a_job_opens_a_zaak_via_the_acl_and_attaches_it()
|
||||
{
|
||||
var registration = Submitted();
|
||||
var store = new FakeRegistrationStore();
|
||||
store.Seed(registration);
|
||||
var acl = new FakeAclClient();
|
||||
var worker = new OpenZaakWorker(store, acl);
|
||||
|
||||
var zaakUrl = await worker.HandleAsync(new OpenZaakJob("job-1", registration.Id));
|
||||
|
||||
Assert.Equal(FakeAclClient.DefaultZaakUrl, zaakUrl);
|
||||
Assert.Equal("123456782", acl.OpenedForBsn);
|
||||
var saved = await store.GetAsync(registration.Id);
|
||||
Assert.Equal(FakeAclClient.DefaultZaakUrl, saved!.ZaakUrl);
|
||||
Assert.Equal(1, store.SaveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handling_a_null_job_is_rejected()
|
||||
{
|
||||
var worker = new OpenZaakWorker(new FakeRegistrationStore(), new FakeAclClient());
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => worker.HandleAsync(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handling_a_job_for_an_unknown_registration_throws_and_opens_no_zaak()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var worker = new OpenZaakWorker(store, acl);
|
||||
|
||||
var job = new OpenZaakJob("job-1", RegistrationId.New());
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => worker.HandleAsync(job));
|
||||
Assert.Contains(job.RegistrationId.ToString(), ex.Message);
|
||||
Assert.Equal(0, acl.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handling_a_redelivered_job_is_idempotent_and_does_not_open_a_second_zaak()
|
||||
{
|
||||
var registration = Submitted();
|
||||
var store = new FakeRegistrationStore();
|
||||
store.Seed(registration);
|
||||
var acl = new FakeAclClient();
|
||||
var worker = new OpenZaakWorker(store, acl);
|
||||
var job = new OpenZaakJob("job-1", registration.Id);
|
||||
|
||||
var first = await worker.HandleAsync(job);
|
||||
var second = await worker.HandleAsync(job);
|
||||
|
||||
Assert.Equal(first, second);
|
||||
Assert.Equal(1, acl.CallCount);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user