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
@@ -111,6 +111,32 @@ public class ZgwDivergenceTests
Assert.Null(stored.ZgwError);
}
/// RB-05/BIO-009: `ZgwError` is persisted to SQLite and written to the application log, so
/// the message it carries may not include the response body (OpenZaak echoes the request in
/// its errors) or the request's query string (ZGW filters travel there, and one of them is
/// `rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn`).
[Fact]
public async Task A_recorded_divergence_carries_no_response_body_and_no_query_string()
{
// The zaak POST succeeds; the statustypen GET — the one call here that carries a query
// string — fails, so the recorded message is built from a url that has one.
var stub = new ZgwStubHandler(SuccessBody,
(url, _) => url.StartsWith($"{ZtBase}/statustypen") ? HttpStatusCode.ServiceUnavailable : HttpStatusCode.OK);
using var factory = Factory(stub);
using var client = factory.CreateClient();
var id = await CreateConcept(client);
(await client.PostAsJsonAsync($"/api/v1/applications/{id}/submit", new { diplomaHerkomst = "duo" }))
.EnsureSuccessStatusCode();
var error = ApplicationStore.ListAll().Single(a => a.Id == id).ZgwError;
Assert.NotNull(error);
Assert.DoesNotContain("stub failure", error); // no response-body snippet
Assert.DoesNotContain("?", error); // no query string
Assert.Contains($"{ZtBase}/statustypen", error); // the path still routes the failure
Assert.Contains("503", error);
}
private static HttpRequestMessage AdminRequest(HttpMethod method, string path)
{
var req = new HttpRequestMessage(method, path);