Compare commits

...

8 Commits

Author SHA1 Message Date
fd5fa5ac3c Merge pull request 'test(acl): ACL mutation-score baseline with Stryker.NET (closes #47)' (#52) from feat/47-acl-mutation-baseline into main
All checks were successful
CI / lint (push) Successful in 50s
CI / build (push) Successful in 41s
CI / unit (push) Successful in 48s
CI / mutation (push) Successful in 1m25s
CI / compose-smoke (push) Successful in 4m1s
Reviewed-on: #52
2026-06-29 09:00:29 +00:00
5f3dd31925 fix(ci): pin upload-artifact to @v3 — @v4 refuses to run on Gitea (refs #47)
All checks were successful
CI / lint (pull_request) Successful in 50s
CI / build (pull_request) Successful in 46s
CI / unit (pull_request) Successful in 43s
CI / mutation (pull_request) Successful in 1m49s
CI / compose-smoke (pull_request) Successful in 4m2s
The artifact step failed the mutation job: upload-artifact@v4 bundles
@actions/artifact v2, which hard-aborts on any non-github.com server ("not
supported on GHES"), even though Gitea 1.25 stores artifacts fine. @v3 uses the
older protocol Gitea speaks and has no GHES guard — a drop-in swap (same inputs).
Document it as gotcha §4 and correct the CI runbook note.

Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:28:07 +02:00
347713766e ci(acl): publish the Stryker HTML report as a CI artifact (refs #47)
Some checks failed
CI / lint (pull_request) Successful in 52s
CI / build (pull_request) Successful in 41s
CI / unit (pull_request) Successful in 47s
CI / mutation (pull_request) Failing after 1m50s
CI / compose-smoke (pull_request) Successful in 3m55s
Add an upload-artifact step to the mutation job so the ACL mutation report is
downloadable from the run summary. `if: always()` uploads it even when the
ratchet fails — exactly when the survivors matter. A glob handles Stryker's
timestamped output directory. First use of actions/upload-artifact (@v4, pinned);
Gitea 1.25.x supports it. Document it in the CI runbook.

Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:21:26 +02:00
7ecc184111 arch(acl): ADR-0005 adopt Stryker.NET for mutation testing (refs #47)
All checks were successful
CI / lint (pull_request) Successful in 51s
CI / build (pull_request) Successful in 42s
CI / unit (pull_request) Successful in 45s
CI / mutation (pull_request) Successful in 1m24s
CI / compose-smoke (pull_request) Successful in 4m1s
Record the decision to adopt Stryker.NET (pinned local tool, solution mode on
Acl.slnx) and to set the first repo-wide mutation baseline on the ACL: observed
95%, enforced break threshold 90%. Document the ratchet, local run, and report
location in the CI runbook; add the ADR to the docs nav.

Proposed in #51 (adr-proposal). Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:59:41 +02:00
e8510bf9c3 ci(acl): run the mutation ratchet as a parallel CI job (refs #47)
Add a `mutation` job mirroring the unit job (checkout + pinned setup-dotnet,
then `make mutation`). It runs in parallel with lint/build/unit/compose-smoke
and gates merges on the ACL mutation baseline (CLAUDE.md §5/§15). The job calls
the same make target developers run, so the pipeline stays a mirror of `make ci`.

Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:58:22 +02:00
6ac2fca384 test(acl): add Stryker config + mutation make target recording the 95% baseline (refs #47)
Configure Stryker.NET for the ACL in solution mode (Acl.slnx), so both
Acl.Application and Acl.Infrastructure — the two projects under test — are
mutated while Acl.Api (untested) is skipped. Record the repo-wide mutation
baseline as the ratchet (CLAUDE.md §5): observed score 95%, enforced break
threshold 90% (one-mutant headroom over the ~20-mutant surface). The ACL is the
first service with branching logic, so it sets the baseline; later slices
ratchet it up deliberately, never down.

Add a `mutation` make target (`dotnet tool restore` + `dotnet stryker`) and wire
it into the `make ci` aggregate, keeping `make ci` an exact mirror of the
pipeline.

Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:58:00 +02:00
10816f5303 test(acl): kill surviving mutants — assert CRS headers, guards, error paths, JWT claims (refs #47)
Stryker exposed thin ACL tests (35% mutation score): the suite never asserted
the geo CRS headers, the ArgumentNullException guards, the non-success and
empty-body error paths, or the structure of the minted ZGW JWT — so mutating
any of those survived.

Strengthen the unit tests to kill those mutants:
- assert Accept-Crs / Content-Crs are EPSG:4326,
- assert OpenZaakAsync rejects a null request/registration without calling out,
- assert a non-2xx response throws and an empty body throws InvalidOperationException,
- decode the Bearer token and assert the HS256 header + acl identity claims.

Raises the ACL mutation score to 95%. The one remaining survivor mutates only
the exception *message* text (an equivalent mutant — message strings are not
worth a brittle assertion).

Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:57:51 +02:00
89b097d015 build(acl): pin Stryker.NET as a local dotnet tool (refs #47)
Add a tool manifest pinning dotnet-stryker 4.15.0 so `make mutation` runs
the same mutation tester locally and in CI from a fresh clone (`dotnet tool
restore`), with no global install. Ignore the generated StrykerOutput/ report
directory.

Refs #47.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:57:38 +02:00
11 changed files with 313 additions and 27 deletions

13
.config/dotnet-tools.json Normal file
View File

@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-stryker": {
"version": "4.15.0",
"commands": [
"dotnet-stryker"
],
"rollForward": false
}
}
}

View File

@@ -43,6 +43,26 @@ jobs:
dotnet-version: '10.0.x'
- run: make unit
mutation:
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'
- run: make mutation
# Publish the Stryker HTML report. `if: always()` uploads it even when the
# ratchet fails — that is exactly when you want to inspect the survivors.
# Glob handles Stryker's non-deterministic StrykerOutput/<timestamp>/ dir.
# Pinned to @v3 deliberately: @v4 refuses to run on Gitea (GHES guard) —
# see docs/runbooks/gitea-actions-gotchas.md §4.
- uses: https://github.com/actions/upload-artifact@v3
if: always()
with:
name: acl-mutation-report
path: services/acl/StrykerOutput/**/reports/mutation-report.html
if-no-files-found: warn
compose-smoke:
runs-on: ubuntu-latest
steps:

3
.gitignore vendored
View File

@@ -15,6 +15,9 @@ coverage*.json
coverage*.xml
*.coverage
# Stryker.NET mutation-testing reports (regenerated by `make mutation`)
StrykerOutput/
# Rider / VS / VS Code
.idea/
.vs/

View File

@@ -43,10 +43,10 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
endif
endif
.PHONY: ci lint build unit 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 smoke up down local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
## ci: run the full pipeline — lint, build, unit, smoke (mirrors Gitea Actions)
ci: lint build unit smoke
## ci: run the full pipeline — lint, build, unit, mutation, smoke (mirrors Gitea Actions)
ci: lint build unit mutation smoke
## lint: verify formatting (no changes)
lint:
@@ -60,6 +60,15 @@ build:
unit:
dotnet test $(SLN) -c Release
## 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`
# makes `make mutation` work from a fresh clone. Config + break threshold (the ratchet,
# CLAUDE.md §5) live in services/acl/stryker-config.json. The ACL is the first service
# with branching logic, so it sets the repo-wide baseline; later slices ratchet it up.
mutation:
dotnet tool restore
cd services/acl && dotnet stryker
## smoke: seed config, bring the whole stack up, wait for health-checked services, tear down
# SEED populates the external config volumes first (upstream images used verbatim;
# only our acl/bff are built). `up -d --build` starts EVERYTHING. Readiness is

View File

@@ -0,0 +1,68 @@
# ADR-0005: Stryker.NET for mutation testing, baseline on the ACL
- **Status:** Accepted
- **Date:** 2026-06-25
- **Deciders:** Respellion engineering
- **Relates to:** S-04b (#47); proposed in #51; supports CLAUDE.md §5 (mutation ratchet) and §3 (Definition of Done)
## Context
CLAUDE.md §5 mandates Stryker on every PR with a **ratchet**: CI fails on a regression
below the established baseline, and the baseline only ever moves up. §3 lists "mutation
(ratchet)" as a Definition-of-Done gate for **every** slice. Yet no baseline existed — so,
strictly, no slice could satisfy that gate. S-04b establishes it.
The ACL is the natural place to set the first baseline: it is the first service with real
branching logic — `OpenZaakGateway` (HTTP contract, geo CRS headers, error handling),
`ZgwToken` (HS256 JWT minting), and the `AclService` default-fill mapping. We need a tool
that:
- mutates C# and runs the existing xUnit suite per mutant,
- is reproducible (same version locally and in CI, no global install),
- understands this repo's `.slnx` solution format (used repo-wide),
- emits a break threshold CI can gate on.
## Decision
**Use [Stryker.NET](https://stryker-mutator.io/docs/stryker-net/) (`dotnet-stryker`),
pinned as a local dotnet tool**, configured in solution mode against `Acl.slnx`.
- Pinned in `.config/dotnet-tools.json` (v4.15.0); `dotnet tool restore` makes
`make mutation` reproducible from a fresh clone, locally and in CI — no global install.
- **Solution mode** (`stryker-config.json``solution: Acl.slnx`) mutates the two projects
under test (`Acl.Application`, `Acl.Infrastructure`); `Acl.Api` is untested and skipped.
Stryker 4.15 reads `.slnx` directly, so no throwaway `.sln` shim is needed.
- A `mutation` make target runs it; it is wired into `make ci` and a parallel Gitea Actions
`mutation` job, keeping `make ci` an exact mirror of the pipeline.
**Baseline:** writing S-04b's tests surfaced that the ACL suite was thin — the initial
score was **35%** (survivors: unasserted CRS headers, null guards, error paths, and JWT
claims). Those tests were strengthened (killing the mutants honestly rather than lowering
the bar), raising the score to **95%**. The enforced `break` threshold is set to **90%**
one-mutant headroom over the ~20-mutant surface, since a single mutant is ≈5%.
## Consequences
- **Positive:** test *strength* is gated, not just coverage; the ratchet protects the ACL's
ZGW contract logic; the baseline is repo-wide and ratchets upward per §5.
- **Cost:** a new dependency (`dotnet-stryker`) and a slower CI job than unit tests (~25 s on
the small ACL). Pinned + tool-restored, so reproducible.
- **One accepted survivor:** a mutation of the empty-response *exception message string*.
Asserting exception message text is brittle and the behaviour (type + control flow) is
unchanged — treated as an equivalent mutant, not a test gap.
- **Commitment:** later slices ratchet the threshold up deliberately, never down (§5). New
services add their own mutation run as they gain branching logic (BFF, Domain, …).
- **Replaceable by:** no realistic .NET alternative — Stryker.NET is the tool §5 already
names; the fallback is no mutation testing, which §5 forbids.
## Alternatives considered
- **Global `dotnet tool install -g`** — rejected: not reproducible/pinned per clone; the
local manifest gives every checkout and the CI runner the same version.
- **Mutate the whole `register-referentie.slnx`** — rejected for this slice: scopes the
baseline to services with no logic yet (BFF skeleton), diluting the signal. Each service
opts in as it gains logic.
- **Application-only scope** — rejected: would leave `Acl.Infrastructure`'s HTTP/JWT logic —
the riskiest code — unguarded by the ratchet.
- **Coverage gate instead of mutation** — rejected: line coverage does not measure whether
tests would *catch* a regression; that is the whole point of §5.

View File

@@ -16,6 +16,7 @@ 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 |
| `mutation` | `make mutation``dotnet tool restore``dotnet stryker` (ACL); uploads the HTML report as an artifact | .NET 10 SDK |
| `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`,
@@ -30,6 +31,38 @@ Actions resolves them from GitHub.
> bare `docker compose up` no longer self-seeds; use `make up`. See
> [gitea-actions-gotchas.md](gitea-actions-gotchas.md).
## Mutation testing (the ratchet)
The `mutation` job enforces test *strength*, not just coverage (CLAUDE.md §5).
[Stryker.NET](https://stryker-mutator.io/docs/stryker-net/) is pinned as a local
dotnet tool (`.config/dotnet-tools.json`), so it runs identically locally and in CI:
```bash
make mutation # dotnet tool restore + dotnet stryker on the ACL
```
Config lives in [`services/acl/stryker-config.json`](../../services/acl/stryker-config.json).
It runs in **solution mode** against `Acl.slnx`, mutating the two projects under test
(`Acl.Application`, `Acl.Infrastructure`); `Acl.Api` has no tests and is skipped.
**Baseline (the ratchet):** the ACL is the first service with branching logic, so it
sets the repo-wide baseline. Observed score **95%**; enforced `break` threshold **90%**
(one-mutant headroom over the ~20-mutant surface). Stryker exits non-zero — failing the
job — when the score drops below `break`. Per §5 the baseline only moves **up**, and only
as a slice's stated outcome; never lower it. New services add their own mutation run as
they gain logic.
The HTML report is written to `services/acl/StrykerOutput/<timestamp>/reports/` (git-ignored);
open it to see survived vs. killed mutants.
In CI the `mutation` job publishes that report as the **`acl-mutation-report`** artifact
(download it from the run's summary page). The upload step uses `if: always()`, so the
report is available even when the ratchet *fails* — which is exactly when you want to inspect
the survivors. It is the repo's first use of `actions/upload-artifact`, pinned to **`@v3`**:
`@v4` refuses to run on Gitea (its `@actions/artifact` v2 library blocks any non-github.com
server as "GHES"), while `@v3` speaks the artifact protocol Gitea implements. See
[gitea-actions-gotchas.md §4](gitea-actions-gotchas.md) (§15).
## Running the stack locally without `make` (Windows / Docker Desktop)
`make` and the bash helpers assume a Unix shell. To bring the whole stack up on a
@@ -55,8 +88,9 @@ 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 + smoke — what the pipeline runs
make ci # lint + build + unit + mutation + smoke — what the pipeline runs
make lint # or a single stage
make mutation # Stryker.NET ratchet on the ACL
make smoke # compose up --wait, curl /health, tear down
```

View File

@@ -13,6 +13,7 @@ those containers share a filesystem — or a `localhost` — breaks.
| Bind-mounted config arrives empty | `docker cp` config into external volumes | `infra/seed-config.sh` |
| `docker compose up --wait` is unsupported / flaky | poll health with `docker inspect` | `infra/wait-healthy.sh` |
| `pg_isready` passes before PostGIS is ready | add a `PostGIS_Version()` probe | the db healthchecks |
| `upload-artifact@v4` fails ("not supported on GHES") | pin `@v3` | `.gitea/workflows/ci.yaml` (`mutation` job) |
---
@@ -98,3 +99,28 @@ plus app start.
container that starts migrating in that window can fail on a missing PostGIS. So
the db healthchecks add a `SELECT PostGIS_Version()` probe, making dependents wait
for the extension, not just the port.
---
## 4. `actions/upload-artifact@v4` refuses to run on Gitea
**Symptom** — the `mutation` job's `make mutation` step passes (95% score), but the
upload step right after it fails the job:
```
::error::@actions/artifact v2.0.0+, upload-artifact@v4+ and download-artifact@v4+
are not currently supported on GHES.
❌ Failure - Main https://github.com/actions/upload-artifact@v4
```
**Why**`upload-artifact@v4` bundles `@actions/artifact` v2, which inspects the
server URL and **hard-aborts on anything that isn't `github.com`**, treating Gitea
as an unsupported GitHub Enterprise Server. The check fires regardless of whether
the Gitea server can actually store artifacts (1.24+ can). It is the *action*, not
the server, that refuses.
**Fix** — pin **`actions/upload-artifact@v3`** (and `download-artifact@v3` if ever
needed). v3 uses the older artifact protocol that Gitea implements, and has no GHES
guard. Inputs are the same (`name`, `path`, `if-no-files-found`), so it is a drop-in
swap. Do **not** bump to `@v4` until act_runner advertises github.com-compatible
artifact support.

View File

@@ -26,6 +26,7 @@ nav:
- "ADR-0002: Catalogus design": architecture/adr-0002-catalogus-design.md
- "ADR-0003: ACL default-fill": architecture/adr-0003-default-fill.md
- "ADR-0004: BDD framework": architecture/adr-0004-bdd-framework.md
- "ADR-0005: Mutation testing": architecture/adr-0005-mutation-testing.md
- Working in Gitea: gitea-workflow.md
- Runbooks:
- CI: runbooks/ci.md

View File

@@ -44,4 +44,21 @@ public class AclServiceTests
Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype);
Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum);
}
[Fact]
public async Task Rejects_a_null_registration_without_calling_the_gateway()
{
var gateway = new FakeGateway();
var defaults = new AclDefaults
{
Bronorganisatie = "517439943",
VerantwoordelijkeOrganisatie = "517439943",
Vertrouwelijkheidaanduiding = "openbaar",
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
};
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
await Assert.ThrowsAsync<ArgumentNullException>(() => service.OpenZaakAsync(null!));
Assert.Null(gateway.Captured);
}
}

View File

@@ -1,5 +1,7 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Acl.Application;
using Acl.Infrastructure;
@@ -14,38 +16,120 @@ public class OpenZaakGatewayTests
=> onSend(request);
}
[Fact]
public async Task Posts_zaak_to_openzaak_with_bearer_and_default_fields_and_returns_url()
private static OpenZaakGateway Gateway(StubHandler handler) => new(
new HttpClient(handler),
new OpenZaakOptions { BaseUrl = new("http://openzaak"), ClientId = "cid", Secret = "sec" });
private static ZaakRequest SampleRequest() => new(
"517439943", "517439943", "openbaar",
new("http://openzaak/catalogi/api/v1/zaaktypen/big"), new DateOnly(2026, 6, 4));
private static StubHandler Created(out RequestCapture capture)
{
HttpRequestMessage? seen = null;
string? body = null;
var handler = new StubHandler(async req =>
var c = new RequestCapture();
capture = c;
return new StubHandler(async req =>
{
seen = req;
body = await req.Content!.ReadAsStringAsync();
c.Seen = req;
c.Body = req.Content is null ? null : await req.Content.ReadAsStringAsync();
return new HttpResponseMessage(HttpStatusCode.Created)
{
Content = JsonContent.Create(new { url = "http://openzaak/zaken/api/v1/zaken/xyz" }),
};
});
var gateway = new OpenZaakGateway(
new HttpClient(handler),
new OpenZaakOptions { BaseUrl = new("http://openzaak"), ClientId = "cid", Secret = "sec" });
var request = new ZaakRequest(
"517439943", "517439943", "openbaar",
new("http://openzaak/catalogi/api/v1/zaaktypen/big"), new DateOnly(2026, 6, 4));
}
var url = await gateway.OpenZaakAsync(request);
private sealed class RequestCapture
{
public HttpRequestMessage? Seen;
public string? Body;
}
[Fact]
public async Task Posts_zaak_to_openzaak_with_bearer_and_default_fields_and_returns_url()
{
var handler = Created(out var capture);
var url = await Gateway(handler).OpenZaakAsync(SampleRequest());
Assert.Equal("http://openzaak/zaken/api/v1/zaken/xyz", url.ToString());
Assert.Equal(HttpMethod.Post, seen!.Method);
Assert.Equal("http://openzaak/zaken/api/v1/zaken", seen.RequestUri!.ToString());
Assert.Equal("Bearer", seen.Headers.Authorization!.Scheme);
Assert.False(string.IsNullOrWhiteSpace(seen.Headers.Authorization.Parameter));
Assert.Contains("\"bronorganisatie\":\"517439943\"", body);
Assert.Contains("\"verantwoordelijkeOrganisatie\":\"517439943\"", body);
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", body);
Assert.Contains("\"startdatum\":\"2026-06-04\"", body);
Assert.Contains("\"zaaktype\":\"http://openzaak/catalogi/api/v1/zaaktypen/big\"", body);
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://openzaak/zaken/api/v1/zaken", capture.Seen.RequestUri!.ToString());
Assert.Equal("Bearer", capture.Seen.Headers.Authorization!.Scheme);
Assert.False(string.IsNullOrWhiteSpace(capture.Seen.Headers.Authorization.Parameter));
Assert.Contains("\"bronorganisatie\":\"517439943\"", capture.Body);
Assert.Contains("\"verantwoordelijkeOrganisatie\":\"517439943\"", capture.Body);
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", capture.Body);
Assert.Contains("\"startdatum\":\"2026-06-04\"", capture.Body);
Assert.Contains("\"zaaktype\":\"http://openzaak/catalogi/api/v1/zaaktypen/big\"", capture.Body);
}
[Fact]
public async Task Sends_the_geo_crs_headers_required_by_the_zaken_api()
{
var handler = Created(out var capture);
await Gateway(handler).OpenZaakAsync(SampleRequest());
Assert.Equal("EPSG:4326", Assert.Single(capture.Seen!.Headers.GetValues("Accept-Crs")));
Assert.Equal("EPSG:4326", Assert.Single(capture.Seen.Content!.Headers.GetValues("Content-Crs")));
}
[Fact]
public async Task Mints_a_hs256_jwt_carrying_the_acl_identity_claims()
{
var handler = Created(out var capture);
await Gateway(handler).OpenZaakAsync(SampleRequest());
var parts = capture.Seen!.Headers.Authorization!.Parameter!.Split('.');
Assert.Equal(3, parts.Length);
using var header = JsonDocument.Parse(DecodeSegment(parts[0]));
Assert.Equal("HS256", header.RootElement.GetProperty("alg").GetString());
Assert.Equal("JWT", header.RootElement.GetProperty("typ").GetString());
using var payload = JsonDocument.Parse(DecodeSegment(parts[1]));
Assert.Equal("cid", payload.RootElement.GetProperty("client_id").GetString());
Assert.Equal("acl", payload.RootElement.GetProperty("user_id").GetString());
Assert.Equal("acl", payload.RootElement.GetProperty("user_representation").GetString());
}
[Fact]
public async Task Throws_when_openzaak_rejects_the_request()
{
var handler = new StubHandler(_ =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)));
await Assert.ThrowsAsync<HttpRequestException>(
() => Gateway(handler).OpenZaakAsync(SampleRequest()));
}
[Fact]
public async Task Throws_when_openzaak_returns_an_empty_body()
{
var handler = new StubHandler(_ =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.Created)
{
Content = new StringContent("null", Encoding.UTF8, "application/json"),
}));
await Assert.ThrowsAsync<InvalidOperationException>(
() => Gateway(handler).OpenZaakAsync(SampleRequest()));
}
[Fact]
public async Task Rejects_a_null_request()
{
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
await Assert.ThrowsAsync<ArgumentNullException>(
() => Gateway(handler).OpenZaakAsync(null!));
}
// ZGW tokens are base64url with padding stripped (ZgwToken.B64Url); restore it to decode.
private static string DecodeSegment(string segment)
{
var b64 = segment.Replace('-', '+').Replace('_', '/');
b64 = (b64.Length % 4) switch { 2 => b64 + "==", 3 => b64 + "=", _ => b64 };
return Encoding.UTF8.GetString(Convert.FromBase64String(b64));
}
}

View File

@@ -0,0 +1,11 @@
{
"stryker-config": {
"solution": "Acl.slnx",
"reporters": ["progress", "html"],
"thresholds": {
"high": 95,
"low": 90,
"break": 90
}
}
}