CI / build (pull_request) Successful in 1m8s
CI / lint (pull_request) Successful in 1m23s
CI / unit (pull_request) Successful in 1m23s
CI / frontend (pull_request) Successful in 2m59s
CI / mutation (pull_request) Successful in 6m8s
CI / verify-stack (pull_request) Failing after 7m24s
CI caught my own check falling into the constraint ADR-0028 documents: it looked Objecttypen up by container IP, so the objecttype URL came back IP-addressed and Objecten rejected it as "not one of the available choices". Reach both by service name — compose DNS resolves them, and neither request has OpenZaak's URL-validity constraint that made IPs necessary elsewhere in this script. The 400 also spent the full 60s timeout disguised as "transport:" because HTTPError is a URLError subclass. Handle it separately: a 4xx now fails immediately with the response body, which is where the real reason was. Verified both ways against a live Objecten: absent record → exit 1 with the reason, present record → exit 0.
97 lines
4.0 KiB
Python
97 lines
4.0 KiB
Python
#!/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.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())
|