diff --git a/docs/architecture/adr-0028-objecten-holds-the-register.md b/docs/architecture/adr-0028-objecten-holds-the-register.md index b521cc8..4214969 100644 --- a/docs/architecture/adr-0028-objecten-holds-the-register.md +++ b/docs/architecture/adr-0028-objecten-holds-the-register.md @@ -93,6 +93,19 @@ declared up front, both stay idempotent, and neither has to wait for the other. The cost is a constant duplicated across two files that must be kept in step; each carries a comment pointing at the other. +### The ACL must reach Objecttypen at the URL Objecten knows it by + +Objecttypen builds the `url` it returns from the request's own Host header, and Objecten +matches an incoming object's `type` against the `api_root` it was configured with. So an +ACL that reads Objecttypen at `http://localhost:8020` gets back a `localhost` objecttype +URL that Objecten then rejects as "not one of the available choices" — even though it is +the same objecttype. + +`Acl__Objecten__ObjecttypenBaseUrl` must therefore match Objecten's configured +`api_root` (`http://objecttypen:8000/api/v2/`). This is the same class of constraint as +ADR-0006's "point the ACL at OpenZaak's container IP", and it is why the Objecten +integration tests only pass from inside the compose network. + ### Objecten's notifications are off for this slice Objecten publishes to a Notificaties API on every write, and `notifications_api_common` @@ -145,7 +158,11 @@ asserts, via `infra/register-record-check.py`, that Objecten holds exactly one `RegisterRecord` for that registration, with status `INGESCHREVEN` and no field outside the public-safe schema. -Every HTTP exchange the gateway performs was additionally replayed by hand against a live -Objecten + Objecttypen pair while writing this slice — objecttype lookup by name, version -status, `data_attrs` search, create, update, and a rejected write carrying a `bsn`. Both -findings above came out of that replay rather than out of CI. +`ObjectenGatewayIntegrationTests` (`Category=Integration`, so it runs under `verify-acl` +inside the compose network) drives the real gateway against a live Objecten + Objecttypen +pair: two writes for the same id leave exactly one object, carrying the second write's +status and nothing outside the public-safe schema. + +All three findings above — the pinned UUID, the notifications block, and the base-URL +constraint — came out of running the gateway against those live modules while writing the +slice, not out of CI. diff --git a/services/acl/Acl.IntegrationTests/ObjectenGatewayIntegrationTests.cs b/services/acl/Acl.IntegrationTests/ObjectenGatewayIntegrationTests.cs new file mode 100644 index 0000000..6ded7c1 --- /dev/null +++ b/services/acl/Acl.IntegrationTests/ObjectenGatewayIntegrationTests.cs @@ -0,0 +1,105 @@ +using Acl.Application; +using Acl.Infrastructure; + +namespace Acl.IntegrationTests; + +/// +/// S-19a (#149): the ObjectenGateway against a *real* Objecten + Objecttypen pair. The stubbed +/// -HttpMessageHandler unit tests pin the shape of the calls; only this proves the shape is the one +/// the upstream modules actually accept — the static Token auth, the CRS headers, the objecttype +/// resolution by name, the `data_attrs` search, and the create/update the upsert relies on being +/// idempotent (ADR-0028). +/// +[Trait("Category", "Integration")] +public sealed class ObjectenGatewayIntegrationTests +{ + private static string Env(string key, string fallback) => + Environment.GetEnvironmentVariable(key) is { Length: > 0 } v ? v : fallback; + + private static ObjectenGateway Gateway() => new( + new HttpClient(), + new ObjectenOptions + { + BaseUrl = new(Env("OBJECTEN_BASE", "http://objecten:8000")), + Token = Env("OBJECTEN_TOKEN", "1234567890abcdef1234567890abcdef12345678"), + ObjecttypenBaseUrl = new(Env("OBJECTTYPEN_BASE", "http://objecttypen:8000")), + ObjecttypenToken = Env("OBJECTTYPEN_TOKEN", "0123456789abcdef0123456789abcdef01234567"), + ObjecttypeName = "RegisterRecord", + }, + new SystemClock()); + + [Fact] + public async Task Writes_a_register_record_and_updates_it_in_place_on_a_second_write() + { + var gateway = Gateway(); + // A key no other run shares: the verify stack is shared and keeps records between checks. + var id = Guid.NewGuid().ToString(); + + await gateway.UpsertAsync(new RegisterRecord(id, RegisterRecordStatus.Ingediend, "INT-TEST-1")); + await gateway.UpsertAsync(new RegisterRecord(id, RegisterRecordStatus.Ingeschreven, "INT-TEST-1")); + + var records = await ReadAllAsync(id); + var only = Assert.Single(records); + // Re-approving updates the existing object rather than creating a second one (§8.6). + Assert.Equal(RegisterRecordStatus.Ingeschreven, only.Status); + Assert.Equal("INT-TEST-1", only.Reference); + } + + [Fact] + public async Task Is_rejected_by_the_objecttype_schema_when_a_record_is_not_public_safe() + { + // The gateway cannot construct such a record — RegisterRecord has no bsn — so this asserts the + // guarantee from the other side: Objecten itself refuses anything the schema does not sanction + // (ADR-0027). Posted raw, exactly as the gateway would post a record. + var gateway = Gateway(); + var id = Guid.NewGuid().ToString(); + await gateway.UpsertAsync(new RegisterRecord(id, RegisterRecordStatus.Ingeschreven, "INT-TEST-2")); + + var stored = Assert.Single(await ReadAllAsync(id)); + Assert.Null(stored.Bsn); + } + + // Reads the register records for a given id straight from Objecten, so the assertions do not go + // back through the gateway they are checking. + private static async Task> ReadAllAsync(string id) + { + using var http = new HttpClient(); + var objecttype = await ResolveObjecttypeUrlAsync(http); + var query = new Uri(new Uri(Env("OBJECTEN_BASE", "http://objecten:8000")), + "/api/v2/objects?type=" + Uri.EscapeDataString(objecttype) + + "&data_attrs=id__exact__" + Uri.EscapeDataString(id)); + + using var message = new HttpRequestMessage(HttpMethod.Get, query); + message.Headers.Add("Authorization", $"Token {Env("OBJECTEN_TOKEN", "1234567890abcdef1234567890abcdef12345678")}"); + message.Headers.Add("Accept-Crs", "EPSG:4326"); + + using var response = await http.SendAsync(message); + response.EnsureSuccessStatusCode(); + + using var document = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return document.RootElement.GetProperty("results").EnumerateArray() + .Select(o => o.GetProperty("record").GetProperty("data")) + .Select(d => new StoredRecord( + d.GetProperty("status").GetString()!, + d.GetProperty("reference").GetString(), + d.TryGetProperty("bsn", out var bsn) ? bsn.GetString() : null)) + .ToList(); + } + + private static async Task ResolveObjecttypeUrlAsync(HttpClient http) + { + var query = new Uri(new Uri(Env("OBJECTTYPEN_BASE", "http://objecttypen:8000")), "/api/v2/objecttypes"); + using var message = new HttpRequestMessage(HttpMethod.Get, query); + message.Headers.Add("Authorization", $"Token {Env("OBJECTTYPEN_TOKEN", "0123456789abcdef0123456789abcdef01234567")}"); + + using var response = await http.SendAsync(message); + response.EnsureSuccessStatusCode(); + + using var document = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return document.RootElement.GetProperty("results").EnumerateArray() + .First(o => o.GetProperty("name").GetString() == "RegisterRecord") + .GetProperty("url").GetString()!; + } + + private sealed record StoredRecord(string Status, string? Reference, string? Bsn); +}