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:
@@ -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}")
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user