From 6cfd70eeebd665712ebfff524371e0fed4f2a537 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Wed, 5 Aug 2026 15:20:36 +0200 Subject: [PATCH] fix(backend): resolve besluit endpoint's id via Referentie, not local PK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /beoordeling/{id}/besluit always 404'd against a real OpenZaak: {id} is the FE-facing case id from IZaakSource.ListCases, which under OpenZaakZaakSource is the ZGW zaak's own uuid, not ApplicationStore's primary key. Resolve the case through ListCases first (same seam the GET sibling already uses), then to the local Aanvraag via its Referentie — the one identifier stable across both sources. Adds ApplicationStore.GetByReferentie and a regression test that reproduces the divergence with a decorating IZaakSource test double instead of a live OpenZaak. Co-Authored-By: Claude Opus 5 --- backend/openzaak/README.md | 2 +- backend/openzaak/bootstrap-catalogus.sh | 4 +- .../BigRegister.Api/Data/ApplicationStore.cs | 15 +++ backend/src/BigRegister.Api/Program.cs | 14 ++- .../BigRegister.Api/Zgw/OpenZaakZaakSource.cs | 74 +++++++----- .../BeoordelingIdMismatchTests.cs | 87 ++++++++++++++ .../OpenZaakIntegrationTests.cs | 32 ++++-- .../OpenZaakZaakSourceTests.cs | 106 ++++++++++++++++-- docs/reference/openzaak-integration.md | 25 +++-- .../src/infrastructure/me.adapter.spec.ts | 11 ++ libs/shared/src/infrastructure/me.adapter.ts | 1 + scripts/openzaak-ui-up.sh | 2 +- 12 files changed, 310 insertions(+), 63 deletions(-) create mode 100644 backend/tests/BigRegister.Tests/BeoordelingIdMismatchTests.cs diff --git a/backend/openzaak/README.md b/backend/openzaak/README.md index b49488a..e47a19b 100644 --- a/backend/openzaak/README.md +++ b/backend/openzaak/README.md @@ -50,7 +50,7 @@ dotnet test --filter Category=Integration `OpenZaakIntegrationTests.cs` points a `WebApplicationFactory` at `Zgw:Enabled=true` + `http://localhost:8000` with the harness's credentials, hits `GET /api/v1/admin/cases`, and asserts the seeded zaak comes back — through the real HTTP + -JWT + Catalogi-label-resolution path, not a mock. This test is tagged `Category=Integration` +JWT + zaaktype→aanvraag-type mapping path, not a mock. This test is tagged `Category=Integration` and is **excluded** from the default `dotnet test` run and from CI (`ci.yml`, `scripts/ci-local.sh` both filter `Category!=Integration`) — it only passes with this harness up, so it never runs where the harness doesn't exist. diff --git a/backend/openzaak/bootstrap-catalogus.sh b/backend/openzaak/bootstrap-catalogus.sh index 22a40f0..96e79f9 100755 --- a/backend/openzaak/bootstrap-catalogus.sh +++ b/backend/openzaak/bootstrap-catalogus.sh @@ -168,8 +168,8 @@ print(json.dumps({ echo " created: $zaaktype_url" fi -echo "Granting zrc scopes (zaken.aanmaken, zaken.bijwerken, zaken.lezen), scoped to $zaaktype_url — the one zaaktype this harness (and the BFF's Zgw:ZaaktypeUrls config) ever uses..." -grant_scopes zrc '["zaken.aanmaken", "zaken.bijwerken", "zaken.lezen"]' \ +echo "Granting zrc scopes (zaken.aanmaken, zaken.bijwerken, zaken.lezen, zaken.statussen.toevoegen), scoped to $zaaktype_url — the one zaaktype this harness (and the BFF's Zgw:ZaaktypeUrls config) ever uses. zaken.statussen.toevoegen is needed for WP-66's besluit write: zaken.aanmaken only covers the ONE status set at zaak creation, a later status (the besluit's eindstatus) needs this scope or OpenZaak 403s ('mag je slechts 1 status zetten')..." +grant_scopes zrc '["zaken.aanmaken", "zaken.bijwerken", "zaken.lezen", "zaken.statussen.toevoegen"]' \ "zaaktype=\"$zaaktype_url\"" \ 'max_vertrouwelijkheidaanduiding="openbaar"' diff --git a/backend/src/BigRegister.Api/Data/ApplicationStore.cs b/backend/src/BigRegister.Api/Data/ApplicationStore.cs index 3a62ba1..8e64d04 100644 --- a/backend/src/BigRegister.Api/Data/ApplicationStore.cs +++ b/backend/src/BigRegister.Api/Data/ApplicationStore.cs @@ -134,6 +134,21 @@ public static class ApplicationStore } } + /// Cross-owner lookup by Referentie — real bug fix (WP-66): the behandelaar besluit + /// endpoint receives the FE-facing case id from IZaakSource.ListCases, which under + /// OpenZaakZaakSource is the ZGW zaak's own uuid, NOT this store's primary key (only + /// LocalZaakSource's id happens to already be the Aanvraag.Id — every besluit 404'd + /// against a real OpenZaak). Referentie is the one identifier stable across both sources — + /// it's also what CreateZaak sent OpenZaak as identificatie. + public static Aanvraag? GetByReferentie(string referentie) + { + lock (_gate) + { + using var db = Db.Create(); + return db.Applications.FirstOrDefault(a => a.Referentie == referentie); + } + } + /// Admin: every case across all owners (WP-36). The per-owner List is the norm; this /// is the deliberate cross-owner read behind the admin-only /admin/cases endpoint. public static IReadOnlyList ListAll() diff --git a/backend/src/BigRegister.Api/Program.cs b/backend/src/BigRegister.Api/Program.cs index 693272d..b2faab0 100644 --- a/backend/src/BigRegister.Api/Program.cs +++ b/backend/src/BigRegister.Api/Program.cs @@ -456,7 +456,13 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H return Results.Problem(detail: $"Onbekend besluit '{req.Besluit}'.", statusCode: StatusCodes.Status400BadRequest); var now = DateTimeOffset.UtcNow; - var a = ApplicationStore.GetAny(id); + // Real bug fix (WP-66): `id` is the FE-facing case id from IZaakSource.ListCases — under + // OpenZaakZaakSource that's the ZGW zaak's own uuid, not this store's primary key (a + // ListCases lookup, not ApplicationStore.GetAny(id), same seam the GET sibling above + // uses), so resolve the case first and go to the local Aanvraag via its Referentie + // (see ApplicationStore.GetByReferentie). + var c = zaken.ListCases(now).FirstOrDefault(x => x.Id == id); + var a = c?.Status.Referentie is { } referentie ? ApplicationStore.GetByReferentie(referentie) : null; var statusTag = a?.ToStatusDto(now).Tag; if (a is null || statusTag == "Concept") return Results.NotFound(); var current = Enum.Parse(statusTag!); @@ -467,8 +473,8 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H if (besluit != Besluit.Goedkeuren && string.IsNullOrWhiteSpace(req.Toelichting)) return Results.Problem(detail: "Toelichting is verplicht bij dit besluit.", statusCode: StatusCodes.Status400BadRequest); - var updated = ApplicationStore.RecordBesluit(id, besluit, req.Toelichting)!; - app.Logger.LogInformation("aanvraag besluit id={Id} besluit={Besluit}", id, besluit); + var updated = ApplicationStore.RecordBesluit(a.Id, besluit, req.Toelichting)!; + app.Logger.LogInformation("aanvraag besluit id={Id} besluit={Besluit}", a.Id, besluit); // WP-60: the local decision above already committed — a ZGW failure here is caught and // flagged rather than allowed to diverge silently, same handling as submit's create-zaak @@ -479,7 +485,7 @@ api.MapPost("/beoordeling/{id}/besluit", (string id, RecordBesluitRequest req, H } catch (Exception ex) { - RecordZgwDivergence(ctx, id, updated.Referentie ?? id, ex); + RecordZgwDivergence(ctx, a.Id, updated.Referentie ?? a.Id, ex); } return Results.Ok(new RecordBesluitResponse(updated.ToStatusDto(now))); diff --git a/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs index bea3929..bef4c7b 100644 --- a/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs +++ b/backend/src/BigRegister.Api/Zgw/OpenZaakZaakSource.cs @@ -15,14 +15,17 @@ public sealed record ZgwPage( /// /// The backed by a real OpenZaak / ZGW Zaken API (WP-49 read, WP-50 -/// write). Reads zaken (following pagination), resolves each zaaktype's human label from the -/// Catalogi API (cached), and maps into via -/// . Creates a zaak + status + rol for a just-submitted aanvraag. -/// Selected only when Zgw:Enabled=true; the default stays . +/// write). Reads zaken (following pagination), maps each zaak's zaaktype URL back to the +/// internal aanvraag-type key via Zgw:ZaaktypeUrls (a local lookup — NOT OpenZaak's +/// human zaaktype label, which isn't a value 's +/// contract accepts; see ), and maps into +/// via . Creates a zaak + +/// status + rol for a just-submitted aanvraag. Selected only when Zgw:Enabled=true; +/// the default stays . /// /// Auth: a fresh HS256 JWT per request () on the Authorization -/// header. Reading a zaak needs read scope on BOTH Zaken and Catalogi (zaaktype resolution); -/// creating one additionally needs write scope on Zaken. +/// header. Creating/deciding a zaak needs read scope on Catalogi too (statustype/resultaattype/ +/// roltype resolution) in addition to write scope on Zaken; a plain read does not. /// public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, ZgwOptions options) : IZaakSource { @@ -47,17 +50,22 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, if (bsn is not null) url += $"?rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn={Uri.EscapeDataString(bsn)}"; var zaken = await GetAllAsync(url, caller); - var labels = new Dictionary(); - var result = new List(zaken.Count); - foreach (var z in zaken) - { - if (!labels.TryGetValue(z.Zaaktype, out var label)) - labels[z.Zaaktype] = label = await ZaaktypeLabelAsync(z.Zaaktype); - result.Add(ZgwZaakMapper.ToSummaryDto(z, label)); - } - return result; + return zaken.Select(z => ZgwZaakMapper.ToSummaryDto(z, AanvraagTypeFor(z.Zaaktype))).ToList(); } + /// Real, live-repro'd bug (behandelportal's werkvoorraad always failed to parse): + /// ApplicationSummaryDto.Type's contract is the internal aanvraag-type key (e.g. + /// "herregistratie" — what /Mappers.ToSummaryDto send, + /// and what the FE's AANVRAAG_TYPES trust boundary accepts), NOT OpenZaak's human + /// zaaktype label ("Herregistratie arts") this used to resolve via an extra Catalogi round + /// trip — every case failed the FE's parse boundary as soon as a real OpenZaak backed this + /// seam. A zaak's zaaktype URL round-trips back to that key via the same + /// Zgw:ZaaktypeUrls config goes the other way with — + /// no Catalogi call needed, and no label cache either. + private string AanvraagTypeFor(string zaaktypeUrl) => + options.ZaaktypeUrls.FirstOrDefault(kv => kv.Value == zaaktypeUrl).Key + ?? throw new InvalidOperationException($"No aanvraag type configured for zaaktype {zaaktypeUrl}."); + /// Follow the next links, accumulating every page's results. private async Task> GetAllAsync(string url, CallerIdentity? caller = null) { @@ -72,13 +80,6 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, return all; } - /// A zaaktype's human label (omschrijving) from the Catalogi API. - private async Task ZaaktypeLabelAsync(string zaaktypeUrl) - { - var zt = await zgw.GetAsync(zaaktypeUrl); - return zt.Omschrijving; - } - // --- Write path (WP-50): create a Zaak, then a Status, then a Rol ------------------------ /// Create a zaak for a just-submitted aanvraag: POST zaak → resolve + POST the @@ -152,7 +153,13 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, /// WP-60: no compensating transaction here either — the local decision already committed /// (ApplicationStore.RecordBesluit, called by the endpoint before this). A failure here /// is caught by the endpoint and recorded as a flagged divergence (Aanvraag.ZgwError), - /// the same way the submit endpoint's create-zaak/document writes are. + /// the same way the submit endpoint's create-zaak/document writes are. + /// + /// ZGW requires a zaak to have a Resultaat before it can reach an eindstatus (OpenZaak 400s + /// "Zaak has no resultaat" otherwise — confirmed against a real instance) — so this posts one + /// first, same "existence-only, take the first" resolution as the statustype above (the + /// harness's catalogus provisions exactly one resultaattype per zaaktype, not one per besluit + /// outcome; a real deployment mapping besluit → resultaattype is future work). public void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller) => RecordBesluitAsync(aanvraag, besluit, toelichting, now, caller).GetAwaiter().GetResult(); @@ -163,12 +170,25 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, throw new InvalidOperationException( $"Zgw:ZaaktypeUrls has no entry for aanvraag type '{aanvraag.Type}'."); + var resultaattypeUrl = await FirstResultaattypeUrlAsync(zaaktypeUrl); + await zgw.PostAsync($"{options.ZrcBaseUrl}/resultaten", + new CreateResultaatRequest(aanvraag.ZaakUrl, resultaattypeUrl), caller); + var statustypeUrl = await LastStatustypeUrlAsync(zaaktypeUrl); var toelichtingText = string.IsNullOrWhiteSpace(toelichting) ? $"{besluit}" : $"{besluit}: {toelichting}"; await zgw.PostAsync($"{options.ZrcBaseUrl}/statussen", new CreateStatusRequest( aanvraag.ZaakUrl, statustypeUrl, now, toelichtingText), caller); } + private async Task FirstResultaattypeUrlAsync(string zaaktypeUrl) + { + var page = await zgw.GetAsync>( + $"{options.ZtcBaseUrl}/resultaattypen?zaaktype={Uri.EscapeDataString(zaaktypeUrl)}"); + var first = page.Results.FirstOrDefault() + ?? throw new InvalidOperationException($"No resultaattype found for zaaktype {zaaktypeUrl}."); + return first.Url; + } + /// The counterpart to — highest volgnummer /// (the eind status) rather than lowest. private async Task LastStatustypeUrlAsync(string zaaktypeUrl) @@ -189,14 +209,14 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, return first.Url; } - private sealed record Zaaktype([property: JsonPropertyName("omschrijving")] string Omschrijving); - private sealed record Statustype( [property: JsonPropertyName("url")] string Url, [property: JsonPropertyName("volgnummer")] int Volgnummer); private sealed record Roltype([property: JsonPropertyName("url")] string Url); + private sealed record Resultaattype([property: JsonPropertyName("url")] string Url); + private sealed record CreateZaakRequest( [property: JsonPropertyName("zaaktype")] string Zaaktype, [property: JsonPropertyName("bronorganisatie")] string Bronorganisatie, @@ -210,6 +230,10 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, [property: JsonPropertyName("datumStatusGezet")] DateTimeOffset DatumStatusGezet, [property: JsonPropertyName("statustoelichting")] string Statustoelichting = ""); + private sealed record CreateResultaatRequest( + [property: JsonPropertyName("zaak")] string Zaak, + [property: JsonPropertyName("resultaattype")] string Resultaattype); + private sealed record CreateRolRequest( [property: JsonPropertyName("zaak")] string Zaak, [property: JsonPropertyName("betrokkeneType")] string BetrokkeneType, diff --git a/backend/tests/BigRegister.Tests/BeoordelingIdMismatchTests.cs b/backend/tests/BigRegister.Tests/BeoordelingIdMismatchTests.cs new file mode 100644 index 0000000..a07f4a2 --- /dev/null +++ b/backend/tests/BigRegister.Tests/BeoordelingIdMismatchTests.cs @@ -0,0 +1,87 @@ +using System.Net.Http.Json; +using BigRegister.Api.Contracts; +using BigRegister.Api.Data; +using BigRegister.Domain.Authorization; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace BigRegister.Tests; + +/// Wraps but returns a DIFFERENT case id than the +/// underlying Aanvraag.Id — reproduces exactly what OpenZaakZaakSource does in +/// production (the FE-facing case id from ListCases is the ZGW zaak's own uuid, not +/// ApplicationStore's primary key) without needing a live OpenZaak, so the besluit +/// endpoint's Referentie-based resolution (the fix below) gets coverage on every push. +file sealed class IdMismatchZaakSource : IZaakSource +{ + private readonly LocalZaakSource inner = new(); + private static ApplicationSummaryDto Rekey(ApplicationSummaryDto dto) => dto with { Id = $"zaak-{dto.Id}" }; + + public IReadOnlyList ListCases(DateTimeOffset now) => + inner.ListCases(now).Select(Rekey).ToList(); + + public IReadOnlyList ListMyCases(ZorgverlenerCaller caller, DateTimeOffset now) => + inner.ListMyCases(caller, now).Select(Rekey).ToList(); + + public (string Referentie, AanvraagStatusDto Status, string? ZaakUrl) CreateZaak( + Aanvraag aanvraag, DateTimeOffset now, CallerIdentity caller) => inner.CreateZaak(aanvraag, now, caller); + + public void RecordBesluit(Aanvraag aanvraag, Besluit besluit, string? toelichting, DateTimeOffset now, CallerIdentity caller) => + inner.RecordBesluit(aanvraag, besluit, toelichting, now, caller); +} + +/// +/// Regression for a real, live-repro'd bug: recording a besluit from the behandelportal always +/// 404'd against a real OpenZaak. Root cause — POST /beoordeling/{id}/besluit looked +/// id up directly in ApplicationStore (its own primary key), but id is +/// whatever IZaakSource.ListCases handed the FE; under OpenZaakZaakSource that's +/// the ZGW zaak's own uuid, a different value entirely. Fixed by resolving the case through +/// the same ListCases seak the GET sibling () already uses, +/// then to the local Aanvraag via its Referentie (ApplicationStore.GetByReferentie) +/// — the one identifier stable across both sources. +/// reproduces the id divergence without a live OpenZaak. +/// +public class BeoordelingIdMismatchTests +{ + private static WebApplicationFactory Factory() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"bigregister-id-mismatch-{Guid.NewGuid():N}.db"); + return new WebApplicationFactory().WithWebHostBuilder(builder => builder + .UseSetting("ConnectionStrings:AppDb", $"Data Source={dbPath}") + .ConfigureTestServices(services => services.AddSingleton())); + } + + private static HttpRequestMessage Behandelaar(HttpMethod method, string path, object? body = null) + { + var req = new HttpRequestMessage(method, path); + req.Headers.Add("X-Medewerker", "medewerker-1"); + if (body is not null) req.Content = JsonContent.Create(body); + return req; + } + + [Fact] + public async Task Besluit_resolves_by_referentie_when_the_case_id_differs_from_the_local_aanvraag_id() + { + using var factory = Factory(); + using var client = factory.CreateClient(); + + var created = await client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" }); + var app = (await created.Content.ReadFromJsonAsync())!; + var submit = await client.PostAsJsonAsync($"/api/v1/applications/{app.Id}/submit", new { diplomaHerkomst = "handmatig" }); + submit.EnsureSuccessStatusCode(); + + var werkvoorraad = await client.SendAsync(Behandelaar(HttpMethod.Get, "/api/v1/werkvoorraad")); + var items = (await werkvoorraad.Content.ReadFromJsonAsync>())!; + var caseId = Assert.Single(items).Id; + // Sanity: the id divergence this test exists for is real, not accidentally absent. + Assert.NotEqual(app.Id, caseId); + + var res = await client.SendAsync(Behandelaar(HttpMethod.Post, $"/api/v1/beoordeling/{caseId}/besluit", + new { besluit = "Afwijzen", toelichting = "onvolledig" })); + + res.EnsureSuccessStatusCode(); + var body = (await res.Content.ReadFromJsonAsync())!; + Assert.Equal("Afgewezen", body.Status.Tag); + } +} diff --git a/backend/tests/BigRegister.Tests/OpenZaakIntegrationTests.cs b/backend/tests/BigRegister.Tests/OpenZaakIntegrationTests.cs index f86c7db..84017e5 100644 --- a/backend/tests/BigRegister.Tests/OpenZaakIntegrationTests.cs +++ b/backend/tests/BigRegister.Tests/OpenZaakIntegrationTests.cs @@ -1,5 +1,7 @@ +using System.Net.Http.Headers; using System.Net.Http.Json; using BigRegister.Api.Contracts; +using BigRegister.Api.Zgw; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; @@ -7,7 +9,7 @@ namespace BigRegister.Tests; /// /// WP-54: the one test that proves the BFF actually talks to a REAL OpenZaak — auth accepted, -/// real response shapes, real pagination/zaaktype resolution — rather than the stub +/// real response shapes, real pagination/zaaktype→aanvraag-type mapping — rather than the stub /// HttpMessageHandler every other Zgw test (, /// ) uses. Requires the harness in backend/openzaak/ /// to be up and seeded first (see its README); tagged Category=Integration so it's excluded @@ -23,7 +25,7 @@ namespace BigRegister.Tests; [Trait("Category", "Integration")] public class OpenZaakIntegrationTests { - private static WebApplicationFactory Factory() + private static WebApplicationFactory Factory(string zaaktypeUrl) { var dbPath = Path.Combine(Path.GetTempPath(), $"bigregister-oz-integration-{Guid.NewGuid():N}.db"); return new WebApplicationFactory().WithWebHostBuilder(builder => builder @@ -34,23 +36,39 @@ public class OpenZaakIntegrationTests .UseSetting("Zgw:ClientId", "bigregister-test") .UseSetting("Zgw:Secret", "bigregister-test-secret") .UseSetting("Zgw:UserId", "bigregister-test") - .UseSetting("Zgw:UserRepresentation", "WP-54 integration test")); + .UseSetting("Zgw:UserRepresentation", "WP-54 integration test") + .UseSetting("Zgw:ZaaktypeUrls:herregistratie", zaaktypeUrl)); + } + + /// bootstrap-catalogus.sh mints the seeded zaaktype's uuid fresh per harness + /// instance, so unlike every other setting Factory hardcodes, this one has to be + /// discovered live — the same real HTTP + JWT this test is meant to exercise, done once up + /// front to learn the URL Zgw:ZaaktypeUrls needs (see ). + private static async Task SeededZaaktypeUrlAsync() + { + var tokenOptions = new ZgwOptions { ClientId = "bigregister-test", Secret = "bigregister-test-secret" }; + using var client = new HttpClient(); + client.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", new ZgwTokenProvider(tokenOptions).Mint()); + client.DefaultRequestHeaders.Add("Accept-Crs", "EPSG:4326"); // else OpenZaak 412s + var page = await client.GetFromJsonAsync>( + "http://localhost:8000/zaken/api/v1/zaken?identificatie=BIG-2026-000123"); + return Assert.Single(page!.Results).Zaaktype; } [Fact] public async Task Admin_cases_returns_the_seeded_zaak_mapped_through_real_HTTP_and_JWT() { - using var factory = Factory(); + using var factory = Factory(await SeededZaaktypeUrlAsync()); using var client = factory.CreateClient(); client.DefaultRequestHeaders.Add("X-Role", "admin"); // CasesAdmin gate (cases:manage) var cases = await client.GetFromJsonAsync>("/api/v1/admin/cases"); Assert.NotNull(cases); - // bootstrap-catalogus.sh seeds exactly one zaak, identificatie BIG-2026-000123, under a - // zaaktype whose omschrijving is "Herregistratie arts" — see backend/openzaak/README.md. + // bootstrap-catalogus.sh seeds exactly one zaak, identificatie BIG-2026-000123. var seeded = Assert.Single(cases!, c => c.Status.Referentie == "BIG-2026-000123"); - Assert.Equal("Herregistratie arts", seeded.Type); + Assert.Equal("herregistratie", seeded.Type); Assert.Equal("InBehandeling", seeded.Status.Tag); } } diff --git a/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs b/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs index 985546f..918898e 100644 --- a/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs +++ b/backend/tests/BigRegister.Tests/OpenZaakZaakSourceTests.cs @@ -7,42 +7,51 @@ namespace BigRegister.Tests; /// /// Exercises the OpenZaak read source against a stub HttpMessageHandler (no live server, no -/// mocking library) — the guarantee that it follows ZGW pagination, resolves + caches -/// zaaktype labels, and always sends a Bearer token. +/// mocking library) — the guarantee that it follows ZGW pagination, maps a zaak's zaaktype +/// back to the internal aanvraag-type key, and always sends a Bearer token. /// public class OpenZaakZaakSourceTests { private const string ZrcBase = "https://oz.example/zaken/api/v1"; private const string ZtBase = "https://oz.example/catalogi/api/v1"; + private const string ZaaktypeUrl = $"{ZtBase}/zaaktypen/zt-1"; private static string Page1 => $$""" { "count": 2, "next": "{{ZrcBase}}/zaken?page=2", "results": [ { "url": "{{ZrcBase}}/zaken/uuid-1", "identificatie": "ZAAK-1", - "zaaktype": "{{ZtBase}}/zaaktypen/zt-1", "startdatum": "2026-03-01", + "zaaktype": "{{ZaaktypeUrl}}", "startdatum": "2026-03-01", "einddatum": null, "registratiedatum": "2026-03-01" } ] } """; private static string Page2 => $$""" { "count": 2, "next": null, "results": [ { "url": "{{ZrcBase}}/zaken/uuid-2", "identificatie": "ZAAK-2", - "zaaktype": "{{ZtBase}}/zaaktypen/zt-1", "startdatum": "2026-01-01", + "zaaktype": "{{ZaaktypeUrl}}", "startdatum": "2026-01-01", "einddatum": "2026-02-01", "registratiedatum": "2026-01-01" } ] } """; - private const string Zaaktype = """{ "omschrijving": "Herregistratie arts" }"""; - [Fact] - public void Follows_pagination_caches_zaaktype_and_sends_bearer_token() + public void Follows_pagination_maps_the_internal_aanvraag_type_and_sends_bearer_token() { + // Regression for a real bug found via a live behandelportal walkthrough: this used to + // return OpenZaak's human zaaktype label ("Herregistratie arts") as Type, which the FE's + // AANVRAAG_TYPES trust boundary always rejects (it only accepts the internal key, the + // same contract LocalZaakSource honors) — every werkvoorraad load failed to parse. var handler = new ZgwStubHandler(url => url switch { _ when url == $"{ZrcBase}/zaken" => Page1, _ when url == $"{ZrcBase}/zaken?page=2" => Page2, - _ when url == $"{ZtBase}/zaaktypen/zt-1" => Zaaktype, _ => throw new InvalidOperationException($"unexpected ZGW GET {url}"), }); - var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" }; + var options = new ZgwOptions + { + ZrcBaseUrl = ZrcBase, + ZtcBaseUrl = ZtBase, + ClientId = "c", + Secret = "s", + ZaaktypeUrls = new() { ["herregistratie"] = ZaaktypeUrl }, + }; var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options); var cases = source.ListCases(DateTimeOffset.UtcNow); @@ -50,16 +59,31 @@ public class OpenZaakZaakSourceTests // Both pages accumulated. Assert.Equal(2, cases.Count); Assert.Equal(new[] { "uuid-1", "uuid-2" }, cases.Select(c => c.Id)); - Assert.All(cases, c => Assert.Equal("Herregistratie arts", c.Type)); + Assert.All(cases, c => Assert.Equal("herregistratie", c.Type)); Assert.Equal("InBehandeling", cases[0].Status.Tag); // open Assert.Equal("Goedgekeurd", cases[1].Status.Tag); // closed - // Zaaktype resolved once despite two zaken sharing it (cache). - Assert.Single(handler.Requests, r => r.Contains("zaaktypen")); + // No Catalogi round-trip needed — the type maps back via the local Zgw:ZaaktypeUrls config. + Assert.DoesNotContain(handler.Requests, r => r.Contains("zaaktypen")); // Every outbound request carried a Bearer token. Assert.All(handler.AuthSchemes, s => Assert.Equal("Bearer", s)); } + [Fact] + public void ListCases_throws_when_a_zaak_zaaktype_has_no_configured_aanvraag_type() + { + var handler = new ZgwStubHandler(url => url switch + { + _ when url == $"{ZrcBase}/zaken" => Page1, + _ when url == $"{ZrcBase}/zaken?page=2" => Page2, + _ => throw new InvalidOperationException($"unexpected ZGW GET {url}"), + }); + var options = new ZgwOptions { ZrcBaseUrl = ZrcBase, ZtcBaseUrl = ZtBase, ClientId = "c", Secret = "s" }; + var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options); + + Assert.Throws(() => source.ListCases(DateTimeOffset.UtcNow)); + } + [Fact] public void ListMyCases_filters_by_the_callers_bsn() { @@ -174,6 +198,11 @@ public class OpenZaakZaakSourceTests { "url": "https://oz.example/catalogi/api/v1/statustypen/st-1", "volgnummer": 1 }, { "url": "https://oz.example/catalogi/api/v1/statustypen/st-2", "volgnummer": 2 } ] } """, + _ when url.StartsWith($"{ZtBase}/resultaattypen") => """ + { "count": 1, "next": null, + "results": [ { "url": "https://oz.example/catalogi/api/v1/resultaattypen/rst-1" } ] } + """, + _ when url == $"{ZrcBase}/resultaten" => "{}", _ when url == $"{ZrcBase}/statussen" => "{}", _ => throw new InvalidOperationException($"unexpected ZGW call {url}"), }); @@ -207,6 +236,59 @@ public class OpenZaakZaakSourceTests Assert.Contains("onvolledig", statusBody); } + [Fact] + public void RecordBesluit_creates_a_resultaat_before_posting_the_eindstatus() + { + // Regression for a real bug found against a live OpenZaak: posting straight to the eind + // statustype without a Resultaat first gets rejected with 400 "Zaak has no resultaat" — + // ZGW requires the Resultaat to exist before a zaak can reach its eindstatus. + const string zaaktypeUrl = $"{ZtBase}/zaaktypen/zt-registratie"; + var handler = new ZgwStubHandler(url => url switch + { + _ when url.StartsWith($"{ZtBase}/statustypen") => """ + { "count": 1, "next": null, + "results": [ { "url": "https://oz.example/catalogi/api/v1/statustypen/st-1", "volgnummer": 1 } ] } + """, + _ when url.StartsWith($"{ZtBase}/resultaattypen") => """ + { "count": 1, "next": null, + "results": [ { "url": "https://oz.example/catalogi/api/v1/resultaattypen/rst-1" } ] } + """, + _ when url == $"{ZrcBase}/resultaten" => "{}", + _ when url == $"{ZrcBase}/statussen" => "{}", + _ => throw new InvalidOperationException($"unexpected ZGW call {url}"), + }); + + var options = new ZgwOptions + { + ZrcBaseUrl = ZrcBase, + ZtcBaseUrl = ZtBase, + ClientId = "c", + Secret = "s", + ZaaktypeUrls = new() { ["registratie"] = zaaktypeUrl }, + }; + var source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options); + var aanvraag = new Aanvraag + { + Id = "a1", + Type = "registratie", + Owner = "111222333", + Referentie = "BIG-2026-000123", + ZaakUrl = $"{ZrcBase}/zaken/uuid-existing", + }; + var caller = new MedewerkerCaller("m1", new[] { MedewerkerRol.Behandelaar }, "Medewerker Test", PrincipalRole.Drafter); + + source.RecordBesluit(aanvraag, Besluit.Goedkeuren, null, DateTimeOffset.UtcNow, caller); + + var resultaatBody = handler.BodyOf($"{ZrcBase}/resultaten"); + Assert.Contains($"{ZrcBase}/zaken/uuid-existing", resultaatBody); + Assert.Contains("resultaattypen/rst-1", resultaatBody); + + // The Resultaat must exist BEFORE the eindstatus is posted, not after. + Assert.True( + handler.Requests.IndexOf($"{ZrcBase}/resultaten") < handler.Requests.IndexOf($"{ZrcBase}/statussen"), + "expected /resultaten to be posted before /statussen"); + } + [Fact] public void RecordBesluit_does_nothing_when_the_aanvraag_has_no_zaak() { diff --git a/docs/reference/openzaak-integration.md b/docs/reference/openzaak-integration.md index 2197748..62cc257 100644 --- a/docs/reference/openzaak-integration.md +++ b/docs/reference/openzaak-integration.md @@ -175,10 +175,13 @@ JWT's audit claims reflect the behandelaar, not a static identity. ontbreekt"). This was missing until WP-54's live harness caught it — the stub-handler tests never modelled the header, so it had shipped silently since WP-49/50. - `ZgwZaakMapper.cs` — the anti-corruption map: ZGW Zaak → `ApplicationSummaryDto`. This is - where **URL identity** becomes the trailing uuid and the **zaaktype URL** is resolved to a - human label (the cross-service join). -- `OpenZaakZaakSource.cs` — follows `{count,next,previous,results}` pagination, resolves + - caches zaaktype labels, attaches `Authorization: Bearer `. + where **URL identity** becomes the trailing uuid; `Type` takes the internal aanvraag-type + key (`AanvraagTypeFor`, below) — a real bug (found via a live behandelportal walkthrough, + fixed post-WP-66) had this carrying OpenZaak's human zaaktype label instead, which the FE's + `AANVRAAG_TYPES` trust boundary always rejected. +- `OpenZaakZaakSource.cs` — follows `{count,next,previous,results}` pagination, maps each + zaak's zaaktype URL back to the internal key via `Zgw:ZaaktypeUrls` (`AanvraagTypeFor` — a + local lookup, no Catalogi round-trip), attaches `Authorization: Bearer `. - `OpenZaakDocumentSource.cs` — DRC upload + zaak-link (WP-51), same auth/JSON pattern. - `NotificatieDto.cs` + the `POST /api/v1/zgw/notificaties` endpoint (`Program.cs`, WP-52) — the **inbound** NRC webhook, not a source/mapper: see the dedicated section below. @@ -272,13 +275,13 @@ flag, never a rollen matrix. ## The five ZGW APIs (context for later slices) -| API | Component | Used by | -| ------------ | --------- | --------------------------------------------------- | -| Zaken | ZRC | slice 1 (read), WP-50 (create) | -| Catalogi | ZTC | slice 1 (zaaktype label; also type URLs for create) | -| Documenten | DRC | WP-51 (upload + zaak↔document link) | -| Besluiten | BRC | later (formal decisions) | -| Notificaties | NRC | WP-52 (live status via webhooks, not polling) | +| API | Component | Used by | +| ------------ | --------- | ---------------------------------------------------------------- | +| Zaken | ZRC | slice 1 (read), WP-50 (create) | +| Catalogi | ZTC | WP-50/66 (statustype/roltype/resultaattype for create + besluit) | +| Documenten | DRC | WP-51 (upload + zaak↔document link) | +| Besluiten | BRC | later (formal decisions) | +| Notificaties | NRC | WP-52 (live status via webhooks, not polling) | ## How to add the next slice diff --git a/libs/shared/src/infrastructure/me.adapter.spec.ts b/libs/shared/src/infrastructure/me.adapter.spec.ts index 2262b7c..5cf5d7f 100644 --- a/libs/shared/src/infrastructure/me.adapter.spec.ts +++ b/libs/shared/src/infrastructure/me.adapter.spec.ts @@ -18,6 +18,17 @@ describe('parseMe (trust boundary)', () => { }); }); + // Regression: WP-66's `aanvraag:beoordelen` (behandelportal) shipped on the `Capability` + // type but was never added to this trust-boundary's runtime KNOWN list, so a real + // behandelaar's `/me` response had the capability silently dropped and the werkvoorraad + // page always denied — every `Capability` union member belongs in KNOWN too. + it('recognizes the behandelportal besluit capability (WP-66)', () => { + expect(parseMe({ capabilities: ['aanvraag:beoordelen'] })).toEqual({ + ok: true, + value: ['aanvraag:beoordelen'], + }); + }); + it('drops unrecognized capability strings instead of rejecting the response', () => { const r = parseMe({ capabilities: ['brief:approve', 'unknown:future-thing'] }); expect(r).toEqual({ ok: true, value: ['brief:approve'] }); diff --git a/libs/shared/src/infrastructure/me.adapter.ts b/libs/shared/src/infrastructure/me.adapter.ts index 0524a6a..ef69706 100644 --- a/libs/shared/src/infrastructure/me.adapter.ts +++ b/libs/shared/src/infrastructure/me.adapter.ts @@ -11,6 +11,7 @@ const KNOWN: readonly Capability[] = [ 'stamdata:edit', 'cases:manage', 'flags:manage', + 'aanvraag:beoordelen', ]; /** diff --git a/scripts/openzaak-ui-up.sh b/scripts/openzaak-ui-up.sh index 0daf756..8791d57 100755 --- a/scripts/openzaak-ui-up.sh +++ b/scripts/openzaak-ui-up.sh @@ -71,7 +71,7 @@ app = Applicatie.objects.get(client_ids__contains=["bigregister-test"]) app.autorisaties.filter(component="zrc").delete() app.autorisaties.create( component="zrc", - scopes=["zaken.aanmaken", "zaken.bijwerken", "zaken.lezen"], + scopes=["zaken.aanmaken", "zaken.bijwerken", "zaken.lezen", "zaken.statussen.toevoegen"], zaaktype="$container_zaaktype_url", max_vertrouwelijkheidaanduiding="openbaar", )