Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5fd47e546 | ||
|
|
d76abf2df2 | ||
|
|
448896206f | ||
|
|
dc9ca2ceb2 | ||
|
|
0cd70ae8c3 |
@@ -219,6 +219,9 @@ jobs:
|
||||
- name: OpenZaak → NRC → Event Subscriber → projection-api
|
||||
id: projection
|
||||
run: make verify-projection
|
||||
- name: Objecten → NRC notification delivery
|
||||
id: objecten_nrc
|
||||
run: make verify-objecten-notifications
|
||||
- name: Domain → Flowable → ACL → OpenZaak
|
||||
id: domain
|
||||
run: make verify-domain
|
||||
@@ -245,6 +248,7 @@ jobs:
|
||||
OBJECTTYPEN: ${{ steps.objecttypen.outcome }}
|
||||
OBJECTEN: ${{ steps.objecten.outcome }}
|
||||
REGISTERRECORD: ${{ steps.registerrecord.outcome }}
|
||||
OBJECTEN_NOTIFICATIONS: ${{ steps.objecten_nrc.outcome }}
|
||||
ACL: ${{ steps.acl.outcome }}
|
||||
NRC: ${{ steps.nrc.outcome }}
|
||||
PROJECTION: ${{ steps.projection.outcome }}
|
||||
@@ -266,6 +270,7 @@ jobs:
|
||||
echo "| Objecttypen API + token | $(icon "$OBJECTTYPEN") |"
|
||||
echo "| Objecten API + token | $(icon "$OBJECTEN") |"
|
||||
echo "| RegisterRecord objecttype | $(icon "$REGISTERRECORD") |"
|
||||
echo "| Objecten → NRC | $(icon "$OBJECTEN_NOTIFICATIONS") |"
|
||||
echo "| ACL ↔ OpenZaak | $(icon "$ACL") |"
|
||||
echo "| OpenZaak → NRC | $(icon "$NRC") |"
|
||||
echo "| NRC → Event Subscriber → projection | $(icon "$PROJECTION") |"
|
||||
@@ -285,7 +290,7 @@ jobs:
|
||||
# Log dump must precede teardown (which removes the containers).
|
||||
- name: Dump container logs on failure
|
||||
if: failure()
|
||||
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer objecttypen-db objecttypen-redis objecttypen-init objecttypen objecten-db objecten-redis objecten-init objecten registerrecord-init tempo prometheus grafana 2>&1 || true
|
||||
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer objecttypen-db objecttypen-redis objecttypen-init objecttypen objecten-db objecten-redis objecten-init objecten objecten-celery registerrecord-init tempo prometheus grafana 2>&1 || true
|
||||
- name: Tear down
|
||||
if: always()
|
||||
run: make down
|
||||
|
||||
+3
-1
@@ -296,7 +296,9 @@ Split into independently deployable sub-slices (CLAUDE.md §13):
|
||||
Split into independently deployable sub-slices (CLAUDE.md §13):
|
||||
|
||||
- **S-19a** (#149, ✅) · ACL writes the `RegisterRecord` to Objecten on approval, idempotently, alongside the ZGW eindstatus. Carries the ADR (ADR-0028).
|
||||
- **S-19b** (#150) · Read projection sourced from Objecten instead of NRC zaak events. Depends on S-19a.
|
||||
- **S-19b** (#150) · Read projection sourced from Objecten instead of NRC zaak events. *(split — #150 closed)*
|
||||
- **S-19b-1** (#152) · Objecten publishes to NRC — broker, celery worker, `objecten` kanaal, notifications config. Turns back on what ADR-0028 deliberately disabled.
|
||||
- **S-19b-2** (#153) · Projection derived from `RegisterRecord` objects, rebuildable from the Objecten-derived log. Depends on S-19b-1.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
|
||||
endif
|
||||
endif
|
||||
|
||||
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-objecttypen verify-objecten verify-registerrecord verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
|
||||
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-objecttypen verify-objecten verify-registerrecord verify-objecten-notifications verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
|
||||
|
||||
## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions)
|
||||
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
|
||||
@@ -201,6 +201,11 @@ verify-objecten:
|
||||
verify-registerrecord:
|
||||
bash infra/run-registerrecord-check.sh
|
||||
|
||||
## verify-objecten-notifications: assert a RegisterRecord write in Objecten is DELIVERED as an
|
||||
## `objecten` notification via NRC (S-19b-1), against the already-running stack.
|
||||
verify-objecten-notifications:
|
||||
bash infra/run-objecten-notifications-check.sh
|
||||
|
||||
## verify: local mirror of the CI verify-stack job — full stack up once, all checks,
|
||||
## tear down (always). For fast single-concern local iteration use `integration`
|
||||
## (oz-only) or `verify-notifications` (oz+nrc) instead.
|
||||
@@ -212,6 +217,7 @@ verify:
|
||||
&& bash infra/run-acl-integration.sh \
|
||||
&& bash infra/run-notification-check.sh \
|
||||
&& bash infra/run-projection-check.sh \
|
||||
&& bash infra/run-objecten-notifications-check.sh \
|
||||
&& bash infra/run-domain-check.sh \
|
||||
&& bash infra/run-bff-check.sh \
|
||||
&& bash infra/run-e2e-check.sh || rc=$$?; \
|
||||
|
||||
@@ -119,8 +119,8 @@ every message was dropped on the floor — a delivery path that looks wired and
|
||||
|
||||
- ponytail ceiling: Objecten emits no notifications, so nothing downstream can react to a
|
||||
register write yet.
|
||||
- Upgrade path: S-19b (#150) needs those notifications to source the projection from
|
||||
Objecten, and turns them on together with the broker, worker, kanaal and abonnement.
|
||||
- **Lifted by ADR-0029** (S-19b-1, #152): broker, worker, `objecten` kanaal and
|
||||
notifications config now exist, and `NOTIFICATIONS_DISABLED` is `false`.
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# ADR-0029: Objecten publishes register events to NRC
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-14
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-19b-1 (#152), first of the S-19b (#150) split
|
||||
- **Supersedes in part:** ADR-0028's "Objecten's notifications are off for this slice"
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0028 put the authoritative register record in the Objecten API and had the ACL write
|
||||
it on approval. It also switched Objecten's notifications **off** — deliberately, with a
|
||||
stated ceiling: there was no broker, no worker, no `objecten` kanaal and no abonnement, so
|
||||
turning the client side on alone would have produced a delivery path that looks wired and
|
||||
drops every message.
|
||||
|
||||
S-19b-2 (#153) wants the read projection sourced from register writes rather than
|
||||
re-derived from ZGW zaak events. That needs the notifications to actually arrive. This ADR
|
||||
builds the four missing pieces and lifts the ceiling.
|
||||
|
||||
## Decision
|
||||
|
||||
**Objecten publishes to the same NRC OpenZaak already publishes to, on the `objecten`
|
||||
kanaal, delivered by its own Celery worker — provisioned declaratively on both sides,
|
||||
exactly as ADR-0007 did for OpenZaak.**
|
||||
|
||||
- **Objecten** (`infra/objecten/setup_configuration/data.yaml`): a `zgw_consumers` service
|
||||
`nrc` (api_type `nrc`) plus a `notifications_config` step naming it, and
|
||||
`NOTIFICATIONS_DISABLED: "false"` in both compose files.
|
||||
- **NRC** (`infra/opennotificaties/setup_configuration/data.yaml`): an `objecten` kanaal
|
||||
alongside `zaken`.
|
||||
- **`objecten-celery`**: a worker container on the Objecten image (`/celery_worker.sh`),
|
||||
mirroring `oz-celery`, with `CELERY_BROKER_URL`/`CELERY_RESULT_BACKEND` on
|
||||
`objecten-redis` db 1 (db 0 is already the cache).
|
||||
|
||||
### One NRC, one credential, one kanaal per publisher
|
||||
|
||||
Objecten reuses the `big-reference-seed` client OpenZaak publishes with. NRC verifies its
|
||||
JWT and authorizes it against OpenZaak's Autorisaties API (ADR-0007), which grants that
|
||||
client `heeft_alle_autorisaties` — so no second credential and no publisher-specific
|
||||
authorization is needed. A second NRC, or a second credential, would buy isolation this
|
||||
reference application has no use for.
|
||||
|
||||
The kanaal name is **not ours to choose**: the Objects API sends
|
||||
`NOTIFICATIONS_KANAAL = "objecten"`. NRC rejects a publish to an unregistered kanaal
|
||||
(`"Kanaal met deze naam bestaat niet"`), which is precisely what the failing check for this
|
||||
slice reported first. Its filter set (`object_type`) matches the kenmerken the Objects API
|
||||
sends, so an abonnement can narrow to one objecttype instead of receiving every write.
|
||||
|
||||
### Writers address Objecten as `objecten.local` — NRC rejects single-label hosts
|
||||
|
||||
NRC types a notification's `hoofdObject` and `resourceUrl` as DRF `URLField`s, so Django's
|
||||
`URLValidator` runs on them — and it refuses a **single-label** host. Objecten fills both
|
||||
from the object `url` that DRF built with `request.build_absolute_uri`, i.e. **the Host the
|
||||
caller used**. Write to `http://objecten:8000` and NRC answers every publish with
|
||||
|
||||
```
|
||||
{"hoofdObject":["Voer een geldige URL in."],"resourceUrl":["Voer een geldige URL in."]}
|
||||
```
|
||||
|
||||
which `objecten-celery` then retries with exponential backoff, forever, in the background —
|
||||
the write itself having returned 201.
|
||||
|
||||
`SITE_DOMAIN` does **not** fix this; it is not what builds those URLs. The fix is on the
|
||||
caller side: the `objecten` service carries an `objecten.local` network alias, and every
|
||||
component whose writes must be notified — the ACL (`Acl__Objecten__BaseUrl`), the gateway
|
||||
integration tests, this slice's verify check — addresses it there. An alias rather than a
|
||||
plain dotted `SITE_DOMAIN` so the host still **resolves in-network**: a subscriber that
|
||||
follows `resourceUrl` reaches the record it points at, which S-19b-2 will do. Readers are
|
||||
unaffected and keep using the plain service name.
|
||||
|
||||
This is the same class of constraint as ADR-0028's "the ACL's Objecttypen base URL must
|
||||
match Objecten's configured `api_root`": these modules put request-derived hosts into data
|
||||
another module then validates or dereferences.
|
||||
|
||||
- ponytail ceiling: nothing *enforces* that a new writer uses the alias — it would get a 201
|
||||
and silently no notification.
|
||||
- Upgrade path: if a second writer ever appears, rename the compose service to `objecten.local`
|
||||
so the plain name stops working, rather than adding a lint.
|
||||
|
||||
### A worker, not a synchronous send
|
||||
|
||||
`notifications_api_common` only schedules the send on transaction commit. Without a worker
|
||||
the task sits in redis forever and every register write is silently undelivered — the exact
|
||||
half-wired state ADR-0028 refused to ship. No `beat` for Objecten: it is a publisher, not a
|
||||
subscriber, and `nrc-beat` already drains NRC's delivery queue.
|
||||
|
||||
## Verification
|
||||
|
||||
`make verify-objecten-notifications` (`infra/run-objecten-notifications-check.sh`, in the
|
||||
CI `verify-stack` job) registers an abonnement on the `objecten` kanaal pointing at a
|
||||
throwaway webhook sink, writes a `RegisterRecord` exactly as the ACL does on approval, and
|
||||
asserts the notification reaches the sink. That is the whole chain in one assertion:
|
||||
Objecten → `objecten-celery` → NRC → `nrc-beat` → the callback. Any missing piece — broker,
|
||||
worker, kanaal, notifications config — shows up as a non-delivery rather than as a green
|
||||
config.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- A register write is now observable by anything that subscribes, which is what S-19b-2
|
||||
(#153) needs to make the projection a cache of Objecten rather than a re-derivation of ZGW.
|
||||
- ADR-0028's ceiling is lifted: the delivery path is proven end to end, not merely configured.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- One more long-running container (`objecten-celery`) on an already memory-tight CI runner.
|
||||
- A second publisher on the shared `big-reference-seed` credential — a credential rotation
|
||||
now touches two modules.
|
||||
- Objecten now has two in-network names, and which one a caller uses silently decides
|
||||
whether its writes are notified (ceiling above).
|
||||
- ponytail ceiling: notification delivery has no dead-letter or alerting — a failed publish
|
||||
is visible only in the worker log.
|
||||
- Upgrade path: if undelivered register events start mattering, subscribe an audit sink or
|
||||
read NRC's own delivery admin rather than building a retry layer here.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None bent. This is infrastructure between two upstream modules, over their documented
|
||||
APIs; no service reaches another's database. §8.6 (idempotency at every event boundary)
|
||||
applies to whatever consumes the new kanaal — S-19b-2's problem, not this slice's.
|
||||
+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).
|
||||
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
|
||||
|
||||
@@ -341,7 +341,8 @@ services:
|
||||
# Objecten holds the register, OpenZaak holds the process (S-19a, ADR-0028). Both APIs take a
|
||||
# static token, not a ZGW JWT. The objecttype URL is assigned at seed time, so the ACL resolves
|
||||
# it by name — lazily, on the first approval, so no depends_on is needed here.
|
||||
Acl__Objecten__BaseUrl: http://objecten:8000/
|
||||
# Dotted host on purpose — see the `objecten.local` alias below (ADR-0029).
|
||||
Acl__Objecten__BaseUrl: http://objecten.local:8000/
|
||||
Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}
|
||||
Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/
|
||||
Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
|
||||
@@ -691,12 +692,14 @@ services:
|
||||
CACHE_AXES: objecten-redis:6379/0
|
||||
DISABLE_2FA: "true"
|
||||
OTEL_SDK_DISABLED: "true"
|
||||
# S-19a: Objecten refuses every write while its Notificaties config is absent
|
||||
# (notifications_api_common raises rather than skipping, so POST /objects 500s). Objecten →
|
||||
# NRC is not wired yet — there is no broker, worker, kanaal or abonnement for it — so turn
|
||||
# notifications off rather than fake a delivery path that silently drops every message.
|
||||
# S-19b (#150) sources the projection from Objecten and turns this back on for real.
|
||||
NOTIFICATIONS_DISABLED: "true"
|
||||
CELERY_BROKER_URL: redis://objecten-redis:6379/1
|
||||
CELERY_RESULT_BACKEND: redis://objecten-redis:6379/1
|
||||
# Publish register-record events to NRC on the `objecten` kanaal (S-19b-1, ADR-0029). The NRC
|
||||
# service + notifications_config are provisioned by setup_configuration
|
||||
# (infra/objecten/setup_configuration/data.yaml), and objecten-celery below actually sends
|
||||
# them — notifications_api_common only queues the task. See ADR-0028 for why S-19a left this
|
||||
# off until all four pieces existed.
|
||||
NOTIFICATIONS_DISABLED: "false"
|
||||
RUN_SETUP_CONFIG: "true"
|
||||
command: /setup_configuration.sh
|
||||
volumes:
|
||||
@@ -721,6 +724,28 @@ services:
|
||||
start_period: 30s
|
||||
ports:
|
||||
- "8021:8000"
|
||||
depends_on:
|
||||
objecten-init:
|
||||
condition: service_completed_successfully
|
||||
networks:
|
||||
cg:
|
||||
# Objecten reflects the *request* Host into the `url` it returns, and
|
||||
# notifications_api_common publishes that url as the notification's hoofdObject /
|
||||
# resourceUrl — which NRC types as a URLField, and Django's URLValidator rejects a
|
||||
# single-label host ("Voer een geldige URL in."). So every caller whose writes must be
|
||||
# notified addresses Objecten by this dotted alias instead of `objecten` (ADR-0029).
|
||||
# Reads are unaffected and still use the plain service name.
|
||||
aliases:
|
||||
- objecten.local
|
||||
|
||||
# The celery worker that actually delivers Objecten's notifications to NRC (S-19b-1, ADR-0029).
|
||||
# notifications_api_common only schedules the send on transaction commit; without a worker the
|
||||
# task sits in redis forever and every register write is silently undelivered. Mirrors oz-celery.
|
||||
# No beat: Objecten is a publisher, not a subscriber — nrc-beat drains the delivery queue.
|
||||
objecten-celery:
|
||||
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
|
||||
environment: *objecten-env-local
|
||||
command: /celery_worker.sh
|
||||
depends_on:
|
||||
objecten-init:
|
||||
condition: service_completed_successfully
|
||||
|
||||
@@ -326,7 +326,8 @@ services:
|
||||
# Objecten holds the register, OpenZaak holds the process (S-19a, ADR-0028). Both APIs take a
|
||||
# static token, not a ZGW JWT. The objecttype URL is assigned at seed time, so the ACL resolves
|
||||
# it by name — lazily, on the first approval, so no depends_on is needed here.
|
||||
Acl__Objecten__BaseUrl: http://objecten:8000/
|
||||
# Dotted host on purpose — see the `objecten.local` alias below (ADR-0029).
|
||||
Acl__Objecten__BaseUrl: http://objecten.local:8000/
|
||||
Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}
|
||||
Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/
|
||||
Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
|
||||
@@ -717,12 +718,14 @@ services:
|
||||
CACHE_AXES: objecten-redis:6379/0
|
||||
DISABLE_2FA: "true"
|
||||
OTEL_SDK_DISABLED: "true"
|
||||
# S-19a: Objecten refuses every write while its Notificaties config is absent
|
||||
# (notifications_api_common raises rather than skipping, so POST /objects 500s). Objecten →
|
||||
# NRC is not wired yet — there is no broker, worker, kanaal or abonnement for it — so turn
|
||||
# notifications off rather than fake a delivery path that silently drops every message.
|
||||
# S-19b (#150) sources the projection from Objecten and turns this back on for real.
|
||||
NOTIFICATIONS_DISABLED: "true"
|
||||
CELERY_BROKER_URL: redis://objecten-redis:6379/1
|
||||
CELERY_RESULT_BACKEND: redis://objecten-redis:6379/1
|
||||
# Publish register-record events to NRC on the `objecten` kanaal (S-19b-1, ADR-0029). The NRC
|
||||
# service + notifications_config are provisioned by setup_configuration
|
||||
# (infra/objecten/setup_configuration/data.yaml), and objecten-celery below actually sends
|
||||
# them — notifications_api_common only queues the task. See ADR-0028 for why S-19a left this
|
||||
# off until all four pieces existed.
|
||||
NOTIFICATIONS_DISABLED: "false"
|
||||
RUN_SETUP_CONFIG: "true"
|
||||
command: /setup_configuration.sh
|
||||
# data.yaml is streamed into this external volume by infra/seed-config.sh before start.
|
||||
@@ -750,6 +753,28 @@ services:
|
||||
start_period: 30s
|
||||
ports:
|
||||
- "8021:8000"
|
||||
depends_on:
|
||||
objecten-init:
|
||||
condition: service_completed_successfully
|
||||
networks:
|
||||
cg:
|
||||
# Objecten reflects the *request* Host into the `url` it returns, and
|
||||
# notifications_api_common publishes that url as the notification's hoofdObject /
|
||||
# resourceUrl — which NRC types as a URLField, and Django's URLValidator rejects a
|
||||
# single-label host ("Voer een geldige URL in."). So every caller whose writes must be
|
||||
# notified addresses Objecten by this dotted alias instead of `objecten` (ADR-0029).
|
||||
# Reads are unaffected and still use the plain service name.
|
||||
aliases:
|
||||
- objecten.local
|
||||
|
||||
# The celery worker that actually delivers Objecten's notifications to NRC (S-19b-1, ADR-0029).
|
||||
# notifications_api_common only schedules the send on transaction commit; without a worker the
|
||||
# task sits in redis forever and every register write is silently undelivered. Mirrors oz-celery.
|
||||
# No beat: Objecten is a publisher, not a subscriber — nrc-beat drains the delivery queue.
|
||||
objecten-celery:
|
||||
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
|
||||
environment: *objecten-env
|
||||
command: /celery_worker.sh
|
||||
depends_on:
|
||||
objecten-init:
|
||||
condition: service_completed_successfully
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""S-19b-1 (#152): driver for the Objecten → NRC notification check.
|
||||
|
||||
Registers an abonnement on the `objecten` kanaal pointing at the webhook sink, then writes a
|
||||
RegisterRecord object exactly as the ACL's ObjectenGateway does (S-19a). The caller
|
||||
(run-objecten-notifications-check.sh) watches the sink for the delivery — this only sets it up,
|
||||
and prints `OBJECT_URL <url>` for the caller to grep on.
|
||||
|
||||
Delivery exercises the whole chain: Objecten → its celery worker → NRC → nrc-beat → the callback.
|
||||
Anything missing (broker, worker, kanaal, notifications config) shows up as a non-delivery.
|
||||
|
||||
Stdlib only so it runs in a bare python:3-slim container on the compose network.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
OBJECTEN = os.environ["OBJECTEN"] # http://objecten:8000
|
||||
OBJECTEN_TOKEN = os.environ["OBJECTEN_TOKEN"]
|
||||
OBJECTTYPEN = os.environ["OBJECTTYPEN"] # http://objecttypen:8000
|
||||
OBJECTTYPEN_TOKEN = os.environ["OBJECTTYPEN_TOKEN"]
|
||||
NRC_BASE = os.environ["NRC_BASE"] # http://<nrc-ip>:8000
|
||||
SINK_CALLBACK = os.environ["SINK_CALLBACK"] # http://<sink-ip>:9000/
|
||||
SINK_AUTH = os.environ["SINK_AUTH"]
|
||||
CLIENT_ID = os.environ.get("NRC_CLIENT_ID", "big-reference-seed")
|
||||
SECRET = os.environ.get("NRC_SECRET", "insecure-dev-secret-change-me")
|
||||
KANAAL = "objecten"
|
||||
|
||||
|
||||
def mint():
|
||||
"""The HS256 JWT NRC expects (same shape as infra/local/register-abonnement.py)."""
|
||||
def seg(d):
|
||||
return base64.urlsafe_b64encode(json.dumps(d).encode()).rstrip(b"=")
|
||||
|
||||
payload = seg({
|
||||
"iss": CLIENT_ID, "iat": int(time.time()), "client_id": CLIENT_ID,
|
||||
"user_id": CLIENT_ID, "user_representation": CLIENT_ID,
|
||||
})
|
||||
signing_input = seg({"typ": "JWT", "alg": "HS256"}) + b"." + payload
|
||||
signature = base64.urlsafe_b64encode(
|
||||
hmac.new(SECRET.encode(), signing_input, hashlib.sha256).digest()).rstrip(b"=")
|
||||
return (signing_input + b"." + signature).decode()
|
||||
|
||||
|
||||
def nrc(method, url, body=None):
|
||||
"""Call NRC. `url` may be a path or an absolute URL (the list returns absolute ones)."""
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(
|
||||
url if url.startswith("http") else f"{NRC_BASE}{url}", data=data, method=method,
|
||||
headers={"Authorization": f"Bearer {mint()}", "Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
return json.load(r) if r.length != 0 else {}
|
||||
except urllib.error.HTTPError as e:
|
||||
# The body carries the reason (e.g. an unregistered kanaal); the status alone does not.
|
||||
raise SystemExit(f"FAIL — NRC {method} {url} → {e.code}: {e.read().decode(errors='replace')[:400]}")
|
||||
|
||||
|
||||
def token_api(base, token, method, path, body=None, crs=False):
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
headers = {"Authorization": f"Token {token}"}
|
||||
if body is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
if crs:
|
||||
headers["Accept-Crs"] = "EPSG:4326"
|
||||
if body is not None:
|
||||
headers["Content-Crs"] = "EPSG:4326"
|
||||
req = urllib.request.Request(f"{base}{path}", data=data, method=method, headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
return json.load(r) if r.length != 0 else {}
|
||||
|
||||
|
||||
def subscribe():
|
||||
"""Register an abonnement on the objecten kanaal, replacing a stale one for the same callback."""
|
||||
# NRC returns a bare list here, not a paginated envelope.
|
||||
for existing in nrc("GET", "/api/v1/abonnement") or []:
|
||||
if existing.get("callbackUrl") == SINK_CALLBACK:
|
||||
nrc("DELETE", existing["url"])
|
||||
nrc("POST", "/api/v1/abonnement", {
|
||||
"callbackUrl": SINK_CALLBACK,
|
||||
"auth": SINK_AUTH,
|
||||
"kanalen": [{"naam": KANAAL, "filters": {}}],
|
||||
})
|
||||
print(f">> abonnement on '{KANAAL}' -> {SINK_CALLBACK}")
|
||||
|
||||
|
||||
def objecttype_url():
|
||||
results = token_api(OBJECTTYPEN, OBJECTTYPEN_TOKEN, "GET", "/api/v2/objecttypes").get("results", [])
|
||||
match = next((o for o in results if o.get("name") == "RegisterRecord"), None)
|
||||
if not match:
|
||||
print("FAIL — no RegisterRecord objecttype in Objecttypen", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
return match["url"]
|
||||
|
||||
|
||||
def main():
|
||||
subscribe()
|
||||
reference = f"NOTIF-{int(time.time())}"
|
||||
created = token_api(OBJECTEN, OBJECTEN_TOKEN, "POST", "/api/v2/objects", {
|
||||
"type": objecttype_url(),
|
||||
"record": {
|
||||
"typeVersion": 1,
|
||||
"data": {"id": f"zaak-{reference}", "status": "INGESCHREVEN", "reference": reference},
|
||||
"startAt": time.strftime("%Y-%m-%d"),
|
||||
},
|
||||
}, crs=True)
|
||||
print(f">> wrote RegisterRecord {created['url']}")
|
||||
# An NRC notification carries no record data — only hoofdObject/resourceUrl — so the object
|
||||
# URL, not the reference in its data, is what the caller can correlate the delivery on.
|
||||
print(f"OBJECT_URL {created['url']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -18,6 +18,16 @@ zgw_consumers:
|
||||
auth_type: api_key
|
||||
header_key: Authorization
|
||||
header_value: Token 0123456789abcdef0123456789abcdef01234567
|
||||
# (1b) The NRC Objecten publishes register-record events to (S-19b-1, ADR-0029). Same shape and
|
||||
# same big-reference-seed credential OpenZaak publishes with — NRC verifies the JWT and
|
||||
# authorizes it via OpenZaak's AC, which grants that client heeft_alle_autorisaties.
|
||||
- identifier: nrc
|
||||
label: Open Notificaties
|
||||
api_type: nrc
|
||||
api_root: http://nrc-web:8000/api/v1/
|
||||
auth_type: zgw
|
||||
client_id: big-reference-seed
|
||||
secret: insecure-dev-secret-change-me
|
||||
|
||||
# (2) Permit the RegisterRecord objecttype (S-19a). Objecten refuses to store an object whose
|
||||
# objecttype it has not been configured with ("ObjectType with url=… is not configured"), and it
|
||||
@@ -40,3 +50,10 @@ tokenauth:
|
||||
email: admin@localhost
|
||||
organization: Respellion
|
||||
is_superuser: true
|
||||
|
||||
# (4) Point Objecten's notifications at that NRC service (S-19b-1, ADR-0029). Requires
|
||||
# NOTIFICATIONS_DISABLED=false plus a celery broker + worker — without the worker the message is
|
||||
# queued and never sent, which is exactly the half-wired state S-19a refused to ship (ADR-0028).
|
||||
notifications_config_enable: true
|
||||
notifications_config:
|
||||
notifications_api_service_identifier: nrc
|
||||
|
||||
@@ -29,7 +29,9 @@ autorisaties_api_config_enable: true
|
||||
autorisaties_api:
|
||||
authorizations_api_service_identifier: openzaak-ac
|
||||
|
||||
# 4. The kanaal OpenZaak publishes zaak events on.
|
||||
# 4. The kanalen publishers announce on: `zaken` (OpenZaak) and `objecten` (Objecten, S-19b-1).
|
||||
# Both authenticate with the big-reference-seed credential above, which OpenZaak's AC grants
|
||||
# heeft_alle_autorisaties — so no separate publisher authorization is needed for Objecten.
|
||||
notifications_kanalen_config_enable: true
|
||||
notifications_kanalen_config:
|
||||
items:
|
||||
@@ -39,3 +41,11 @@ notifications_kanalen_config:
|
||||
- bronorganisatie
|
||||
- zaaktype
|
||||
- vertrouwelijkheidaanduiding
|
||||
# 5. The kanaal Objecten publishes register-record events on (S-19b-1, ADR-0029). Its name is
|
||||
# fixed by the Objects API itself (NOTIFICATIONS_KANAAL = "objecten"), not chosen here. The
|
||||
# filter set matches what the Objects API sends as kenmerken, so an abonnement can narrow by
|
||||
# objecttype rather than receiving every object write in the register.
|
||||
- naam: objecten
|
||||
documentatie_link: https://objects-and-objecttypes-api.readthedocs.io/
|
||||
filters:
|
||||
- object_type
|
||||
|
||||
@@ -1,88 +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.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,30 +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.
|
||||
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
|
||||
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# S-19b-1 (#152): verify the Objecten → NRC notification path against an ALREADY-RUNNING full
|
||||
# stack. Registers an abonnement on the `objecten` kanaal pointing at a throwaway webhook sink,
|
||||
# writes a RegisterRecord object (exactly as the ACL does on approval, S-19a), and asserts the sink
|
||||
# receives the notification.
|
||||
#
|
||||
# This is the whole publish chain in one assertion: Objecten → its celery worker → NRC → nrc-beat →
|
||||
# the subscriber callback. S-19a deliberately left it disconnected (ADR-0028); this proves it is
|
||||
# connected for real, rather than merely configured.
|
||||
#
|
||||
# All in-network, reaching services by container IP (a single-label host isn't URL-valid for NRC's
|
||||
# callbackUrl validator; the runner can't reach published ports — gitea-actions-gotchas.md §5/§6).
|
||||
# EXCEPT Objecttypen, which must be reached by SERVICE NAME: it echoes the request Host into the
|
||||
# objecttype `url` and Objecten only accepts the one matching its configured api_root (ADR-0028);
|
||||
# and Objecten, reached by its `objecten.local` alias because it reflects the request Host into the
|
||||
# notification's hoofdObject/resourceUrl, which NRC validates as a URL (ADR-0029).
|
||||
#
|
||||
# Does NOT manage the stack lifecycle, but cleans up the sink/driver it creates.
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SINK_AUTH="Bearer objecten-notification-sink-token"
|
||||
|
||||
cleanup() { docker rm -f rr-osink rr-overify >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
|
||||
|
||||
# Anchored on the compose replica suffix so they don't also match objecten-db / objecten-redis.
|
||||
obj="$(docker ps -q --filter 'name=objecten[-_][0-9]+$' | head -1)"
|
||||
nrc="$(docker ps -q --filter 'name=nrc-web' | head -1)"
|
||||
[ -n "$obj" ] || { echo "ERROR: no running objecten container — bring the stack up first" >&2; exit 1; }
|
||||
[ -n "$nrc" ] || { echo "ERROR: no running nrc-web container — bring the stack up first" >&2; exit 1; }
|
||||
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$obj" | head -1)"
|
||||
nrc_ip="$(ip "$nrc")"
|
||||
echo ">> network=$net nrc=$nrc_ip"
|
||||
|
||||
echo ">> starting the webhook sink"
|
||||
docker rm -f rr-osink >/dev/null 2>&1 || true
|
||||
sink="$(docker create --network "$net" --name rr-osink -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-osink)"
|
||||
echo ">> sink at $sink_ip:9000"
|
||||
|
||||
echo ">> registering the abonnement + writing a RegisterRecord"
|
||||
docker rm -f rr-overify >/dev/null 2>&1 || true
|
||||
drv="$(docker create --network "$net" --name rr-overify \
|
||||
-e "OBJECTEN=http://objecten.local:8000" \
|
||||
-e "OBJECTEN_TOKEN=${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}" \
|
||||
-e "OBJECTTYPEN=http://objecttypen:8000" \
|
||||
-e "OBJECTTYPEN_TOKEN=${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}" \
|
||||
-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/objecten-notifications-check.py" "$drv:/driver.py" >/dev/null
|
||||
docker start -a "$drv"
|
||||
object_url="$(docker logs rr-overify 2>/dev/null | sed -n 's/^OBJECT_URL //p' | head -1)"
|
||||
docker rm -f rr-overify >/dev/null
|
||||
[ -n "$object_url" ] || { echo "FAIL — the driver did not write a RegisterRecord" >&2; exit 1; }
|
||||
echo ">> wrote $object_url"
|
||||
|
||||
# Correlate on the object URL: a notification carries hoofdObject/resourceUrl, never the record
|
||||
# data, so the reference inside the record is not in the delivered message.
|
||||
echo ">> waiting for the notification to reach the sink"
|
||||
for _ in $(seq 1 "${NOTIFICATION_TRIES:-40}"); do
|
||||
if docker logs rr-osink 2>&1 | grep -qF "$object_url"; then
|
||||
echo "OK — Objecten published to NRC and the abonnement delivered it:"
|
||||
docker logs rr-osink 2>&1 | grep -F "$object_url" | tail -1 | cut -c1-500
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "FAIL — no 'objecten' notification for $object_url reached the sink." >&2
|
||||
echo " Objecten accepted the write, so the gap is downstream: the celery broker/worker," >&2
|
||||
echo " the kanaal registration, or Objecten's notifications_config." >&2
|
||||
echo "--- sink log ---" >&2; docker logs rr-osink 2>&1 | tail -8 >&2
|
||||
echo "--- objecten log ---" >&2; docker logs "$obj" 2>&1 | tail -15 >&2
|
||||
exit 1
|
||||
@@ -20,7 +20,7 @@ public sealed class ObjectenGatewayIntegrationTests
|
||||
new HttpClient(),
|
||||
new ObjectenOptions
|
||||
{
|
||||
BaseUrl = new(Env("OBJECTEN_BASE", "http://objecten:8000")),
|
||||
BaseUrl = new(Env("OBJECTEN_BASE", "http://objecten.local:8000")),
|
||||
Token = Env("OBJECTEN_TOKEN", "1234567890abcdef1234567890abcdef12345678"),
|
||||
ObjecttypenBaseUrl = new(Env("OBJECTTYPEN_BASE", "http://objecttypen:8000")),
|
||||
ObjecttypenToken = Env("OBJECTTYPEN_TOKEN", "0123456789abcdef0123456789abcdef01234567"),
|
||||
@@ -65,7 +65,7 @@ public sealed class ObjectenGatewayIntegrationTests
|
||||
{
|
||||
using var http = new HttpClient();
|
||||
var objecttype = await ResolveObjecttypeUrlAsync(http);
|
||||
var query = new Uri(new Uri(Env("OBJECTEN_BASE", "http://objecten:8000")),
|
||||
var query = new Uri(new Uri(Env("OBJECTEN_BASE", "http://objecten.local:8000")),
|
||||
"/api/v2/objects?type=" + Uri.EscapeDataString(objecttype) +
|
||||
"&data_attrs=id__exact__" + Uri.EscapeDataString(id));
|
||||
|
||||
|
||||
@@ -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<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