feat(infra): subscribe the projection to the objecten kanaal (refs #153)

Completes the re-source (ADR-0030): the Event Subscriber's abonnement moves from `zaken` to
`objecten`, in both the local stack's `nrc-subscribe` and the CI projection check. The
OpenZaak → NRC check keeps its own `zaken` abonnement — OpenZaak still publishes, nothing
in the product listens.

- register-abonnement.py subscribes to `objecten`, and now treats the kanaal as part of
  "already current" — an abonnement left from before this slice points at the right callback
  but the wrong kanaal, and would never have been replaced on IP alone.
- run-projection-check.sh opens its zaak *through the ACL* instead of straight against
  OpenZaak, because the ACL is what writes the register record the projection is now derived
  from. A zaak created behind the ACL's back produces no row — which is the re-source working.
- The acceptance scenario is restated in register terms and gains the approval case: the same
  row moving INGEDIEND → INGESCHREVEN is now one registration's record being updated, not two
  unrelated ZGW events.
This commit is contained in:
not
2026-08-28 12:35:44 +02:00
parent 8af09b2c92
commit ceb65991de
5 changed files with 92 additions and 53 deletions
+11 -6
View File
@@ -2,10 +2,10 @@
"""Local-stack bootstrap (S-B04, #110, ADR-0020) — register the NRC abonnement. """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 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 abonnement on the `objecten` kanaal pointing at the event-subscriber's /notifications callback, so
OpenZaak's notifications (zaak create + status set) reach the projection — without this the openbaar the register writes the ACL makes (INGEDIEND on submit, INGESCHREVEN on approval) reach the
(public) register stays empty. This is what infra/verify-notification-driver.py does for CI (minus projection — without this the openbaar (public) register stays empty. Since S-19b-2 the projection
the test zaak it also creates). 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 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 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") SINK_AUTH = os.environ.get("SINK_AUTH", "Bearer big-reference-notifications")
CID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed") CID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed")
SECRET = os.environ.get("OZ_SECRET", "insecure-dev-secret-change-me") 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(): def token():
@@ -60,7 +62,10 @@ def main():
status, body = call("GET", f"{NRC}/api/v1/abonnement") status, body = call("GET", f"{NRC}/api/v1/abonnement")
for ab in (body or []) if status == 200 else []: for ab in (body or []) if status == 200 else []:
if str(ab.get("callbackUrl", "")).endswith("/notifications"): 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']}") print(f"abonnement already current: {ab['url']}")
return return
call("DELETE", ab["url"]) call("DELETE", ab["url"])
@@ -68,7 +73,7 @@ def main():
status, ab = call("POST", f"{NRC}/api/v1/abonnement", { status, ab = call("POST", f"{NRC}/api/v1/abonnement", {
"callbackUrl": callback, "auth": SINK_AUTH, "callbackUrl": callback, "auth": SINK_AUTH,
"kanalen": [{"naam": "zaken", "filters": {}}]}) "kanalen": [{"naam": KANAAL, "filters": {}}]})
if status != 201: if status != 201:
sys.exit(f"create abonnement -> {status}: {json.dumps(ab)}") sys.exit(f"create abonnement -> {status}: {json.dumps(ab)}")
print(f"abonnement registered: {ab['url']} -> {callback}") print(f"abonnement registered: {ab['url']} -> {callback}")
+33 -18
View File
@@ -1,15 +1,19 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# #
# Verify the end-to-end read-projection path (S-06) against an ALREADY-RUNNING full stack: # Verify the end-to-end read-projection path (S-06, re-sourced by S-19b-2) against an ALREADY-RUNNING
# OpenZaak → NRC → Event Subscriber → projection → projection-api. Seeds a published BIG # full stack: ACL → Objecten → NRC → Event Subscriber → projection → projection-api. Seeds a
# zaaktype (idempotent), registers an abonnement on the `zaken` kanaal pointing at the real # published BIG zaaktype (idempotent), registers an abonnement on the `objecten` kanaal pointing at
# Event Subscriber's /notifications callback (with the bearer it enforces), creates a zaak, # the real Event Subscriber's /notifications callback (with the bearer it enforces), opens a zaak
# and asserts projection-api serves a row for that zaak with status INGEDIEND. # *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 # 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 # the runner can't reach published ports (gitea-actions-gotchas.md §5/§6). Does NOT manage the stack
# 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/0030.
# lifecycle (the caller owns bring-up + teardown). Plain docker primitives only. See ADR-0007/0008.
set -euo pipefail set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 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)" nrc="$(docker ps -q --filter 'name=nrc-web' | head -1)"
es="$(docker ps -q --filter 'name=event-subscriber' | head -1)" es="$(docker ps -q --filter 'name=event-subscriber' | head -1)"
proj="$(docker ps -q --filter 'name=projection-api' | 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 "$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 "$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)" 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")" 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" 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)" echo ">> seeding a published BIG zaaktype (idempotent)"
sid="$(docker create --network "$net" -e "OZ_BASE=http://$oz_ip:8000" -e OZ_PUBLISH=1 \ 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 start -a "$sid"
docker rm -f "$sid" >/dev/null 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 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 \ drv="$(docker create --network "$net" --name rr-pverify \
-e "OZ_BASE=http://$oz_ip:8000" -e "NRC_BASE=http://$nrc_ip:8000" \ -e "NRC_BASE=http://$nrc_ip:8000" \
-e "SINK_CALLBACK=http://$es_ip:8080/notifications" -e "SINK_AUTH=$WEBHOOK_AUTH" \ -e "SINK_HOST=$es_ip" -e "SINK_PORT=8080" -e "SINK_AUTH=$WEBHOOK_AUTH" \
python:3-slim python /driver.py)" python:3-slim python /subscribe.py)"
docker cp "$here/verify-notification-driver.py" "$drv:/driver.py" >/dev/null docker cp "$here/local/register-abonnement.py" "$drv:/subscribe.py" >/dev/null
docker start -a "$drv" 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 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##*/}" 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)" echo ">> polling projection-api for the projected row (status INGEDIEND)"
for _ in $(seq 1 30); do for _ in $(seq 1 30); do
@@ -63,6 +76,8 @@ for _ in $(seq 1 30); do
sleep 2 sleep 2
done done
echo "FAIL — projection-api never served an INGEDIEND row for zaak $zaak_uuid" >&2 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 "--- 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 "--- 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 exit 1
@@ -1,19 +1,28 @@
# language: en # language: en
# Drives S-06 (#7). On a zaak-created notification from NRC the Event Subscriber writes a # Drives S-19b-2 (#153), re-sourcing S-06 (#7). The read projection is derived from the
# rebuildable read-projection row (PRD §8.4). This scenario exercises the use case against an # RegisterRecord in Objecten (ADR-0030), not from ZGW zaak events: the ACL records a registration
# in-memory stand-in for the projection store and notification log; real OpenZaak → NRC → # in the register, Objecten notifies, and the Event Subscriber projects the record that
# subscriber delivery is verified by the live-stack check (verify-projection, ADR-0007/#58). # notification points at. This scenario exercises the use case against in-memory stand-ins for the
Feature: Register-projectie bijwerken op een zaaknotificatie # register, the projection store and the notification log; real Objecten → NRC → subscriber
Als openbaar register wil ik dat een aangemaakte zaak in de projectie verschijnt # delivery is verified by the live-stack check (verify-projection, ADR-0007/0030).
zodat het register de ingediende registratie kan tonen. 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 Scenario: Een ingediende registratie levert een rij met status INGEDIEND
Given a zaak is created in OpenZaak with id "11111111-1111-1111-1111-111111111111" Given registration "11111111-1111-1111-1111-111111111111" is recorded in the register with status "INGEDIEND"
When the NRC notification for that zaak is delivered to the event subscriber 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" 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 Scenario: Dezelfde notificatie tweemaal levert geen duplicaat
Given a zaak is created in OpenZaak with id "22222222-2222-2222-2222-222222222222" Given registration "22222222-2222-2222-2222-222222222222" is recorded in the register with status "INGEDIEND"
When the NRC notification for that zaak is delivered to the event subscriber When the register notification is delivered to the event subscriber
And the same NRC notification is delivered again And the same register notification is delivered again
Then the register projection contains exactly one row for "22222222-2222-2222-2222-222222222222" Then the register projection contains exactly one row for "22222222-2222-2222-2222-222222222222"
@@ -5,31 +5,39 @@ using Xunit;
namespace Acceptance.Steps; namespace Acceptance.Steps;
/// <summary>Bindings for <c>RegisterProjectieBijwerken.feature</c> (S-06). Reqnroll creates /// <summary>Bindings for <c>RegisterProjectieBijwerken.feature</c> (S-06, re-sourced by S-19b-2).
/// one instance per scenario, so instance fields hold scenario-scoped state.</summary> /// Reqnroll creates one instance per scenario, so instance fields hold scenario-scoped state.</summary>
[Binding] [Binding]
public sealed class RegisterProjectieBijwerkenSteps 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 InMemoryNotificationLog _log = new();
private readonly InMemoryProjectionStore _store = new(); private readonly InMemoryProjectionStore _store = new();
private readonly InMemoryRegisterRecordClient _register = new();
private readonly NotificationProjector _projector; private readonly NotificationProjector _projector;
private Notification? _notification; private Notification? _notification;
public RegisterProjectieBijwerkenSteps() public RegisterProjectieBijwerkenSteps()
=> _projector = new NotificationProjector(_log, _store, new InMemoryAclReferenceClient()); => _projector = new NotificationProjector(_log, _store, _register);
[Given("a zaak is created in OpenZaak with id \"(.*)\"")] [Given("registration \"(.*)\" is recorded in the register with status \"(.*)\"")]
public void GivenAZaakIsCreatedInOpenZaakWithId(string id) [When("registration \"(.*)\" is recorded in the register with status \"(.*)\"")]
=> _notification = new Notification("zaken", "zaak", "create", new Uri(ZaakBase + id)); 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")] [Given("the register notification is delivered to the event subscriber")]
public Task WhenTheNotificationIsDelivered() [When("the register notification is delivered to the event subscriber")]
public Task TheNotificationIsDelivered()
=> _projector.HandleAsync(_notification!); => _projector.HandleAsync(_notification!);
[When("the same NRC notification is delivered again")] [When("the same register notification is delivered again")]
public Task WhenTheSameNotificationIsDeliveredAgain() public Task TheSameNotificationIsDeliveredAgain()
=> _projector.HandleAsync(_notification!); => _projector.HandleAsync(_notification!);
[Then("the register projection contains a row for \"(.*)\" with status \"(.*)\"")] [Then("the register projection contains a row for \"(.*)\" with status \"(.*)\"")]
@@ -39,10 +39,12 @@ public sealed class InMemoryProjectionStore : IProjectionStore
=> [.. _byId.Values.Where(e => e.Id == id)]; => [.. _byId.Values.Where(e => e.Id == id)];
} }
/// <summary>A fake ACL client for the projection acceptance scenario: returns a reference derived /// <summary>An in-memory stand-in for the register the ACL reads back for the projector, so the
/// from the zaak, so the projector can enrich rows without a running ACL (#78).</summary> /// scenario runs without a running ACL or Objecten (S-19b-2, ADR-0030).</summary>
public sealed class InMemoryAclReferenceClient : IAclClient public sealed class InMemoryRegisterRecordClient : IAclClient
{ {
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default) public Dictionary<string, RegisterRecord> Records { get; } = [];
=> Task.FromResult("REG-" + zaakUrl.Segments[^1].Trim('/'));
public Task<RegisterRecord?> GetRegisterRecordAsync(Uri objectUrl, CancellationToken ct = default)
=> Task.FromResult(Records.TryGetValue(objectUrl.ToString(), out var record) ? record : null);
} }