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", )