test(infra): assert the approval wrote the register record to Objecten (refs #149)
verify-domain already drives a full approval; it now also asserts Objecten holds exactly one RegisterRecord for that registration — matched on its own reference, because the shared verify stack carries records from earlier runs. The check covers the three things that can silently go wrong: the record is missing (the ACL's Objecten hop never ran), duplicated (the upsert is not idempotent), or carries a field outside the public-safe schema.
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
#!/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://<ip>:8000
|
||||
OBJECTTYPEN_TOKEN = os.environ["OBJECTTYPEN_TOKEN"]
|
||||
OBJECTEN = os.environ["OBJECTEN"] # http://<ip>: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.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())
|
||||
@@ -142,6 +142,31 @@ 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.
|
||||
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://$(ip "$obj"):8000" \
|
||||
-e "OBJECTEN_TOKEN=${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}" \
|
||||
-e "OBJECTTYPEN=http://$(ip "$objt"):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
|
||||
# BPMN boundary event so the process ends and the Beoordelen task disappears (ADR-0014). ────────────
|
||||
|
||||
Reference in New Issue
Block a user