From 4d07285dcdd0168ca08861d8f127ea7397376bd9 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Mon, 29 Jun 2026 14:29:12 +0200 Subject: [PATCH] =?UTF-8?q?test(infra):=20verify-notifications=20smoke=20+?= =?UTF-8?q?=20CI=20job=20for=20the=20OZ=E2=86=92NRC=20path=20(refs=20#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit make verify-notifications brings the stack up, seeds a published BIG zaaktype, and asserts a zaak-create notification is delivered to a webhook-sink abonnement. The sink + driver run as containers inside the compose network and reach OpenZaak/NRC by container IP (the runner can't reach published ports, and a single-label host isn't URL-valid). The sink enforces a bearer token because NRC refuses an unauthenticated callback. New 'notifications' Gitea Actions job runs it (Docker-only). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/ci.yaml | 16 +++++ infra/notification-sink.py | 39 ++++++++++++ infra/verify-notification-driver.py | 90 ++++++++++++++++++++++++++++ infra/verify-notifications.sh | 93 +++++++++++++++++++++++++++++ 4 files changed, 238 insertions(+) create mode 100755 infra/notification-sink.py create mode 100755 infra/verify-notification-driver.py create mode 100755 infra/verify-notifications.sh diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 5acf277..fc2528c 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -80,6 +80,22 @@ jobs: if: failure() run: docker compose -f infra/openzaak/docker-compose.yml down --volumes 2>&1 || true + notifications: + runs-on: ubuntu-latest + steps: + - uses: https://github.com/actions/checkout@v4 + # `make verify-notifications` brings up OpenZaak + NRC, seeds, and asserts a + # zaak-create notification is delivered to a subscriber — all via containers on + # the compose network (the runner can't reach published ports; §5). Needs only + # Docker + egress (base images, selectielijst.openzaak.nl). + - run: make verify-notifications + - name: dump OpenZaak/NRC logs on failure + if: failure() + run: docker compose -f infra/openzaak/docker-compose.yml -f infra/opennotificaties/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat 2>&1 || true + - name: tear down on failure + if: failure() + run: docker compose -f infra/openzaak/docker-compose.yml -f infra/opennotificaties/docker-compose.yml down --volumes 2>&1 || true + compose-smoke: runs-on: ubuntu-latest steps: diff --git a/infra/notification-sink.py b/infra/notification-sink.py new file mode 100755 index 0000000..e0d8b47 --- /dev/null +++ b/infra/notification-sink.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""A throwaway webhook sink for verifying the OpenZaak → NRC notification path. + +NRC delivers abonnement callbacks here as POSTs; each body is printed to stdout +(prefixed `NOTIFICATION `) so the verify harness can assert on `docker logs`. + +NRC refuses to register an abonnement whose callback is unauthenticated +(`no-auth-on-callback`): when validating it sends a probe and expects the callback +to reject a request without the configured `Authorization` value. So this sink +enforces that header (EXPECTED_AUTH env) — 401 without it, 204 with it. + +Stdlib only. Listens on :9000. See infra/verify-notifications.sh / S-01-c (#56). +""" +import http.server +import os +import sys + +EXPECTED_AUTH = os.environ.get("EXPECTED_AUTH", "Bearer notification-sink-token") + + +class Handler(http.server.BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("content-length", 0)) + body = self.rfile.read(length).decode("utf-8", "replace") + if self.headers.get("Authorization") != EXPECTED_AUTH: + self.send_response(401) + self.end_headers() + return + print("NOTIFICATION " + body, flush=True) + self.send_response(204) + self.end_headers() + + def log_message(self, *args): # silence default request logging + pass + + +if __name__ == "__main__": + http.server.HTTPServer(("0.0.0.0", 9000), Handler).serve_forever() + sys.exit(0) diff --git a/infra/verify-notification-driver.py b/infra/verify-notification-driver.py new file mode 100755 index 0000000..88b0abf --- /dev/null +++ b/infra/verify-notification-driver.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Drive the OpenZaak → NRC notification check from *inside* the compose network. + +Registers an abonnement on the `zaken` kanaal pointing at a webhook sink, then +creates a zaak against the published BIG zaaktype. OpenZaak publishes a +`zaken`/`create` notification; NRC delivers it to the sink. The host harness +(infra/verify-notifications.sh) then asserts the sink received it. + +Reached by container IP, not service name: OpenZaak/NRC validate URLs with Django's +URLValidator, which rejects a single-label host like `openzaak`. Stdlib only. +Env: OZ_BASE, NRC_BASE, SINK_CALLBACK, SINK_AUTH, OZ_CLIENT_ID, OZ_SECRET. +""" +import base64 +import hashlib +import hmac +import json +import os +import sys +import time +import urllib.error +import urllib.request + +OZ = os.environ["OZ_BASE"].rstrip("/") +NRC = os.environ["NRC_BASE"].rstrip("/") +SINK_CALLBACK = os.environ["SINK_CALLBACK"] +SINK_AUTH = os.environ.get("SINK_AUTH", "Bearer notification-sink-token") +CID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed") +SECRET = os.environ.get("OZ_SECRET", "insecure-dev-secret-change-me") +RSIN = "517439943" + + +def token(): + b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=") + seg = ( + b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode()) + + b"." + + b64(json.dumps( + {"iss": CID, "iat": int(time.time()), "client_id": CID, + "user_id": "verify", "user_representation": "verify"}, + separators=(",", ":")).encode()) + ) + return (seg + b"." + b64(hmac.new(SECRET.encode(), seg, hashlib.sha256).digest())).decode() + + +def call(method, url, body=None, crs=False): + headers = {"Authorization": "Bearer " + token(), + "Content-Type": "application/json", "Accept": "application/json"} + if crs: + headers["Accept-Crs"] = "EPSG:4326" + headers["Content-Crs"] = "EPSG:4326" + data = json.dumps(body).encode() if body is not None else None + try: + with urllib.request.urlopen( + urllib.request.Request(url, data=data, method=method, headers=headers), timeout=30 + ) as r: + return r.status, json.loads(r.read() or "null") + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read() or "null") + + +def main(): + status, ab = call("POST", f"{NRC}/api/v1/abonnement", { + "callbackUrl": SINK_CALLBACK, + "auth": SINK_AUTH, + "kanalen": [{"naam": "zaken", "filters": {}}], + }) + if status != 201: + sys.exit(f"create abonnement -> {status}: {json.dumps(ab)}") + print(f"abonnement: {ab['url']}") + + status, body = call( + "GET", f"{OZ}/catalogi/api/v1/zaaktypen?identificatie=BIG-REGISTRATIE&status=definitief") + results = body.get("results", []) if status == 200 else [] + if not results: + sys.exit("no published BIG-REGISTRATIE zaaktype — seed with OZ_PUBLISH=1 first") + zaaktype = results[0]["url"] + + status, zaak = call("POST", f"{OZ}/zaken/api/v1/zaken", { + "bronorganisatie": RSIN, "verantwoordelijkeOrganisatie": RSIN, + "zaaktype": zaaktype, "startdatum": time.strftime("%Y-%m-%d"), + "vertrouwelijkheidaanduiding": "openbaar", + }, crs=True) + if status != 201: + sys.exit(f"create zaak -> {status}: {json.dumps(zaak)}") + # The harness greps the sink for this exact URL. + print(f"ZAAK_CREATED {zaak['url']}") + + +if __name__ == "__main__": + main() diff --git a/infra/verify-notifications.sh b/infra/verify-notifications.sh new file mode 100755 index 0000000..0e7fa90 --- /dev/null +++ b/infra/verify-notifications.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# +# Verify the OpenZaak → Open Notificaties (NRC) notification path end to end (S-01-c, +# #56): a zaak created in OpenZaak is published to NRC and delivered to a subscriber. +# +# Everything that talks to OpenZaak/NRC runs *inside* the compose network and reaches +# them by container IP — the hosted CI runner can't reach published ports (sibling +# containers, gitea-actions-gotchas.md §5) and OpenZaak/NRC reject a single-label host +# in URLs (Django URLValidator). Plain docker primitives only (docker/podman-portable), +# like infra/seed-config.sh and infra/run-integration.sh. See ADR-0007. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OZ_COMPOSE="$here/openzaak/docker-compose.yml" +NRC_COMPOSE="$here/opennotificaties/docker-compose.yml" +SINK_AUTH="Bearer notification-sink-token" + +cleanup() { + docker rm -f rr-nsink rr-nverify >/dev/null 2>&1 || true + docker compose -f "$OZ_COMPOSE" -f "$NRC_COMPOSE" down --volumes >/dev/null 2>&1 || true + docker volume rm -f rr-oz-config rr-nrc-config >/dev/null 2>&1 || true +} +trap cleanup EXIT + +ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; } + +wait_healthy() { # name-regex -> echoes container id + local re="$1" cid status + for _ in $(seq 1 140); do + cid="$(docker ps -q --filter "name=$re" | head -1)" + if [ -n "$cid" ]; then + status="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$cid" 2>/dev/null || true)" + [ "$status" = healthy ] && { echo "$cid"; return 0; } + fi + sleep 3 + done + return 1 +} + +echo ">> bringing up OpenZaak + Open Notificaties (notifications enabled)" +bash "$here/seed-config.sh" oz nrc +OZ_NOTIFICATIONS_DISABLED=false docker compose -f "$OZ_COMPOSE" -f "$NRC_COMPOSE" up -d + +echo ">> waiting for OpenZaak + NRC to be healthy" +oz="$(wait_healthy 'openzaak[-_]openzaak')" || { echo "ERROR: OpenZaak not healthy" >&2; exit 1; } +nrc="$(wait_healthy 'nrc-web')" || { echo "ERROR: NRC not healthy" >&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")" +echo ">> network=$net openzaak=$oz_ip nrc=$nrc_ip" + +echo ">> seeding a published BIG zaaktype" +sid="$(docker create --network "$net" -e "OZ_BASE=http://$oz_ip:8000" -e OZ_PUBLISH=1 \ + python:3-slim python /seed.py)" +docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null +docker start -a "$sid" +docker rm -f "$sid" >/dev/null + +echo ">> starting the webhook sink" +docker rm -f rr-nsink >/dev/null 2>&1 || true +sink="$(docker create --network "$net" --name rr-nsink -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-nsink)" +echo ">> sink at $sink_ip:9000" + +echo ">> registering abonnement + creating a zaak" +docker rm -f rr-nverify >/dev/null 2>&1 || true +drv="$(docker create --network "$net" --name rr-nverify \ + -e "OZ_BASE=http://$oz_ip:8000" -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/verify-notification-driver.py" "$drv:/driver.py" >/dev/null +docker start -a "$drv" +zaak_url="$(docker logs rr-nverify 2>/dev/null | sed -n 's/^ZAAK_CREATED //p' | head -1)" +docker rm -f rr-nverify >/dev/null +[ -n "$zaak_url" ] || { echo "ERROR: driver did not create a zaak" >&2; exit 1; } +zaak_uuid="${zaak_url##*/}" +echo ">> zaak created: $zaak_url" + +echo ">> waiting for the notification to reach the sink" +for _ in $(seq 1 30); do + if docker logs rr-nsink 2>&1 | grep -q "$zaak_uuid"; then + echo "OK — NRC delivered the zaken notification for zaak $zaak_uuid to the sink" + docker logs rr-nsink 2>&1 | grep "$zaak_uuid" | tail -1 | cut -c1-300 + exit 0 + fi + sleep 2 +done +echo "FAIL — the sink never received a notification for zaak $zaak_uuid" >&2 +echo "--- sink log ---" >&2; docker logs rr-nsink 2>&1 | tail -8 >&2 +exit 1