From 9089bd5e884d56cdf8f1d8c2a2e57217671f8ebe Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Tue, 14 Jul 2026 10:39:19 +0200 Subject: [PATCH] fix(acl): set a resultaat before the eindstatus so OpenZaak accepts the status (refs #75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status-set integration test hit 400 "resultaat-does-not-exist: Zaak has no resultaat" — OpenZaak refuses a zaak's eindstatus until the zaak has a resultaat. Resolve the zaaktype's resultaattype and POST /resultaten before POST /statussen. Also surface the ZGW response body in the failure exception (EnsureSuccessStatusCode hid the 400 detail that pinpointed this). Reworked the gateway with shared GetCatalogusAsync/PostAsync helpers and rewrote the stub-handler tests for the new 4-call flow incl. failure paths. Verified against real OpenZaak (integration test green) and locally: acl mutation 100%. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../acl/Acl.Infrastructure/OpenZaakGateway.cs | 88 ++++++--- .../acl/Acl.Tests/OpenZaakGatewayTests.cs | 174 +++++++++++++----- 2 files changed, 192 insertions(+), 70 deletions(-) diff --git a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs index 10a3c3f..523f215 100644 --- a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs +++ b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs @@ -47,40 +47,51 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : ArgumentNullException.ThrowIfNull(zaaktypeUrl); var eindstatus = await ResolveEindstatusAsync(zaaktypeUrl, ct); + var resultaattype = await ResolveResultaattypeAsync(zaaktypeUrl, ct); - using var message = new HttpRequestMessage( - HttpMethod.Post, new Uri(options.BaseUrl, "/zaken/api/v1/statussen")) + // OpenZaak refuses to set a zaak's eindstatus unless the zaak has a resultaat + // ("resultaat-does-not-exist"), so record the resultaat first, then the status. + await PostAsync("/zaken/api/v1/resultaten", + new ResultaatDto(zaakUrl.ToString(), resultaattype.ToString()), "Setting the zaak resultaat", ct); + + await PostAsync("/zaken/api/v1/statussen", + // datumStatusGezet is a ZGW date-time; set it at the start of the given day (UTC). + new StatusDto(zaakUrl.ToString(), eindstatus.ToString(), + datumStatusGezet.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ")), + "Setting the zaak status", ct); + } + + // POSTs a non-geo ZGW resource (resultaat/status — no CRS headers). Buffers the body so uwsgi gets + // a Content-Length instead of a chunked body (as with zaak-create). + private async Task PostAsync(string path, object dto, string action, CancellationToken ct) + { + using var message = new HttpRequestMessage(HttpMethod.Post, new Uri(options.BaseUrl, path)) { - Content = JsonContent.Create(new StatusDto( - zaakUrl.ToString(), - eindstatus.ToString(), - // datumStatusGezet is a ZGW date-time; set it at the start of the given day (UTC). - datumStatusGezet.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ"))), + Content = JsonContent.Create(dto), }; message.Headers.Authorization = new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret)); - // The status resource carries no geometry, so no CRS headers. As with zaak-create, buffer the - // body so uwsgi gets a Content-Length instead of a chunked body. await message.Content.LoadIntoBufferAsync(ct); using var response = await http.SendAsync(message, ct); - response.EnsureSuccessStatusCode(); + await EnsureSuccessAsync(response, action, ct); + } + + // EnsureSuccessStatusCode discards the response body; ZGW returns a JSON problem detail on 400 that + // is essential for diagnosing a rejected request, so surface it in the exception. + private static async Task EnsureSuccessAsync(HttpResponseMessage response, string action, CancellationToken ct) + { + if (response.IsSuccessStatusCode) + return; + + var body = await response.Content.ReadAsStringAsync(ct); + throw new HttpRequestException($"{action} failed: {(int)response.StatusCode} {response.ReasonPhrase}. {body}"); } /// Resolve the zaaktype's eindstatus (the terminal statustype) from the catalogus. private async Task ResolveEindstatusAsync(Uri zaaktypeUrl, CancellationToken ct) { - var query = new Uri(options.BaseUrl, - "/catalogi/api/v1/statustypen?status=alles&zaaktype=" + Uri.EscapeDataString(zaaktypeUrl.ToString())); - using var message = new HttpRequestMessage(HttpMethod.Get, query); - message.Headers.Authorization = - new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret)); - - using var response = await http.SendAsync(message, ct); - response.EnsureSuccessStatusCode(); - - var page = await response.Content.ReadFromJsonAsync(ct) - ?? throw new InvalidOperationException("OpenZaak returned an empty statustypen response"); + var page = await GetCatalogusAsync("statustypen", zaaktypeUrl, "statustypen", ct); var results = page.Results ?? []; // OpenZaak flags the terminal statustype (highest volgnummer) as isEindstatus; fall back to the @@ -91,6 +102,31 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : return new Uri(eindstatus.Url); } + /// Resolve the zaaktype's resultaattype from the catalogus (the seed defines one). + private async Task ResolveResultaattypeAsync(Uri zaaktypeUrl, CancellationToken ct) + { + var page = await GetCatalogusAsync("resultaattypen", zaaktypeUrl, "resultaattypen", ct); + var resultaattype = (page.Results ?? []).FirstOrDefault() + ?? throw new InvalidOperationException($"No resultaattypen found for zaaktype {zaaktypeUrl}"); + return new Uri(resultaattype.Url); + } + + // GETs a catalogus collection filtered by zaaktype (status=alles includes concept + published). + private async Task GetCatalogusAsync(string resource, Uri zaaktypeUrl, string label, CancellationToken ct) + { + var query = new Uri(options.BaseUrl, + $"/catalogi/api/v1/{resource}?status=alles&zaaktype=" + Uri.EscapeDataString(zaaktypeUrl.ToString())); + using var message = new HttpRequestMessage(HttpMethod.Get, query); + message.Headers.Authorization = + new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret)); + + using var response = await http.SendAsync(message, ct); + await EnsureSuccessAsync(response, $"Querying {label}", ct); + + return await response.Content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException($"OpenZaak returned an empty {label} response"); + } + private sealed record ZaakDto( [property: JsonPropertyName("bronorganisatie")] string Bronorganisatie, [property: JsonPropertyName("zaaktype")] string Zaaktype, @@ -113,4 +149,14 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : [property: JsonPropertyName("url")] string Url, [property: JsonPropertyName("volgnummer")] int Volgnummer, [property: JsonPropertyName("isEindstatus")] bool IsEindstatus); + + private sealed record ResultaatDto( + [property: JsonPropertyName("zaak")] string Zaak, + [property: JsonPropertyName("resultaattype")] string Resultaattype); + + private sealed record ResultaattypePage( + [property: JsonPropertyName("results")] IReadOnlyList? Results); + + private sealed record ResultaattypeDto( + [property: JsonPropertyName("url")] string Url); } diff --git a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs index 8b9388e..7ca0271 100644 --- a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs +++ b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs @@ -155,28 +155,50 @@ public class OpenZaakGatewayTests public List Requests { get; } = []; public List Bodies { get; } = []; public List ContentLengths { get; } = []; + + public int IndexOf(string pathContains) => + Requests.FindIndex(r => r.RequestUri!.ToString().Contains(pathContains)); + + // The (body, content-length, request) of the single request whose URL contains the segment. + public (string? Body, long? Length, HttpRequestMessage Request) Sent(string pathContains) + { + var i = IndexOf(pathContains); + return (Bodies[i], ContentLengths[i], Requests[i]); + } } - // Routes the two calls the approval makes: GET statustypen (returns the given page) then POST statussen. - private static StubHandler ApprovalStub(Recorder rec, string statustypenJson, - HttpStatusCode getStatus = HttpStatusCode.OK, HttpStatusCode postStatus = HttpStatusCode.Created) - => new(async req => - { - rec.Requests.Add(req); - // Capture the length BEFORE reading the body (ReadAsStringAsync buffers as a side effect, - // which would mask whether the gateway buffered it itself — mirrors the zaak-create tests). - rec.ContentLengths.Add(req.Content?.Headers.ContentLength); - rec.Bodies.Add(req.Content is null ? null : await req.Content.ReadAsStringAsync()); - if (req.Method == HttpMethod.Get) - return new HttpResponseMessage(getStatus) - { - Content = new StringContent(statustypenJson, Encoding.UTF8, "application/json"), - }; - return new HttpResponseMessage(postStatus) - { - Content = JsonContent.Create(new { url = "http://openzaak/zaken/api/v1/statussen/new" }), - }; - }); + // Per-route response config for the four calls the approval makes. + private sealed class OzRoutes + { + public string StatustypenJson { get; init; } = StatustypenPage(withEindstatusFlag: true); + public string ResultaattypenJson { get; init; } = """{"results":[{"url":"http://openzaak/catalogi/api/v1/resultaattypen/1"}]}"""; + public HttpStatusCode StatustypenStatus { get; init; } = HttpStatusCode.OK; + public HttpStatusCode ResultaattypenStatus { get; init; } = HttpStatusCode.OK; + public HttpStatusCode ResultaatPostStatus { get; init; } = HttpStatusCode.Created; + public HttpStatusCode StatusPostStatus { get; init; } = HttpStatusCode.Created; + } + + // Routes the approval's four calls by URL: GET /statustypen, GET /resultaattypen (catalogus), + // then POST /resultaten and POST /statussen (zaken). + private static StubHandler ApprovalStub(Recorder rec, OzRoutes routes) => new(async req => + { + rec.Requests.Add(req); + // Capture the length BEFORE reading the body (ReadAsStringAsync buffers as a side effect). + rec.ContentLengths.Add(req.Content?.Headers.ContentLength); + rec.Bodies.Add(req.Content is null ? null : await req.Content.ReadAsStringAsync()); + + var url = req.RequestUri!.ToString(); + if (req.Method == HttpMethod.Get && url.Contains("/statustypen")) + return Json(routes.StatustypenStatus, routes.StatustypenJson); + if (req.Method == HttpMethod.Get && url.Contains("/resultaattypen")) + return Json(routes.ResultaattypenStatus, routes.ResultaattypenJson); + if (url.Contains("/resultaten")) + return Json(routes.ResultaatPostStatus, """{"url":"http://openzaak/zaken/api/v1/resultaten/new"}"""); + return Json(routes.StatusPostStatus, """{"url":"http://openzaak/zaken/api/v1/statussen/new"}"""); + }); + + private static HttpResponseMessage Json(HttpStatusCode status, string body) => + new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; // Two statustypen; the eindstatus is flagged on the *lower* volgnummer so the tests prove the // isEindstatus flag is preferred over "highest volgnummer", not coincidentally equal to it. @@ -190,32 +212,41 @@ public class OpenZaakGatewayTests }); [Fact] - public async Task Approving_queries_statustypen_then_posts_the_flagged_eindstatus_against_the_zaak() + public async Task Approving_sets_a_resultaat_then_posts_the_flagged_eindstatus_against_the_zaak() { var rec = new Recorder(); - await Gateway(ApprovalStub(rec, StatustypenPage(withEindstatusFlag: true))) + await Gateway(ApprovalStub(rec, new OzRoutes())) .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)); - Assert.Equal(2, rec.Requests.Count); + Assert.Equal(4, rec.Requests.Count); - var get = rec.Requests[0]; - Assert.Equal(HttpMethod.Get, get.Method); - Assert.StartsWith("http://openzaak/catalogi/api/v1/statustypen", get.RequestUri!.ToString()); - Assert.Contains(Uri.EscapeDataString(Zaaktype.ToString()), get.RequestUri.ToString()); - Assert.Equal("Bearer", get.Headers.Authorization!.Scheme); + // Both catalogus queries filter by the zaaktype and carry the bearer. + var statustypenGet = rec.Sent("/statustypen").Request; + Assert.Equal(HttpMethod.Get, statustypenGet.Method); + Assert.Contains(Uri.EscapeDataString(Zaaktype.ToString()), statustypenGet.RequestUri!.ToString()); + Assert.Equal("Bearer", statustypenGet.Headers.Authorization!.Scheme); + Assert.Contains(Uri.EscapeDataString(Zaaktype.ToString()), rec.Sent("/resultaattypen").Request.RequestUri!.ToString()); - var post = rec.Requests[1]; - Assert.Equal(HttpMethod.Post, post.Method); - Assert.Equal("http://openzaak/zaken/api/v1/statussen", post.RequestUri!.ToString()); - Assert.Equal("Bearer", post.Headers.Authorization!.Scheme); + // OpenZaak requires a resultaat before the eindstatus, so /resultaten precedes /statussen. + Assert.True(rec.IndexOf("/resultaten") < rec.IndexOf("/statussen")); + + var resultaat = rec.Sent("/resultaten"); + Assert.Equal("http://openzaak/zaken/api/v1/resultaten", resultaat.Request.RequestUri!.ToString()); + Assert.Equal("Bearer", resultaat.Request.Headers.Authorization!.Scheme); + Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", resultaat.Body); + Assert.Contains("\"resultaattype\":\"http://openzaak/catalogi/api/v1/resultaattypen/1\"", resultaat.Body); + Assert.True(resultaat.Length > 0); + + var status = rec.Sent("/statussen"); + Assert.Equal("http://openzaak/zaken/api/v1/statussen", status.Request.RequestUri!.ToString()); + Assert.Equal("Bearer", status.Request.Headers.Authorization!.Scheme); + Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", status.Body); // The isEindstatus-flagged statustype (/1) is chosen — even though /2 has a higher volgnummer. - Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", rec.Bodies[1]); - Assert.Contains("\"statustype\":\"http://openzaak/catalogi/api/v1/statustypen/1\"", rec.Bodies[1]); - Assert.Contains("\"datumStatusGezet\":\"2026-06-04T00:00:00Z\"", rec.Bodies[1]); - // The POST body is buffered (Content-Length set), so uwsgi doesn't get a chunked body. - Assert.NotNull(rec.ContentLengths[1]); - Assert.True(rec.ContentLengths[1] > 0); + Assert.Contains("\"statustype\":\"http://openzaak/catalogi/api/v1/statustypen/1\"", status.Body); + Assert.Contains("\"datumStatusGezet\":\"2026-06-04T00:00:00Z\"", status.Body); + // Bodies are buffered (Content-Length set), so uwsgi doesn't get a chunked body. + Assert.True(status.Length > 0); } [Fact] @@ -223,11 +254,11 @@ public class OpenZaakGatewayTests { var rec = new Recorder(); - await Gateway(ApprovalStub(rec, StatustypenPage(withEindstatusFlag: false))) + await Gateway(ApprovalStub(rec, new OzRoutes { StatustypenJson = StatustypenPage(withEindstatusFlag: false) })) .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)); // No isEindstatus flag → the highest volgnummer (/2) is chosen. - Assert.Contains("\"statustype\":\"http://openzaak/catalogi/api/v1/statustypen/2\"", rec.Bodies[1]); + Assert.Contains("\"statustype\":\"http://openzaak/catalogi/api/v1/statustypen/2\"", rec.Sent("/statussen").Body); } [Fact] @@ -237,11 +268,11 @@ public class OpenZaakGatewayTests // A page with no `results` property (Results is null) — the eindstatus cannot be resolved. var ex = await Assert.ThrowsAsync(() => - Gateway(ApprovalStub(rec, "{}")) + Gateway(ApprovalStub(rec, new OzRoutes { StatustypenJson = "{}" })) .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4))); Assert.Contains("No statustypen found", ex.Message); - // It never posts a status when it cannot resolve the eindstatus. + // It never posts anything when it cannot resolve the eindstatus. Assert.Single(rec.Requests); } @@ -251,7 +282,7 @@ public class OpenZaakGatewayTests var rec = new Recorder(); var ex = await Assert.ThrowsAsync(() => - Gateway(ApprovalStub(rec, "null")) + Gateway(ApprovalStub(rec, new OzRoutes { StatustypenJson = "null" })) .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4))); Assert.Contains("empty statustypen", ex.Message); @@ -263,9 +294,53 @@ public class OpenZaakGatewayTests { var rec = new Recorder(); - await Assert.ThrowsAsync(() => - Gateway(ApprovalStub(rec, "{}", getStatus: HttpStatusCode.InternalServerError)) + var ex = await Assert.ThrowsAsync(() => + Gateway(ApprovalStub(rec, new OzRoutes { StatustypenStatus = HttpStatusCode.InternalServerError })) .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4))); + + Assert.Contains("Querying statustypen", ex.Message); + } + + [Fact] + public async Task Approving_throws_when_the_resultaattypen_query_fails() + { + var rec = new Recorder(); + + var ex = await Assert.ThrowsAsync(() => + Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenStatus = HttpStatusCode.InternalServerError })) + .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4))); + + Assert.Contains("Querying resultaattypen", ex.Message); + Assert.Equal(-1, rec.IndexOf("/resultaten")); + } + + [Fact] + public async Task Approving_throws_when_the_zaaktype_has_no_resultaattype() + { + var rec = new Recorder(); + + var ex = await Assert.ThrowsAsync(() => + Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenJson = "{}" })) + .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4))); + + Assert.Contains("No resultaattypen found", ex.Message); + // Resolved the eindstatus + queried resultaattypen, but posted nothing. + Assert.Equal(-1, rec.IndexOf("/resultaten")); + Assert.Equal(-1, rec.IndexOf("/statussen")); + } + + [Fact] + public async Task Approving_throws_when_posting_the_resultaat_fails() + { + var rec = new Recorder(); + + var ex = await Assert.ThrowsAsync(() => + Gateway(ApprovalStub(rec, new OzRoutes { ResultaatPostStatus = HttpStatusCode.BadRequest })) + .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4))); + + Assert.Contains("Setting the zaak resultaat", ex.Message); + // The status is never posted if the resultaat could not be recorded. + Assert.Equal(-1, rec.IndexOf("/statussen")); } [Fact] @@ -273,12 +348,13 @@ public class OpenZaakGatewayTests { var rec = new Recorder(); - await Assert.ThrowsAsync(() => - Gateway(ApprovalStub(rec, StatustypenPage(withEindstatusFlag: true), postStatus: HttpStatusCode.BadRequest)) + var ex = await Assert.ThrowsAsync(() => + Gateway(ApprovalStub(rec, new OzRoutes { StatusPostStatus = HttpStatusCode.BadRequest })) .SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4))); - // It resolved the eindstatus and attempted the POST before failing. - Assert.Equal(2, rec.Requests.Count); + Assert.Contains("Setting the zaak status", ex.Message); + // It got as far as the resultaat + the status POST (4 calls) before failing. + Assert.Equal(4, rec.Requests.Count); } [Fact]