feat(backend): expand stamdata + OpenZaak-ready cases seam (WP-49)
CI / frontend (push) Successful in 2m59s
CI / backend (push) Successful in 1m27s
CI / semgrep (push) Successful in 58s
CI / e2e (push) Successful in 2m30s
CI / api-client-drift (push) Canceled after 1m14s
CI / storybook-a11y (push) Canceled after 29m8s

Stamdata: add beroepen, opleidingen (temporal), and specialismen tables to the
schema-driven catalog (zero UI code). opleidingen.beroep and specialismen.beroep
both reference beroepen.code — the first stamdata->stamdata references, enforced by
two new StamdataRef entries in the CI gate.

OpenZaak/ZGW (WP-49, slice 1 — read-only zaken): introduce IZaakSource as the cases
read seam. Default LocalZaakSource reads the local SQLite store (offline); an
OpenZaakZaakSource (Zgw/ client: HS256 per-call JWT, ZGW->existing-DTO mapper,
paginating HTTP source) is selected behind Zgw:Enabled (default false). The FE never
changes — same ApplicationSummaryDto, no api-client drift. Unit-tested with fixtures
+ a stub HttpMessageHandler; no live OpenZaak needed.

Docs: ADR-0005, reference/openzaak-integration.md, WP-49..52 roadmap, stamdata.md
update, README index rows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-24 15:01:06 +02:00
co-authored by Claude Opus 4.8
parent cff711504f
commit 1c3c195d32
28 changed files with 974 additions and 8 deletions
@@ -0,0 +1,21 @@
using BigRegister.Api.Contracts;
namespace BigRegister.Api.Data;
/// <summary>
/// The cases (zaken) READ seam (WP-49). A "zaak" in ZGW terms is an <see cref="Aanvraag"/>
/// here; this interface is the one injection point that lets a real ZGW backend (OpenZaak)
/// replace the local SQLite store <em>behind the same <see cref="ApplicationSummaryDto"/>
/// contract</em> — so the frontend never changes (BFF-lite anti-corruption, ADR-0001).
///
/// Default binding is <see cref="LocalZaakSource"/> (offline). Setting <c>Zgw:Enabled=true</c>
/// swaps in <c>OpenZaakZaakSource</c>. Slice 1 is read-only; create/update stay on the
/// local write path until WP-50. The interface returns the wire DTO (not the domain
/// <see cref="Aanvraag"/>) precisely so each source owns its own mapping — the OpenZaak
/// source maps a ZGW Zaak into this shape, the local source maps the stored aanvraag.
/// </summary>
public interface IZaakSource
{
/// <summary>Every case, newest-first (the admin cross-owner list, WP-36).</summary>
IReadOnlyList<ApplicationSummaryDto> ListCases(DateTimeOffset now);
}
@@ -0,0 +1,15 @@
using BigRegister.Api.Contracts;
namespace BigRegister.Api.Data;
/// <summary>
/// The default <see cref="IZaakSource"/> — the cases come from the local SQLite
/// <see cref="ApplicationStore"/>, exactly as before the seam existed (WP-49). Zero
/// behaviour change: this is the same <c>ListAll().ToAdminSummaryDto(now)</c> the
/// <c>/admin/cases</c> endpoint used to call inline.
/// </summary>
public sealed class LocalZaakSource : IZaakSource
{
public IReadOnlyList<ApplicationSummaryDto> ListCases(DateTimeOffset now) =>
ApplicationStore.ListAll().Select(a => a.ToAdminSummaryDto(now)).ToList();
}
+19 -5
View File
@@ -10,6 +10,7 @@ using BigRegister.Domain.Intake;
using BigRegister.Domain.Letters;
using BigRegister.Domain.Registrations;
using BigRegister.Domain.Submissions;
using BigRegister.Api.Zgw;
using BigRegister.Stamdata;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Console;
@@ -40,6 +41,22 @@ builder.Services.AddCors(o => o.AddPolicy(SpaCors, p =>
// override it (ConnectionStrings:AppDb) without touching this file.
Db.ConnectionString = builder.Configuration.GetConnectionString("AppDb") ?? Db.ConnectionString;
// WP-49: the cases (zaken) READ path goes through IZaakSource so a real ZGW backend
// (OpenZaak) can replace the local SQLite store behind the same DTO contract — the FE never
// changes (ADR-0001). Default = LocalZaakSource (offline). Zgw:Enabled=true swaps in the
// OpenZaak client (needs the base URLs + credentials in the Zgw config section).
var zgw = builder.Configuration.GetSection("Zgw").Get<ZgwOptions>() ?? new ZgwOptions();
if (zgw.Enabled)
{
builder.Services.AddSingleton(zgw);
builder.Services.AddSingleton<ZgwTokenProvider>();
builder.Services.AddHttpClient<IZaakSource, OpenZaakZaakSource>();
}
else
{
builder.Services.AddSingleton<IZaakSource, LocalZaakSource>();
}
var app = builder.Build();
// Migrate on every startup, seed nothing (WP-22): unlike SeedData's read-only
@@ -314,11 +331,8 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
.Produces(StatusCodes.Status404NotFound);
// --- Admin cases (WP-36): cross-owner list + admin delete, gated by `cases:manage`. ---
api.MapGet("/admin/cases", (HttpContext ctx) => CasesAdmin(ctx, () =>
{
var now = DateTimeOffset.UtcNow;
return Results.Ok(ApplicationStore.ListAll().Select(a => a.ToAdminSummaryDto(now)).ToList());
}))
api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ctx, () =>
Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow))))
.Produces<List<ApplicationSummaryDto>>()
.ProducesProblem(StatusCodes.Status403Forbidden);
@@ -0,0 +1,13 @@
namespace BigRegister.Stamdata;
/// <summary>
/// One row of the beroepen (BIG professions) stamdata (config-as-code, ADR-0004): the
/// master list of registered professions. The first property (<see cref="Code"/>) is the
/// table key by convention (see <c>StamdataTable</c>) and the target of the FK-like
/// references from <c>opleidingen</c> and <c>specialismen</c> (see
/// <c>StamdataValidationTests</c>). Non-temporal — a profession is either registrable or it
/// isn't; the mapping's validity window lives on <c>opleidingen</c>.
///
/// This is the typed shape <c>beroepen.json</c> deserializes into.
/// </summary>
public sealed record Beroep(string Code, string Naam);
@@ -0,0 +1,14 @@
namespace BigRegister.Stamdata;
/// <summary>
/// One row of the opleidingen (study programs) stamdata (config-as-code, ADR-0004): a study
/// program and the BIG profession it leads to, valid for a period. The first property
/// (<see cref="Code"/>) is the table key; <see cref="Beroep"/> holds a <c>beroepen.Code</c>
/// — a stamdata → stamdata reference the build gate enforces (<c>StamdataValidationTests</c>),
/// so orphaning a beroep fails CI, never prod. Temporal (<see cref="GeldigVan"/>/
/// <see cref="GeldigTot"/>, half-open <c>[van, tot)</c>): a null <see cref="GeldigTot"/>
/// means "still valid"; a future <see cref="GeldigVan"/> pre-schedules a program.
///
/// This is the typed shape <c>opleidingen.json</c> deserializes into.
/// </summary>
public sealed record Opleiding(string Code, string Naam, string Beroep, DateOnly GeldigVan, DateOnly? GeldigTot);
@@ -0,0 +1,13 @@
namespace BigRegister.Stamdata;
/// <summary>
/// One row of the specialismen (professional specialisms) stamdata (config-as-code,
/// ADR-0004): a registered specialism and the base BIG profession it belongs to. The first
/// property (<see cref="Code"/>) is the table key; <see cref="Beroep"/> holds a
/// <c>beroepen.Code</c> — the second stamdata → stamdata reference the build gate enforces
/// (<c>StamdataValidationTests</c>), demonstrating one table referenced by two others.
/// Non-temporal.
///
/// This is the typed shape <c>specialismen.json</c> deserializes into.
/// </summary>
public sealed record Specialisme(string Code, string Naam, string Beroep);
@@ -11,6 +11,9 @@ public static class StamdataCatalog
public static readonly IReadOnlyList<StamdataTable> All = new[]
{
StamdataTable.Of<ProfessionMapping>("professions", "Opleiding → beroep"),
StamdataTable.Of<Beroep>("beroepen", "Beroepen (BIG)"),
StamdataTable.Of<Opleiding>("opleidingen", "Opleidingen → beroep"),
StamdataTable.Of<Specialisme>("specialismen", "Specialismen → beroep"),
// PolicyQuestions and future tables migrate here, same one-liner each.
};
@@ -0,0 +1,10 @@
[
{ "code": "arts", "naam": "Arts" },
{ "code": "verpleegkundige", "naam": "Verpleegkundige" },
{ "code": "fysiotherapeut", "naam": "Fysiotherapeut" },
{ "code": "apotheker", "naam": "Apotheker" },
{ "code": "tandarts", "naam": "Tandarts" },
{ "code": "verloskundige", "naam": "Verloskundige" },
{ "code": "gz-psycholoog", "naam": "Gezondheidszorgpsycholoog" },
{ "code": "psychotherapeut", "naam": "Psychotherapeut" }
]
@@ -0,0 +1,10 @@
[
{ "code": "geneeskunde", "naam": "Geneeskunde", "beroep": "arts", "geldigVan": "2000-01-01", "geldigTot": null },
{ "code": "verpleegkunde", "naam": "Verpleegkunde (hbo-v)", "beroep": "verpleegkundige", "geldigVan": "2000-01-01", "geldigTot": null },
{ "code": "fysiotherapie", "naam": "Fysiotherapie", "beroep": "fysiotherapeut", "geldigVan": "2000-01-01", "geldigTot": null },
{ "code": "farmacie", "naam": "Farmacie", "beroep": "apotheker", "geldigVan": "2000-01-01", "geldigTot": null },
{ "code": "tandheelkunde", "naam": "Tandheelkunde", "beroep": "tandarts", "geldigVan": "2000-01-01", "geldigTot": null },
{ "code": "verloskunde", "naam": "Verloskunde", "beroep": "verloskundige", "geldigVan": "2000-01-01", "geldigTot": null },
{ "code": "gz-psychologie", "naam": "Gezondheidszorgpsychologie", "beroep": "gz-psycholoog", "geldigVan": "2005-01-01", "geldigTot": null },
{ "code": "inservice-a", "naam": "Inservice A-opleiding (verpleegkunde, oud)", "beroep": "verpleegkundige", "geldigVan": "2000-01-01", "geldigTot": "2012-01-01" }
]
@@ -0,0 +1,8 @@
[
{ "code": "huisartsgeneeskunde", "naam": "Huisartsgeneeskunde", "beroep": "arts" },
{ "code": "cardiologie", "naam": "Cardiologie", "beroep": "arts" },
{ "code": "kindergeneeskunde", "naam": "Kindergeneeskunde", "beroep": "arts" },
{ "code": "ziekenhuisfarmacie", "naam": "Ziekenhuisfarmacie", "beroep": "apotheker" },
{ "code": "orthodontie", "naam": "Orthodontie", "beroep": "tandarts" },
{ "code": "geriatriefysiotherapie", "naam": "Geriatriefysiotherapie", "beroep": "fysiotherapeut" }
]
@@ -0,0 +1,82 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using BigRegister.Api.Contracts;
using BigRegister.Api.Data;
namespace BigRegister.Api.Zgw;
/// <summary>One page of a ZGW list response — the uniform <c>{count,next,previous,results}</c>
/// envelope every ZGW collection uses. <c>next</c> is a full URL (or null) to follow.</summary>
public sealed record ZgwPage<T>(
[property: JsonPropertyName("count")] int Count,
[property: JsonPropertyName("next")] string? Next,
[property: JsonPropertyName("results")] IReadOnlyList<T> Results);
/// <summary>
/// The <see cref="IZaakSource"/> backed by a real OpenZaak / ZGW Zaken API (WP-49). 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"/>.
/// 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).
/// </summary>
public sealed class OpenZaakZaakSource(HttpClient http, ZgwTokenProvider tokens, ZgwOptions options) : IZaakSource
{
// ponytail: sync-over-async — IZaakSource is sync to match the local store + the existing
// sync /admin/cases endpoint, and ASP.NET Core has no sync-context to deadlock on. Make the
// whole cases read path async (endpoint + CasesAdmin + interface) if OpenZaak becomes the
// default and this blocking call shows up under load.
public IReadOnlyList<ApplicationSummaryDto> ListCases(DateTimeOffset now) =>
ListCasesAsync().GetAwaiter().GetResult();
private async Task<IReadOnlyList<ApplicationSummaryDto>> ListCasesAsync()
{
var zaken = await GetAllAsync<ZgwZaak>($"{options.ZrcBaseUrl}/zaken");
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;
}
/// <summary>Follow the <c>next</c> links, accumulating every page's results.</summary>
private async Task<IReadOnlyList<T>> GetAllAsync<T>(string url)
{
var all = new List<T>();
string? next = url;
while (next is not null)
{
var page = await GetAsync<ZgwPage<T>>(next);
all.AddRange(page.Results);
next = page.Next;
}
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 GetAsync<Zaaktype>(zaaktypeUrl);
return zt.Omschrijving;
}
private async Task<T> GetAsync<T>(string url)
{
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.Mint());
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
using var res = await http.SendAsync(req);
res.EnsureSuccessStatusCode();
return (await res.Content.ReadFromJsonAsync<T>())
?? throw new InvalidOperationException($"ZGW GET {url} returned null body.");
}
private sealed record Zaaktype([property: JsonPropertyName("omschrijving")] string Omschrijving);
}
@@ -0,0 +1,34 @@
namespace BigRegister.Api.Zgw;
/// <summary>
/// Config for connecting to OpenZaak / the ZGW APIs (WP-49), bound from the <c>Zgw</c>
/// section of appsettings. Disabled by default so the POC runs fully offline on the local
/// SQLite store; set <c>Zgw:Enabled=true</c> (plus the URLs + credentials) to source cases
/// from a real OpenZaak.
///
/// The ZGW standard is FIVE separate services, each its own base URL — Slice 1 only needs
/// the Zaken API (ZRC) and, to resolve human labels for a zaaktype, the Catalogi API (ZTC).
/// The others (DRC/BRC/NRC) arrive with later slices (WP-51/52).
/// </summary>
public sealed class ZgwOptions
{
public bool Enabled { get; init; }
/// <summary>Zaken API (ZRC) base URL, e.g. <c>https://open-zaak.example/zaken/api/v1</c>.</summary>
public string ZrcBaseUrl { get; init; } = "";
/// <summary>Catalogi API (ZTC) base URL — used to resolve a zaaktype URL to its label.</summary>
public string ZtcBaseUrl { get; init; } = "";
/// <summary>Client ID registered in the Autorisaties API (goes into the JWT <c>iss</c>/<c>client_id</c>).</summary>
public string ClientId { get; init; } = "";
/// <summary>Client secret — the HS256 signing key for the JWT. Held only by the BFF, never the browser.</summary>
public string Secret { get; init; } = "";
/// <summary>End-user identity for the ZGW audit trail (JWT <c>user_id</c>). Wire from the session in prod.</summary>
public string UserId { get; init; } = "big-register-bff";
/// <summary>Human-readable end-user name for the audit trail (JWT <c>user_representation</c>).</summary>
public string UserRepresentation { get; init; } = "BIG-register BFF";
}
@@ -0,0 +1,43 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace BigRegister.Api.Zgw;
/// <summary>
/// Mints the JWT that authenticates the BFF to the ZGW APIs (WP-49). OpenZaak expects a
/// short-lived HS256 assertion signed with the client secret, carrying <c>iss</c>/
/// <c>client_id</c> (identity), <c>iat</c> (issued-at), and <c>user_id</c>/
/// <c>user_representation</c> (for the ZGW audit trail). There is no OAuth refresh dance —
/// OpenZaak rejects tokens more than an hour past <c>iat</c>, so we <b>mint a fresh token
/// per call</b> (cheap, keeps iat current and the audit user correct).
///
/// ponytail: hand-rolled base64url JWT with <see cref="HMACSHA256"/> — a ZGW token is a
/// plain HS256 JWS, so this ~15 lines beats adding Microsoft.IdentityModel.* just to sign
/// three claims. Swap in a library if we ever need RS256 / JWKS.
/// </summary>
public sealed class ZgwTokenProvider(ZgwOptions options)
{
public string Mint()
{
var header = new { alg = "HS256", typ = "JWT" };
var payload = new
{
iss = options.ClientId,
iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
client_id = options.ClientId,
user_id = options.UserId,
user_representation = options.UserRepresentation,
};
var signingInput = $"{Encode(header)}.{Encode(payload)}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(options.Secret));
var signature = Base64Url(hmac.ComputeHash(Encoding.UTF8.GetBytes(signingInput)));
return $"{signingInput}.{signature}";
}
private static string Encode(object o) => Base64Url(JsonSerializer.SerializeToUtf8Bytes(o));
private static string Base64Url(byte[] bytes) =>
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
@@ -0,0 +1,58 @@
using System.Text.Json.Serialization;
using BigRegister.Api.Contracts;
namespace BigRegister.Api.Zgw;
/// <summary>
/// The subset of a ZGW Zaak (Zaken API / ZRC) the read slice needs. The full resource has
/// dozens of fields; we bind only what maps to <see cref="ApplicationSummaryDto"/>. Note the
/// two ZGW traits that force an anti-corruption layer: <see cref="Url"/> is the resource's
/// identity (not a bare id), and <see cref="Zaaktype"/> is a URL <em>into another service</em>
/// (Catalogi/ZTC) that must be resolved to a human label.
/// </summary>
public sealed record ZgwZaak(
[property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("identificatie")] string Identificatie,
[property: JsonPropertyName("zaaktype")] string Zaaktype,
[property: JsonPropertyName("startdatum")] DateOnly Startdatum,
[property: JsonPropertyName("einddatum")] DateOnly? Einddatum,
[property: JsonPropertyName("registratiedatum")] DateOnly? Registratiedatum);
/// <summary>
/// Anti-corruption map: ZGW Zaak → the existing <see cref="ApplicationSummaryDto"/> the FE
/// already renders (WP-49). This is where "URL as identity" and the cross-service zaaktype
/// join get flattened away, so nothing downstream (the FE) sees ZGW shapes.
/// </summary>
public static class ZgwZaakMapper
{
/// <summary>Last path segment of a ZGW resource URL — the uuid that identifies it.</summary>
public static string Uuid(string url) => url.TrimEnd('/').Split('/').Last();
public static ApplicationSummaryDto ToSummaryDto(ZgwZaak z, string zaaktypeLabel)
{
// ponytail: coarse status map — an open zaak (no einddatum) is In behandeling, a closed
// one is Goedgekeurd. Real fidelity (statustype/resultaat lookups) is a later slice; the
// Afgewezen path needs the resultaat resource. Enough to prove the seam end-to-end.
var status = z.Einddatum is null
? new AanvraagStatusDto("InBehandeling", Referentie: z.Identificatie, Manual: true)
: new AanvraagStatusDto("Goedgekeurd", Referentie: z.Identificatie);
var created = Iso(z.Registratiedatum ?? z.Startdatum);
var updated = Iso(z.Einddatum ?? z.Registratiedatum ?? z.Startdatum);
return new ApplicationSummaryDto(
Id: Uuid(z.Url),
Type: zaaktypeLabel,
Status: status,
DocumentIds: Array.Empty<string>(), // zaak↔document links arrive with WP-51 (DRC)
CreatedAt: created,
UpdatedAt: updated,
SubmittedAt: created,
Owner: z.Identificatie); // real initiator (rol/BSN) needs a rollen lookup — later slice
}
// Keep the wire shape identical to the local source (round-trip datetime): a ZGW date
// becomes midnight UTC so the FE's date parsing sees the same format either backend.
private static string Iso(DateOnly d) =>
d.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("o");
}
+11 -1
View File
@@ -5,5 +5,15 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"_Zgw": "WP-49: set Enabled=true + the URLs/credentials to source cases from a real OpenZaak. Off = local SQLite store (offline POC default).",
"Zgw": {
"Enabled": false,
"ZrcBaseUrl": "",
"ZtcBaseUrl": "",
"ClientId": "",
"Secret": "",
"UserId": "big-register-bff",
"UserRepresentation": "BIG-register BFF"
}
}
@@ -0,0 +1,78 @@
using System.Net;
using System.Text;
using BigRegister.Api.Zgw;
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.
/// </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 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",
"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",
"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()
{
var handler = new StubHandler(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 source = new OpenZaakZaakSource(new HttpClient(handler), new ZgwTokenProvider(options), options);
var cases = source.ListCases(DateTimeOffset.UtcNow);
// 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.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"));
// Every outbound request carried a Bearer token.
Assert.All(handler.AuthSchemes, s => Assert.Equal("Bearer", s));
}
private sealed class StubHandler(Func<string, string> respond) : HttpMessageHandler
{
public List<string> Requests { get; } = new();
public List<string?> AuthSchemes { get; } = new();
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var url = request.RequestUri!.ToString();
Requests.Add(url);
AuthSchemes.Add(request.Headers.Authorization?.Scheme);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(respond(url), Encoding.UTF8, "application/json"),
});
}
}
}
@@ -18,12 +18,26 @@ public class StamdataValidationTests
/// which steers the editor toward closing validity only once nothing current relies on it.
private sealed record StamdataRef(string Description, IEnumerable<string> Keys, Func<string, bool> Resolves);
// The beroepen master-list keys every other profession table points at.
private static readonly IReadOnlySet<string> BeroepCodes =
StamdataFile.Load<Beroep>("beroepen").Select(b => b.Code).ToHashSet(StringComparer.Ordinal);
private static readonly IReadOnlyList<StamdataRef> References = new[]
{
new StamdataRef(
"Diploma.Opleiding → professions.program (valid today)",
SeedData.Diplomas.Select(d => d.Opleiding),
key => Professions.ByProgram.ContainsKey(key)),
// Stamdata → stamdata references: two tables point at beroepen.code, so deleting or
// renaming a beroep that either still uses fails the build (WP-48 gate, generalized).
new StamdataRef(
"Opleiding.beroep → beroepen.code",
StamdataFile.Load<Opleiding>("opleidingen").Select(o => o.Beroep),
key => BeroepCodes.Contains(key)),
new StamdataRef(
"Specialisme.beroep → beroepen.code",
StamdataFile.Load<Specialisme>("specialismen").Select(s => s.Beroep),
key => BeroepCodes.Contains(key)),
};
[Fact]
@@ -0,0 +1,66 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using BigRegister.Api.Zgw;
namespace BigRegister.Tests;
/// <summary>
/// The ZGW JWT is hand-signed (no library), so it needs a check that it's actually a valid
/// HS256 JWS with the claims OpenZaak requires. Decodes the minted token and re-verifies the
/// signature with the shared secret.
/// </summary>
public class ZgwTokenProviderTests
{
private static readonly ZgwOptions Options = new()
{
ClientId = "big-register",
Secret = "super-secret-signing-key",
UserId = "u-123",
UserRepresentation = "Dr. Test",
};
[Fact]
public void Mints_a_three_part_jwt_with_the_required_claims()
{
var token = new ZgwTokenProvider(Options).Mint();
var parts = token.Split('.');
Assert.Equal(3, parts.Length);
var header = JsonSerializer.Deserialize<JsonElement>(Decode(parts[0]));
Assert.Equal("HS256", header.GetProperty("alg").GetString());
Assert.Equal("JWT", header.GetProperty("typ").GetString());
var payload = JsonSerializer.Deserialize<JsonElement>(Decode(parts[1]));
Assert.Equal("big-register", payload.GetProperty("iss").GetString());
Assert.Equal("big-register", payload.GetProperty("client_id").GetString());
Assert.Equal("u-123", payload.GetProperty("user_id").GetString());
Assert.Equal("Dr. Test", payload.GetProperty("user_representation").GetString());
// iat is a recent unix second
var iat = payload.GetProperty("iat").GetInt64();
Assert.InRange(iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds() - 5, DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 5);
}
[Fact]
public void Signature_verifies_with_the_shared_secret()
{
var token = new ZgwTokenProvider(Options).Mint();
var parts = token.Split('.');
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(Options.Secret));
var expected = Base64Url(hmac.ComputeHash(Encoding.UTF8.GetBytes($"{parts[0]}.{parts[1]}")));
Assert.Equal(expected, parts[2]);
}
private static string Decode(string b64Url)
{
var s = b64Url.Replace('-', '+').Replace('_', '/');
s = s.PadRight(s.Length + (4 - s.Length % 4) % 4, '=');
return Encoding.UTF8.GetString(Convert.FromBase64String(s));
}
private static string Base64Url(byte[] bytes) =>
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
@@ -0,0 +1,71 @@
using System.Text.Json;
using BigRegister.Api.Zgw;
namespace BigRegister.Tests;
/// <summary>
/// The anti-corruption map is where ZGW's URL-as-identity + cross-service zaaktype join get
/// flattened into the FE's existing DTO. Feeds a captured ZGW Zaak shape and asserts the
/// mapping — the guarantee that the FE never sees a ZGW shape.
/// </summary>
public class ZgwZaakMapperTests
{
private const string OpenZaakJson = """
{
"url": "https://open-zaak.example/zaken/api/v1/zaken/6f2c5f6e-1b1a-4b7e-9c3d-000000000001",
"identificatie": "ZAAK-2026-0000000001",
"zaaktype": "https://open-zaak.example/catalogi/api/v1/zaaktypen/aaaaaaaa-0000-0000-0000-000000000001",
"startdatum": "2026-03-01",
"einddatum": null,
"registratiedatum": "2026-03-02"
}
""";
private const string ClosedZaakJson = """
{
"url": "https://open-zaak.example/zaken/api/v1/zaken/6f2c5f6e-1b1a-4b7e-9c3d-000000000002",
"identificatie": "ZAAK-2026-0000000002",
"zaaktype": "https://open-zaak.example/catalogi/api/v1/zaaktypen/aaaaaaaa-0000-0000-0000-000000000001",
"startdatum": "2026-01-05",
"einddatum": "2026-02-10",
"registratiedatum": "2026-01-06"
}
""";
[Fact]
public void Maps_url_identity_zaaktype_and_open_status()
{
var zaak = JsonSerializer.Deserialize<ZgwZaak>(OpenZaakJson)!;
var dto = ZgwZaakMapper.ToSummaryDto(zaak, "Herregistratie arts");
// URL as identity → the trailing uuid, not the whole URL.
Assert.Equal("6f2c5f6e-1b1a-4b7e-9c3d-000000000001", dto.Id);
// zaaktype URL resolved to its human label (the cross-service join).
Assert.Equal("Herregistratie arts", dto.Type);
Assert.Equal("ZAAK-2026-0000000001", dto.Status.Referentie);
Assert.Equal("InBehandeling", dto.Status.Tag); // no einddatum → open
Assert.True(dto.Status.Manual);
Assert.Empty(dto.DocumentIds);
// Wire shape matches the local source: round-trip datetime at midnight UTC.
Assert.Equal("2026-03-02T00:00:00.0000000Z", dto.CreatedAt);
}
[Fact]
public void Closed_zaak_maps_to_goedgekeurd()
{
var zaak = JsonSerializer.Deserialize<ZgwZaak>(ClosedZaakJson)!;
var dto = ZgwZaakMapper.ToSummaryDto(zaak, "Herregistratie arts");
Assert.Equal("Goedgekeurd", dto.Status.Tag);
Assert.Equal("2026-02-10T00:00:00.0000000Z", dto.UpdatedAt); // einddatum drives UpdatedAt
}
[Fact]
public void Uuid_extracts_trailing_segment_ignoring_trailing_slash()
{
Assert.Equal("abc", ZgwZaakMapper.Uuid("https://host/zaken/api/v1/zaken/abc"));
Assert.Equal("abc", ZgwZaakMapper.Uuid("https://host/zaken/api/v1/zaken/abc/"));
}
}