From f2e575b427b0041850c55804e690d1cb2be5681b Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 29 Jun 2026 11:43:05 +0200 Subject: [PATCH 1/7] feat(infra): publish the BIG zaaktype on demand via OZ_PUBLISH (refs #46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- infra/openzaak/seed_catalogus.py | 90 ++++++++++++++++++++++++++++++-- 1 file changed, 85 insertions(+), 5 deletions(-) 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__": -- 2.49.1 From d0582cef658a2281c84b72f15824c24521ca7a0b Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 29 Jun 2026 11:43:17 +0200 Subject: [PATCH 2/7] test(acl): integration test opens a real zaak against OpenZaak (refs #46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Makefile | 21 +++- register-referentie.slnx | 1 + .../Acl.IntegrationTests.csproj | 30 ++++++ .../Acl.IntegrationTests/OpenZaakFixture.cs | 97 +++++++++++++++++++ .../OpenZaakGatewayIntegrationTests.cs | 45 +++++++++ services/acl/Acl.slnx | 1 + services/acl/stryker-config.json | 1 + 7 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 services/acl/Acl.IntegrationTests/Acl.IntegrationTests.csproj create mode 100644 services/acl/Acl.IntegrationTests/OpenZaakFixture.cs create mode 100644 services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs diff --git a/Makefile b/Makefile index 50a6049..21ee91b 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,21 @@ local-down: changelog: git-cliff --output CHANGELOG.md +## integration: ACL integration tests against a real OpenZaak (S-04a, #46). Brings +## the stack up, seeds a PUBLISHED BIG zaaktype (OZ_PUBLISH=1, so OpenZaak accepts a +## real zaak POST), runs the Integration-category tests, then always tears down. +## Kept out of `unit`/`mutation` because it needs the live stack. See ADR-0006. +integration: openzaak-up + @bash -c 'set -e; \ + for i in $$(seq 1 60); do \ + c=$$(curl -s -o /dev/null -w "%{http_code}" $(OZ_BASE)/catalogi/api/v1/ || true); \ + [ "$$c" = "200" ] && break; sleep 3; done; echo "OpenZaak ready ($$c)"; \ + OZ_PUBLISH=1 python3 infra/openzaak/seed_catalogus.py; \ + rc=0; dotnet test $(SLN) -c Release --filter "Category=Integration" || rc=$$?; \ + docker compose -f $(OZ_COMPOSE) down --volumes >/dev/null 2>&1 || true; \ + docker volume rm -f rr-oz-config >/dev/null 2>&1 || true; \ + exit $$rc' + ## openzaak-up: start the OpenZaak stack (migrations run on first start) openzaak-up: $(SEED) oz 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.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.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/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, -- 2.49.1 From 4322c607cb8afca23732ac54ea1018f17809b034 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 29 Jun 2026 11:43:27 +0200 Subject: [PATCH 3/7] fix(acl): buffer the zaak POST body so OpenZaak accepts it (refs #46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../acl/Acl.Infrastructure/OpenZaakGateway.cs | 5 +++++ .../acl/Acl.Tests/OpenZaakGatewayTests.cs | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) 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.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() { -- 2.49.1 From 09de500fb81c77e38f69f9e584913298cd032077 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 29 Jun 2026 11:43:38 +0200 Subject: [PATCH 4/7] =?UTF-8?q?arch(acl):=20ADR-0006=20=E2=80=94=20provisi?= =?UTF-8?q?on=20the=20ACL=20integration=20test=20against=20the=20compose?= =?UTF-8?q?=20stack=20(refs=20#46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../adr-0006-integration-test-provisioning.md | 84 +++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 85 insertions(+) create mode 100644 docs/architecture/adr-0006-integration-test-provisioning.md 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..a10da6d --- /dev/null +++ b/docs/architecture/adr-0006-integration-test-provisioning.md @@ -0,0 +1,84 @@ +# 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 + 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; CI runs it as a dedicated job (Docker + dotnet + + python3), separate from the fast lanes. + +## 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/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 -- 2.49.1 From 855a5565fe8b10f3907af88865cfbc6abf61f857 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 29 Jun 2026 11:43:38 +0200 Subject: [PATCH 5/7] ci(acl): run the ACL integration test as a Gitea Actions job (refs #46) 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) --- .gitea/workflows/ci.yaml | 19 +++++++++++++++++++ docs/runbooks/ci.md | 10 ++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index e090e7f..12d885e 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -63,6 +63,25 @@ 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 + - uses: https://github.com/actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + # `make integration` brings the OpenZaak stack up, seeds a PUBLISHED BIG + # zaaktype (OZ_PUBLISH=1) and runs the Integration-category tests, then tears + # down. Needs Docker + python3 (both on ubuntu-latest) and outbound access to + # selectielijst.openzaak.nl from the OpenZaak container (see ADR-0006). + - 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/docs/runbooks/ci.md b/docs/runbooks/ci.md index 155fe30..2d09172 100644 --- a/docs/runbooks/ci.md +++ b/docs/runbooks/ci.md @@ -15,8 +15,9 @@ 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` → `openzaak-up` → seed a **published** BIG zaaktype (`OZ_PUBLISH=1`) → `dotnet test … --filter "Category=Integration"` → tear down | .NET 10 SDK + container engine + python3 + egress to `selectielijst.openzaak.nl` | | `compose-smoke` | `make smoke` → seed config volumes → `up -d` (full stack) → `up --wait` durable services → `down` | container engine + compose v2 | All `uses:` references are absolute, tag-pinned URLs (`https://github.com/actions/checkout@v4`, @@ -88,12 +89,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 -- 2.49.1 From 3829cb0b6893a2b25940aa3785e4d78918b54bf6 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 29 Jun 2026 12:04:44 +0200 Subject: [PATCH 6/7] ci(acl): keep the integration lane local-only; document the runner gap (refs #46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitea/workflows/ci.yaml | 23 +++----------- .../adr-0006-integration-test-provisioning.md | 13 +++++--- docs/runbooks/ci.md | 7 ++++- docs/runbooks/gitea-actions-gotchas.md | 31 +++++++++++++++++++ 4 files changed, 51 insertions(+), 23 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 12d885e..d3b6e36 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -63,24 +63,11 @@ 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 - - uses: https://github.com/actions/setup-dotnet@v4 - with: - dotnet-version: '10.0.x' - # `make integration` brings the OpenZaak stack up, seeds a PUBLISHED BIG - # zaaktype (OZ_PUBLISH=1) and runs the Integration-category tests, then tears - # down. Needs Docker + python3 (both on ubuntu-latest) and outbound access to - # selectielijst.openzaak.nl from the OpenZaak container (see ADR-0006). - - 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 + # NOTE: there is deliberately no `integration` job here yet. `make integration` + # runs the ACL ↔ real-OpenZaak test locally / on a host-executing runner, but on + # the hosted runner a process on the runner cannot reach the stack's published + # ports (sibling containers — see gitea-actions-gotchas.md §5). Running it inside + # the compose network is tracked in #55. compose-smoke: runs-on: ubuntu-latest diff --git a/docs/architecture/adr-0006-integration-test-provisioning.md b/docs/architecture/adr-0006-integration-test-provisioning.md index a10da6d..5494736 100644 --- a/docs/architecture/adr-0006-integration-test-provisioning.md +++ b/docs/architecture/adr-0006-integration-test-provisioning.md @@ -47,8 +47,8 @@ itself. No new test dependency is added.** - **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 - 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). + brings the stack up, seeds, runs the lane, and always tears down. 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 @@ -69,8 +69,13 @@ itself. No new test dependency is added.** `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; CI runs it as a dedicated job (Docker + dotnet + - python3), separate from the fast lanes. +- **Cost:** the lane needs the stack up first, so it is separate from the fast lanes. +- **Not yet a hosted-runner job.** `make integration` passes locally and on a host-executing + runner, but on Gitea's hosted runner a process *on the runner* cannot reach the stack's + published ports — Compose starts sibling containers via the host daemon, so the ports bind to + the host, not the runner (gitea-actions-gotchas.md §5, the same split as §1). Running the test + and seed *inside* the compose network (via a built image, not bind mounts) to gate it in CI is + tracked in **#55**. Until then the lane runs locally and is not a merge gate. ## Alternatives considered diff --git a/docs/runbooks/ci.md b/docs/runbooks/ci.md index 2d09172..d925115 100644 --- a/docs/runbooks/ci.md +++ b/docs/runbooks/ci.md @@ -17,9 +17,14 @@ and CI cannot drift: | `build` | `make build` → `dotnet build … -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` → `openzaak-up` → seed a **published** BIG zaaktype (`OZ_PUBLISH=1`) → `dotnet test … --filter "Category=Integration"` → tear down | .NET 10 SDK + container engine + python3 + egress to `selectielijst.openzaak.nl` | | `compose-smoke` | `make smoke` → seed config volumes → `up -d` (full stack) → `up --wait` durable services → `down` | container engine + compose v2 | +> **`make integration` is not a hosted-runner job yet.** The ACL ↔ real-OpenZaak +> test (`make integration`, ADR-0006) passes locally and on a host-executing runner, +> but a process on the hosted runner can't reach the stack's published ports +> (sibling containers — see [gitea-actions-gotchas.md §5](gitea-actions-gotchas.md)). +> Run it inside the compose network to gate it in CI — tracked in **#55**. + 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. diff --git a/docs/runbooks/gitea-actions-gotchas.md b/docs/runbooks/gitea-actions-gotchas.md index 0c7408b..1967454 100644 --- a/docs/runbooks/gitea-actions-gotchas.md +++ b/docs/runbooks/gitea-actions-gotchas.md @@ -124,3 +124,34 @@ 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 …`. + +**Status** — `make integration` (the ACL ↔ real-OpenZaak test, ADR-0006) therefore +runs locally / on a host-executing runner only. Gating it on the hosted runner via +the compose network is tracked in **#55**. -- 2.49.1 From 44745856065d7fb96074c809547abe70783e8743 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 29 Jun 2026 12:28:43 +0200 Subject: [PATCH 7/7] ci(acl): run the ACL integration test in CI inside the compose network (closes #55) (refs #46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitea/workflows/ci.yaml | 21 ++++-- Makefile | 22 +++--- .../adr-0006-integration-test-provisioning.md | 18 ++--- docs/runbooks/ci.md | 10 +-- docs/runbooks/gitea-actions-gotchas.md | 8 ++- infra/run-integration.sh | 68 +++++++++++++++++++ services/acl/Dockerfile.integration | 26 +++++++ 7 files changed, 138 insertions(+), 35 deletions(-) create mode 100755 infra/run-integration.sh create mode 100644 services/acl/Dockerfile.integration diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index d3b6e36..5acf277 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -63,11 +63,22 @@ jobs: path: services/acl/StrykerOutput/**/reports/mutation-report.html if-no-files-found: warn - # NOTE: there is deliberately no `integration` job here yet. `make integration` - # runs the ACL ↔ real-OpenZaak test locally / on a host-executing runner, but on - # the hosted runner a process on the runner cannot reach the stack's published - # ports (sibling containers — see gitea-actions-gotchas.md §5). Running it inside - # the compose network is tracked in #55. + 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 diff --git a/Makefile b/Makefile index 21ee91b..8fde334 100644 --- a/Makefile +++ b/Makefile @@ -106,20 +106,14 @@ local-down: changelog: git-cliff --output CHANGELOG.md -## integration: ACL integration tests against a real OpenZaak (S-04a, #46). Brings -## the stack up, seeds a PUBLISHED BIG zaaktype (OZ_PUBLISH=1, so OpenZaak accepts a -## real zaak POST), runs the Integration-category tests, then always tears down. -## Kept out of `unit`/`mutation` because it needs the live stack. See ADR-0006. -integration: openzaak-up - @bash -c 'set -e; \ - for i in $$(seq 1 60); do \ - c=$$(curl -s -o /dev/null -w "%{http_code}" $(OZ_BASE)/catalogi/api/v1/ || true); \ - [ "$$c" = "200" ] && break; sleep 3; done; echo "OpenZaak ready ($$c)"; \ - OZ_PUBLISH=1 python3 infra/openzaak/seed_catalogus.py; \ - rc=0; dotnet test $(SLN) -c Release --filter "Category=Integration" || rc=$$?; \ - docker compose -f $(OZ_COMPOSE) down --volumes >/dev/null 2>&1 || true; \ - docker volume rm -f rr-oz-config >/dev/null 2>&1 || true; \ - exit $$rc' +## 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: diff --git a/docs/architecture/adr-0006-integration-test-provisioning.md b/docs/architecture/adr-0006-integration-test-provisioning.md index 5494736..2fd8e00 100644 --- a/docs/architecture/adr-0006-integration-test-provisioning.md +++ b/docs/architecture/adr-0006-integration-test-provisioning.md @@ -47,8 +47,9 @@ itself. No new test dependency is added.** - **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 - brings the stack up, seeds, runs the lane, and always tears down. This matches `make` being - the single source of truth (ADR-0005). + (`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 @@ -70,12 +71,13 @@ itself. No new test dependency is added.** 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. -- **Not yet a hosted-runner job.** `make integration` passes locally and on a host-executing - runner, but on Gitea's hosted runner a process *on the runner* cannot reach the stack's - published ports — Compose starts sibling containers via the host daemon, so the ports bind to - the host, not the runner (gitea-actions-gotchas.md §5, the same split as §1). Running the test - and seed *inside* the compose network (via a built image, not bind mounts) to gate it in CI is - tracked in **#55**. Until then the lane runs locally and is not a merge gate. +- **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 diff --git a/docs/runbooks/ci.md b/docs/runbooks/ci.md index d925115..abd035e 100644 --- a/docs/runbooks/ci.md +++ b/docs/runbooks/ci.md @@ -17,13 +17,13 @@ and CI cannot drift: | `build` | `make build` → `dotnet build … -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 | -> **`make integration` is not a hosted-runner job yet.** The ACL ↔ real-OpenZaak -> test (`make integration`, ADR-0006) passes locally and on a host-executing runner, -> but a process on the hosted runner can't reach the stack's published ports -> (sibling containers — see [gitea-actions-gotchas.md §5](gitea-actions-gotchas.md)). -> Run it inside the compose network to gate it in CI — tracked in **#55**. +> **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 diff --git a/docs/runbooks/gitea-actions-gotchas.md b/docs/runbooks/gitea-actions-gotchas.md index 1967454..c9c9ae4 100644 --- a/docs/runbooks/gitea-actions-gotchas.md +++ b/docs/runbooks/gitea-actions-gotchas.md @@ -152,6 +152,8 @@ service by name (`http://openzaak:8000`). For a test/seed that needs the repo's code, deliver it via a **built image** (not a bind mount — §1), then `docker run --network _cg …`. -**Status** — `make integration` (the ACL ↔ real-OpenZaak test, ADR-0006) therefore -runs locally / on a host-executing runner only. Gating it on the hosted runner via -the compose network is tracked in **#55**. +**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/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/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"] -- 2.49.1