fix(backend): resolve besluit endpoint's id via Referentie, not local PK
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 <noreply@anthropic.com>
This commit is contained in:
@@ -50,7 +50,7 @@ dotnet test --filter Category=Integration
|
||||
`OpenZaakIntegrationTests.cs` points a `WebApplicationFactory<Program>` 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.
|
||||
|
||||
@@ -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"'
|
||||
|
||||
|
||||
@@ -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 <c>IZaakSource.ListCases</c>, which under
|
||||
/// <c>OpenZaakZaakSource</c> is the ZGW zaak's own uuid, NOT this store's primary key (only
|
||||
/// <c>LocalZaakSource</c>'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 <c>CreateZaak</c> sent OpenZaak as <c>identificatie</c>.
|
||||
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<Aanvraag> ListAll()
|
||||
|
||||
@@ -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<AanvraagStatusTag>(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)));
|
||||
|
||||
@@ -15,14 +15,17 @@ public sealed record ZgwPage<T>(
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="IZaakSource"/> 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 <see cref="ApplicationSummaryDto"/> via
|
||||
/// <see cref="ZgwZaakMapper"/>. Creates a zaak + status + rol for a just-submitted aanvraag.
|
||||
/// Selected only when <c>Zgw:Enabled=true</c>; the default stays <see cref="LocalZaakSource"/>.
|
||||
/// write). Reads zaken (following pagination), maps each zaak's zaaktype URL back to the
|
||||
/// internal aanvraag-type key via <c>Zgw:ZaaktypeUrls</c> (a local lookup — NOT OpenZaak's
|
||||
/// human zaaktype label, which isn't a value <see cref="ApplicationSummaryDto.Type"/>'s
|
||||
/// contract accepts; see <see cref="AanvraagTypeFor"/>), and maps into
|
||||
/// <see cref="ApplicationSummaryDto"/> via <see cref="ZgwZaakMapper"/>. Creates a zaak +
|
||||
/// status + rol for a just-submitted aanvraag. Selected only when <c>Zgw:Enabled=true</c>;
|
||||
/// the default stays <see cref="LocalZaakSource"/>.
|
||||
///
|
||||
/// Auth: a fresh HS256 JWT per request (<see cref="ZgwTokenProvider"/>) 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.
|
||||
/// </summary>
|
||||
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<ZgwZaak>(url, caller);
|
||||
var labels = new Dictionary<string, string>();
|
||||
var result = new List<ApplicationSummaryDto>(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();
|
||||
}
|
||||
|
||||
/// <summary>Real, live-repro'd bug (behandelportal's werkvoorraad always failed to parse):
|
||||
/// <c>ApplicationSummaryDto.Type</c>'s contract is the internal aanvraag-type key (e.g.
|
||||
/// "herregistratie" — what <see cref="LocalZaakSource"/>/<c>Mappers.ToSummaryDto</c> send,
|
||||
/// and what the FE's <c>AANVRAAG_TYPES</c> 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
|
||||
/// <c>Zgw:ZaaktypeUrls</c> config <see cref="CreateZaakAsync"/> goes the other way with —
|
||||
/// no Catalogi call needed, and no label cache either.</summary>
|
||||
private string AanvraagTypeFor(string zaaktypeUrl) =>
|
||||
options.ZaaktypeUrls.FirstOrDefault(kv => kv.Value == zaaktypeUrl).Key
|
||||
?? throw new InvalidOperationException($"No aanvraag type configured for zaaktype {zaaktypeUrl}.");
|
||||
|
||||
/// <summary>Follow the <c>next</c> links, accumulating every page's results.</summary>
|
||||
private async Task<IReadOnlyList<T>> GetAllAsync<T>(string url, CallerIdentity? caller = null)
|
||||
{
|
||||
@@ -72,13 +80,6 @@ public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens,
|
||||
return all;
|
||||
}
|
||||
|
||||
/// <summary>A zaaktype's human label (<c>omschrijving</c>) from the Catalogi API.</summary>
|
||||
private async Task<string> ZaaktypeLabelAsync(string zaaktypeUrl)
|
||||
{
|
||||
var zt = await zgw.GetAsync<Zaaktype>(zaaktypeUrl);
|
||||
return zt.Omschrijving;
|
||||
}
|
||||
|
||||
// --- Write path (WP-50): create a Zaak, then a Status, then a Rol ------------------------
|
||||
|
||||
/// <summary>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
|
||||
/// (<c>ApplicationStore.RecordBesluit</c>, called by the endpoint before this). A failure here
|
||||
/// is caught by the endpoint and recorded as a flagged divergence (<c>Aanvraag.ZgwError</c>),
|
||||
/// the same way the submit endpoint's create-zaak/document writes are.</summary>
|
||||
/// 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).</summary>
|
||||
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<JsonElement>($"{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<JsonElement>($"{options.ZrcBaseUrl}/statussen", new CreateStatusRequest(
|
||||
aanvraag.ZaakUrl, statustypeUrl, now, toelichtingText), caller);
|
||||
}
|
||||
|
||||
private async Task<string> FirstResultaattypeUrlAsync(string zaaktypeUrl)
|
||||
{
|
||||
var page = await zgw.GetAsync<ZgwPage<Resultaattype>>(
|
||||
$"{options.ZtcBaseUrl}/resultaattypen?zaaktype={Uri.EscapeDataString(zaaktypeUrl)}");
|
||||
var first = page.Results.FirstOrDefault()
|
||||
?? throw new InvalidOperationException($"No resultaattype found for zaaktype {zaaktypeUrl}.");
|
||||
return first.Url;
|
||||
}
|
||||
|
||||
/// <summary>The counterpart to <see cref="FirstStatustypeUrlAsync"/> — highest volgnummer
|
||||
/// (the eind status) rather than lowest.</summary>
|
||||
private async Task<string> 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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Wraps <see cref="LocalZaakSource"/> but returns a DIFFERENT case id than the
|
||||
/// underlying Aanvraag.Id — reproduces exactly what <c>OpenZaakZaakSource</c> does in
|
||||
/// production (the FE-facing case id from <c>ListCases</c> is the ZGW zaak's own uuid, not
|
||||
/// <c>ApplicationStore</c>'s primary key) without needing a live OpenZaak, so the besluit
|
||||
/// endpoint's Referentie-based resolution (the fix below) gets coverage on every push.</summary>
|
||||
file sealed class IdMismatchZaakSource : IZaakSource
|
||||
{
|
||||
private readonly LocalZaakSource inner = new();
|
||||
private static ApplicationSummaryDto Rekey(ApplicationSummaryDto dto) => dto with { Id = $"zaak-{dto.Id}" };
|
||||
|
||||
public IReadOnlyList<ApplicationSummaryDto> ListCases(DateTimeOffset now) =>
|
||||
inner.ListCases(now).Select(Rekey).ToList();
|
||||
|
||||
public IReadOnlyList<ApplicationSummaryDto> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression for a real, live-repro'd bug: recording a besluit from the behandelportal always
|
||||
/// 404'd against a real OpenZaak. Root cause — <c>POST /beoordeling/{id}/besluit</c> looked
|
||||
/// <c>id</c> up directly in <c>ApplicationStore</c> (its own primary key), but <c>id</c> is
|
||||
/// whatever <c>IZaakSource.ListCases</c> handed the FE; under <c>OpenZaakZaakSource</c> that's
|
||||
/// the ZGW zaak's own uuid, a different value entirely. Fixed by resolving the case through
|
||||
/// the same <c>ListCases</c> seak the GET sibling (<see cref="BeoordelingTests"/>) already uses,
|
||||
/// then to the local <c>Aanvraag</c> via its Referentie (<c>ApplicationStore.GetByReferentie</c>)
|
||||
/// — the one identifier stable across both sources. <see cref="IdMismatchZaakSource"/>
|
||||
/// reproduces the id divergence without a live OpenZaak.
|
||||
/// </summary>
|
||||
public class BeoordelingIdMismatchTests
|
||||
{
|
||||
private static WebApplicationFactory<Program> Factory()
|
||||
{
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), $"bigregister-id-mismatch-{Guid.NewGuid():N}.db");
|
||||
return new WebApplicationFactory<Program>().WithWebHostBuilder(builder => builder
|
||||
.UseSetting("ConnectionStrings:AppDb", $"Data Source={dbPath}")
|
||||
.ConfigureTestServices(services => services.AddSingleton<IZaakSource, IdMismatchZaakSource>()));
|
||||
}
|
||||
|
||||
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<ApplicationDetailDto>())!;
|
||||
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<List<ApplicationSummaryDto>>())!;
|
||||
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<RecordBesluitResponse>())!;
|
||||
Assert.Equal("Afgewezen", body.Status.Tag);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 (<see cref="ZgwZaakMapperTests"/>,
|
||||
/// <see cref="OpenZaakZaakSourceTests"/>) uses. Requires the harness in <c>backend/openzaak/</c>
|
||||
/// 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<Program> Factory()
|
||||
private static WebApplicationFactory<Program> Factory(string zaaktypeUrl)
|
||||
{
|
||||
var dbPath = Path.Combine(Path.GetTempPath(), $"bigregister-oz-integration-{Guid.NewGuid():N}.db");
|
||||
return new WebApplicationFactory<Program>().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));
|
||||
}
|
||||
|
||||
/// <summary>bootstrap-catalogus.sh mints the seeded zaaktype's uuid fresh per harness
|
||||
/// instance, so unlike every other setting <c>Factory</c> 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 <c>Zgw:ZaaktypeUrls</c> needs (see <see cref="OpenZaakZaakSource.AanvraagTypeFor"/>).</summary>
|
||||
private static async Task<string> 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<ZgwPage<ZgwZaak>>(
|
||||
"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<List<ApplicationSummaryDto>>("/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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,42 +7,51 @@ namespace BigRegister.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<InvalidOperationException>(() => 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()
|
||||
{
|
||||
|
||||
@@ -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 <jwt>`.
|
||||
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 <jwt>`.
|
||||
- `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
|
||||
|
||||
|
||||
@@ -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'] });
|
||||
|
||||
@@ -11,6 +11,7 @@ const KNOWN: readonly Capability[] = [
|
||||
'stamdata:edit',
|
||||
'cases:manage',
|
||||
'flags:manage',
|
||||
'aanvraag:beoordelen',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user