diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index e090e7f..5acf277 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -63,6 +63,23 @@ jobs: path: services/acl/StrykerOutput/**/reports/mutation-report.html if-no-files-found: warn + integration: + runs-on: ubuntu-latest + steps: + - uses: https://github.com/actions/checkout@v4 + # No setup-dotnet: `make integration` runs the seed and the test as containers + # *inside* the OpenZaak compose network (reaching it by container IP), so dotnet + # lives in the test image and the runner needs only Docker. This sidesteps the + # runner being unable to reach published ports (gitea-actions-gotchas.md §5). + # Needs egress to pull base images + nuget + selectielijst.openzaak.nl. + - run: make integration + - name: dump OpenZaak logs on failure + if: failure() + run: docker compose -f infra/openzaak/docker-compose.yml logs --no-color --tail=80 oz-init openzaak 2>&1 || true + - name: tear down on failure + if: failure() + run: docker compose -f infra/openzaak/docker-compose.yml down --volumes 2>&1 || true + compose-smoke: runs-on: ubuntu-latest steps: diff --git a/Makefile b/Makefile index 50a6049..8fde334 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK) 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: lint build unit mutation smoke @@ -56,9 +56,9 @@ lint: build: dotnet build $(SLN) -c Release -## unit: run unit tests +## unit: run unit tests (excludes the container-backed Integration lane) unit: - dotnet test $(SLN) -c Release + dotnet test $(SLN) -c Release --filter "Category!=Integration" ## mutation: run the Stryker.NET ratchet on the ACL (fails below the recorded baseline) # Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore` @@ -106,6 +106,15 @@ local-down: changelog: 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: $(SEED) oz diff --git a/docs/architecture/adr-0006-integration-test-provisioning.md b/docs/architecture/adr-0006-integration-test-provisioning.md new file mode 100644 index 0000000..2fd8e00 --- /dev/null +++ b/docs/architecture/adr-0006-integration-test-provisioning.md @@ -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. diff --git a/docs/runbooks/ci.md b/docs/runbooks/ci.md index 155fe30..abd035e 100644 --- a/docs/runbooks/ci.md +++ b/docs/runbooks/ci.md @@ -15,10 +15,16 @@ 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 | +| `unit` | `make unit` → `dotnet test … -c Release --filter "Category!=Integration"` | .NET 10 SDK | | `mutation` | `make mutation` → `dotnet tool restore` → `dotnet stryker` (ACL); uploads the HTML report as an artifact | .NET 10 SDK | +| `integration` | `make integration` → `infra/run-integration.sh`: OpenZaak up → seed a **published** BIG zaaktype + run `Acl.IntegrationTests` **as containers inside the compose network** → tear down | container engine + egress (base images, nuget, `selectielijst.openzaak.nl`) | | `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`, `https://github.com/actions/setup-dotnet@v4`) per CLAUDE.md §8.7 and §15 — Gitea 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: ```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 mutation # Stryker.NET ratchet on the ACL 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`. On a **rootless Podman** box (the default dev setup here), the `smoke` target needs diff --git a/docs/runbooks/gitea-actions-gotchas.md b/docs/runbooks/gitea-actions-gotchas.md index 0c7408b..c9c9ae4 100644 --- a/docs/runbooks/gitea-actions-gotchas.md +++ b/docs/runbooks/gitea-actions-gotchas.md @@ -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 swap. Do **not** bump to `@v4` until act_runner advertises github.com-compatible artifact support. + +--- + +## 5. A runner process can't reach a service container's published port + +**Symptom** — green locally, but a CI step that runs *on the runner* and talks to a +compose service over `localhost` fails. The ACL integration test's seed died with: + +``` +OpenZaak ready (000) +urllib.error.URLError: +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 _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). diff --git a/infra/openzaak/seed_catalogus.py b/infra/openzaak/seed_catalogus.py index 1c913c0..a9aa4c6 100644 --- a/infra/openzaak/seed_catalogus.py +++ b/infra/openzaak/seed_catalogus.py @@ -18,6 +18,16 @@ SECRET = os.environ.get("OZ_SECRET", "insecure-dev-secret-change-me") ZTC = f"{BASE}/catalogi/api/v1" RSIN = "517439943" # elfproef-valid test RSIN +# Opt-in: also publish the zaaktype so OpenZaak's Zaken API accepts a zaak against +# it (a concept zaaktype is rejected with `not-published`). Off by default — the +# S-01 compose seed keeps it a concept (ADR-0002). The ACL integration test +# (S-04a, #46) sets OZ_PUBLISH=1. Publishing requires ≥2 statustypen, ≥1 roltype +# and ≥1 resultaattype; the resultaattype is validated against the external +# Selectielijst reference API, so this path needs outbound access to it. See ADR-0006. +PUBLISH = os.environ.get("OZ_PUBLISH", "").lower() in ("1", "true", "yes") +SELECTIELIJST = os.environ.get( + "OZ_SELECTIELIJST", "https://selectielijst.openzaak.nl/api/v1").rstrip("/") + def token(): b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=") @@ -52,6 +62,68 @@ def find(path): return body.get("results", []) +def selectielijst(path): + """GET the external Selectielijst reference API (no auth). Used only when publishing.""" + req = urllib.request.Request(f"{SELECTIELIJST}{path}", headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=30) as r: + return json.loads(r.read()) + + +def publish_zaaktype(zt): + """Add the relations OpenZaak requires to publish, then publish (idempotent). + + Publish validation (verified against OpenZaak 1.28.2) demands: ≥2 statustypen + (begin + eind), ≥1 roltype, ≥1 resultaattype. A resultaattype needs a + Selectielijst `selectielijstklasse` whose procestype matches the zaaktype's + `selectielijstProcestype`, plus a `resultaattypeomschrijving`. + """ + have_st = {s.get("volgnummer") for s in find(f"/statustypen?zaaktype={zt['url']}&status=alles")} + for volgnummer, omschrijving in [(1, "Ontvangen"), (2, "Afgehandeld")]: + if volgnummer not in have_st: + st, body = api("POST", "/statustypen", { + "omschrijving": omschrijving, "zaaktype": zt["url"], "volgnummer": volgnummer}) + if st != 201: + sys.exit(f"create statustype {volgnummer} -> {st}: {json.dumps(body, indent=2)}") + print(f"create statustype {volgnummer} ({omschrijving})") + + if find(f"/roltypen?zaaktype={zt['url']}&status=alles"): + print("skip roltype Aanvrager") + else: + st, body = api("POST", "/roltypen", { + "zaaktype": zt["url"], "omschrijving": "Aanvrager", "omschrijvingGeneriek": "initiator"}) + if st != 201: + sys.exit(f"create roltype -> {st}: {json.dumps(body, indent=2)}") + print("create roltype Aanvrager") + + if find(f"/resultaattypen?zaaktype={zt['url']}&status=alles"): + print("skip resultaattype Geregistreerd") + else: + resultaat = selectielijst("/resultaten?pageSize=1")["results"][0] + omschrijvingen = selectielijst("/resultaattypeomschrijvingen") + oms = (omschrijvingen if isinstance(omschrijvingen, list) else omschrijvingen["results"])[0]["url"] + # The selectielijstklasse and the zaaktype must share a procestype. + st, body = api("PATCH", zt["url"], {"selectielijstProcestype": resultaat["procesType"]}) + if st != 200: + sys.exit(f"set procestype -> {st}: {json.dumps(body, indent=2)}") + st, body = api("POST", "/resultaattypen", { + "zaaktype": zt["url"], "omschrijving": "Geregistreerd", + "resultaattypeomschrijving": oms, "selectielijstklasse": resultaat["url"], + "archiefnominatie": "blijvend_bewaren", + "brondatumArchiefprocedure": {"afleidingswijze": "afgehandeld"}, + }) + if st != 201: + sys.exit(f"create resultaattype -> {st}: {json.dumps(body, indent=2)}") + print("create resultaattype Geregistreerd") + + if zt.get("concept", True): + st, body = api("POST", f"{zt['url']}/publish") + if st != 200: + sys.exit(f"publish zaaktype -> {st}: {json.dumps(body, indent=2)}") + print(f"publish zaaktype BIG-REGISTRATIE ({zt['url']})") + else: + print("skip publish (already published)") + + def main(): # 1. Catalogus existing = [c for c in find(f"/catalogussen?domein=BIG") if c.get("domein") == "BIG"] @@ -121,16 +193,24 @@ def main(): else: print("warn zaaktype already published; cannot add bsn eigenschap") - # Intentionally NOT published. Publishing requires roltypen, resultaattypen - # and statustypen, which go beyond the "lean / schema-mandatory" zaaktype this - # slice asks for; they arrive with the workflow/zaak slices. See ADR-0002. + # 4. Optionally publish. By default the zaaktype stays a concept: publishing + # requires roltypen, resultaattypen and statustypen, beyond the "lean / + # schema-mandatory" zaaktype S-01 asks for (ADR-0002). Set OZ_PUBLISH=1 to add + # those relations and publish — needed so a real zaak POST is accepted, which + # the ACL integration test (S-04a, #46) exercises. See ADR-0006. + if PUBLISH: + # Re-fetch: the bsn-eigenschap branch above may hold a stale concept flag. + zt = next(z for z in find(f"/zaaktypen?catalogus={cat['url']}&status=alles") + if z.get("identificatie") == "BIG-REGISTRATIE") + publish_zaaktype(zt) - # 4. Verify the JWT client can list the zaaktype (concepts included). + # 5. Verify the JWT client can list the zaaktype (concepts included). zaaktypen = find(f"/zaaktypen?catalogus={cat['url']}&status=alles") names = [z.get("identificatie") for z in zaaktypen] print(f"zaaktypen in BIG: {names}") assert "BIG-REGISTRATIE" in names, "BIG-REGISTRATIE not listed" - print("OK — BIG catalogus seeded (BIG-REGISTRATIE concept + bsn eigenschap)") + state = "published" if PUBLISH else "concept" + print(f"OK — BIG catalogus seeded (BIG-REGISTRATIE {state} + bsn eigenschap)") if __name__ == "__main__": diff --git a/infra/run-integration.sh b/infra/run-integration.sh new file mode 100755 index 0000000..c43fb25 --- /dev/null +++ b/infra/run-integration.sh @@ -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 diff --git a/mkdocs.yml b/mkdocs.yml index 33f4935..8173c11 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -27,6 +27,7 @@ nav: - "ADR-0003: ACL default-fill": architecture/adr-0003-default-fill.md - "ADR-0004: BDD framework": architecture/adr-0004-bdd-framework.md - "ADR-0005: Mutation testing": architecture/adr-0005-mutation-testing.md + - "ADR-0006: ACL integration test provisioning": architecture/adr-0006-integration-test-provisioning.md - Working in Gitea: gitea-workflow.md - Runbooks: - CI: runbooks/ci.md diff --git a/register-referentie.slnx b/register-referentie.slnx index fe5cf56..f5b32ab 100644 --- a/register-referentie.slnx +++ b/register-referentie.slnx @@ -4,6 +4,7 @@ + diff --git a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs index 0a6e2e4..f9cce31 100644 --- a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs +++ b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs @@ -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(); diff --git a/services/acl/Acl.IntegrationTests/Acl.IntegrationTests.csproj b/services/acl/Acl.IntegrationTests/Acl.IntegrationTests.csproj new file mode 100644 index 0000000..fff0e46 --- /dev/null +++ b/services/acl/Acl.IntegrationTests/Acl.IntegrationTests.csproj @@ -0,0 +1,30 @@ + + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + diff --git a/services/acl/Acl.IntegrationTests/OpenZaakFixture.cs b/services/acl/Acl.IntegrationTests/OpenZaakFixture.cs new file mode 100644 index 0000000..bcdfdcc --- /dev/null +++ b/services/acl/Acl.IntegrationTests/OpenZaakFixture.cs @@ -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; + +/// +/// 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. +/// +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, + }; + + /// + /// 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. + /// + public async Task 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()!); + } + + /// GETs a previously-created zaak to prove it was really persisted. + public async Task 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 +{ + public const string Name = "OpenZaak"; +} diff --git a/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs b/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs new file mode 100644 index 0000000..16f2b7e --- /dev/null +++ b/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs @@ -0,0 +1,45 @@ +using Acl.Application; +using Acl.Infrastructure; + +namespace Acl.IntegrationTests; + +/// +/// 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. +/// +[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()); + } +} diff --git a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs index 5110660..b9f9099 100644 --- a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs +++ b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs @@ -31,6 +31,10 @@ public class OpenZaakGatewayTests return new StubHandler(async 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(); return new HttpResponseMessage(HttpStatusCode.Created) { @@ -43,6 +47,7 @@ public class OpenZaakGatewayTests { public HttpRequestMessage? Seen; public string? Body; + public long? ContentLength; } [Fact] @@ -75,6 +80,20 @@ public class OpenZaakGatewayTests 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() { diff --git a/services/acl/Acl.slnx b/services/acl/Acl.slnx index d33e693..aa02b3b 100644 --- a/services/acl/Acl.slnx +++ b/services/acl/Acl.slnx @@ -2,5 +2,6 @@ + diff --git a/services/acl/Dockerfile.integration b/services/acl/Dockerfile.integration new file mode 100644 index 0000000..969843d --- /dev/null +++ b/services/acl/Dockerfile.integration @@ -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://:8000`; a single-label host is not URL-valid). +ENTRYPOINT ["dotnet", "test", "Acl.IntegrationTests/Acl.IntegrationTests.csproj", \ + "-c", "Release", "--filter", "Category=Integration"] diff --git a/services/acl/stryker-config.json b/services/acl/stryker-config.json index 4321e06..0412cc4 100644 --- a/services/acl/stryker-config.json +++ b/services/acl/stryker-config.json @@ -1,6 +1,7 @@ { "stryker-config": { "solution": "Acl.slnx", + "test-projects": ["Acl.Tests/Acl.Tests.csproj"], "reporters": ["progress", "html"], "thresholds": { "high": 95,