From dc9ca2ceb2f92617317468a28d6afe1dc623e1dd Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 14 Aug 2026 11:50:16 +0200 Subject: [PATCH] test(infra): verify-objecten-notifications asserts Objecten publishes to NRC (refs #152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registers an abonnement on the `objecten` kanaal pointing at the existing webhook sink, writes a RegisterRecord exactly as the ACL does on approval, and waits for the delivery. That is the whole publish chain in one assertion: Objecten → its celery worker → NRC → nrc-beat → the callback. Fails today at the first hop, which is the point: NRC POST /api/v1/abonnement → 400: {"name":"naam","code":"kanaal_naam", "reason":"Kanaal met deze naam bestaat niet."} Also splits S-19b (#150) into #152/#153 in BACKLOG.md — the notification wiring and the projection re-source are independently deployable (CLAUDE.md §13). --- .gitea/workflows/ci.yaml | 7 +- BACKLOG.md | 4 +- Makefile | 8 +- infra/objecten-notifications-check.py | 119 ++++++++++++++++++++++ infra/run-objecten-notifications-check.sh | 80 +++++++++++++++ 5 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 infra/objecten-notifications-check.py create mode 100755 infra/run-objecten-notifications-check.sh diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index b5d96df..1441ed4 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -219,6 +219,9 @@ jobs: - name: OpenZaak → NRC → Event Subscriber → projection-api id: projection run: make verify-projection + - name: Objecten → NRC notification delivery + id: objecten_nrc + run: make verify-objecten-notifications - name: Domain → Flowable → ACL → OpenZaak id: domain run: make verify-domain @@ -245,6 +248,7 @@ jobs: OBJECTTYPEN: ${{ steps.objecttypen.outcome }} OBJECTEN: ${{ steps.objecten.outcome }} REGISTERRECORD: ${{ steps.registerrecord.outcome }} + OBJECTEN_NOTIFICATIONS: ${{ steps.objecten_nrc.outcome }} ACL: ${{ steps.acl.outcome }} NRC: ${{ steps.nrc.outcome }} PROJECTION: ${{ steps.projection.outcome }} @@ -266,6 +270,7 @@ jobs: echo "| Objecttypen API + token | $(icon "$OBJECTTYPEN") |" echo "| Objecten API + token | $(icon "$OBJECTEN") |" echo "| RegisterRecord objecttype | $(icon "$REGISTERRECORD") |" + echo "| Objecten → NRC | $(icon "$OBJECTEN_NOTIFICATIONS") |" echo "| ACL ↔ OpenZaak | $(icon "$ACL") |" echo "| OpenZaak → NRC | $(icon "$NRC") |" echo "| NRC → Event Subscriber → projection | $(icon "$PROJECTION") |" @@ -285,7 +290,7 @@ jobs: # Log dump must precede teardown (which removes the containers). - name: Dump container logs on failure if: failure() - run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer objecttypen-db objecttypen-redis objecttypen-init objecttypen objecten-db objecten-redis objecten-init objecten registerrecord-init tempo prometheus grafana 2>&1 || true + run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer objecttypen-db objecttypen-redis objecttypen-init objecttypen objecten-db objecten-redis objecten-init objecten objecten-celery registerrecord-init tempo prometheus grafana 2>&1 || true - name: Tear down if: always() run: make down diff --git a/BACKLOG.md b/BACKLOG.md index b45a9de..0d0304b 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -296,7 +296,9 @@ Split into independently deployable sub-slices (CLAUDE.md §13): Split into independently deployable sub-slices (CLAUDE.md §13): - **S-19a** (#149, ✅) · ACL writes the `RegisterRecord` to Objecten on approval, idempotently, alongside the ZGW eindstatus. Carries the ADR (ADR-0028). -- **S-19b** (#150) · Read projection sourced from Objecten instead of NRC zaak events. Depends on S-19a. +- **S-19b** (#150) · Read projection sourced from Objecten instead of NRC zaak events. *(split — #150 closed)* + - **S-19b-1** (#152) · Objecten publishes to NRC — broker, celery worker, `objecten` kanaal, notifications config. Turns back on what ADR-0028 deliberately disabled. + - **S-19b-2** (#153) · Projection derived from `RegisterRecord` objects, rebuildable from the Objecten-derived log. Depends on S-19b-1. --- diff --git a/Makefile b/Makefile index 5b817e2..75d7163 100644 --- a/Makefile +++ b/Makefile @@ -43,7 +43,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK) endif endif -.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-objecttypen verify-objecten verify-registerrecord verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help +.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-objecttypen verify-objecten verify-registerrecord verify-objecten-notifications verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help ## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions) ## `verify` is the live-stack stage (full stack up once → ACL + notification checks). @@ -201,6 +201,11 @@ verify-objecten: verify-registerrecord: bash infra/run-registerrecord-check.sh +## verify-objecten-notifications: assert a RegisterRecord write in Objecten is DELIVERED as an +## `objecten` notification via NRC (S-19b-1), against the already-running stack. +verify-objecten-notifications: + bash infra/run-objecten-notifications-check.sh + ## verify: local mirror of the CI verify-stack job — full stack up once, all checks, ## tear down (always). For fast single-concern local iteration use `integration` ## (oz-only) or `verify-notifications` (oz+nrc) instead. @@ -212,6 +217,7 @@ verify: && bash infra/run-acl-integration.sh \ && bash infra/run-notification-check.sh \ && bash infra/run-projection-check.sh \ + && bash infra/run-objecten-notifications-check.sh \ && bash infra/run-domain-check.sh \ && bash infra/run-bff-check.sh \ && bash infra/run-e2e-check.sh || rc=$$?; \ diff --git a/infra/objecten-notifications-check.py b/infra/objecten-notifications-check.py new file mode 100644 index 0000000..3b82fee --- /dev/null +++ b/infra/objecten-notifications-check.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""S-19b-1 (#152): driver for the Objecten → NRC notification check. + +Registers an abonnement on the `objecten` kanaal pointing at the webhook sink, then writes a +RegisterRecord object exactly as the ACL's ObjectenGateway does (S-19a). The caller +(run-objecten-notifications-check.sh) watches the sink for the delivery — this only sets it up, +and prints `REFERENCE ` for the caller to grep on. + +Delivery exercises the whole chain: Objecten → its celery worker → NRC → nrc-beat → the callback. +Anything missing (broker, worker, kanaal, notifications config) shows up as a non-delivery. + +Stdlib only so it runs in a bare python:3-slim container on the compose network. +""" +import base64 +import hashlib +import hmac +import json +import os +import sys +import time +import urllib.error +import urllib.request + +OBJECTEN = os.environ["OBJECTEN"] # http://objecten:8000 +OBJECTEN_TOKEN = os.environ["OBJECTEN_TOKEN"] +OBJECTTYPEN = os.environ["OBJECTTYPEN"] # http://objecttypen:8000 +OBJECTTYPEN_TOKEN = os.environ["OBJECTTYPEN_TOKEN"] +NRC_BASE = os.environ["NRC_BASE"] # http://:8000 +SINK_CALLBACK = os.environ["SINK_CALLBACK"] # http://:9000/ +SINK_AUTH = os.environ["SINK_AUTH"] +CLIENT_ID = os.environ.get("NRC_CLIENT_ID", "big-reference-seed") +SECRET = os.environ.get("NRC_SECRET", "insecure-dev-secret-change-me") +KANAAL = "objecten" + + +def mint(): + """The HS256 JWT NRC expects (same shape as infra/local/register-abonnement.py).""" + def seg(d): + return base64.urlsafe_b64encode(json.dumps(d).encode()).rstrip(b"=") + + payload = seg({ + "iss": CLIENT_ID, "iat": int(time.time()), "client_id": CLIENT_ID, + "user_id": CLIENT_ID, "user_representation": CLIENT_ID, + }) + signing_input = seg({"typ": "JWT", "alg": "HS256"}) + b"." + payload + signature = base64.urlsafe_b64encode( + hmac.new(SECRET.encode(), signing_input, hashlib.sha256).digest()).rstrip(b"=") + return (signing_input + b"." + signature).decode() + + +def nrc(method, url, body=None): + """Call NRC. `url` may be a path or an absolute URL (the list returns absolute ones).""" + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request( + url if url.startswith("http") else f"{NRC_BASE}{url}", data=data, method=method, + headers={"Authorization": f"Bearer {mint()}", "Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=15) as r: + return json.load(r) if r.length != 0 else {} + except urllib.error.HTTPError as e: + # The body carries the reason (e.g. an unregistered kanaal); the status alone does not. + raise SystemExit(f"FAIL — NRC {method} {url} → {e.code}: {e.read().decode(errors='replace')[:400]}") + + +def token_api(base, token, method, path, body=None, crs=False): + data = json.dumps(body).encode() if body is not None else None + headers = {"Authorization": f"Token {token}"} + if body is not None: + headers["Content-Type"] = "application/json" + if crs: + headers["Accept-Crs"] = "EPSG:4326" + if body is not None: + headers["Content-Crs"] = "EPSG:4326" + req = urllib.request.Request(f"{base}{path}", data=data, method=method, headers=headers) + with urllib.request.urlopen(req, timeout=15) as r: + return json.load(r) if r.length != 0 else {} + + +def subscribe(): + """Register an abonnement on the objecten kanaal, replacing a stale one for the same callback.""" + # NRC returns a bare list here, not a paginated envelope. + for existing in nrc("GET", "/api/v1/abonnement") or []: + if existing.get("callbackUrl") == SINK_CALLBACK: + nrc("DELETE", existing["url"]) + nrc("POST", "/api/v1/abonnement", { + "callbackUrl": SINK_CALLBACK, + "auth": SINK_AUTH, + "kanalen": [{"naam": KANAAL, "filters": {}}], + }) + print(f">> abonnement on '{KANAAL}' -> {SINK_CALLBACK}") + + +def objecttype_url(): + results = token_api(OBJECTTYPEN, OBJECTTYPEN_TOKEN, "GET", "/api/v2/objecttypes").get("results", []) + match = next((o for o in results if o.get("name") == "RegisterRecord"), None) + if not match: + print("FAIL — no RegisterRecord objecttype in Objecttypen", file=sys.stderr) + raise SystemExit(1) + return match["url"] + + +def main(): + subscribe() + reference = f"NOTIF-{int(time.time())}" + created = token_api(OBJECTEN, OBJECTEN_TOKEN, "POST", "/api/v2/objects", { + "type": objecttype_url(), + "record": { + "typeVersion": 1, + "data": {"id": f"zaak-{reference}", "status": "INGESCHREVEN", "reference": reference}, + "startAt": time.strftime("%Y-%m-%d"), + }, + }, crs=True) + print(f">> wrote RegisterRecord {created['url']}") + print(f"REFERENCE {reference}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/infra/run-objecten-notifications-check.sh b/infra/run-objecten-notifications-check.sh new file mode 100755 index 0000000..3b77b50 --- /dev/null +++ b/infra/run-objecten-notifications-check.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# +# S-19b-1 (#152): verify the Objecten → NRC notification path against an ALREADY-RUNNING full +# stack. Registers an abonnement on the `objecten` kanaal pointing at a throwaway webhook sink, +# writes a RegisterRecord object (exactly as the ACL does on approval, S-19a), and asserts the sink +# receives the notification. +# +# This is the whole publish chain in one assertion: Objecten → its celery worker → NRC → nrc-beat → +# the subscriber callback. S-19a deliberately left it disconnected (ADR-0028); this proves it is +# connected for real, rather than merely configured. +# +# All in-network, reaching services by container IP (a single-label host isn't URL-valid for NRC's +# callbackUrl validator; the runner can't reach published ports — gitea-actions-gotchas.md §5/§6). +# EXCEPT Objecten/Objecttypen, which must be reached by SERVICE NAME: Objecttypen echoes the request +# Host into the objecttype `url` and Objecten only accepts the one matching its configured api_root +# (ADR-0028). +# +# Does NOT manage the stack lifecycle, but cleans up the sink/driver it creates. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SINK_AUTH="Bearer objecten-notification-sink-token" + +cleanup() { docker rm -f rr-osink rr-overify >/dev/null 2>&1 || true; } +trap cleanup EXIT + +ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; } + +# Anchored on the compose replica suffix so they don't also match objecten-db / objecten-redis. +obj="$(docker ps -q --filter 'name=objecten[-_][0-9]+$' | head -1)" +nrc="$(docker ps -q --filter 'name=nrc-web' | head -1)" +[ -n "$obj" ] || { echo "ERROR: no running objecten container — bring the stack up first" >&2; exit 1; } +[ -n "$nrc" ] || { echo "ERROR: no running nrc-web container — bring the stack up first" >&2; exit 1; } +net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$obj" | head -1)" +nrc_ip="$(ip "$nrc")" +echo ">> network=$net nrc=$nrc_ip" + +echo ">> starting the webhook sink" +docker rm -f rr-osink >/dev/null 2>&1 || true +sink="$(docker create --network "$net" --name rr-osink -e "EXPECTED_AUTH=$SINK_AUTH" \ + python:3-slim python /sink.py)" +docker cp "$here/notification-sink.py" "$sink:/sink.py" >/dev/null +docker start "$sink" >/dev/null +sleep 1 +sink_ip="$(ip rr-osink)" +echo ">> sink at $sink_ip:9000" + +echo ">> registering the abonnement + writing a RegisterRecord" +docker rm -f rr-overify >/dev/null 2>&1 || true +drv="$(docker create --network "$net" --name rr-overify \ + -e "OBJECTEN=http://objecten:8000" \ + -e "OBJECTEN_TOKEN=${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}" \ + -e "OBJECTTYPEN=http://objecttypen:8000" \ + -e "OBJECTTYPEN_TOKEN=${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}" \ + -e "NRC_BASE=http://$nrc_ip:8000" \ + -e "SINK_CALLBACK=http://$sink_ip:9000/" -e "SINK_AUTH=$SINK_AUTH" \ + python:3-slim python /driver.py)" +docker cp "$here/objecten-notifications-check.py" "$drv:/driver.py" >/dev/null +docker start -a "$drv" +reference="$(docker logs rr-overify 2>/dev/null | sed -n 's/^REFERENCE //p' | head -1)" +docker rm -f rr-overify >/dev/null +[ -n "$reference" ] || { echo "FAIL — the driver did not write a RegisterRecord" >&2; exit 1; } +echo ">> wrote reference $reference" + +echo ">> waiting for the notification to reach the sink" +for _ in $(seq 1 "${NOTIFICATION_TRIES:-40}"); do + if docker logs rr-osink 2>&1 | grep -q "$reference"; then + echo "OK — Objecten published to NRC and the abonnement delivered it:" + docker logs rr-osink 2>&1 | grep "$reference" | tail -1 | cut -c1-500 + exit 0 + fi + sleep 2 +done + +echo "FAIL — no 'objecten' notification for $reference reached the sink." >&2 +echo " Objecten accepted the write, so the gap is downstream: the celery broker/worker," >&2 +echo " the kanaal registration, or Objecten's notifications_config." >&2 +echo "--- sink log ---" >&2; docker logs rr-osink 2>&1 | tail -8 >&2 +echo "--- objecten log ---" >&2; docker logs "$obj" 2>&1 | tail -15 >&2 +exit 1