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:
eho
2026-08-05 15:20:36 +02:00
co-authored by Claude Opus 5
parent d2c2cffc1f
commit 6cfd70eeeb
12 changed files with 310 additions and 63 deletions
@@ -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()
{