From 2d783448b7a314c28cfc934ec737d572c54b7697 Mon Sep 17 00:00:00 2001 From: Niek Otten Date: Fri, 14 Aug 2026 10:43:34 +0200 Subject: [PATCH] fix(e2e): assert the register record where a real approval happens (refs #149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify-domain was the wrong home for the assertion, and CI was right to fail it. That check completes the Beoordelen task straight through Flowable REST — on purpose, it exists to exercise the Workflow Client's REST contract — which bypasses the domain `decide` path that calls the ACL. No approval reached the ACL there, so no record was ever written. The Playwright happy path is the only check that drives a real approval (behandel portal → BFF → domain → ACL), and it already knows its own reference. Assert there instead: exactly one RegisterRecord for that reference, INGESCHREVEN, carrying nothing outside the public-safe schema. Drops register-record-check.py and the verify-domain block. The helper was run under real Playwright against a live Objecten before committing — one record found, none for an unknown reference. --- .../adr-0028-objecten-holds-the-register.md | 14 ++- docs/demo-script.md | 11 ++- infra/register-record-check.py | 96 ------------------- infra/run-domain-check.sh | 30 ------ tests/e2e/registration.spec.ts | 51 +++++++++- 5 files changed, 67 insertions(+), 135 deletions(-) delete mode 100644 infra/register-record-check.py diff --git a/docs/architecture/adr-0028-objecten-holds-the-register.md b/docs/architecture/adr-0028-objecten-holds-the-register.md index 4214969..8f2321b 100644 --- a/docs/architecture/adr-0028-objecten-holds-the-register.md +++ b/docs/architecture/adr-0028-objecten-holds-the-register.md @@ -153,10 +153,16 @@ and no service reaches Objecten's database. ## Verification -`verify-domain` (`infra/run-domain-check.sh`) drives a real approval end-to-end and then -asserts, via `infra/register-record-check.py`, that Objecten holds exactly one -`RegisterRecord` for that registration, with status `INGESCHREVEN` and no field outside -the public-safe schema. +The end-to-end assertion lives in the Playwright happy path +(`tests/e2e/registration.spec.ts`, run by `verify-e2e`): after the behandelaar approves and +the openbaar register shows `INGESCHREVEN`, it asserts Objecten holds exactly one +`RegisterRecord` for *that* reference, with status `INGESCHREVEN` and no field outside the +public-safe schema. + +It belongs there and not in `verify-domain`, which looks like the obvious home: that check +completes the Beoordelen task straight through Flowable REST (deliberately — it exists to +exercise the Workflow Client's REST contract), which bypasses the domain `decide` path that +calls the ACL. The e2e is the only check that drives a real approval. `ObjectenGatewayIntegrationTests` (`Category=Integration`, so it runs under `verify-acl` inside the compose network) drives the real gateway against a live Objecten + Objecttypen diff --git a/docs/demo-script.md b/docs/demo-script.md index 1c4f3f8..25423aa 100644 --- a/docs/demo-script.md +++ b/docs/demo-script.md @@ -16,11 +16,14 @@ approval updates the existing object instead of creating a second one. # 1. Bring the stack up (Objecten, Objecttypen and the RegisterRecord objecttype come with it). make up # -# 2. End-to-end: the domain check submits a registration, walks it to Beoordelen, approves it, and -# then asserts Objecten holds exactly one RegisterRecord for *that* registration: -make verify-domain # → "OK — approval wrote the register record to Objecten: id=… status=INGESCHREVEN reference=…" +# 2. End-to-end: the walking-skeleton e2e submits, approves via the behandel portal, and then +# asserts Objecten holds exactly one RegisterRecord for *that* registration: +make verify-e2e # → "DigiD submit → … → behandelaar goedkeurt → public INGESCHREVEN" # -# 3. See it for yourself — every register record currently in Objecten: +# 3. The ACL integration test proves the same writes against a live Objecten (upsert stays one object): +make verify-acl # → "Writes a register record and updates it in place on a second write" +# +# 4. See it for yourself — every register record currently in Objecten: curl -s -H 'Authorization: Token 1234567890abcdef1234567890abcdef12345678' \ -H 'Accept-Crs: EPSG:4326' \ 'http://localhost:8021/api/v2/objects' | python3 -m json.tool diff --git a/infra/register-record-check.py b/infra/register-record-check.py deleted file mode 100644 index 538bec9..0000000 --- a/infra/register-record-check.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env python3 -"""S-19a (#149): prove the approval path wrote the register record to Objecten. - -Given the registration whose Beoordelen task the caller just completed with `goedkeuren`, assert -that Objecten holds exactly one RegisterRecord object for it, with status INGESCHREVEN and the -registration's reference — i.e. the ACL's Objecten hop ran, the record validates against the -objecttype schema (Objecten rejects a mismatch), and it carries no personal data (ADR-0027/0028). - -Stdlib only so it runs in a bare python:3-slim container on the compose network. -""" -import json -import os -import sys -import time -import urllib.error -import urllib.parse -import urllib.request - -OBJECTTYPEN = os.environ["OBJECTTYPEN"] # http://:8000 -OBJECTTYPEN_TOKEN = os.environ["OBJECTTYPEN_TOKEN"] -OBJECTEN = os.environ["OBJECTEN"] # http://:8000 -OBJECTEN_TOKEN = os.environ["OBJECTEN_TOKEN"] -REFERENCE = os.environ["REGISTRATION_REFERENCE"] -TIMEOUT = int(os.environ.get("REGISTER_RECORD_TIMEOUT", "60")) -NAME = "RegisterRecord" -# The register is world-readable: a record must never carry anything identifying (ADR-0027). -ALLOWED_FIELDS = {"id", "status", "reference"} - - -def get(base, token, path, crs=False): - headers = {"Authorization": f"Token {token}"} - if crs: - headers["Accept-Crs"] = "EPSG:4326" - req = urllib.request.Request(f"{base}{path}", headers=headers) - with urllib.request.urlopen(req, timeout=10) as r: - return json.load(r) - - -def objecttype_url(): - """The RegisterRecord objecttype URL, or None while registerrecord-init has yet to run.""" - ots = get(OBJECTTYPEN, OBJECTTYPEN_TOKEN, "/api/v2/objecttypes").get("results", []) - match = next((o for o in ots if o.get("name") == NAME), None) - return match["url"] if match else None - - -def check(): - """Return (ok, detail). Raises on transport errors so the caller can retry.""" - type_url = objecttype_url() - if not type_url: - return False, f"no objecttype named {NAME!r} in Objecttypen yet" - - query = urllib.parse.urlencode({"type": type_url, "data_attrs": f"reference__exact__{REFERENCE}"}) - results = get(OBJECTEN, OBJECTEN_TOKEN, f"/api/v2/objects?{query}", crs=True).get("results", []) - if not results: - return False, f"no RegisterRecord object with reference {REFERENCE}" - if len(results) > 1: - # The ACL upserts, so a replayed approval must update rather than duplicate (§8.6). - return False, f"{len(results)} RegisterRecord objects for reference {REFERENCE} — the write is not idempotent" - - data = (results[0].get("record") or {}).get("data") or {} - if data.get("status") != "INGESCHREVEN": - return False, f"record status is {data.get('status')!r}, expected 'INGESCHREVEN'" - if not data.get("id"): - return False, "record carries no id (the zaak the projection keys on)" - extra = set(data) - ALLOWED_FIELDS - if extra: - return False, f"record leaks non-public fields: {sorted(extra)}" - return True, f"id={data['id']} status={data['status']} reference={data['reference']}" - - -def main(): - deadline = time.time() + TIMEOUT - detail = "no attempt" - while time.time() < deadline: - try: - ok, detail = check() - if ok: - print(f"OK — approval wrote the register record to Objecten: {detail}") - return 0 - except urllib.error.HTTPError as e: - # A 4xx is us, not a cold start — retrying just hides the reason until the deadline. - # (A rejected objecttype URL shows up here as a 400 with a very specific body.) - body = e.read().decode(errors="replace")[:400] - if e.code < 500: - print(f"FAIL — HTTP {e.code} from {e.url}: {body}", file=sys.stderr) - return 1 - detail = f"HTTP {e.code}: {body}" - except (urllib.error.URLError, ConnectionError, TimeoutError) as e: - detail = f"transport: {e}" - time.sleep(3) - print(f"FAIL — {detail}", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/infra/run-domain-check.sh b/infra/run-domain-check.sh index 3d0cbb2..1ee7bbd 100755 --- a/infra/run-domain-check.sh +++ b/infra/run-domain-check.sh @@ -142,36 +142,6 @@ still="$(printf '%s' "$resp" | task_for_reg "$reg_id")" [ -z "$still" ] || { echo "FAIL — Beoordelen task $still still active after completion" >&2; exit 1; } echo "OK — behandelaar claimed and completed the Beoordelen task; the registratie process finished" -# ── S-19a: the same approval also wrote the canonical register record to Objecten (ADR-0028). -# Assert it for THIS registration (matched on its reference) rather than "some INGESCHREVEN record": -# the shared verify stack carries records from earlier runs. The container-name filters are anchored -# on the compose replica suffix so they don't also match objecten-db / objecttypen-db. -# -# Unlike every other check here, these two are reached by SERVICE NAME, not container IP. Objecttypen -# echoes the request Host into the objecttype `url`, and Objecten only accepts the objecttype URL that -# matches its configured api_root (http://objecttypen:8000/api/v2/) — an IP-addressed lookup yields a -# URL Objecten rejects with 400 (ADR-0028). Compose DNS resolves both names on this network, and -# neither request has OpenZaak's URL-validity constraint. -echo ">> asserting the approval wrote the register record to Objecten (S-19a)" -obj="$(docker ps -q --filter 'name=objecten[-_][0-9]+$' | head -1)" -objt="$(docker ps -q --filter 'name=objecttypen[-_][0-9]+$' | head -1)" -[ -n "$obj" ] || { echo "FAIL — no running objecten container" >&2; exit 1; } -[ -n "$objt" ] || { echo "FAIL — no running objecttypen container" >&2; exit 1; } -rr="$(docker create --network "$net" \ - -e "OBJECTEN=http://objecten:8000" \ - -e "OBJECTEN_TOKEN=${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}" \ - -e "OBJECTTYPEN=http://objecttypen:8000" \ - -e "OBJECTTYPEN_TOKEN=${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}" \ - -e "REGISTRATION_REFERENCE=$reg_id" \ - python:3-slim python /register-record-check.py)" -docker cp "$here/register-record-check.py" "$rr:/register-record-check.py" >/dev/null -rr_rc=0; docker start -a "$rr" || rr_rc=$? -docker rm -f "$rr" >/dev/null -if [ "$rr_rc" -ne 0 ]; then - acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)" - [ -n "$acl" ] && { echo "--- acl log ---" >&2; docker logs "$acl" 2>&1 | tail -20 >&2; } - exit "$rr_rc" -fi # ── S-11: withdrawal. A second registration parks at Beoordelen; the citizen withdraws it via the # domain, which delivers the RegistratieIngetrokken message to the task's execution, tripping the diff --git a/tests/e2e/registration.spec.ts b/tests/e2e/registration.spec.ts index 5a767bf..4c28e31 100644 --- a/tests/e2e/registration.spec.ts +++ b/tests/e2e/registration.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, request, test } from '@playwright/test'; // Walking-skeleton happy path (S-08d + S-09 + S-09b + S-12 + S-10a): a zorgprofessional logs in via // mock DigiD and submits through the self-service portal → BFF → domain; the entry appears in the @@ -109,4 +109,53 @@ test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt return staff.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGESCHREVEN' }).count(); }, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] }) .toBeGreaterThan(0); + + // S-19a: the same approval also wrote the canonical register record to Objecten (ADR-0028). + // Asserted here rather than in verify-domain because this is the only check that drives a *real* + // approval — verify-domain completes the Beoordelen task straight through Flowable REST, which + // bypasses the domain `decide` path that calls the ACL. + const records = await registerRecordsFor(reference); + // Matched on OUR reference: the verify stack is shared and holds records from earlier checks. + expect(records, `expected exactly one RegisterRecord for ${reference}`).toHaveLength(1); + expect(records[0].status).toBe('INGESCHREVEN'); + // The register is world-readable, so the record must carry nothing but the public-safe fields + // (ADR-0027) — Objecten's own schema validation enforces this, and this proves it end to end. + expect(Object.keys(records[0]).sort()).toEqual(['id', 'reference', 'status']); }); + +const OBJECTEN = process.env.OBJECTEN_URL ?? 'http://objecten:8000'; +const OBJECTTYPEN = process.env.OBJECTTYPEN_URL ?? 'http://objecttypen:8000'; +const OBJECTEN_TOKEN = process.env.OBJECTEN_TOKEN ?? '1234567890abcdef1234567890abcdef12345678'; +const OBJECTTYPEN_TOKEN = process.env.OBJECTTYPEN_TOKEN ?? '0123456789abcdef0123456789abcdef01234567'; + +/** + * The RegisterRecord objects Objecten holds for a registration reference. + * + * The objecttype is resolved by name rather than pinned: Objecttypen echoes the request Host into + * the objecttype `url`, and Objecten only accepts the one matching its configured api_root — so + * both must be reached by service name, exactly as the ACL reaches them (ADR-0028). + */ +async function registerRecordsFor(reference: string): Promise[]> { + const api = await request.newContext(); + try { + const types = await api.get(`${OBJECTTYPEN}/api/v2/objecttypes`, { + headers: { Authorization: `Token ${OBJECTTYPEN_TOKEN}` }, + }); + expect(types.ok(), `Objecttypen returned ${types.status()}`).toBeTruthy(); + const objecttype = ((await types.json()).results as { url: string; name: string }[]).find( + (o) => o.name === 'RegisterRecord', + ); + if (!objecttype) throw new Error('the RegisterRecord objecttype is not registered in Objecttypen'); + + const objects = await api.get(`${OBJECTEN}/api/v2/objects`, { + headers: { Authorization: `Token ${OBJECTEN_TOKEN}`, 'Accept-Crs': 'EPSG:4326' }, + params: { type: objecttype.url, data_attrs: `reference__exact__${reference}` }, + }); + expect(objects.ok(), `Objecten returned ${objects.status()}: ${await objects.text()}`).toBeTruthy(); + return ((await objects.json()).results as { record: { data: Record } }[]).map( + (o) => o.record.data, + ); + } finally { + await api.dispose(); + } +}