S-19a · ACL writes the RegisterRecord to Objecten on approval (closes #149) #151

Merged
not merged 11 commits from feat/149-acl-writes-registerrecord into main 2026-08-14 09:34:05 +00:00
2 changed files with 126 additions and 4 deletions
Showing only changes of commit 2bb7d9c165 - Show all commits
@@ -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 The cost is a constant duplicated across two files that must be kept in step; each carries
a comment pointing at the other. 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's notifications are off for this slice
Objecten publishes to a Notificaties API on every write, and `notifications_api_common` 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 `RegisterRecord` for that registration, with status `INGESCHREVEN` and no field outside
the public-safe schema. the public-safe schema.
Every HTTP exchange the gateway performs was additionally replayed by hand against a live `ObjectenGatewayIntegrationTests` (`Category=Integration`, so it runs under `verify-acl`
Objecten + Objecttypen pair while writing this slice — objecttype lookup by name, version inside the compose network) drives the real gateway against a live Objecten + Objecttypen
status, `data_attrs` search, create, update, and a rejected write carrying a `bsn`. Both pair: two writes for the same id leave exactly one object, carrying the second write's
findings above came out of that replay rather than out of CI. 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.
@@ -0,0 +1,105 @@
using Acl.Application;
using Acl.Infrastructure;
namespace Acl.IntegrationTests;
/// <summary>
/// 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).
/// </summary>
[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<IReadOnlyList<StoredRecord>> 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<string> 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);
}