test(acl): raise the Objecten gateway above the mutation ratchet (refs #149)

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%.
This commit is contained in:
not
2026-08-14 09:29:03 +02:00
parent 3705a18e18
commit 5502e4c099
+104 -5
View File
@@ -19,7 +19,8 @@ public class ObjectenGatewayTests
public DateOnly Today { get; } = today; 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"; private const string ObjecttypeUrl = "http://objecttypen:8000/api/v2/objecttypes/ot-1";
@@ -27,13 +28,17 @@ public class ObjectenGatewayTests
new( new(
new HttpClient(new StubHandler(async req => 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( sent.Add(new Sent(
req.Method, req.Method,
req.RequestUri!, req.RequestUri!,
req.Content is null ? null : await req.Content.ReadAsStringAsync(), ContentLength: req.Content?.Headers.ContentLength,
req.Headers.Authorization?.ToString(), Body: req.Content is null ? null : await req.Content.ReadAsStringAsync(),
req.Content?.Headers.TryGetValues("Content-Crs", out var c) == true ? string.Join(",", c!) : null, Auth: req.Headers.Authorization?.ToString(),
req.Headers.TryGetValues("Accept-Crs", out var a) ? string.Join(",", a) : null)); 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); return respond(req);
})), })),
new ObjectenOptions new ObjectenOptions
@@ -192,6 +197,100 @@ public class ObjectenGatewayTests
var error = await Assert.ThrowsAsync<HttpRequestException>(() => gateway.UpsertAsync(Record())); var error = await Assert.ThrowsAsync<HttpRequestException>(() => gateway.UpsertAsync(Record()));
Assert.Contains("schema mismatch", error.Message); 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<Sent>();
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<HttpRequestException>(() => 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<Sent>();
var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.Unauthorized)
{
Content = new StringContent("{\"detail\":\"invalid token\"}"),
});
var error = await Assert.ThrowsAsync<HttpRequestException>(() => 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<Sent>();
var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("null", System.Text.Encoding.UTF8, "application/json"),
});
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => 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<Sent>();
// 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<object>())
: Json(new { }));
await Assert.ThrowsAsync<InvalidOperationException>(() => gateway.UpsertAsync(Record()));
}
[Fact]
public async Task Creates_the_object_when_the_search_response_carries_no_results_key()
{
var sent = new List<Sent>();
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<Sent>();
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<Sent>();
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] [Fact]