Compare commits

...

8 Commits

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:43:05 +02:00
17 changed files with 545 additions and 10 deletions

View File

@@ -63,6 +63,23 @@ jobs:
path: services/acl/StrykerOutput/**/reports/mutation-report.html path: services/acl/StrykerOutput/**/reports/mutation-report.html
if-no-files-found: warn if-no-files-found: warn
integration:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
# No setup-dotnet: `make integration` runs the seed and the test as containers
# *inside* the OpenZaak compose network (reaching it by container IP), so dotnet
# lives in the test image and the runner needs only Docker. This sidesteps the
# runner being unable to reach published ports (gitea-actions-gotchas.md §5).
# Needs egress to pull base images + nuget + selectielijst.openzaak.nl.
- run: make integration
- name: dump OpenZaak logs on failure
if: failure()
run: docker compose -f infra/openzaak/docker-compose.yml logs --no-color --tail=80 oz-init openzaak 2>&1 || true
- name: tear down on failure
if: failure()
run: docker compose -f infra/openzaak/docker-compose.yml down --volumes 2>&1 || true
compose-smoke: compose-smoke:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

View File

@@ -43,7 +43,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
endif endif
endif endif
.PHONY: ci lint build unit mutation smoke up down local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help .PHONY: ci lint build unit mutation integration 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, mutation, smoke (mirrors Gitea Actions) ## ci: run the full pipeline — lint, build, unit, mutation, smoke (mirrors Gitea Actions)
ci: lint build unit mutation smoke ci: lint build unit mutation smoke
@@ -56,9 +56,9 @@ lint:
build: build:
dotnet build $(SLN) -c Release dotnet build $(SLN) -c Release
## unit: run unit tests ## unit: run unit tests (excludes the container-backed Integration lane)
unit: unit:
dotnet test $(SLN) -c Release dotnet test $(SLN) -c Release --filter "Category!=Integration"
## mutation: run the Stryker.NET ratchet on the ACL (fails below the recorded baseline) ## mutation: run the Stryker.NET ratchet on the ACL (fails below the recorded baseline)
# Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore` # Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore`
@@ -106,6 +106,15 @@ local-down:
changelog: changelog:
git-cliff --output CHANGELOG.md git-cliff --output CHANGELOG.md
## integration: ACL integration tests against a real OpenZaak (S-04a, #46). The
## seed and the test run inside the compose network (reaching http://openzaak:8000),
## so this works on the hosted CI runner where a runner process can't reach the
## stack's published ports. Brings the stack up, seeds a PUBLISHED BIG zaaktype,
## runs the Integration-category tests, then always tears down. Kept out of
## `unit`/`mutation` because it needs the live stack. See infra/run-integration.sh + ADR-0006.
integration:
bash infra/run-integration.sh
## openzaak-up: start the OpenZaak stack (migrations run on first start) ## openzaak-up: start the OpenZaak stack (migrations run on first start)
openzaak-up: openzaak-up:
$(SEED) oz $(SEED) oz

View File

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

View File

@@ -15,10 +15,16 @@ and CI cannot drift:
|---|---|---| |---|---|---|
| `lint` | `make lint``dotnet format … --verify-no-changes` | .NET 10 SDK | | `lint` | `make lint``dotnet format … --verify-no-changes` | .NET 10 SDK |
| `build` | `make build``dotnet build … -c Release` | .NET 10 SDK | | `build` | `make build``dotnet build … -c Release` | .NET 10 SDK |
| `unit` | `make unit``dotnet test … -c Release` | .NET 10 SDK | | `unit` | `make unit``dotnet test … -c Release --filter "Category!=Integration"` | .NET 10 SDK |
| `mutation` | `make mutation``dotnet tool restore``dotnet stryker` (ACL); uploads the HTML report as an artifact | .NET 10 SDK | | `mutation` | `make mutation``dotnet tool restore``dotnet stryker` (ACL); uploads the HTML report as an artifact | .NET 10 SDK |
| `integration` | `make integration``infra/run-integration.sh`: OpenZaak up → seed a **published** BIG zaaktype + run `Acl.IntegrationTests` **as containers inside the compose network** → tear down | container engine + egress (base images, nuget, `selectielijst.openzaak.nl`) |
| `compose-smoke` | `make smoke` → seed config volumes → `up -d` (full stack) → `up --wait` durable services → `down` | container engine + compose v2 | | `compose-smoke` | `make smoke` → seed config volumes → `up -d` (full stack) → `up --wait` durable services → `down` | container engine + compose v2 |
> **The `integration` job needs no `setup-dotnet`.** dotnet runs inside the test
> image, and both the seed and the test join the OpenZaak network and reach it by
> container IP — so the runner never has to reach a published port
> (see [gitea-actions-gotchas.md §5](gitea-actions-gotchas.md)).
All `uses:` references are absolute, tag-pinned URLs (`https://github.com/actions/checkout@v4`, All `uses:` references are absolute, tag-pinned URLs (`https://github.com/actions/checkout@v4`,
`https://github.com/actions/setup-dotnet@v4`) per CLAUDE.md §8.7 and §15 — Gitea `https://github.com/actions/setup-dotnet@v4`) per CLAUDE.md §8.7 and §15 — Gitea
Actions resolves them from GitHub. Actions resolves them from GitHub.
@@ -88,12 +94,17 @@ the containerized CI runner). Keep the two files in sync.
Until the runner exists, run the full pipeline yourself before pushing: Until the runner exists, run the full pipeline yourself before pushing:
```bash ```bash
make ci # lint + build + unit + mutation + smoke — what the pipeline runs make ci # lint + build + unit + mutation + smoke — the fast pipeline lanes
make lint # or a single stage make lint # or a single stage
make mutation # Stryker.NET ratchet on the ACL make mutation # Stryker.NET ratchet on the ACL
make smoke # compose up --wait, curl /health, tear down make smoke # compose up --wait, curl /health, tear down
make integration # ACL ↔ real OpenZaak (its own CI job; not part of `make ci`)
``` ```
> `make integration` is a separate, heavier lane (it stands the OpenZaak stack up and
> seeds a published zaaktype), so it is **not** folded into `make ci`. Run it before
> pushing changes that touch the ACL gateway or the OpenZaak seed. See ADR-0006.
**Prerequisites:** .NET 10 SDK, a container engine with Compose v2, and `curl`. **Prerequisites:** .NET 10 SDK, a container engine with Compose v2, and `curl`.
On a **rootless Podman** box (the default dev setup here), the `smoke` target needs On a **rootless Podman** box (the default dev setup here), the `smoke` target needs

View File

@@ -124,3 +124,36 @@ needed). v3 uses the older artifact protocol that Gitea implements, and has no G
guard. Inputs are the same (`name`, `path`, `if-no-files-found`), so it is a drop-in 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 swap. Do **not** bump to `@v4` until act_runner advertises github.com-compatible
artifact support. artifact support.
---
## 5. A runner process can't reach a service container's published port
**Symptom** — green locally, but a CI step that runs *on the runner* and talks to a
compose service over `localhost` fails. The ACL integration test's seed died with:
```
OpenZaak ready (000)
urllib.error.URLError: <urlopen error [Errno 111] Connection refused>
make: *** [Makefile:114: integration] Error 1
```
OpenZaak was demonstrably up — uwsgi had been serving for ~2 minutes — yet
`curl`/`urllib` to `localhost:8000` from the runner were refused the whole time.
**Why** — the same sibling-container split as §1. Compose starts the stack via the
host daemon, so `ports: ["8000:8000"]` publishes to the *daemon host*, not to the job
container. From the runner, `localhost:8000` has nothing listening. (`make smoke`
sidesteps this by polling readiness via `docker inspect` (§2), never a service port.)
**Fix** — don't talk to service ports from the runner. Either check state via `docker
inspect` (health), or run the client **inside the compose network** so it reaches the
service by name (`http://openzaak:8000`). For a test/seed that needs the repo's own
code, deliver it via a **built image** (not a bind mount — §1), then
`docker run --network <stack>_cg …`.
**Applied**`make integration` (the ACL ↔ real-OpenZaak test, ADR-0006) does
exactly this: `infra/run-integration.sh` runs the seed and the test as containers on
the OpenZaak network and reaches it by **container IP** (a single-label service name
like `openzaak` isn't URL-valid — OpenZaak echoes the request host into the URLs it
returns and then rejects them with Django's `URLValidator`; an IPv4 literal passes).

View File

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

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

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

View File

@@ -27,6 +27,7 @@ nav:
- "ADR-0003: ACL default-fill": architecture/adr-0003-default-fill.md - "ADR-0003: ACL default-fill": architecture/adr-0003-default-fill.md
- "ADR-0004: BDD framework": architecture/adr-0004-bdd-framework.md - "ADR-0004: BDD framework": architecture/adr-0004-bdd-framework.md
- "ADR-0005: Mutation testing": architecture/adr-0005-mutation-testing.md - "ADR-0005: Mutation testing": architecture/adr-0005-mutation-testing.md
- "ADR-0006: ACL integration test provisioning": architecture/adr-0006-integration-test-provisioning.md
- Working in Gitea: gitea-workflow.md - Working in Gitea: gitea-workflow.md
- Runbooks: - Runbooks:
- CI: runbooks/ci.md - CI: runbooks/ci.md

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,6 +31,10 @@ public class OpenZaakGatewayTests
return new StubHandler(async req => return new StubHandler(async req =>
{ {
c.Seen = req; 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(); c.Body = req.Content is null ? null : await req.Content.ReadAsStringAsync();
return new HttpResponseMessage(HttpStatusCode.Created) return new HttpResponseMessage(HttpStatusCode.Created)
{ {
@@ -43,6 +47,7 @@ public class OpenZaakGatewayTests
{ {
public HttpRequestMessage? Seen; public HttpRequestMessage? Seen;
public string? Body; public string? Body;
public long? ContentLength;
} }
[Fact] [Fact]
@@ -75,6 +80,20 @@ public class OpenZaakGatewayTests
Assert.Equal("EPSG:4326", Assert.Single(capture.Seen.Content!.Headers.GetValues("Content-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] [Fact]
public async Task Mints_a_hs256_jwt_carrying_the_acl_identity_claims() public async Task Mints_a_hs256_jwt_carrying_the_acl_identity_claims()
{ {

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
{ {
"stryker-config": { "stryker-config": {
"solution": "Acl.slnx", "solution": "Acl.slnx",
"test-projects": ["Acl.Tests/Acl.Tests.csproj"],
"reporters": ["progress", "html"], "reporters": ["progress", "html"],
"thresholds": { "thresholds": {
"high": 95, "high": 95,