fix(e2e): assert the register record where a real approval happens (refs #149)
CI / build (pull_request) Successful in 1m6s
CI / lint (pull_request) Successful in 1m19s
CI / unit (pull_request) Successful in 1m24s
CI / frontend (pull_request) Successful in 2m57s
CI / mutation (pull_request) Successful in 6m7s
CI / verify-stack (pull_request) Successful in 7m49s
CI / build (pull_request) Successful in 1m6s
CI / lint (pull_request) Successful in 1m19s
CI / unit (pull_request) Successful in 1m24s
CI / frontend (pull_request) Successful in 2m57s
CI / mutation (pull_request) Successful in 6m7s
CI / verify-stack (pull_request) Successful in 7m49s
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.
This commit is contained in:
@@ -153,10 +153,16 @@ and no service reaches Objecten's database.
|
|||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
`verify-domain` (`infra/run-domain-check.sh`) drives a real approval end-to-end and then
|
The end-to-end assertion lives in the Playwright happy path
|
||||||
asserts, via `infra/register-record-check.py`, that Objecten holds exactly one
|
(`tests/e2e/registration.spec.ts`, run by `verify-e2e`): after the behandelaar approves and
|
||||||
`RegisterRecord` for that registration, with status `INGESCHREVEN` and no field outside
|
the openbaar register shows `INGESCHREVEN`, it asserts Objecten holds exactly one
|
||||||
the public-safe schema.
|
`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`
|
`ObjectenGatewayIntegrationTests` (`Category=Integration`, so it runs under `verify-acl`
|
||||||
inside the compose network) drives the real gateway against a live Objecten + Objecttypen
|
inside the compose network) drives the real gateway against a live Objecten + Objecttypen
|
||||||
|
|||||||
+7
-4
@@ -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).
|
# 1. Bring the stack up (Objecten, Objecttypen and the RegisterRecord objecttype come with it).
|
||||||
make up
|
make up
|
||||||
#
|
#
|
||||||
# 2. End-to-end: the domain check submits a registration, walks it to Beoordelen, approves it, and
|
# 2. End-to-end: the walking-skeleton e2e submits, approves via the behandel portal, and then
|
||||||
# then asserts Objecten holds exactly one RegisterRecord for *that* registration:
|
# asserts Objecten holds exactly one RegisterRecord for *that* registration:
|
||||||
make verify-domain # → "OK — approval wrote the register record to Objecten: id=… status=INGESCHREVEN reference=…"
|
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' \
|
curl -s -H 'Authorization: Token 1234567890abcdef1234567890abcdef12345678' \
|
||||||
-H 'Accept-Crs: EPSG:4326' \
|
-H 'Accept-Crs: EPSG:4326' \
|
||||||
'http://localhost:8021/api/v2/objects' | python3 -m json.tool
|
'http://localhost:8021/api/v2/objects' | python3 -m json.tool
|
||||||
|
|||||||
@@ -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://<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())
|
|
||||||
@@ -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; }
|
[ -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"
|
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
|
# ── 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
|
# domain, which delivers the RegistratieIngetrokken message to the task's execution, tripping the
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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();
|
return staff.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGESCHREVEN' }).count();
|
||||||
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
||||||
.toBeGreaterThan(0);
|
.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<Record<string, string>[]> {
|
||||||
|
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<string, string> } }[]).map(
|
||||||
|
(o) => o.record.data,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user