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>
This commit is contained in:
2026-06-25 14:57:51 +02:00
parent 89b097d015
commit 10816f5303
2 changed files with 124 additions and 23 deletions

View File

@@ -44,4 +44,21 @@ public class AclServiceTests
Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype); Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype);
Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum); 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;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Acl.Application; using Acl.Application;
using Acl.Infrastructure; using Acl.Infrastructure;
@@ -14,38 +16,120 @@ public class OpenZaakGatewayTests
=> onSend(request); => onSend(request);
} }
[Fact] private static OpenZaakGateway Gateway(StubHandler handler) => new(
public async Task Posts_zaak_to_openzaak_with_bearer_and_default_fields_and_returns_url() 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; var c = new RequestCapture();
string? body = null; capture = c;
var handler = new StubHandler(async req => return new StubHandler(async req =>
{ {
seen = req; c.Seen = req;
body = await req.Content!.ReadAsStringAsync(); c.Body = req.Content is null ? null : await req.Content.ReadAsStringAsync();
return new HttpResponseMessage(HttpStatusCode.Created) return new HttpResponseMessage(HttpStatusCode.Created)
{ {
Content = JsonContent.Create(new { url = "http://openzaak/zaken/api/v1/zaken/xyz" }), 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("http://openzaak/zaken/api/v1/zaken/xyz", url.ToString());
Assert.Equal(HttpMethod.Post, seen!.Method); Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://openzaak/zaken/api/v1/zaken", seen.RequestUri!.ToString()); Assert.Equal("http://openzaak/zaken/api/v1/zaken", capture.Seen.RequestUri!.ToString());
Assert.Equal("Bearer", seen.Headers.Authorization!.Scheme); Assert.Equal("Bearer", capture.Seen.Headers.Authorization!.Scheme);
Assert.False(string.IsNullOrWhiteSpace(seen.Headers.Authorization.Parameter)); Assert.False(string.IsNullOrWhiteSpace(capture.Seen.Headers.Authorization.Parameter));
Assert.Contains("\"bronorganisatie\":\"517439943\"", body); Assert.Contains("\"bronorganisatie\":\"517439943\"", capture.Body);
Assert.Contains("\"verantwoordelijkeOrganisatie\":\"517439943\"", body); Assert.Contains("\"verantwoordelijkeOrganisatie\":\"517439943\"", capture.Body);
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", body); Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", capture.Body);
Assert.Contains("\"startdatum\":\"2026-06-04\"", body); Assert.Contains("\"startdatum\":\"2026-06-04\"", capture.Body);
Assert.Contains("\"zaaktype\":\"http://openzaak/catalogi/api/v1/zaaktypen/big\"", 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));
} }
} }