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:
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user