The backend half of the sweep RD-18 did for the front end. git blame holds the provenance and stays correct when the code moves; the comment names a closed ticket and tells the reader nothing the sentence around it does not. public/letter.css and LetterHtml.golden.html change together, because the renderer inlines the CSS and the golden file snapshots the result. Co-Authored-By: Claude Sonnet 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>
|
|
/// The 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 /aanvragen 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/aanvragen", new { type });
|
|
res.EnsureSuccessStatusCode();
|
|
var body = (await res.Content.ReadFromJsonAsync<AanvraagDetailDto>())!;
|
|
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/aanvragen/{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<AanvraagIndienenResponse>())!;
|
|
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/aanvragen/{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);
|
|
}
|
|
|
|
/// 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/aanvragen/{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;
|
|
}
|
|
}
|