## What & why S-19b-1. A write to the Objecten API now produces a **delivered** notification on the `objecten` kanaal in Open Notificaties. ADR-0028 switched Objecten's notifications off on purpose — there was no broker, worker, kanaal or abonnement, so wiring only the client side would have dropped every message on the floor. This slice builds the real path and turns it back on. - `objecten-celery` worker (mirrors `oz-celery`) + `CELERY_BROKER_URL`/`RESULT_BACKEND` on objecten-redis db 1 (db 0 is already the cache). `notifications_api_common` only *queues* the send; without a worker every register write is silently undelivered. - `nrc` service + `notifications_config` in Objecten's `setup_configuration`, reusing the `big-reference-seed` credential OpenZaak publishes with (NRC authorizes it via OpenZaak's AC, which grants it `heeft_alle_autorisaties` — no second credential needed). - The `objecten` kanaal in NRC's `setup_configuration`. The name is fixed by the Objects API (`NOTIFICATIONS_KANAAL`), not chosen here; publishing to an unregistered kanaal is exactly what the red check reported first. - `NOTIFICATIONS_DISABLED: "false"` in both compose files. - Writers address Objecten as `objecten.local` — see *Notes for reviewers*. - `make verify-objecten-notifications` — registers an abonnement on `objecten` pointing at a throwaway sink, writes a `RegisterRecord` exactly as the ACL does on approval, asserts the delivery. One assertion covering the whole chain: Objecten -> objecten-celery -> NRC -> nrc-beat -> callback. Wired into the CI `verify-stack` job and the summary table. **ADR-0029** records the decisions; ADR-0028's ceiling now points at it. Closes #152 ## Definition of Done - [x] Linked Gitea issue (above). - [x] Failing test committed before the implementation (dc9ca2c, red at the first hop: `NRC POST /api/v1/abonnement -> 400 "Kanaal met deze naam bestaat niet."`). - [x] Implementation makes the test pass (4488962, + two fixes found by CI, below). - [x] Conventional Commits referencing the issue (`refs #152`). - [x] CI green — all six jobs ona5fd47e, including `verify-stack` end to end (e2e included). - [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes (`verify-stack`'s bring-up step). - [x] Docs updated — ADR-0029 added, ADR-0028's ceiling annotated, BACKLOG.md split. - [x] ADR added in `docs/architecture/`. - [x] Demo note in `docs/demo-script.md` if user-visible — n/a, infrastructure only; nothing consumes the kanaal until S-19b-2 (#153). ## Notes for reviewers **The one genuinely non-obvious bit: writers address Objecten as `objecten.local:8000`, not `objecten:8000`.** NRC types a notification's `hoofdObject`/`resourceUrl` as DRF `URLField`, so Django's `URLValidator` runs on them — and it rejects a **single-label** host. Objecten fills both from the object url DRF built with `request.build_absolute_uri`, i.e. *the Host the caller used*. Writing via the plain service name returns 201 and then fails every publish in the background, forever, with ``` 400 {"hoofdObject":["Voer een geldige URL in."],"resourceUrl":["Voer een geldige URL in."]} ``` So the `objecten` service carries an `objecten.local` network alias and every writer uses it — `Acl__Objecten__BaseUrl`, `ObjectenGatewayIntegrationTests`, this slice's verify driver. An alias rather than a bare dotted `SITE_DOMAIN` so the host still *resolves*: a subscriber following `resourceUrl` reaches the record, which S-19b-2 will do. Readers keep the plain name. Same class of constraint as ADR-0028's Objecttypen base-URL rule. **Ceiling, stated in the ADR:** nothing enforces the alias — a future writer using `objecten:8000` gets a 201 and silently no notification. If a second writer ever appears, rename the compose service rather than adding a lint. **Two CI-only failures on the way here**, both worth knowing: 1. `SITE_DOMAIN` was my first guess at the mechanism and is simply not what builds those URLs — dropped ind76abf2. 2. The check correlated the delivery on the `reference` inside the record it wrote. An NRC notification carries `kanaal`/`resource`/`kenmerken`/`hoofdObject`/`resourceUrl` and **never the record data**, so it correlates on the object URL now (a5fd47e). **Cost:** one more long-running container on the memory-tight runner. It inherits the capped `UWSGI_PROCESSES: "1"` env, which the celery command ignores; if `verify-stack` gets tight again, celery concurrency is the next knob. **Follow-up:** S-19b-2 (#153) sources the projection from these events. Nothing subscribes to the `objecten` kanaal in the product yet — only the verify check does.Reviewed-on: #154
106 lines
5.1 KiB
C#
106 lines
5.1 KiB
C#
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.local: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.local: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);
|
|
}
|