From 5502e4c0991943ff09c5c846641e543b22581a39 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 14 Aug 2026 09:29:03 +0200 Subject: [PATCH] test(acl): raise the Objecten gateway above the mutation ratchet (refs #149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new gateway landed at 77.6%, dragging the ACL score under its 90 break threshold. The gaps were all real behaviour nobody was asserting: a failed or empty read being mistaken for "nothing there yet" and followed by a blind write, a `results`-less response taking down the resolve with an ArgumentNullException, the CRS headers going to Objecttypen (which is not a geo API), and the write body being sent chunked. ACL score 86.63% → 92.08%. --- .../acl/Acl.Tests/ObjectenGatewayTests.cs | 109 +++++++++++++++++- 1 file changed, 104 insertions(+), 5 deletions(-) diff --git a/services/acl/Acl.Tests/ObjectenGatewayTests.cs b/services/acl/Acl.Tests/ObjectenGatewayTests.cs index 6a3de55..d42625c 100644 --- a/services/acl/Acl.Tests/ObjectenGatewayTests.cs +++ b/services/acl/Acl.Tests/ObjectenGatewayTests.cs @@ -19,7 +19,8 @@ public class ObjectenGatewayTests public DateOnly Today { get; } = today; } - private sealed record Sent(HttpMethod Method, Uri Uri, string? Body, string? Auth, string? ContentCrs, string? AcceptCrs); + private sealed record Sent( + HttpMethod Method, Uri Uri, string? Body, string? Auth, string? ContentCrs, string? AcceptCrs, long? ContentLength); private const string ObjecttypeUrl = "http://objecttypen:8000/api/v2/objecttypes/ot-1"; @@ -27,13 +28,17 @@ public class ObjectenGatewayTests new( new HttpClient(new StubHandler(async req => { + // Read the length BEFORE the body: ReadAsStringAsync buffers the content and would set + // ContentLength as a side effect, masking whether the gateway buffered it itself (uwsgi + // rejects a chunked body). sent.Add(new Sent( req.Method, req.RequestUri!, - req.Content is null ? null : await req.Content.ReadAsStringAsync(), - req.Headers.Authorization?.ToString(), - req.Content?.Headers.TryGetValues("Content-Crs", out var c) == true ? string.Join(",", c!) : null, - req.Headers.TryGetValues("Accept-Crs", out var a) ? string.Join(",", a) : null)); + ContentLength: req.Content?.Headers.ContentLength, + Body: req.Content is null ? null : await req.Content.ReadAsStringAsync(), + Auth: req.Headers.Authorization?.ToString(), + ContentCrs: req.Content?.Headers.TryGetValues("Content-Crs", out var c) == true ? string.Join(",", c!) : null, + AcceptCrs: req.Headers.TryGetValues("Accept-Crs", out var a) ? string.Join(",", a) : null)); return respond(req); })), new ObjectenOptions @@ -192,6 +197,100 @@ public class ObjectenGatewayTests var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); Assert.Contains("schema mismatch", error.Message); + Assert.Contains("Creating the register record", error.Message); + } + + [Fact] + public async Task Surfaces_the_objecten_error_body_when_an_update_is_rejected() + { + var sent = new List(); + object[] existing = [new { url = "http://objecten:8000/api/v2/objects/obj-9" }]; + var gateway = Gateway(sent, req => req.Method == HttpMethod.Patch + ? new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("{\"detail\":\"stale version\"}") } + : Route(req, existing)); + + var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + Assert.Contains("stale version", error.Message); + Assert.Contains("Updating the register record", error.Message); + } + + [Fact] + public async Task Surfaces_a_failed_read_instead_of_writing_blind() + { + var sent = new List(); + var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.Unauthorized) + { + Content = new StringContent("{\"detail\":\"invalid token\"}"), + }); + + var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + Assert.Contains("Querying objecttypen", error.Message); + Assert.Contains("invalid token", error.Message); + // A read that failed must never be mistaken for "nothing there yet" and followed by a write. + Assert.DoesNotContain(sent, s => s.Method == HttpMethod.Post || s.Method == HttpMethod.Patch); + } + + [Fact] + public async Task Surfaces_an_empty_read_body_rather_than_dereferencing_it() + { + var sent = new List(); + var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("null", System.Text.Encoding.UTF8, "application/json"), + }); + + var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + Assert.Contains("objecttypen", error.Message); + } + + [Fact] + public async Task Treats_a_result_less_response_as_no_match_rather_than_crashing() + { + var sent = new List(); + // Neither collection carries a `results` key — the objecttype is absent, which must surface as + // the "not registered" error rather than a NullReferenceException. + var gateway = Gateway(sent, req => req.RequestUri!.AbsolutePath.EndsWith("/versions", StringComparison.Ordinal) + ? Json(Array.Empty()) + : Json(new { })); + + await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + } + + [Fact] + public async Task Creates_the_object_when_the_search_response_carries_no_results_key() + { + var sent = new List(); + var gateway = Gateway(sent, req => req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath == "/api/v2/objects" + ? Json(new { }) + : Route(req, [])); + + await gateway.UpsertAsync(Record()); + + Assert.Contains(sent, s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects"); + } + + [Fact] + public async Task Reads_objecttypen_without_the_crs_headers_it_does_not_accept() + { + var sent = new List(); + + await Gateway(sent, req => Route(req, [])).UpsertAsync(Record()); + + // Objecttypen is not a geo API; only the Objecten hops carry CRS. + Assert.All( + sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)), + s => Assert.Null(s.AcceptCrs)); + } + + [Fact] + public async Task Buffers_the_write_body_so_uwsgi_gets_a_content_length() + { + var sent = new List(); + + await Gateway(sent, req => Route(req, [])).UpsertAsync(Record()); + + var write = sent.Single(s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects"); + Assert.NotNull(write.ContentLength); } [Fact]