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>
147 lines
6.8 KiB
C#
147 lines
6.8 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using BigRegister.Api.Contracts;
|
|
using BigRegister.Api.Data;
|
|
using Microsoft.AspNetCore.Hosting;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
namespace BigRegister.Tests;
|
|
|
|
/// <summary>
|
|
/// WP-60's required verification: a ZGW failure mid-submit must not leave the two write sides
|
|
/// silently diverged — it's flagged (<see cref="Aanvraag.ZgwError"/>, an audit row) instead.
|
|
/// Not an <see cref="IClassFixture{TFixture}"/> off <see cref="TestWebApplicationFactory"/>: that
|
|
/// fixture hardcodes <c>Zgw:Enabled=false</c>, so this builds its own factory the same way
|
|
/// <see cref="OpenZaakIntegrationTests"/> does, but with a stub primary handler
|
|
/// (<see cref="ZgwStubHandler"/>) instead of a live OpenZaak.
|
|
/// </summary>
|
|
public class ZgwDivergenceTests
|
|
{
|
|
private const string ZrcBase = "https://oz.example/zaken/api/v1";
|
|
private const string ZtBase = "https://oz.example/catalogi/api/v1";
|
|
private const string ZaaktypeUrl = $"{ZtBase}/zaaktypen/zt-1";
|
|
|
|
private static WebApplicationFactory<Program> Factory(ZgwStubHandler stub)
|
|
{
|
|
var dbPath = Path.Combine(Path.GetTempPath(), $"bigregister-zgw-divergence-{Guid.NewGuid():N}.db");
|
|
return new WebApplicationFactory<Program>().WithWebHostBuilder(builder => builder
|
|
.UseSetting("ConnectionStrings:AppDb", $"Data Source={dbPath}")
|
|
.UseSetting("Zgw:Enabled", "true")
|
|
.UseSetting("Zgw:ZrcBaseUrl", ZrcBase)
|
|
.UseSetting("Zgw:ZtcBaseUrl", ZtBase)
|
|
.UseSetting("Zgw:ClientId", "c")
|
|
.UseSetting("Zgw:Secret", "s")
|
|
.UseSetting("Zgw:Bronorganisatie", "123443210")
|
|
.UseSetting("Zgw:VerantwoordelijkeOrganisatie", "123443210")
|
|
.UseSetting("Zgw:ZaaktypeUrls:registratie", ZaaktypeUrl)
|
|
.ConfigureServices(services => services.ConfigureHttpClientDefaults(b =>
|
|
b.ConfigurePrimaryHttpMessageHandler(() => stub))));
|
|
}
|
|
|
|
/// <summary>Doesn't call GET /applications first (unlike ApplicationTests.Create) — under
|
|
/// Zgw:Enabled=true that route goes through IZaakSource too, which this test's stub doesn't
|
|
/// need to answer since every test here uses a fresh db and creates exactly one aanvraag.</summary>
|
|
private static async Task<string> CreateConcept(HttpClient client, string type = "registratie")
|
|
{
|
|
var res = await client.PostAsJsonAsync("/api/v1/applications", new { type });
|
|
res.EnsureSuccessStatusCode();
|
|
var body = (await res.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
|
|
return body.Id;
|
|
}
|
|
|
|
private static string SuccessBody(string url) => url switch
|
|
{
|
|
_ when url == $"{ZrcBase}/zaken" => $$"""
|
|
{ "url": "{{ZrcBase}}/zaken/uuid-new", "identificatie": "BIG-2026-000123",
|
|
"zaaktype": "{{ZaaktypeUrl}}", "startdatum": "2026-07-30",
|
|
"einddatum": null, "registratiedatum": "2026-07-30" }
|
|
""",
|
|
_ when url.StartsWith($"{ZtBase}/statustypen") => """
|
|
{ "count": 1, "next": null,
|
|
"results": [ { "url": "https://oz.example/catalogi/api/v1/statustypen/st-1", "volgnummer": 1 } ] }
|
|
""",
|
|
_ when url.StartsWith($"{ZtBase}/roltypen") => """
|
|
{ "count": 1, "next": null,
|
|
"results": [ { "url": "https://oz.example/catalogi/api/v1/roltypen/rt-initiator" } ] }
|
|
""",
|
|
_ when url == $"{ZrcBase}/statussen" => "{}",
|
|
_ when url == $"{ZrcBase}/rollen" => "{}",
|
|
_ => throw new InvalidOperationException($"unexpected ZGW call {url}"),
|
|
};
|
|
|
|
[Fact]
|
|
public async Task Submit_with_a_failing_zgw_flags_the_divergence_instead_of_diverging_silently()
|
|
{
|
|
var stub = new ZgwStubHandler(SuccessBody, (url, _) => url == $"{ZrcBase}/zaken" ? HttpStatusCode.ServiceUnavailable : HttpStatusCode.OK);
|
|
using var factory = Factory(stub);
|
|
using var client = factory.CreateClient();
|
|
|
|
var id = await CreateConcept(client);
|
|
var res = await client.PostAsJsonAsync($"/api/v1/applications/{id}/submit", new { diplomaHerkomst = "duo" });
|
|
|
|
// The local write is still authoritative: 200 with a real reference, not a 500.
|
|
res.EnsureSuccessStatusCode();
|
|
var body = (await res.Content.ReadFromJsonAsync<SubmitApplicationResponse>())!;
|
|
Assert.NotEmpty(body.Referentie);
|
|
|
|
var stored = ApplicationStore.ListAll().Single(a => a.Id == id);
|
|
Assert.Null(stored.ZaakUrl);
|
|
Assert.NotNull(stored.ZgwError);
|
|
|
|
var audit = await client.SendAsync(AdminRequest(HttpMethod.Get, "/api/v1/admin/audit"));
|
|
audit.EnsureSuccessStatusCode();
|
|
var entries = (await audit.Content.ReadFromJsonAsync<List<AuthzAuditDto>>())!;
|
|
Assert.Contains(entries, e => e.Action == "zgw:divergence" && e.Decision == "deny" && e.Resource == body.Referentie);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Submit_with_a_healthy_zgw_leaves_no_divergence_flag()
|
|
{
|
|
var stub = new ZgwStubHandler(SuccessBody);
|
|
using var factory = Factory(stub);
|
|
using var client = factory.CreateClient();
|
|
|
|
var id = await CreateConcept(client);
|
|
var res = await client.PostAsJsonAsync($"/api/v1/applications/{id}/submit", new { diplomaHerkomst = "duo" });
|
|
res.EnsureSuccessStatusCode();
|
|
|
|
var stored = ApplicationStore.ListAll().Single(a => a.Id == id);
|
|
Assert.Equal($"{ZrcBase}/zaken/uuid-new", stored.ZaakUrl);
|
|
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);
|
|
req.Headers.Add("X-Role", "admin");
|
|
return req;
|
|
}
|
|
}
|