feat(openzaak): one-command UI-OpenZaak bridge, real BSN fix, flake mitigation
CI / changes (push) Successful in 8s
CI / lint (push) Successful in 55s
CI / frontend (push) Successful in 1m33s
CI / backend (push) Successful in 1m46s
CI / e2e (push) Successful in 3m14s
CI / storybook-a11y (push) Successful in 6m50s
CI / semgrep (push) Successful in 1m13s
CI / api-client-drift (push) Successful in 1m47s
CI / changes (push) Successful in 8s
CI / lint (push) Successful in 55s
CI / frontend (push) Successful in 1m33s
CI / backend (push) Successful in 1m46s
CI / e2e (push) Successful in 3m14s
CI / storybook-a11y (push) Successful in 6m50s
CI / semgrep (push) Successful in 1m13s
CI / api-client-drift (push) Successful in 1m47s
scripts/openzaak-ui-up.sh brings up the root app and the OpenZaak harness together, wires them onto one docker network, seeds the catalogus, grants the container-alias zaaktype scope, and verifies a real aanvraag submitted through the UI lands in OpenZaak. Along the way: DocumentStore.DemoOwner was reusing the seeded doctor's 11-digit BIG-nummer as a stand-in BSN, which isn't a valid 9-digit BSN shape — OpenZaak rejects it, breaking both submit's rol-creation step and the citizen's own applications list under Zgw:Enabled=true. Fixed to a real elfproef-valid BSN. Also adds mitigation for a still-unexplained per-container flake (every outbound ZGW POST fails as if the body were empty, for that container's whole lifetime) that correlates with host memory pressure: the script now warns when host swap is heavily used, and an opt-in ZgwDiagnosticHandler (ZGW_DEBUG_HTTP=1) logs Content-Length vs. actual bytes sent so the next reproduction can confirm or rule out client-side body corruption. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -80,6 +80,42 @@ BFF_AUTH='<the same secret>' ./verify-notificatie.sh # proves a real delivery,
|
||||
To go back to the fast, no-notifications default: `docker compose -f docker-compose.openzaak.yml
|
||||
up -d --remove-orphans` (drops the celery worker, restores `NOTIFICATIONS_DISABLED: 'true'`).
|
||||
|
||||
## Testing the Angular UI against this harness
|
||||
|
||||
The base harness above and the app's own root `docker-compose.yml` are independent projects on
|
||||
purpose (see the top of this file) — this is the opt-in bridge between them, for when you want
|
||||
to click through the real UI and see an aanvraag land in a real OpenZaak instead of just
|
||||
running `dotnet test`. One command from the repo root:
|
||||
|
||||
```bash
|
||||
scripts/openzaak-ui-up.sh
|
||||
```
|
||||
|
||||
It brings up the root app (so its docker network exists), brings up this harness plus
|
||||
`docker-compose.openzaak.bff.yml` (gives this harness's `web` service a dotted alias,
|
||||
`openzaak.local`, on its own network — the root project's `api` container joins THIS network,
|
||||
in the opposite direction from the notifications overlay below, to avoid a real alias
|
||||
collision: the root project's frontend service is also called `web`. The alias needs a dot
|
||||
because Django's URLValidator rejects a bare hostname in a URL field; this environment's
|
||||
rootless Podman also can't route container→host-port traffic through `host.docker.internal`,
|
||||
so container-to-container is the only reliable path either way — see that file's header
|
||||
comment for the full, empirically-confirmed reasoning), seeds the catalogus, additively
|
||||
replaces the zrc authorization grant to match the alias (ZGW authorization is scoped by the
|
||||
*exact* zaaktype URL string, not just the resource; see `scripts/openzaak-ui-up.sh`'s own
|
||||
comment for why this is a replace, not an add), and brings the root app back up pointed at
|
||||
OpenZaak (`docker-compose.openzaak.yml` at the repo root). A final self-check submits a
|
||||
throwaway aanvraag and confirms it actually lands in OpenZaak, restarting `api` (up to 5
|
||||
times) if not — see that script for a caveat about an intermittent per-container networking
|
||||
flake this environment can hit under memory pressure (the script now warns if host swap is
|
||||
already high going in; `ZGW_DEBUG_HTTP=1` on `api`, see `docker-compose.openzaak.yml`, logs
|
||||
diagnostics to help nail the cause next time it reproduces). Prints the URLs to check
|
||||
afterward and the teardown commands.
|
||||
|
||||
Two caveats, both non-fatal (WP-60 catches and flags rather than surfacing an error):
|
||||
**only `herregistratie` has a seeded zaaktype** here, so submit that wizard to prove a real
|
||||
write; and **no Documenten content is seeded**, so a document upload's ZGW half no-ops (pick
|
||||
"per post" in the wizard's document step, or ignore it).
|
||||
|
||||
## Tear down
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Opt-in overlay, layered ON TOP of docker-compose.openzaak.yml (never alone):
|
||||
#
|
||||
# docker compose -f docker-compose.openzaak.yml -f docker-compose.openzaak.bff.yml up -d
|
||||
#
|
||||
# Gives this harness's `web` (the OpenZaak API) an extra, dotted hostname alias
|
||||
# (`openzaak.local`) on its OWN network, so the root project's `api` container (joined in via
|
||||
# `docker-compose.openzaak.yml` at the repo root, as an EXTERNAL network) can reach it. See
|
||||
# `scripts/openzaak-ui-up.sh` for the one-command version that brings both projects up
|
||||
# together, seeds the catalogus, and grants the extra authorization scope this alias needs.
|
||||
#
|
||||
# Three real things this works around, each discovered empirically (curl against the
|
||||
# running containers), not guessed:
|
||||
#
|
||||
# 1. Why container-to-container instead of `http://localhost:8000`: this dev environment's
|
||||
# rootless Podman drops container→host-port traffic through `host.docker.internal`
|
||||
# (confirmed for the WP-58 notifications overlay's celery worker — DNS resolves it, every
|
||||
# TCP connect times out).
|
||||
#
|
||||
# 2. Why the ROOT project's `api` joins INTO this project's network (below), not the other way
|
||||
# around: the root project's frontend service is also called `web`. Docker Compose always
|
||||
# adds a service's own name as a network alias on every network it joins — so if THIS `web`
|
||||
# joined the root project's network, "web" would resolve to two different containers there.
|
||||
# Only `api` crosses into this network, under its own already-unique name.
|
||||
#
|
||||
# 3. Why the alias has a dot in it (`openzaak.local`, not e.g. `openzaak`): Django's built-in
|
||||
# URLValidator rejects a bare, dotless hostname in a URL field (it special-cases exactly
|
||||
# "localhost"; anything else needs a dot or to be a valid IP). OpenZaak's `zaaktype` field
|
||||
# (and others) run through this validator — confirmed with a POST referencing
|
||||
# `http://<dotless-alias>:8000/...` failing "Voer een geldige URL in" (enter a valid URL)
|
||||
# before any authorization check even runs.
|
||||
services:
|
||||
web:
|
||||
environment:
|
||||
# Django rejects any request whose Host header isn't in ALLOWED_HOSTS.
|
||||
ALLOWED_HOSTS: localhost,127.0.0.1,web,openzaak.local
|
||||
networks:
|
||||
default:
|
||||
aliases: [openzaak.local]
|
||||
@@ -36,8 +36,16 @@ public sealed record AuditEntry(DateTimeOffset At, string Action, string Documen
|
||||
/// </summary>
|
||||
public static class DocumentStore
|
||||
{
|
||||
/// The single seeded user (the demo has no real auth; ownership = this id).
|
||||
public const string DemoOwner = "19012345601";
|
||||
/// The single seeded user (the demo has no real auth; ownership = this id) — a real,
|
||||
/// elfproef-valid 9-digit BSN (src/app/shared/kernel/bsn.ts's own checksum), distinct from
|
||||
/// SeedData.Registration.BigNummer ("19012345601", 11 digits — the seeded doctor's BIG-nummer,
|
||||
/// a different Dutch identifier scheme). Previously this constant reused that BigNummer value
|
||||
/// as a stand-in BSN, which is invalid Dutch-BSN shape: harmless against the local store, but
|
||||
/// a real OpenZaak instance rejects it outright — GET /api/v1/applications 500s (`inpBsn` query
|
||||
/// filter validation) and every submit's rol-creation POST fails (`inpBsn` max_length) once
|
||||
/// Zgw:Enabled=true. Not "111222333" or "999888777" — both already mean a different fixture
|
||||
/// identity (the OpenZaak-harness/unit-test caller, and ApplicationTests' "other citizen").
|
||||
public const string DemoOwner = "123456782";
|
||||
|
||||
private static readonly object _gate = new();
|
||||
|
||||
|
||||
@@ -61,9 +61,18 @@ if (zgw.Enabled)
|
||||
// WP-60: a bounded client timeout matters once ZgwHttpClient retries — without one, the
|
||||
// sources' sync-over-async call (no CancellationToken threaded through) could block a
|
||||
// thread-pool thread for HttpClient's 100s default times 3 attempts.
|
||||
builder.Services.AddHttpClient<IZaakSource, OpenZaakZaakSource>(c => c.Timeout = TimeSpan.FromSeconds(15));
|
||||
var zaakClientBuilder = builder.Services.AddHttpClient<IZaakSource, OpenZaakZaakSource>(c => c.Timeout = TimeSpan.FromSeconds(15));
|
||||
// WP-51: the documents (Documenten API / DRC) seam — same pattern as IZaakSource above.
|
||||
builder.Services.AddHttpClient<IDocumentSource, OpenZaakDocumentSource>(c => c.Timeout = TimeSpan.FromSeconds(15));
|
||||
var documentClientBuilder = builder.Services.AddHttpClient<IDocumentSource, OpenZaakDocumentSource>(c => c.Timeout = TimeSpan.FromSeconds(15));
|
||||
|
||||
// Opt-in diagnostic for the still-unexplained per-container flake (see
|
||||
// scripts/openzaak-ui-up.sh's header comment) — off by default, zero cost unless set.
|
||||
if (Environment.GetEnvironmentVariable("ZGW_DEBUG_HTTP") == "1")
|
||||
{
|
||||
builder.Services.AddTransient<ZgwDiagnosticHandler>();
|
||||
zaakClientBuilder.AddHttpMessageHandler<ZgwDiagnosticHandler>();
|
||||
documentClientBuilder.AddHttpMessageHandler<ZgwDiagnosticHandler>();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace BigRegister.Api.Zgw;
|
||||
|
||||
/// <summary>
|
||||
/// Opt-in only (wired in <c>Program.cs</c> behind <c>ZGW_DEBUG_HTTP=1</c>) — chases the
|
||||
/// still-unexplained flake where a freshly-(re)started `api` container has every outbound ZGW
|
||||
/// POST fail with what looks like an empty body reaching OpenZaak (see
|
||||
/// <c>scripts/openzaak-ui-up.sh</c>'s header comment). Logs the one signal that would actually
|
||||
/// distinguish "client built an empty body" from "something ate it after send": the declared
|
||||
/// Content-Length vs. the byte count actually read from the request right before it goes out.
|
||||
/// A mismatch here would prove client-side corruption; agreement would point downstream instead.
|
||||
/// </summary>
|
||||
public sealed class ZgwDiagnosticHandler(ILogger<ZgwDiagnosticHandler> logger) : DelegatingHandler
|
||||
{
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Content is not null)
|
||||
{
|
||||
var bytes = await request.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
logger.LogInformation(
|
||||
"ZGW {Method} {Url}: Content-Length={ContentLength} actualBytes={Actual}",
|
||||
request.Method, request.RequestUri, request.Content.Headers.ContentLength, bytes.Length);
|
||||
}
|
||||
return await base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Net.Http.Json;
|
||||
using BigRegister.Api.Zgw;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The opt-in diagnostic handler (see ZgwDiagnosticHandler's own doc comment) must be inert on
|
||||
/// the request/response — it only observes. This doesn't catch the flake itself (that needs a
|
||||
/// real repro with ZGW_DEBUG_HTTP=1), just proves the hook doesn't alter what's sent or break
|
||||
/// the response passthrough.
|
||||
/// </summary>
|
||||
public class ZgwDiagnosticHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Passes_request_and_response_through_unchanged()
|
||||
{
|
||||
var stub = new ZgwStubHandler(_ => """{ "ok": true }""");
|
||||
var diagnostic = new ZgwDiagnosticHandler(NullLogger<ZgwDiagnosticHandler>.Instance) { InnerHandler = stub };
|
||||
using var client = new HttpClient(diagnostic);
|
||||
|
||||
var response = await client.PostAsJsonAsync("https://oz.example/zaken", new { foo = "bar" });
|
||||
|
||||
Assert.True(response.IsSuccessStatusCode);
|
||||
Assert.Single(stub.Requests);
|
||||
Assert.Contains("\"foo\":\"bar\"", stub.BodyOf("https://oz.example/zaken"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user