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
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:
@@ -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/"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user