fix(zgw): keep the BSN out of the recorded ZGW failure message (RB-05)

ZgwHttpClient interpolated the full request uri and up to 500 characters of
the response body into its failure message. That message is persisted as
Aanvraag.ZgwError in SQLite and written to the log, and both halves can carry
a BSN: ZGW filters travel as query parameters (the citizen-scoped zaken list
filters on rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn), and
OpenZaak echoes the offending request in its error bodies, so a rejected POST
/rollen comes back holding the owner BSN it was sent.

All three interpolation sites now use Redact(url) — the path without its
query — and the body snippet is replaced by the reason phrase. Status plus
path still routes a failure to the right endpoint; the lost detail already
has a deliberate home in ZGW_DEBUG_HTTP=1 (ZgwDiagnosticHandler), which is
opt-in, dev-only and not persisted.

The new test fails the one call in the fixture whose url carries a query
string and asserts the persisted ZgwError has neither the body snippet nor a
"?", while keeping the path and the 503. Verified red without the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-27 10:57:09 +02:00
co-authored by Claude Opus 5
parent fbd27ed641
commit 5187bfa19a
3 changed files with 93 additions and 6 deletions
@@ -0,0 +1,50 @@
# RB-05 — drop the BSN-bearing query and body snippet from the ZGW failure message
Status: **implemented** · 2026-08-27 · Source findings: `07-bio2-compliance.md` BIO-009 · `99-backlog.md` RB-05
## What was wrong
`ZgwHttpClient.SendWithRetryAsync` built its failure message as
```csharp
$"ZGW {req.Method} {req.RequestUri} failed: {(int)res.StatusCode} {snippet}"
```
with `snippet` being up to 500 characters of the **response body**. That message is not
transient: `Program.cs`'s submit endpoint catches it and stores it as `Aanvraag.ZgwError`
in SQLite, and logs it.
Two BSN paths into it:
- **the query string.** ZGW filters travel as query parameters, and the citizen-scoped zaken
list filters on `rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn=<BSN>`.
- **the body snippet.** OpenZaak's error responses echo the offending request, so a rejected
`POST /rollen` (whose body carries `BetrokkeneIdentificatie(aanvraag.Owner)`) comes back
with the BSN in it.
The two `"returned null body"` throws in `GetAsync`/`PostAsync` interpolated the same url.
## What changed
| File | Change |
| ------------------------ | --------------------------------------------------------------------------------- |
| `Zgw/ZgwHttpClient.cs` | `Redact(url)` (path only) at all three sites; snippet → `res.ReasonPhrase` |
| `ZgwDivergenceTests.cs` | **new** `A_recorded_divergence_carries_no_response_body_and_no_query_string` |
Status + path is enough to route a failure to the right endpoint. The diagnostic detail
that was lost already has a deliberate home: `ZGW_DEBUG_HTTP=1` wires
`ZgwDiagnosticHandler`, which logs the full url and request bytes — opt-in, dev-only, and
not persisted.
## The test
Fails the `statustypen` GET (the only call in that fixture whose url carries a query
string) after the zaak POST succeeds, then asserts on the persisted `ZgwError`:
no `"stub failure"` (the body snippet), no `"?"` (the query string), but still the path and
the `503`. **Confirmed it fails without the fix** — restoring the old interpolation turns it
red on both counts.
## Verification
`dotnet format --verify-no-changes` clean. `dotnet test`: **253 passed, 1 failed** — the
pre-existing `OpenZaakIntegrationTests.Admin_cases_…`, which needs a live container.