test(acl): ObjectenGateway integration test against live Objecten (refs #149)
CI / build (pull_request) Successful in 4m20s
CI / lint (pull_request) Successful in 4m38s
CI / unit (pull_request) Successful in 1m27s
CI / frontend (pull_request) Successful in 4m1s
CI / mutation (pull_request) Successful in 6m52s
CI / verify-stack (pull_request) Failing after 18m4s
CI / build (pull_request) Successful in 4m20s
CI / lint (pull_request) Successful in 4m38s
CI / unit (pull_request) Successful in 1m27s
CI / frontend (pull_request) Successful in 4m1s
CI / mutation (pull_request) Successful in 6m52s
CI / verify-stack (pull_request) Failing after 18m4s
Drives the real gateway against a running 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. Runs under verify-acl, inside the compose network — which it must, because Objecttypen echoes the request Host into the objecttype `url` and Objecten only accepts the one matching its configured api_root. ADR-0028 records that constraint.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user