Files
atomic-design-poc/backend/tests/BigRegister.Tests/ZgwDivergenceTests.cs
T
ehoandClaude Sonnet 5 3ff80c124f feat(openzaak): bounded retry + flagged write divergence (WP-60)
Local aanvraag/document writes and their paired ZGW writes aren't
transactional; a ZGW failure after the local write succeeds used to
diverge silently. ZgwHttpClient now retries transport-shaped failures
(not 500, which can follow a partial commit on the non-idempotent
statussen/rollen POSTs), and a ZGW failure that survives retry sets
Aanvraag.ZgwError plus a zgw:divergence audit row instead of failing
or diverging quietly. No outbox/reconcile job: three request-triggered
write paths don't justify a persisted queue that would also need to
carry citizen PII for the JWT audit claims.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 18:11:55 +02:00

121 lines
5.4 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);
}
private static HttpRequestMessage AdminRequest(HttpMethod method, string path)
{
var req = new HttpRequestMessage(method, path);
req.Headers.Add("X-Role", "admin");
return req;
}
}