From 10816f5303fa29f8464768cbf9699e1e68b5f48f Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Thu, 25 Jun 2026 14:57:51 +0200 Subject: [PATCH] =?UTF-8?q?test(acl):=20kill=20surviving=20mutants=20?= =?UTF-8?q?=E2=80=94=20assert=20CRS=20headers,=20guards,=20error=20paths,?= =?UTF-8?q?=20JWT=20claims=20(refs=20#47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- services/acl/Acl.Tests/AclServiceTests.cs | 17 +++ .../acl/Acl.Tests/OpenZaakGatewayTests.cs | 130 ++++++++++++++---- 2 files changed, 124 insertions(+), 23 deletions(-) diff --git a/services/acl/Acl.Tests/AclServiceTests.cs b/services/acl/Acl.Tests/AclServiceTests.cs index e1348e1..d1bc38b 100644 --- a/services/acl/Acl.Tests/AclServiceTests.cs +++ b/services/acl/Acl.Tests/AclServiceTests.cs @@ -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(() => service.OpenZaakAsync(null!)); + Assert.Null(gateway.Captured); + } } diff --git a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs index b6aa129..5110660 100644 --- a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs +++ b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs @@ -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( + () => 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( + () => 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( + () => 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)); } }