diff --git a/infra/local/register-abonnement.py b/infra/local/register-abonnement.py index 8ce50c1..57b462f 100755 --- a/infra/local/register-abonnement.py +++ b/infra/local/register-abonnement.py @@ -2,10 +2,10 @@ """Local-stack bootstrap (S-B04, #110, ADR-0020) — register the NRC abonnement. Runs as the `nrc-subscribe` init container of infra/docker-compose.local.yml. Registers an -abonnement on the `zaken` kanaal pointing at the event-subscriber's /notifications callback, so -OpenZaak's notifications (zaak create + status set) reach the projection — without this the openbaar -(public) register stays empty. This is what infra/verify-notification-driver.py does for CI (minus -the test zaak it also creates). +abonnement on the `objecten` kanaal pointing at the event-subscriber's /notifications callback, so +the register writes the ACL makes (INGEDIEND on submit, INGESCHREVEN on approval) reach the +projection — without this the openbaar (public) register stays empty. Since S-19b-2 the projection +is sourced from the register in Objecten, not from ZGW zaak events (ADR-0030). The callback host is the event-subscriber's resolved **container IP**, not `event-subscriber`, because NRC validates callbackUrl with Django's URLValidator (a single-label host is rejected — same reason the @@ -22,6 +22,8 @@ SINK_PORT = os.environ.get("SINK_PORT", "8080") SINK_AUTH = os.environ.get("SINK_AUTH", "Bearer big-reference-notifications") CID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed") SECRET = os.environ.get("OZ_SECRET", "insecure-dev-secret-change-me") +# The projection is sourced from the register in Objecten, not from ZGW zaak events (S-19b-2). +KANAAL = "objecten" def token(): @@ -60,7 +62,10 @@ def main(): status, body = call("GET", f"{NRC}/api/v1/abonnement") for ab in (body or []) if status == 200 else []: if str(ab.get("callbackUrl", "")).endswith("/notifications"): - if ab.get("callbackUrl") == callback: + # The kanaal is part of "current": an abonnement left over from before S-19b-2 points at + # the right callback but listens on `zaken`, and would never be replaced on IP alone. + kanalen = [k.get("naam") for k in ab.get("kanalen", [])] + if ab.get("callbackUrl") == callback and kanalen == [KANAAL]: print(f"abonnement already current: {ab['url']}") return call("DELETE", ab["url"]) @@ -68,7 +73,7 @@ def main(): status, ab = call("POST", f"{NRC}/api/v1/abonnement", { "callbackUrl": callback, "auth": SINK_AUTH, - "kanalen": [{"naam": "zaken", "filters": {}}]}) + "kanalen": [{"naam": KANAAL, "filters": {}}]}) if status != 201: sys.exit(f"create abonnement -> {status}: {json.dumps(ab)}") print(f"abonnement registered: {ab['url']} -> {callback}") diff --git a/infra/run-projection-check.sh b/infra/run-projection-check.sh index 6499277..2ea12ac 100755 --- a/infra/run-projection-check.sh +++ b/infra/run-projection-check.sh @@ -1,15 +1,19 @@ #!/usr/bin/env bash # -# Verify the end-to-end read-projection path (S-06) against an ALREADY-RUNNING full stack: -# OpenZaak → NRC → Event Subscriber → projection → projection-api. Seeds a published BIG -# zaaktype (idempotent), registers an abonnement on the `zaken` kanaal pointing at the real -# Event Subscriber's /notifications callback (with the bearer it enforces), creates a zaak, -# and asserts projection-api serves a row for that zaak with status INGEDIEND. +# Verify the end-to-end read-projection path (S-06, re-sourced by S-19b-2) against an ALREADY-RUNNING +# full stack: ACL → Objecten → NRC → Event Subscriber → projection → projection-api. Seeds a +# published BIG zaaktype (idempotent), registers an abonnement on the `objecten` kanaal pointing at +# the real Event Subscriber's /notifications callback (with the bearer it enforces), opens a zaak +# *through the ACL*, and asserts projection-api serves a row for it with status INGEDIEND. +# +# The zaak is opened through the ACL, not straight against OpenZaak: since ADR-0030 the projection is +# derived from the RegisterRecord in Objecten, and the ACL is what writes that record (INGEDIEND on +# submit). A zaak created behind the ACL's back produces no register write and so no projection row — +# which is the point of the re-source. # # All in-network, reaching services by container IP — single-label hosts aren't URL-valid and -# the runner can't reach published ports (gitea-actions-gotchas.md §5/§6). Reuses the -# notification driver to register the abonnement + create the zaak. Does NOT manage the stack -# lifecycle (the caller owns bring-up + teardown). Plain docker primitives only. See ADR-0007/0008. +# the runner can't reach published ports (gitea-actions-gotchas.md §5/§6). Does NOT manage the stack +# lifecycle (the caller owns bring-up + teardown). Plain docker primitives only. See ADR-0007/0008/0030. set -euo pipefail here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -24,11 +28,13 @@ oz="$(docker ps -q --filter 'name=[-_]openzaak[-_]' | head -1)" nrc="$(docker ps -q --filter 'name=nrc-web' | head -1)" es="$(docker ps -q --filter 'name=event-subscriber' | head -1)" proj="$(docker ps -q --filter 'name=projection-api' | head -1)" +acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)" [ -n "$oz" ] && [ -n "$nrc" ] || { echo "ERROR: OpenZaak and/or NRC not running — bring the stack up first" >&2; exit 1; } [ -n "$es" ] && [ -n "$proj" ] || { echo "ERROR: event-subscriber and/or projection-api not running — bring the stack up first" >&2; exit 1; } +[ -n "$acl" ] || { echo "ERROR: acl not running — bring the stack up first" >&2; exit 1; } net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$oz" | head -1)" -oz_ip="$(ip "$oz")"; nrc_ip="$(ip "$nrc")"; es_ip="$(ip "$es")"; proj_ip="$(ip "$proj")" -echo ">> network=$net openzaak=$oz_ip nrc=$nrc_ip event-subscriber=$es_ip projection-api=$proj_ip" +oz_ip="$(ip "$oz")"; nrc_ip="$(ip "$nrc")"; es_ip="$(ip "$es")"; proj_ip="$(ip "$proj")"; acl_ip="$(ip "$acl")" +echo ">> network=$net openzaak=$oz_ip nrc=$nrc_ip event-subscriber=$es_ip projection-api=$proj_ip acl=$acl_ip" echo ">> seeding a published BIG zaaktype (idempotent)" sid="$(docker create --network "$net" -e "OZ_BASE=http://$oz_ip:8000" -e OZ_PUBLISH=1 \ @@ -37,19 +43,26 @@ docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null docker start -a "$sid" docker rm -f "$sid" >/dev/null -echo ">> registering abonnement at the Event Subscriber + creating a zaak" +echo ">> registering the event-subscriber abonnement on the objecten kanaal" docker rm -f rr-pverify >/dev/null 2>&1 || true +# The same script the local stack uses (ADR-0020), so both paths register the identical abonnement. drv="$(docker create --network "$net" --name rr-pverify \ - -e "OZ_BASE=http://$oz_ip:8000" -e "NRC_BASE=http://$nrc_ip:8000" \ - -e "SINK_CALLBACK=http://$es_ip:8080/notifications" -e "SINK_AUTH=$WEBHOOK_AUTH" \ - python:3-slim python /driver.py)" -docker cp "$here/verify-notification-driver.py" "$drv:/driver.py" >/dev/null + -e "NRC_BASE=http://$nrc_ip:8000" \ + -e "SINK_HOST=$es_ip" -e "SINK_PORT=8080" -e "SINK_AUTH=$WEBHOOK_AUTH" \ + python:3-slim python /subscribe.py)" +docker cp "$here/local/register-abonnement.py" "$drv:/subscribe.py" >/dev/null docker start -a "$drv" -zaak_url="$(docker logs rr-pverify 2>/dev/null | sed -n 's/^ZAAK_CREATED //p' | head -1)" docker rm -f rr-pverify >/dev/null -[ -n "$zaak_url" ] || { echo "ERROR: driver did not create a zaak" >&2; exit 1; } + +echo ">> opening a zaak through the ACL (which writes the INGEDIEND register record)" +reference="PROJ-$(date +%s)" +zaak_url="$(docker run --rm --network "$net" curlimages/curl:latest \ + -fsS -X POST "http://$acl_ip:8080/zaken" -H 'Content-Type: application/json' \ + -d "{\"bsn\":\"123456782\",\"reference\":\"$reference\"}" \ + | sed -n 's/.*"zaakUrl":"\([^"]*\)".*/\1/p')" +[ -n "$zaak_url" ] || { echo "ERROR: the ACL did not open a zaak" >&2; exit 1; } zaak_uuid="${zaak_url##*/}" -echo ">> zaak created: $zaak_url" +echo ">> zaak created: $zaak_url (reference $reference)" echo ">> polling projection-api for the projected row (status INGEDIEND)" for _ in $(seq 1 30); do @@ -63,6 +76,8 @@ for _ in $(seq 1 30); do sleep 2 done echo "FAIL — projection-api never served an INGEDIEND row for zaak $zaak_uuid" >&2 +echo " The chain is ACL → Objecten → NRC → event-subscriber → projection (ADR-0030)." >&2 echo "--- event-subscriber log ---" >&2; docker logs "$es" 2>&1 | tail -10 >&2 echo "--- projection-api log ---" >&2; docker logs "$proj" 2>&1 | tail -10 >&2 +echo "--- acl log ---" >&2; docker logs "$acl" 2>&1 | tail -10 >&2 exit 1 diff --git a/tests/acceptance/Features/RegisterProjectieBijwerken.feature b/tests/acceptance/Features/RegisterProjectieBijwerken.feature index f9d1220..f31e802 100644 --- a/tests/acceptance/Features/RegisterProjectieBijwerken.feature +++ b/tests/acceptance/Features/RegisterProjectieBijwerken.feature @@ -1,19 +1,28 @@ # language: en -# Drives S-06 (#7). On a zaak-created notification from NRC the Event Subscriber writes a -# rebuildable read-projection row (PRD §8.4). This scenario exercises the use case against an -# in-memory stand-in for the projection store and notification log; real OpenZaak → NRC → -# subscriber delivery is verified by the live-stack check (verify-projection, ADR-0007/#58). -Feature: Register-projectie bijwerken op een zaaknotificatie - Als openbaar register wil ik dat een aangemaakte zaak in de projectie verschijnt - zodat het register de ingediende registratie kan tonen. +# Drives S-19b-2 (#153), re-sourcing S-06 (#7). The read projection is derived from the +# RegisterRecord in Objecten (ADR-0030), not from ZGW zaak events: the ACL records a registration +# in the register, Objecten notifies, and the Event Subscriber projects the record that +# notification points at. This scenario exercises the use case against in-memory stand-ins for the +# register, the projection store and the notification log; real Objecten → NRC → subscriber +# delivery is verified by the live-stack check (verify-projection, ADR-0007/0030). +Feature: Register-projectie bijwerken op een registerwijziging + Als openbaar register wil ik dat een registratie in de projectie verschijnt zodra zij + in het register is vastgelegd, zodat het register haar actuele status kan tonen. - Scenario: Een zaaknotificatie levert een rij met status INGEDIEND - Given a zaak is created in OpenZaak with id "11111111-1111-1111-1111-111111111111" - When the NRC notification for that zaak is delivered to the event subscriber + Scenario: Een ingediende registratie levert een rij met status INGEDIEND + Given registration "11111111-1111-1111-1111-111111111111" is recorded in the register with status "INGEDIEND" + When the register notification is delivered to the event subscriber Then the register projection contains a row for "11111111-1111-1111-1111-111111111111" with status "INGEDIEND" + Scenario: Een goedgekeurde registratie werkt dezelfde rij bij + Given registration "33333333-3333-3333-3333-333333333333" is recorded in the register with status "INGEDIEND" + And the register notification is delivered to the event subscriber + When registration "33333333-3333-3333-3333-333333333333" is recorded in the register with status "INGESCHREVEN" + And the register notification is delivered to the event subscriber + Then the register projection contains a row for "33333333-3333-3333-3333-333333333333" with status "INGESCHREVEN" + Scenario: Dezelfde notificatie tweemaal levert geen duplicaat - Given a zaak is created in OpenZaak with id "22222222-2222-2222-2222-222222222222" - When the NRC notification for that zaak is delivered to the event subscriber - And the same NRC notification is delivered again + Given registration "22222222-2222-2222-2222-222222222222" is recorded in the register with status "INGEDIEND" + When the register notification is delivered to the event subscriber + And the same register notification is delivered again Then the register projection contains exactly one row for "22222222-2222-2222-2222-222222222222" diff --git a/tests/acceptance/Steps/RegisterProjectieBijwerkenSteps.cs b/tests/acceptance/Steps/RegisterProjectieBijwerkenSteps.cs index 39f485b..b2e535c 100644 --- a/tests/acceptance/Steps/RegisterProjectieBijwerkenSteps.cs +++ b/tests/acceptance/Steps/RegisterProjectieBijwerkenSteps.cs @@ -5,31 +5,39 @@ using Xunit; namespace Acceptance.Steps; -/// Bindings for RegisterProjectieBijwerken.feature (S-06). Reqnroll creates -/// one instance per scenario, so instance fields hold scenario-scoped state. +/// Bindings for RegisterProjectieBijwerken.feature (S-06, re-sourced by S-19b-2). +/// Reqnroll creates one instance per scenario, so instance fields hold scenario-scoped state. [Binding] public sealed class RegisterProjectieBijwerkenSteps { - private const string ZaakBase = "http://openzaak:8000/zaken/api/v1/zaken/"; + private const string ObjectBase = "http://objecten.local:8000/api/v2/objects/"; private readonly InMemoryNotificationLog _log = new(); private readonly InMemoryProjectionStore _store = new(); + private readonly InMemoryRegisterRecordClient _register = new(); private readonly NotificationProjector _projector; private Notification? _notification; public RegisterProjectieBijwerkenSteps() - => _projector = new NotificationProjector(_log, _store, new InMemoryAclReferenceClient()); + => _projector = new NotificationProjector(_log, _store, _register); - [Given("a zaak is created in OpenZaak with id \"(.*)\"")] - public void GivenAZaakIsCreatedInOpenZaakWithId(string id) - => _notification = new Notification("zaken", "zaak", "create", new Uri(ZaakBase + id)); + [Given("registration \"(.*)\" is recorded in the register with status \"(.*)\"")] + [When("registration \"(.*)\" is recorded in the register with status \"(.*)\"")] + public void RegistrationIsRecorded(string id, string status) + { + // The ACL upserts one object per registration, so submit and approval share an object URL. + var objectUrl = ObjectBase + id; + _register.Records[objectUrl] = new RegisterRecord(id, status, "REG-" + id); + _notification = new Notification("objecten", "object", "create", new Uri(objectUrl), new Uri(objectUrl)); + } - [When("the NRC notification for that zaak is delivered to the event subscriber")] - public Task WhenTheNotificationIsDelivered() + [Given("the register notification is delivered to the event subscriber")] + [When("the register notification is delivered to the event subscriber")] + public Task TheNotificationIsDelivered() => _projector.HandleAsync(_notification!); - [When("the same NRC notification is delivered again")] - public Task WhenTheSameNotificationIsDeliveredAgain() + [When("the same register notification is delivered again")] + public Task TheSameNotificationIsDeliveredAgain() => _projector.HandleAsync(_notification!); [Then("the register projection contains a row for \"(.*)\" with status \"(.*)\"")] diff --git a/tests/acceptance/Support/InMemoryProjectionStores.cs b/tests/acceptance/Support/InMemoryProjectionStores.cs index ade1106..9a100c0 100644 --- a/tests/acceptance/Support/InMemoryProjectionStores.cs +++ b/tests/acceptance/Support/InMemoryProjectionStores.cs @@ -39,10 +39,12 @@ public sealed class InMemoryProjectionStore : IProjectionStore => [.. _byId.Values.Where(e => e.Id == id)]; } -/// A fake ACL client for the projection acceptance scenario: returns a reference derived -/// from the zaak, so the projector can enrich rows without a running ACL (#78). -public sealed class InMemoryAclReferenceClient : IAclClient +/// An in-memory stand-in for the register the ACL reads back for the projector, so the +/// scenario runs without a running ACL or Objecten (S-19b-2, ADR-0030). +public sealed class InMemoryRegisterRecordClient : IAclClient { - public Task GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default) - => Task.FromResult("REG-" + zaakUrl.Segments[^1].Trim('/')); + public Dictionary Records { get; } = []; + + public Task GetRegisterRecordAsync(Uri objectUrl, CancellationToken ct = default) + => Task.FromResult(Records.TryGetValue(objectUrl.ToString(), out var record) ? record : null); }