Compare commits
6 Commits
feat/47-ac
...
855a5565fe
| Author | SHA1 | Date | |
|---|---|---|---|
| 855a5565fe | |||
| 09de500fb8 | |||
| 4322c607cb | |||
| d0582cef65 | |||
| f2e575b427 | |||
| fd5fa5ac3c |
@@ -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:
|
||||
|
||||
21
Makefile
21
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
|
||||
|
||||
84
docs/architecture/adr-0006-integration-test-provisioning.md
Normal file
84
docs/architecture/adr-0006-integration-test-provisioning.md
Normal file
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<Project Path="services/acl/Acl.Api/Acl.Api.csproj" />
|
||||
<Project Path="services/acl/Acl.Application/Acl.Application.csproj" />
|
||||
<Project Path="services/acl/Acl.Infrastructure/Acl.Infrastructure.csproj" />
|
||||
<Project Path="services/acl/Acl.IntegrationTests/Acl.IntegrationTests.csproj" />
|
||||
<Project Path="services/acl/Acl.Tests/Acl.Tests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/services/bff/">
|
||||
|
||||
@@ -27,6 +27,11 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
||||
// ZRC is a geo API; it requires the CRS headers.
|
||||
message.Headers.Add("Accept-Crs", "EPSG:4326");
|
||||
message.Content.Headers.Add("Content-Crs", "EPSG:4326");
|
||||
// OpenZaak runs behind uwsgi, which rejects a chunked request body with 400.
|
||||
// JsonContent streams without a known length (→ Transfer-Encoding: chunked),
|
||||
// so buffer it first to send a Content-Length instead. Only a real OpenZaak
|
||||
// surfaces this — a stubbed HttpMessageHandler accepts either framing.
|
||||
await message.Content.LoadIntoBufferAsync(ct);
|
||||
|
||||
using var response = await http.SendAsync(message, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!-- Integration tests: they talk to a real OpenZaak (the compose stack), so they
|
||||
are gated behind [Trait("Category","Integration")] and excluded from the fast
|
||||
`make unit` / mutation lanes. `make integration` brings the stack up, seeds a
|
||||
published BIG zaaktype (OZ_PUBLISH=1) and runs this project. See ADR-0006. -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Acl.Application\Acl.Application.csproj" />
|
||||
<ProjectReference Include="..\Acl.Infrastructure\Acl.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
97
services/acl/Acl.IntegrationTests/OpenZaakFixture.cs
Normal file
97
services/acl/Acl.IntegrationTests/OpenZaakFixture.cs
Normal 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";
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,5 +2,6 @@
|
||||
<Project Path="Acl.Api/Acl.Api.csproj" />
|
||||
<Project Path="Acl.Application/Acl.Application.csproj" />
|
||||
<Project Path="Acl.Infrastructure/Acl.Infrastructure.csproj" />
|
||||
<Project Path="Acl.IntegrationTests/Acl.IntegrationTests.csproj" />
|
||||
<Project Path="Acl.Tests/Acl.Tests.csproj" />
|
||||
</Solution>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"stryker-config": {
|
||||
"solution": "Acl.slnx",
|
||||
"test-projects": ["Acl.Tests/Acl.Tests.csproj"],
|
||||
"reporters": ["progress", "html"],
|
||||
"thresholds": {
|
||||
"high": 95,
|
||||
|
||||
Reference in New Issue
Block a user