Closes #149. **Outcome:** approving a registration now writes the canonical register record to the **Objecten** API as a `RegisterRecord` object, alongside the ZGW eindstatus. OpenZaak holds the process, Objecten holds the register (ADR-0028). The write goes through the ACL (§8.1) and is idempotent on the zaak id, so a replayed approval updates the existing object rather than creating a second one. S-19 (#20) was split first (CLAUDE.md §13) — it bundled this with re-sourcing the read projection, which is now #150. ### What landed - `IRegisterRecordGateway` + `RegisterRecord` in `Acl.Application`; `ObjectenGateway` in `Acl.Infrastructure` (static Token auth, CRS headers, objecttype resolved by name to its highest **published** version). - `AclService.ApproveZaakAsync` writes the record after the eindstatus, keyed on the zaak UUID with the zaak's identificatie as reference. - Compose wiring for both stacks; `ADR-0028`; demo note; PRD §15 out-of-scope line retired. ### Three things only a live stack found Running the gateway against a real Objecten + Objecttypen pair while writing this turned up blockers CI would have hit after the fact: 1. **Objecten rejects an objecttype it has not been configured with**, by UUID — assigned at seed time by a one-shot that runs *after* Objecten's static setup_configuration. The UUID is now pinned on both sides. 2. **Objecten 500s on every write when its Notificaties config is absent** (`notifications_api_common` raises rather than skipping). Objecten → NRC has no broker, worker, kanaal or abonnement, so notifications are **disabled** rather than wired to drop every message; #150 turns them on for real. 3. **Objecttypen echoes the request Host into the objecttype `url`**, and Objecten only accepts the one matching its configured `api_root` — so the ACL must read Objecttypen at `http://objecttypen:8000`. This is why the new integration test only passes inside the compose network. All three are recorded in ADR-0028. ### Verification - `ObjectenGatewayIntegrationTests` (verify-acl, in-network): two writes for one id leave exactly one object with the second write's status. **Passing locally against live Objecten.** - The **Playwright happy path** asserts, after the behandelaar approves, that Objecten holds exactly one `RegisterRecord` for *that* reference — missing, duplicated, or non-public-safe all fail. - ACL mutation score **92.23%** (baseline 91.37%, break 90). - `make lint` / `make unit` green locally; full-stack `make verify` runs in CI. ## Definition of Done - [x] A linked Gitea issue exists (#149). - [x] Failing test written and committed first. - [x] Implementation makes the test pass. - [x] Refactor commit follows if structure improved. - [x] Conventional Commit messages referencing the issue (`refs #149`). - [x] All Gitea Actions CI jobs green (run 684). - [x] `docker compose up` from a fresh clone reaches green health checks within 3 minutes (verify-stack step 1). - [x] Docs touched — ADR-0028, demo note, PRD §15, BACKLOG. - [x] ADR added: `docs/architecture/adr-0028-objecten-holds-the-register.md`. - [x] Demo note appended to `docs/demo-script.md`. - [x] Closed by the merging PR (`closes #149`). 🤖 Generated with [Claude Code](https://claude.com/claude-code)Reviewed-on: #151
This commit was merged in pull request #151.
This commit is contained in:
+6
-1
@@ -287,12 +287,17 @@ Split into independently deployable sub-slices (CLAUDE.md §13):
|
|||||||
- **S-18b** (#140, ✅) · Objecten API up in compose, wired to Objecttypen. Depends on S-18a.
|
- **S-18b** (#140, ✅) · Objecten API up in compose, wired to Objecttypen. Depends on S-18a.
|
||||||
- **S-18c** (#141, ✅) · RegisterRecord objecttype defined + registered (public-safe JSON schema). Depends on S-18a/b.
|
- **S-18c** (#141, ✅) · RegisterRecord objecttype defined + registered (public-safe JSON schema). Depends on S-18a/b.
|
||||||
|
|
||||||
### S-19 · ACL extension: write register-record to Objecten on approval
|
### S-19 · ACL extension: write register-record to Objecten on approval *(split — #20 closed)*
|
||||||
|
|
||||||
**Outcome:** Approval path writes the canonical register record to Objecten, not OpenZaak eigenschappen. Projection now sourced from Objecten events.
|
**Outcome:** Approval path writes the canonical register record to Objecten, not OpenZaak eigenschappen. Projection now sourced from Objecten events.
|
||||||
|
|
||||||
**ADR required:** "Why Objecten holds the register, OpenZaak holds the process."
|
**ADR required:** "Why Objecten holds the register, OpenZaak holds the process."
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Iteration 5 — Data governance module *(milestone: `Iteration 5 — Data Governance`)*
|
## Iteration 5 — Data governance module *(milestone: `Iteration 5 — Data Governance`)*
|
||||||
|
|||||||
+1
-1
@@ -207,7 +207,7 @@ A slice is done when:
|
|||||||
## 15. Out of scope for v1
|
## 15. Out of scope for v1
|
||||||
|
|
||||||
- OpenMetadata data governance module (v3 slice).
|
- OpenMetadata data governance module (v3 slice).
|
||||||
- Objecten as the authoritative register record store (v2 slice — v1 uses OpenZaak zaak-eigenschappen as a placeholder).
|
- ~~Objecten as the authoritative register record store~~ — **delivered** in S-19a (#149, ADR-0028); the approval path writes a `RegisterRecord` object to Objecten rather than the planned zaak-eigenschappen placeholder.
|
||||||
- Production-grade Helm chart (sketch only).
|
- Production-grade Helm chart (sketch only).
|
||||||
- Multi-tenancy.
|
- Multi-tenancy.
|
||||||
- Real outbound notifications (email/SMS) — logged to console in v1.
|
- Real outbound notifications (email/SMS) — logged to console in v1.
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
# ADR-0028: Objecten holds the register, OpenZaak holds the process
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-08-14
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Slice:** S-19a (#149), first of the S-19 (#20) split
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Until this slice the register existed only as a **derived** thing: the read projection
|
||||||
|
rows the Event Subscriber builds from NRC zaak notifications (ADR-0008). There is no
|
||||||
|
system anywhere that holds "who is registered" as a first-class record — drop the
|
||||||
|
projection database and the only way back is to replay ZGW history and re-derive it.
|
||||||
|
|
||||||
|
That is the wrong shape for a register. A BIG registration is a **fact about a person**
|
||||||
|
that outlives the case that produced it: it is looked up, corrected, superseded, and
|
||||||
|
retained on its own schedule. The zaak that produced it is a **process record** — it
|
||||||
|
opens, moves through statussen, and closes. Storing the fact inside the process record
|
||||||
|
(as zaak `eigenschappen`, the v1 placeholder PRD §"Registration" mentions) welds the two
|
||||||
|
lifecycles together: the register can then never be read, retained, or corrected without
|
||||||
|
going through the case system that happened to create it.
|
||||||
|
|
||||||
|
S-18 stood up Objecten + Objecttypen and registered the public-safe `RegisterRecord`
|
||||||
|
objecttype (ADR-0027). The open question this ADR closes: **where the authoritative
|
||||||
|
register record lives, and who writes it.**
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**The register record lives in the Objecten API as a `RegisterRecord` object. OpenZaak
|
||||||
|
keeps only the process. On approval the ACL writes both: the ZGW eindstatus, then the
|
||||||
|
register record.**
|
||||||
|
|
||||||
|
### Not zaak eigenschappen
|
||||||
|
|
||||||
|
Eigenschappen are per-zaaktype, untyped strings, and readable only by walking the zaak.
|
||||||
|
They inherit the zaak's lifecycle and its archiving regime, and they give the public
|
||||||
|
register no queryable surface of its own. Objecten gives a JSON-schema-validated record
|
||||||
|
(ADR-0027 makes that schema the disclosure boundary), a queryable collection, and a
|
||||||
|
lifecycle the zaak cannot drag around with it.
|
||||||
|
|
||||||
|
### The ACL writes it, not the domain or the Event Subscriber
|
||||||
|
|
||||||
|
CLAUDE.md §8.1 keeps upstream Common Ground modules behind the ACL. Objecten is such a
|
||||||
|
module, so the same rule applies: `ObjectenGateway` is the only code that talks to it,
|
||||||
|
and the domain keeps handing the ACL nothing but a zaak URL. The alternative — having the
|
||||||
|
Event Subscriber write the record when it sees the status notification — would make the
|
||||||
|
register a *second* derived artefact of ZGW, which is exactly the coupling this ADR
|
||||||
|
removes.
|
||||||
|
|
||||||
|
### Two writes, converging rather than transactional
|
||||||
|
|
||||||
|
Approval is now two writes across two modules, so it cannot be atomic. Both are made
|
||||||
|
idempotent instead:
|
||||||
|
|
||||||
|
- a ZGW status is an append-only log entry, so re-setting the eindstatus is harmless;
|
||||||
|
- the register write is an **upsert keyed on the zaak id** — search Objecten for an
|
||||||
|
existing object with that `id`, then PATCH it or POST a new one.
|
||||||
|
|
||||||
|
A caller that retries a half-failed approval therefore converges. This is the same
|
||||||
|
eventual-consistency posture as everywhere else in the system (CLAUDE.md §2.2, §8.6),
|
||||||
|
not an exception carved out for this path.
|
||||||
|
|
||||||
|
### The objecttype is resolved by name, lazily
|
||||||
|
|
||||||
|
The objecttype URL and version number are assigned by Objecttypen at seed time, so they
|
||||||
|
cannot be pinned in config — the ACL resolves them by the configured name
|
||||||
|
(`Acl__Objecten__ObjecttypeName`), taking the highest **published** version. This is the
|
||||||
|
same reasoning as ADR-0021 for zaaktypen.
|
||||||
|
|
||||||
|
Resolution happens on the first approval, not at startup, so the ACL needs no `depends_on`
|
||||||
|
on Objecten and will not crash-loop when it boots ahead of the seed. A failed resolution
|
||||||
|
is not cached, so it is retried on the next approval.
|
||||||
|
|
||||||
|
- ponytail ceiling: the resolution is memoised per gateway instance, and the gateway is a
|
||||||
|
transient typed `HttpClient` — in practice one extra GET per approval against a
|
||||||
|
neighbouring container.
|
||||||
|
- Upgrade path: lift it into a singleton cache (as `CachedZaaktypeCatalog` does for ZGW)
|
||||||
|
if approvals ever get hot enough for that GET to matter.
|
||||||
|
|
||||||
|
### The objecttype's UUID is pinned, not server-assigned
|
||||||
|
|
||||||
|
Objecten refuses to store an object whose objecttype it has not been configured with
|
||||||
|
(`ObjectType with url=… is not configured`), and its configuration identifies an
|
||||||
|
objecttype **by UUID** — supplied through a static `setup_configuration` file applied
|
||||||
|
when the container starts, before the `registerrecord-init` one-shot has run.
|
||||||
|
|
||||||
|
Rather than thread a seed-time UUID from one container into another's config, the UUID is
|
||||||
|
**pinned**: `infra/objecttypen-registerrecord/register.py` creates the objecttype with a
|
||||||
|
fixed UUID (the Objecttypen API accepts a client-supplied one), and
|
||||||
|
`infra/objecten/setup_configuration/data.yaml` declares that same UUID. Both sides are
|
||||||
|
declared up front, both stay idempotent, and neither has to wait for the other.
|
||||||
|
|
||||||
|
The cost is a constant duplicated across two files that must be kept in step; each carries
|
||||||
|
a comment pointing at the other.
|
||||||
|
|
||||||
|
### The ACL must reach Objecttypen at the URL Objecten knows it by
|
||||||
|
|
||||||
|
Objecttypen builds the `url` it returns from the request's own Host header, and Objecten
|
||||||
|
matches an incoming object's `type` against the `api_root` it was configured with. So an
|
||||||
|
ACL that reads Objecttypen at `http://localhost:8020` gets back a `localhost` objecttype
|
||||||
|
URL that Objecten then rejects as "not one of the available choices" — even though it is
|
||||||
|
the same objecttype.
|
||||||
|
|
||||||
|
`Acl__Objecten__ObjecttypenBaseUrl` must therefore match Objecten's configured
|
||||||
|
`api_root` (`http://objecttypen:8000/api/v2/`). This is the same class of constraint as
|
||||||
|
ADR-0006's "point the ACL at OpenZaak's container IP", and it is why the Objecten
|
||||||
|
integration tests only pass from inside the compose network.
|
||||||
|
|
||||||
|
### Objecten's notifications are off for this slice
|
||||||
|
|
||||||
|
Objecten publishes to a Notificaties API on every write, and `notifications_api_common`
|
||||||
|
**raises** rather than skipping when that configuration is absent — so with no NRC wiring,
|
||||||
|
every `POST /api/v2/objects` returns 500 after creating and rolling back the object.
|
||||||
|
|
||||||
|
Objecten → NRC is not wired: there is no broker, no Celery worker, no `objecten` kanaal and
|
||||||
|
no abonnement for it. Configuring only the client side would make writes succeed while
|
||||||
|
every message was dropped on the floor — a delivery path that looks wired and isn't. So
|
||||||
|
`NOTIFICATIONS_DISABLED` is set for Objecten in both compose files instead.
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- The register is a first-class record with its own schema, lifecycle and query surface,
|
||||||
|
independent of the case that produced it.
|
||||||
|
- The disclosure boundary is enforced by Objecten's schema validation (ADR-0027), not by
|
||||||
|
discipline in projection code.
|
||||||
|
- The read projection can become a cache of Objecten rather than a re-derivation of ZGW
|
||||||
|
(S-19b, #150).
|
||||||
|
|
||||||
|
**Negative / costs**
|
||||||
|
|
||||||
|
- Approval writes to two modules and is eventually consistent; a failure between them
|
||||||
|
leaves a zaak in eindstatus without a register record until the approval is retried.
|
||||||
|
Nothing repairs that automatically yet.
|
||||||
|
- One more upstream module on the approval path, and one more dev credential
|
||||||
|
(`Acl__Objecten__Token`) in compose.
|
||||||
|
- Two new hand-kept constants: the pinned objecttype UUID (two files) and the objecttype
|
||||||
|
name (compose + `register.py`).
|
||||||
|
- Until S-19b lands, the public register is still read from the NRC-derived projection, so
|
||||||
|
the register record is written but not yet read — the two must agree.
|
||||||
|
|
||||||
|
## Coupling rules touched (CLAUDE.md §8)
|
||||||
|
|
||||||
|
None bent. §8.1 is extended in spirit — the ACL is the only code that talks to Objecten,
|
||||||
|
exactly as it is the only code that talks to ZGW. The domain still passes only a zaak URL,
|
||||||
|
and no service reaches Objecten's database.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
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
|
||||||
|
pair: two writes for the same id leave exactly one object, carrying the second write's
|
||||||
|
status and nothing outside the public-safe schema.
|
||||||
|
|
||||||
|
All three findings above — the pinned UUID, the notifications block, and the base-URL
|
||||||
|
constraint — came out of running the gateway against those live modules while writing the
|
||||||
|
slice, not out of CI.
|
||||||
@@ -5,6 +5,43 @@ copy-pasteable walkthrough against a local `make up` stack.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## S-19a — approval writes the register record to Objecten (#149, ADR-0028)
|
||||||
|
|
||||||
|
**Outcome:** approving a registration no longer only moves the ZGW zaak to its eindstatus — it also
|
||||||
|
writes the canonical **register record** into the **Objecten** API. OpenZaak keeps the process,
|
||||||
|
Objecten holds the register. The write goes through the ACL (§8.1) and is **idempotent**: replaying an
|
||||||
|
approval updates the existing object instead of creating a second one.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Bring the stack up (Objecten, Objecttypen and the RegisterRecord objecttype come with it).
|
||||||
|
make up
|
||||||
|
#
|
||||||
|
# 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. 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
|
||||||
|
```
|
||||||
|
|
||||||
|
Each object's `record.data` carries exactly `id`, `status`, `reference` — the schema forbids anything
|
||||||
|
else (ADR-0027), so no personal data can reach the world-readable register even by mistake.
|
||||||
|
|
||||||
|
**The path:** behandel portal → BFF → domain `BeoordeelRegistratie` → ACL `POST /statussen` → ZGW
|
||||||
|
`resultaten` + `statussen` (the process), **then** ACL → Objecten `POST`/`PATCH /api/v2/objects` (the
|
||||||
|
register). The objecttype URL is resolved by name from Objecttypen on first use, so nothing seed-time
|
||||||
|
is pinned in config (ADR-0028, same reasoning as ADR-0021).
|
||||||
|
|
||||||
|
**Not yet:** the public register still reads the NRC-derived projection — re-sourcing it from Objecten
|
||||||
|
is S-19b (#150).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## S-18c — RegisterRecord objecttype defined + registered (#141, ADR-0027)
|
## S-18c — RegisterRecord objecttype defined + registered (#141, ADR-0027)
|
||||||
|
|
||||||
**Outcome:** a **RegisterRecord** objecttype with a **published** JSON schema is registered in the
|
**Outcome:** a **RegisterRecord** objecttype with a **published** JSON schema is registered in the
|
||||||
|
|||||||
@@ -338,6 +338,14 @@ services:
|
|||||||
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
|
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
|
||||||
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
|
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
|
||||||
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
|
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
|
||||||
|
# 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/
|
||||||
|
Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}
|
||||||
|
Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/
|
||||||
|
Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
|
||||||
|
Acl__Objecten__ObjecttypeName: RegisterRecord
|
||||||
ports:
|
ports:
|
||||||
- "8100:8080"
|
- "8100:8080"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -683,6 +691,12 @@ services:
|
|||||||
CACHE_AXES: objecten-redis:6379/0
|
CACHE_AXES: objecten-redis:6379/0
|
||||||
DISABLE_2FA: "true"
|
DISABLE_2FA: "true"
|
||||||
OTEL_SDK_DISABLED: "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"
|
||||||
RUN_SETUP_CONFIG: "true"
|
RUN_SETUP_CONFIG: "true"
|
||||||
command: /setup_configuration.sh
|
command: /setup_configuration.sh
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -323,6 +323,14 @@ services:
|
|||||||
# so verify-domain still points the ACL at OpenZaak's container IP.
|
# so verify-domain still points the ACL at OpenZaak's container IP.
|
||||||
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
|
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
|
||||||
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
|
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
|
||||||
|
# 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/
|
||||||
|
Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}
|
||||||
|
Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/
|
||||||
|
Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
|
||||||
|
Acl__Objecten__ObjecttypeName: RegisterRecord
|
||||||
ports:
|
ports:
|
||||||
- "8100:8080"
|
- "8100:8080"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -709,6 +717,12 @@ services:
|
|||||||
CACHE_AXES: objecten-redis:6379/0
|
CACHE_AXES: objecten-redis:6379/0
|
||||||
DISABLE_2FA: "true"
|
DISABLE_2FA: "true"
|
||||||
OTEL_SDK_DISABLED: "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"
|
||||||
RUN_SETUP_CONFIG: "true"
|
RUN_SETUP_CONFIG: "true"
|
||||||
command: /setup_configuration.sh
|
command: /setup_configuration.sh
|
||||||
# data.yaml is streamed into this external volume by infra/seed-config.sh before start.
|
# data.yaml is streamed into this external volume by infra/seed-config.sh before start.
|
||||||
|
|||||||
@@ -19,7 +19,18 @@ zgw_consumers:
|
|||||||
header_key: Authorization
|
header_key: Authorization
|
||||||
header_value: Token 0123456789abcdef0123456789abcdef01234567
|
header_value: Token 0123456789abcdef0123456789abcdef01234567
|
||||||
|
|
||||||
# (2) Static API token peers use to write/read objects.
|
# (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
|
||||||
|
# identifies one by uuid — which is why infra/objecttypen-registerrecord/register.py pins that uuid
|
||||||
|
# instead of letting Objecttypen assign one. Keep the two in step.
|
||||||
|
objecttypes_config_enable: true
|
||||||
|
objecttypes:
|
||||||
|
items:
|
||||||
|
- uuid: 1f4b4e26-8b1f-4e2f-9d6c-6a1b7a2f0e01
|
||||||
|
name: RegisterRecord
|
||||||
|
service_identifier: objecttypen
|
||||||
|
|
||||||
|
# (3) Static API token peers use to write/read objects.
|
||||||
tokenauth_config_enable: true
|
tokenauth_config_enable: true
|
||||||
tokenauth:
|
tokenauth:
|
||||||
items:
|
items:
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ BASE = os.environ.get("OBJECTTYPEN", "http://objecttypen:8000").rstrip("/")
|
|||||||
TOKEN = os.environ["OBJECTTYPEN_TOKEN"]
|
TOKEN = os.environ["OBJECTTYPEN_TOKEN"]
|
||||||
SCHEMA_PATH = os.environ.get("SCHEMA", "/config/registerrecord.schema.json")
|
SCHEMA_PATH = os.environ.get("SCHEMA", "/config/registerrecord.schema.json")
|
||||||
NAME = "RegisterRecord"
|
NAME = "RegisterRecord"
|
||||||
|
# Pinned rather than server-assigned (S-19a): the Objecten API will only accept objects whose
|
||||||
|
# objecttype it has been configured with *by uuid*, and its own setup_configuration is a static
|
||||||
|
# file applied before this one-shot runs. A fixed uuid lets both sides be declared up front instead
|
||||||
|
# of threading a seed-time value between two containers. See infra/objecten/setup_configuration.
|
||||||
|
UUID = "1f4b4e26-8b1f-4e2f-9d6c-6a1b7a2f0e01"
|
||||||
|
|
||||||
|
|
||||||
def api(method, path, body=None):
|
def api(method, path, body=None):
|
||||||
@@ -53,6 +58,7 @@ def main():
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
ot = existing or api("POST", "/api/v2/objecttypes", {
|
ot = existing or api("POST", "/api/v2/objecttypes", {
|
||||||
|
"uuid": UUID,
|
||||||
"name": NAME,
|
"name": NAME,
|
||||||
"namePlural": "RegisterRecords",
|
"namePlural": "RegisterRecords",
|
||||||
"description": schema.get("description", ""),
|
"description": schema.get("description", ""),
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ 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-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
|
||||||
# BPMN boundary event so the process ends and the Beoordelen task disappears (ADR-0014). ────────────
|
# BPMN boundary event so the process ends and the Beoordelen task disappears (ADR-0014). ────────────
|
||||||
|
|||||||
@@ -42,7 +42,12 @@ builder.Services.AddSingleton<IDefaultFillStore>(sp =>
|
|||||||
return new InMemoryDefaultFillStore(
|
return new InMemoryDefaultFillStore(
|
||||||
new DefaultFillSettings(d.Bronorganisatie, d.VerantwoordelijkeOrganisatie, d.Vertrouwelijkheidaanduiding));
|
new DefaultFillSettings(d.Bronorganisatie, d.VerantwoordelijkeOrganisatie, d.Vertrouwelijkheidaanduiding));
|
||||||
});
|
});
|
||||||
|
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
|
||||||
|
.GetSection("Acl:Objecten").Get<ObjectenOptions>()
|
||||||
|
?? throw new InvalidOperationException("Missing configuration section 'Acl:Objecten'"));
|
||||||
builder.Services.AddHttpClient<IZaakGateway, OpenZaakGateway>();
|
builder.Services.AddHttpClient<IZaakGateway, OpenZaakGateway>();
|
||||||
|
// The Objecten hop that writes the register record on approval (S-19a, ADR-0028).
|
||||||
|
builder.Services.AddHttpClient<IRegisterRecordGateway, ObjectenGateway>();
|
||||||
// Singleton so the resolved zaaktype/informatieobjecttype URLs are cached across requests (S-27).
|
// Singleton so the resolved zaaktype/informatieobjecttype URLs are cached across requests (S-27).
|
||||||
builder.Services.AddSingleton<IZaaktypeCatalog, CachedZaaktypeCatalog>();
|
builder.Services.AddSingleton<IZaaktypeCatalog, CachedZaaktypeCatalog>();
|
||||||
builder.Services.AddScoped<AclService>();
|
builder.Services.AddScoped<AclService>();
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ namespace Acl.Application;
|
|||||||
|
|
||||||
/// <summary>The ACL's single operation: open a zaak from a domain payload,
|
/// <summary>The ACL's single operation: open a zaak from a domain payload,
|
||||||
/// default-filling the ZGW-mandatory fields (ADR-0003).</summary>
|
/// default-filling the ZGW-mandatory fields (ADR-0003).</summary>
|
||||||
public sealed class AclService(IZaakGateway gateway, IDefaultFillStore fill, IZaaktypeCatalog catalog, IClock clock)
|
public sealed class AclService(
|
||||||
|
IZaakGateway gateway,
|
||||||
|
IRegisterRecordGateway register,
|
||||||
|
IDefaultFillStore fill,
|
||||||
|
IZaaktypeCatalog catalog,
|
||||||
|
IClock clock)
|
||||||
{
|
{
|
||||||
public async Task<Uri> OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default)
|
public async Task<Uri> OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -23,16 +28,33 @@ public sealed class AclService(IZaakGateway gateway, IDefaultFillStore fill, IZa
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Approve a zaak: set it to the eindstatus of the BIG zaaktype (resolved by identificatie, S-27).
|
/// Approve a zaak: set it to the eindstatus of the BIG zaaktype (resolved by identificatie, S-27),
|
||||||
/// The domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1).
|
/// then write the register record to Objecten (S-19a). The domain hands over only the zaak URL; the
|
||||||
|
/// ACL owns which statustype means "approved" and what the register record looks like (§8.1).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// OpenZaak holds the process, Objecten holds the register (ADR-0028), so approval is two writes
|
||||||
|
/// across two modules and is eventually consistent by construction. Both are idempotent — a status
|
||||||
|
/// is a log entry, the record upsert is keyed on the zaak id — so a caller that retries a failed
|
||||||
|
/// approval converges rather than duplicating.
|
||||||
|
/// </remarks>
|
||||||
public async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
public async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||||
|
|
||||||
await gateway.SetZaakToEindstatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct);
|
await gateway.SetZaakToEindstatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct);
|
||||||
|
|
||||||
|
await register.UpsertAsync(
|
||||||
|
new RegisterRecord(
|
||||||
|
ZaakId(zaakUrl),
|
||||||
|
RegisterRecordStatus.Ingeschreven,
|
||||||
|
await gateway.GetZaakIdentificatieAsync(zaakUrl, ct)),
|
||||||
|
ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The zaak's UUID — the key the register record and the read projection rows share.</summary>
|
||||||
|
private static string ZaakId(Uri zaakUrl) => zaakUrl.Segments[^1].TrimEnd('/');
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cancel a zaak on document-timeout expiry (S-10c): set it to the BIG zaaktype's cancellation
|
/// Cancel a zaak on document-timeout expiry (S-10c): set it to the BIG zaaktype's cancellation
|
||||||
/// statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which
|
/// statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
namespace Acl.Application;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Port to the Objecten API, which holds the authoritative register record (S-19a, ADR-0028).
|
||||||
|
/// Implemented in Infrastructure — as with ZGW, the ACL is the only code that talks to the
|
||||||
|
/// upstream Common Ground module (§8.1).
|
||||||
|
/// </summary>
|
||||||
|
public interface IRegisterRecordGateway
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Write the register record for a registration, creating it if absent and updating it if it
|
||||||
|
/// already exists. Idempotent on <see cref="RegisterRecord.Id"/>: a replayed approval updates
|
||||||
|
/// the existing object instead of creating a second one (§8.6).
|
||||||
|
/// </summary>
|
||||||
|
Task UpsertAsync(RegisterRecord record, CancellationToken ct = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The public-safe register record, matching the <c>RegisterRecord</c> objecttype schema registered
|
||||||
|
/// in S-18c (ADR-0027). No bsn, no name — the register is world-readable.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record RegisterRecord(string Id, string Status, string? Reference);
|
||||||
|
|
||||||
|
/// <summary>The register statuses the RegisterRecord objecttype's schema allows (ADR-0027).</summary>
|
||||||
|
public static class RegisterRecordStatus
|
||||||
|
{
|
||||||
|
public const string Ingediend = "INGEDIEND";
|
||||||
|
|
||||||
|
public const string Ingeschreven = "INGESCHREVEN";
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using Acl.Application;
|
||||||
|
|
||||||
|
namespace Acl.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The only code that talks to the Objecten API (ADR-0028). Writes the register record as an object
|
||||||
|
/// of the <c>RegisterRecord</c> objecttype registered in S-18c.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ObjectenGateway(HttpClient http, ObjectenOptions options, IClock clock) : IRegisterRecordGateway
|
||||||
|
{
|
||||||
|
// The objecttype URL + version are assigned by Objecttypen at seed time, so they are resolved by
|
||||||
|
// name on first use rather than pinned in config (same reasoning as ADR-0021).
|
||||||
|
// ponytail: memoised per instance only — the gateway is a transient typed client, so in practice
|
||||||
|
// that is one extra GET per approval against a neighbouring container. Lift it into a singleton
|
||||||
|
// cache (as CachedZaaktypeCatalog does for ZGW) if approvals ever get hot.
|
||||||
|
private Objecttype? objecttype;
|
||||||
|
|
||||||
|
public async Task UpsertAsync(RegisterRecord record, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(record);
|
||||||
|
|
||||||
|
var type = objecttype ??= await ResolveObjecttypeAsync(ct);
|
||||||
|
var existing = await FindExistingAsync(type.Url, record.Id, ct);
|
||||||
|
var data = new RecordDataDto(record.Id, record.Status, record.Reference);
|
||||||
|
|
||||||
|
// No existing object → create; otherwise PATCH, which appends a new record version to the same
|
||||||
|
// object. Either way the register ends up with exactly one object per registration (§8.6).
|
||||||
|
if (existing is null)
|
||||||
|
await SendAsync(HttpMethod.Post, new Uri(options.BaseUrl, "/api/v2/objects"),
|
||||||
|
new CreateObjectDto(type.Url.ToString(), NewRecord(type.Version, data)),
|
||||||
|
"Creating the register record", ct);
|
||||||
|
else
|
||||||
|
await SendAsync(HttpMethod.Patch, existing,
|
||||||
|
new PatchObjectDto(NewRecord(type.Version, data)),
|
||||||
|
"Updating the register record", ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RecordDto NewRecord(int typeVersion, RecordDataDto data) =>
|
||||||
|
new(typeVersion, data, clock.Today.ToString("yyyy-MM-dd"));
|
||||||
|
|
||||||
|
/// <summary>The URL + latest published version of the configured objecttype, read from Objecttypen.</summary>
|
||||||
|
private async Task<Objecttype> ResolveObjecttypeAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var page = await GetAsync<ObjecttypePage>(
|
||||||
|
new Uri(options.ObjecttypenBaseUrl, "/api/v2/objecttypes"),
|
||||||
|
options.ObjecttypenToken, crs: false, "objecttypen", ct);
|
||||||
|
|
||||||
|
var match = (page.Results ?? []).FirstOrDefault(o => o.Name == options.ObjecttypeName)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"No objecttype '{options.ObjecttypeName}' registered in Objecttypen — is the RegisterRecord seed applied?");
|
||||||
|
|
||||||
|
// Write against the highest *published* version: a draft version's schema is still being
|
||||||
|
// shaped, and objects written against it would be validated by a moving target. The objecttype
|
||||||
|
// carries its versions as URLs, so each is fetched for its status (the collection response
|
||||||
|
// gives no status) — once per gateway instance, alongside the lookup above.
|
||||||
|
var latest = 0;
|
||||||
|
foreach (var versionUrl in match.Versions ?? [])
|
||||||
|
{
|
||||||
|
var version = await GetAsync<ObjecttypeVersionDto>(
|
||||||
|
new Uri(versionUrl), options.ObjecttypenToken, crs: false, "objecttype version", ct);
|
||||||
|
if (version.Status == "published" && version.Version > latest)
|
||||||
|
latest = version.Version;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latest == 0)
|
||||||
|
throw new InvalidOperationException($"Objecttype '{options.ObjecttypeName}' has no published version");
|
||||||
|
|
||||||
|
return new Objecttype(new Uri(match.Url), latest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The URL of the object already holding this registration's record, or null if there is none.</summary>
|
||||||
|
private async Task<Uri?> FindExistingAsync(Uri objecttypeUrl, string id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var query = new Uri(options.BaseUrl,
|
||||||
|
"/api/v2/objects?type=" + Uri.EscapeDataString(objecttypeUrl.ToString()) +
|
||||||
|
"&data_attrs=id__exact__" + Uri.EscapeDataString(id));
|
||||||
|
var page = await GetAsync<ObjectPage>(query, options.Token, crs: true, "objects", ct);
|
||||||
|
var match = (page.Results ?? []).FirstOrDefault();
|
||||||
|
return match is null ? null : new Uri(match.Url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<T> GetAsync<T>(Uri uri, string token, bool crs, string label, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var message = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||||
|
message.Headers.Authorization = new AuthenticationHeaderValue("Token", token);
|
||||||
|
if (crs)
|
||||||
|
message.Headers.Add("Accept-Crs", "EPSG:4326");
|
||||||
|
|
||||||
|
using var response = await http.SendAsync(message, ct);
|
||||||
|
await EnsureSuccessAsync(response, $"Querying {label}", ct);
|
||||||
|
|
||||||
|
return await response.Content.ReadFromJsonAsync<T>(ct)
|
||||||
|
?? throw new InvalidOperationException($"Objecten returned an empty {label} response");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendAsync(HttpMethod method, Uri uri, object dto, string action, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var message = new HttpRequestMessage(method, uri) { Content = JsonContent.Create(dto) };
|
||||||
|
message.Headers.Authorization = new AuthenticationHeaderValue("Token", options.Token);
|
||||||
|
// The Objecten API is a geo API: it requires the CRS headers on reads and writes alike.
|
||||||
|
message.Headers.Add("Accept-Crs", "EPSG:4326");
|
||||||
|
message.Content.Headers.Add("Content-Crs", "EPSG:4326");
|
||||||
|
// As with OpenZaak, Objecten runs behind uwsgi, which rejects a chunked request body.
|
||||||
|
await message.Content.LoadIntoBufferAsync(ct);
|
||||||
|
|
||||||
|
using var response = await http.SendAsync(message, ct);
|
||||||
|
await EnsureSuccessAsync(response, action, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// As in OpenZaakGateway: EnsureSuccessStatusCode discards the body, and the JSON validation error
|
||||||
|
// Objecten returns on a schema mismatch is exactly what you need to diagnose a rejected write.
|
||||||
|
private static async Task EnsureSuccessAsync(HttpResponseMessage response, string action, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var body = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
throw new HttpRequestException($"{action} failed: {(int)response.StatusCode} {response.ReasonPhrase}. {body}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record Objecttype(Uri Url, int Version);
|
||||||
|
|
||||||
|
private sealed record ObjecttypePage(
|
||||||
|
[property: JsonPropertyName("results")] IReadOnlyList<ObjecttypeDto>? Results);
|
||||||
|
|
||||||
|
private sealed record ObjecttypeDto(
|
||||||
|
[property: JsonPropertyName("url")] string Url,
|
||||||
|
[property: JsonPropertyName("name")] string? Name,
|
||||||
|
[property: JsonPropertyName("versions")] IReadOnlyList<string>? Versions);
|
||||||
|
|
||||||
|
private sealed record ObjecttypeVersionDto(
|
||||||
|
[property: JsonPropertyName("version")] int Version,
|
||||||
|
[property: JsonPropertyName("status")] string? Status);
|
||||||
|
|
||||||
|
private sealed record ObjectPage(
|
||||||
|
[property: JsonPropertyName("results")] IReadOnlyList<ObjectDto>? Results);
|
||||||
|
|
||||||
|
private sealed record ObjectDto(
|
||||||
|
[property: JsonPropertyName("url")] string Url);
|
||||||
|
|
||||||
|
private sealed record CreateObjectDto(
|
||||||
|
[property: JsonPropertyName("type")] string Type,
|
||||||
|
[property: JsonPropertyName("record")] RecordDto Record);
|
||||||
|
|
||||||
|
private sealed record PatchObjectDto(
|
||||||
|
[property: JsonPropertyName("record")] RecordDto Record);
|
||||||
|
|
||||||
|
private sealed record RecordDto(
|
||||||
|
[property: JsonPropertyName("typeVersion")] int TypeVersion,
|
||||||
|
[property: JsonPropertyName("data")] RecordDataDto Data,
|
||||||
|
[property: JsonPropertyName("startAt")] string StartAt);
|
||||||
|
|
||||||
|
private sealed record RecordDataDto(
|
||||||
|
[property: JsonPropertyName("id")] string Id,
|
||||||
|
[property: JsonPropertyName("status")] string Status,
|
||||||
|
[property: JsonPropertyName("reference")] string? Reference);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace Acl.Infrastructure;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Connection + credential config for the Objecten and Objecttypen APIs. Both authenticate with a
|
||||||
|
/// static <c>Authorization: Token …</c> (they are not ZGW JWT APIs), so there is no client-id/secret
|
||||||
|
/// pair as with OpenZaak.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ObjectenOptions
|
||||||
|
{
|
||||||
|
public required Uri BaseUrl { get; init; }
|
||||||
|
public required string Token { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Objecttypen API root — the ACL resolves the objecttype URL + version from it by name
|
||||||
|
/// rather than pinning a seed-time UUID in config (same reasoning as ADR-0021).</summary>
|
||||||
|
public required Uri ObjecttypenBaseUrl { get; init; }
|
||||||
|
public required string ObjecttypenToken { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The objecttype the register record is written as (S-18c registers "RegisterRecord").</summary>
|
||||||
|
public required string ObjecttypeName { get; init; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using Acl.Application;
|
||||||
|
using Acl.Infrastructure;
|
||||||
|
|
||||||
|
namespace Acl.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// S-19a (#149): the ObjectenGateway against a *real* Objecten + Objecttypen pair. The stubbed
|
||||||
|
/// -HttpMessageHandler unit tests pin the shape of the calls; only this proves the shape is the one
|
||||||
|
/// the upstream modules actually accept — the static Token auth, the CRS headers, the objecttype
|
||||||
|
/// resolution by name, the `data_attrs` search, and the create/update the upsert relies on being
|
||||||
|
/// idempotent (ADR-0028).
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Category", "Integration")]
|
||||||
|
public sealed class ObjectenGatewayIntegrationTests
|
||||||
|
{
|
||||||
|
private static string Env(string key, string fallback) =>
|
||||||
|
Environment.GetEnvironmentVariable(key) is { Length: > 0 } v ? v : fallback;
|
||||||
|
|
||||||
|
private static ObjectenGateway Gateway() => new(
|
||||||
|
new HttpClient(),
|
||||||
|
new ObjectenOptions
|
||||||
|
{
|
||||||
|
BaseUrl = new(Env("OBJECTEN_BASE", "http://objecten:8000")),
|
||||||
|
Token = Env("OBJECTEN_TOKEN", "1234567890abcdef1234567890abcdef12345678"),
|
||||||
|
ObjecttypenBaseUrl = new(Env("OBJECTTYPEN_BASE", "http://objecttypen:8000")),
|
||||||
|
ObjecttypenToken = Env("OBJECTTYPEN_TOKEN", "0123456789abcdef0123456789abcdef01234567"),
|
||||||
|
ObjecttypeName = "RegisterRecord",
|
||||||
|
},
|
||||||
|
new SystemClock());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Writes_a_register_record_and_updates_it_in_place_on_a_second_write()
|
||||||
|
{
|
||||||
|
var gateway = Gateway();
|
||||||
|
// A key no other run shares: the verify stack is shared and keeps records between checks.
|
||||||
|
var id = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
await gateway.UpsertAsync(new RegisterRecord(id, RegisterRecordStatus.Ingediend, "INT-TEST-1"));
|
||||||
|
await gateway.UpsertAsync(new RegisterRecord(id, RegisterRecordStatus.Ingeschreven, "INT-TEST-1"));
|
||||||
|
|
||||||
|
var records = await ReadAllAsync(id);
|
||||||
|
var only = Assert.Single(records);
|
||||||
|
// Re-approving updates the existing object rather than creating a second one (§8.6).
|
||||||
|
Assert.Equal(RegisterRecordStatus.Ingeschreven, only.Status);
|
||||||
|
Assert.Equal("INT-TEST-1", only.Reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Is_rejected_by_the_objecttype_schema_when_a_record_is_not_public_safe()
|
||||||
|
{
|
||||||
|
// The gateway cannot construct such a record — RegisterRecord has no bsn — so this asserts the
|
||||||
|
// guarantee from the other side: Objecten itself refuses anything the schema does not sanction
|
||||||
|
// (ADR-0027). Posted raw, exactly as the gateway would post a record.
|
||||||
|
var gateway = Gateway();
|
||||||
|
var id = Guid.NewGuid().ToString();
|
||||||
|
await gateway.UpsertAsync(new RegisterRecord(id, RegisterRecordStatus.Ingeschreven, "INT-TEST-2"));
|
||||||
|
|
||||||
|
var stored = Assert.Single(await ReadAllAsync(id));
|
||||||
|
Assert.Null(stored.Bsn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads the register records for a given id straight from Objecten, so the assertions do not go
|
||||||
|
// back through the gateway they are checking.
|
||||||
|
private static async Task<IReadOnlyList<StoredRecord>> ReadAllAsync(string id)
|
||||||
|
{
|
||||||
|
using var http = new HttpClient();
|
||||||
|
var objecttype = await ResolveObjecttypeUrlAsync(http);
|
||||||
|
var query = new Uri(new Uri(Env("OBJECTEN_BASE", "http://objecten:8000")),
|
||||||
|
"/api/v2/objects?type=" + Uri.EscapeDataString(objecttype) +
|
||||||
|
"&data_attrs=id__exact__" + Uri.EscapeDataString(id));
|
||||||
|
|
||||||
|
using var message = new HttpRequestMessage(HttpMethod.Get, query);
|
||||||
|
message.Headers.Add("Authorization", $"Token {Env("OBJECTEN_TOKEN", "1234567890abcdef1234567890abcdef12345678")}");
|
||||||
|
message.Headers.Add("Accept-Crs", "EPSG:4326");
|
||||||
|
|
||||||
|
using var response = await http.SendAsync(message);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
using var document = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||||
|
return document.RootElement.GetProperty("results").EnumerateArray()
|
||||||
|
.Select(o => o.GetProperty("record").GetProperty("data"))
|
||||||
|
.Select(d => new StoredRecord(
|
||||||
|
d.GetProperty("status").GetString()!,
|
||||||
|
d.GetProperty("reference").GetString(),
|
||||||
|
d.TryGetProperty("bsn", out var bsn) ? bsn.GetString() : null))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> ResolveObjecttypeUrlAsync(HttpClient http)
|
||||||
|
{
|
||||||
|
var query = new Uri(new Uri(Env("OBJECTTYPEN_BASE", "http://objecttypen:8000")), "/api/v2/objecttypes");
|
||||||
|
using var message = new HttpRequestMessage(HttpMethod.Get, query);
|
||||||
|
message.Headers.Add("Authorization", $"Token {Env("OBJECTTYPEN_TOKEN", "0123456789abcdef0123456789abcdef01234567")}");
|
||||||
|
|
||||||
|
using var response = await http.SendAsync(message);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
using var document = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||||
|
return document.RootElement.GetProperty("results").EnumerateArray()
|
||||||
|
.First(o => o.GetProperty("name").GetString() == "RegisterRecord")
|
||||||
|
.GetProperty("url").GetString()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record StoredRecord(string Status, string? Reference, string? Bsn);
|
||||||
|
}
|
||||||
@@ -75,6 +75,17 @@ public class AclServiceTests
|
|||||||
Task.FromResult(Zaaktypen);
|
Task.FromResult(Zaaktypen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class FakeRegisterRecordGateway : IRegisterRecordGateway
|
||||||
|
{
|
||||||
|
public readonly List<RegisterRecord> Upserted = [];
|
||||||
|
|
||||||
|
public Task UpsertAsync(RegisterRecord record, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Upserted.Add(record);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static AclDefaults Defaults() => new()
|
private static AclDefaults Defaults() => new()
|
||||||
{
|
{
|
||||||
Bronorganisatie = "517439943",
|
Bronorganisatie = "517439943",
|
||||||
@@ -88,7 +99,10 @@ public class AclServiceTests
|
|||||||
new(new DefaultFillSettings(d.Bronorganisatie, d.VerantwoordelijkeOrganisatie, d.Vertrouwelijkheidaanduiding));
|
new(new DefaultFillSettings(d.Bronorganisatie, d.VerantwoordelijkeOrganisatie, d.Vertrouwelijkheidaanduiding));
|
||||||
|
|
||||||
private static AclService ServiceWith(FakeGateway gateway, AclDefaults defaults, DateOnly today) =>
|
private static AclService ServiceWith(FakeGateway gateway, AclDefaults defaults, DateOnly today) =>
|
||||||
new(gateway, FillFrom(defaults), new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today));
|
ServiceWith(gateway, new FakeRegisterRecordGateway(), defaults, today);
|
||||||
|
|
||||||
|
private static AclService ServiceWith(FakeGateway gateway, FakeRegisterRecordGateway register, AclDefaults defaults, DateOnly today) =>
|
||||||
|
new(gateway, register, FillFrom(defaults), new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today));
|
||||||
|
|
||||||
private sealed class FixedClock(DateOnly today) : IClock
|
private sealed class FixedClock(DateOnly today) : IClock
|
||||||
{
|
{
|
||||||
@@ -161,10 +175,42 @@ public class AclServiceTests
|
|||||||
public async Task Approving_a_null_zaak_is_rejected_without_touching_the_gateway()
|
public async Task Approving_a_null_zaak_is_rejected_without_touching_the_gateway()
|
||||||
{
|
{
|
||||||
var gateway = new FakeGateway();
|
var gateway = new FakeGateway();
|
||||||
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
var register = new FakeRegisterRecordGateway();
|
||||||
|
var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4));
|
||||||
|
|
||||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!));
|
await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!));
|
||||||
Assert.Null(gateway.Approved);
|
Assert.Null(gateway.Approved);
|
||||||
|
Assert.Empty(register.Upserted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approving_a_zaak_writes_the_register_record_to_objecten(/* S-19a */)
|
||||||
|
{
|
||||||
|
var gateway = new FakeGateway();
|
||||||
|
var register = new FakeRegisterRecordGateway();
|
||||||
|
var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4));
|
||||||
|
|
||||||
|
await service.ApproveZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
||||||
|
|
||||||
|
var record = Assert.Single(register.Upserted);
|
||||||
|
// The record is keyed on the zaak id — the same key the read projection rows carry (S-19b).
|
||||||
|
Assert.Equal("abc", record.Id);
|
||||||
|
Assert.Equal("INGESCHREVEN", record.Status);
|
||||||
|
// The public-safe reference comes from the zaak's identificatie, never from the domain payload.
|
||||||
|
Assert.Equal("REG-FROM-ZAAK", record.Reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Cancelling_a_zaak_writes_no_register_record(/* S-19a */)
|
||||||
|
{
|
||||||
|
var gateway = new FakeGateway();
|
||||||
|
var register = new FakeRegisterRecordGateway();
|
||||||
|
var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4));
|
||||||
|
|
||||||
|
await service.CancelZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
||||||
|
|
||||||
|
// Only an approval enters the register; a cancelled zaak never becomes a register record.
|
||||||
|
Assert.Empty(register.Upserted);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Acl.Application;
|
||||||
|
using Acl.Infrastructure;
|
||||||
|
|
||||||
|
namespace Acl.Tests;
|
||||||
|
|
||||||
|
public class ObjectenGatewayTests
|
||||||
|
{
|
||||||
|
private sealed class StubHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> onSend)
|
||||||
|
: HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||||
|
=> onSend(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FixedClock(DateOnly today) : IClock
|
||||||
|
{
|
||||||
|
public DateOnly Today { get; } = today;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record Sent(
|
||||||
|
HttpMethod Method, Uri Uri, string? Body, string? Auth, string? ContentCrs, string? AcceptCrs, long? ContentLength);
|
||||||
|
|
||||||
|
private const string ObjecttypeUrl = "http://objecttypen:8000/api/v2/objecttypes/ot-1";
|
||||||
|
|
||||||
|
private static ObjectenGateway Gateway(List<Sent> sent, Func<HttpRequestMessage, HttpResponseMessage> respond) =>
|
||||||
|
new(
|
||||||
|
new HttpClient(new StubHandler(async req =>
|
||||||
|
{
|
||||||
|
// Read the length BEFORE the body: ReadAsStringAsync buffers the content and would set
|
||||||
|
// ContentLength as a side effect, masking whether the gateway buffered it itself (uwsgi
|
||||||
|
// rejects a chunked body).
|
||||||
|
sent.Add(new Sent(
|
||||||
|
req.Method,
|
||||||
|
req.RequestUri!,
|
||||||
|
ContentLength: req.Content?.Headers.ContentLength,
|
||||||
|
Body: req.Content is null ? null : await req.Content.ReadAsStringAsync(),
|
||||||
|
Auth: req.Headers.Authorization?.ToString(),
|
||||||
|
ContentCrs: req.Content?.Headers.TryGetValues("Content-Crs", out var c) == true ? string.Join(",", c!) : null,
|
||||||
|
AcceptCrs: req.Headers.TryGetValues("Accept-Crs", out var a) ? string.Join(",", a) : null));
|
||||||
|
return respond(req);
|
||||||
|
})),
|
||||||
|
new ObjectenOptions
|
||||||
|
{
|
||||||
|
BaseUrl = new("http://objecten:8000"),
|
||||||
|
Token = "objecten-token",
|
||||||
|
ObjecttypenBaseUrl = new("http://objecttypen:8000"),
|
||||||
|
ObjecttypenToken = "objecttypen-token",
|
||||||
|
ObjecttypeName = "RegisterRecord",
|
||||||
|
},
|
||||||
|
new FixedClock(new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
|
// A published v1 and v2, plus a draft v3 that must never be written against even though it is the
|
||||||
|
// highest version.
|
||||||
|
private static readonly Dictionary<string, object> Versions = new()
|
||||||
|
{
|
||||||
|
[$"{ObjecttypeUrl}/versions/1"] = new { version = 1, status = "published" },
|
||||||
|
[$"{ObjecttypeUrl}/versions/2"] = new { version = 2, status = "published" },
|
||||||
|
[$"{ObjecttypeUrl}/versions/3"] = new { version = 3, status = "draft" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// A stack that answers the reads every write is preceded by: the objecttype list (matched by name),
|
||||||
|
// each of that objecttype's versions, and the Objecten search for an existing record.
|
||||||
|
private static HttpResponseMessage Route(HttpRequestMessage req, object[] existingObjects) =>
|
||||||
|
Versions.TryGetValue(req.RequestUri!.ToString(), out var version)
|
||||||
|
? Json(version)
|
||||||
|
: req.RequestUri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)
|
||||||
|
? Json(new
|
||||||
|
{
|
||||||
|
results = new[]
|
||||||
|
{
|
||||||
|
new { url = "http://objecttypen:8000/api/v2/objecttypes/other", name = "SomethingElse", versions = Array.Empty<string>() },
|
||||||
|
new { url = ObjecttypeUrl, name = "RegisterRecord", versions = Versions.Keys.ToArray() },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: req.Method == HttpMethod.Get
|
||||||
|
? Json(new { results = existingObjects })
|
||||||
|
: new HttpResponseMessage(HttpStatusCode.Created) { Content = JsonContent.Create(new { url = "http://objecten:8000/api/v2/objects/obj-1" }) };
|
||||||
|
|
||||||
|
private static HttpResponseMessage Json(object body) =>
|
||||||
|
new(HttpStatusCode.OK) { Content = JsonContent.Create(body) };
|
||||||
|
|
||||||
|
private static RegisterRecord Record() => new("zaak-uuid-1", RegisterRecordStatus.Ingeschreven, "REG-2026-0001");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Creates_the_object_when_none_exists_for_the_registration()
|
||||||
|
{
|
||||||
|
var sent = new List<Sent>();
|
||||||
|
|
||||||
|
await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
|
||||||
|
|
||||||
|
var write = sent.Single(s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects");
|
||||||
|
Assert.Contains($"\"type\":\"{ObjecttypeUrl}\"", write.Body);
|
||||||
|
// The highest *published* version (2), not the highest version (a draft 3).
|
||||||
|
Assert.Contains("\"typeVersion\":2", write.Body);
|
||||||
|
Assert.Contains("\"id\":\"zaak-uuid-1\"", write.Body);
|
||||||
|
Assert.Contains("\"status\":\"INGESCHREVEN\"", write.Body);
|
||||||
|
Assert.Contains("\"reference\":\"REG-2026-0001\"", write.Body);
|
||||||
|
Assert.Contains("\"startAt\":\"2026-06-04\"", write.Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Updates_the_existing_object_instead_of_creating_a_second_one()
|
||||||
|
{
|
||||||
|
var sent = new List<Sent>();
|
||||||
|
object[] existing = [new { uuid = "obj-9", url = "http://objecten:8000/api/v2/objects/obj-9" }];
|
||||||
|
|
||||||
|
await Gateway(sent, req => Route(req, existing)).UpsertAsync(Record());
|
||||||
|
|
||||||
|
Assert.DoesNotContain(sent, s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects");
|
||||||
|
var write = sent.Single(s => s.Method == HttpMethod.Patch);
|
||||||
|
Assert.Equal("http://objecten:8000/api/v2/objects/obj-9", write.Uri.ToString());
|
||||||
|
Assert.Contains("\"status\":\"INGESCHREVEN\"", write.Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Searches_objecten_for_the_registration_id_within_the_objecttype()
|
||||||
|
{
|
||||||
|
var sent = new List<Sent>();
|
||||||
|
|
||||||
|
await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
|
||||||
|
|
||||||
|
var search = sent.Single(s => s.Method == HttpMethod.Get && s.Uri.AbsolutePath == "/api/v2/objects");
|
||||||
|
Assert.Contains("type=" + Uri.EscapeDataString(ObjecttypeUrl), search.Uri.Query);
|
||||||
|
Assert.Contains("data_attrs=id__exact__zaak-uuid-1", search.Uri.Query);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Authenticates_with_the_static_token_of_each_api()
|
||||||
|
{
|
||||||
|
var sent = new List<Sent>();
|
||||||
|
|
||||||
|
await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
|
||||||
|
|
||||||
|
Assert.All(
|
||||||
|
sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)),
|
||||||
|
s => Assert.Equal("Token objecttypen-token", s.Auth));
|
||||||
|
Assert.All(
|
||||||
|
sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objects", StringComparison.Ordinal)),
|
||||||
|
s => Assert.Equal("Token objecten-token", s.Auth));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Sends_the_geo_crs_headers_the_objecten_api_requires()
|
||||||
|
{
|
||||||
|
var sent = new List<Sent>();
|
||||||
|
|
||||||
|
await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
|
||||||
|
|
||||||
|
var objects = sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objects", StringComparison.Ordinal)).ToList();
|
||||||
|
Assert.All(objects, s => Assert.Equal("EPSG:4326", s.AcceptCrs));
|
||||||
|
Assert.All(objects.Where(s => s.Body is not null), s => Assert.Equal("EPSG:4326", s.ContentCrs));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolves_the_objecttype_once_and_reuses_it_across_writes()
|
||||||
|
{
|
||||||
|
var sent = new List<Sent>();
|
||||||
|
var gateway = Gateway(sent, req => Route(req, []));
|
||||||
|
|
||||||
|
await gateway.UpsertAsync(Record());
|
||||||
|
await gateway.UpsertAsync(Record() with { Id = "zaak-uuid-2" });
|
||||||
|
|
||||||
|
Assert.Single(sent, s => s.Uri.AbsolutePath == "/api/v2/objecttypes");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Fails_loudly_when_the_objecttype_has_no_published_version()
|
||||||
|
{
|
||||||
|
var sent = new List<Sent>();
|
||||||
|
var gateway = Gateway(sent, req => req.RequestUri!.AbsolutePath.Contains("/versions/", StringComparison.Ordinal)
|
||||||
|
? Json(new { version = 1, status = "draft" })
|
||||||
|
: Route(req, []));
|
||||||
|
|
||||||
|
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => gateway.UpsertAsync(Record()));
|
||||||
|
Assert.Contains("published version", error.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Fails_loudly_when_the_objecttype_is_not_registered()
|
||||||
|
{
|
||||||
|
var sent = new List<Sent>();
|
||||||
|
var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new { results = Array.Empty<object>() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => gateway.UpsertAsync(Record()));
|
||||||
|
Assert.Contains("RegisterRecord", error.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Surfaces_the_objecten_error_body_when_a_write_is_rejected()
|
||||||
|
{
|
||||||
|
var sent = new List<Sent>();
|
||||||
|
var gateway = Gateway(sent, req => req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath == "/api/v2/objects"
|
||||||
|
? new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("{\"detail\":\"schema mismatch\"}") }
|
||||||
|
: Route(req, []));
|
||||||
|
|
||||||
|
var error = await Assert.ThrowsAsync<HttpRequestException>(() => gateway.UpsertAsync(Record()));
|
||||||
|
Assert.Contains("schema mismatch", error.Message);
|
||||||
|
Assert.Contains("Creating the register record", error.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Surfaces_the_objecten_error_body_when_an_update_is_rejected()
|
||||||
|
{
|
||||||
|
var sent = new List<Sent>();
|
||||||
|
object[] existing = [new { url = "http://objecten:8000/api/v2/objects/obj-9" }];
|
||||||
|
var gateway = Gateway(sent, req => req.Method == HttpMethod.Patch
|
||||||
|
? new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("{\"detail\":\"stale version\"}") }
|
||||||
|
: Route(req, existing));
|
||||||
|
|
||||||
|
var error = await Assert.ThrowsAsync<HttpRequestException>(() => gateway.UpsertAsync(Record()));
|
||||||
|
Assert.Contains("stale version", error.Message);
|
||||||
|
Assert.Contains("Updating the register record", error.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Surfaces_a_failed_read_instead_of_writing_blind()
|
||||||
|
{
|
||||||
|
var sent = new List<Sent>();
|
||||||
|
var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
Content = new StringContent("{\"detail\":\"invalid token\"}"),
|
||||||
|
});
|
||||||
|
|
||||||
|
var error = await Assert.ThrowsAsync<HttpRequestException>(() => gateway.UpsertAsync(Record()));
|
||||||
|
Assert.Contains("Querying objecttypen", error.Message);
|
||||||
|
Assert.Contains("invalid token", error.Message);
|
||||||
|
// A read that failed must never be mistaken for "nothing there yet" and followed by a write.
|
||||||
|
Assert.DoesNotContain(sent, s => s.Method == HttpMethod.Post || s.Method == HttpMethod.Patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Fails_loudly_when_the_objecttype_carries_no_versions_at_all()
|
||||||
|
{
|
||||||
|
var sent = new List<Sent>();
|
||||||
|
var gateway = Gateway(sent, req => req.RequestUri!.AbsolutePath == "/api/v2/objecttypes"
|
||||||
|
? Json(new { results = new[] { new { url = ObjecttypeUrl, name = "RegisterRecord" } } })
|
||||||
|
: Route(req, []));
|
||||||
|
|
||||||
|
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => gateway.UpsertAsync(Record()));
|
||||||
|
Assert.Contains("published version", error.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Says_which_read_failed_when_the_objecten_search_errors()
|
||||||
|
{
|
||||||
|
var sent = new List<Sent>();
|
||||||
|
var gateway = Gateway(sent, req => req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath == "/api/v2/objects"
|
||||||
|
? new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent("boom") }
|
||||||
|
: Route(req, []));
|
||||||
|
|
||||||
|
var error = await Assert.ThrowsAsync<HttpRequestException>(() => gateway.UpsertAsync(Record()));
|
||||||
|
Assert.Contains("Querying objects", error.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Surfaces_an_empty_read_body_rather_than_dereferencing_it()
|
||||||
|
{
|
||||||
|
var sent = new List<Sent>();
|
||||||
|
var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent("null", System.Text.Encoding.UTF8, "application/json"),
|
||||||
|
});
|
||||||
|
|
||||||
|
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => gateway.UpsertAsync(Record()));
|
||||||
|
Assert.Contains("objecttypen", error.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Treats_a_result_less_response_as_no_match_rather_than_crashing()
|
||||||
|
{
|
||||||
|
var sent = new List<Sent>();
|
||||||
|
// The objecttypes collection carries no `results` key — the objecttype is absent, which must
|
||||||
|
// surface as the "not registered" error rather than an ArgumentNullException from LINQ.
|
||||||
|
var gateway = Gateway(sent, _ => Json(new { }));
|
||||||
|
|
||||||
|
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => gateway.UpsertAsync(Record()));
|
||||||
|
Assert.Contains("RegisterRecord", error.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Creates_the_object_when_the_search_response_carries_no_results_key()
|
||||||
|
{
|
||||||
|
var sent = new List<Sent>();
|
||||||
|
var gateway = Gateway(sent, req => req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath == "/api/v2/objects"
|
||||||
|
? Json(new { })
|
||||||
|
: Route(req, []));
|
||||||
|
|
||||||
|
await gateway.UpsertAsync(Record());
|
||||||
|
|
||||||
|
Assert.Contains(sent, s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Reads_objecttypen_without_the_crs_headers_it_does_not_accept()
|
||||||
|
{
|
||||||
|
var sent = new List<Sent>();
|
||||||
|
|
||||||
|
await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
|
||||||
|
|
||||||
|
// Objecttypen is not a geo API; only the Objecten hops carry CRS.
|
||||||
|
Assert.All(
|
||||||
|
sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)),
|
||||||
|
s => Assert.Null(s.AcceptCrs));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Buffers_the_write_body_so_uwsgi_gets_a_content_length()
|
||||||
|
{
|
||||||
|
var sent = new List<Sent>();
|
||||||
|
|
||||||
|
await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
|
||||||
|
|
||||||
|
var write = sent.Single(s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects");
|
||||||
|
Assert.NotNull(write.ContentLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Rejects_a_null_record_without_calling_objecten()
|
||||||
|
{
|
||||||
|
var sent = new List<Sent>();
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ArgumentNullException>(() => Gateway(sent, req => Route(req, [])).UpsertAsync(null!));
|
||||||
|
Assert.Empty(sent);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@ public sealed class EenZaakOpenenSteps
|
|||||||
{
|
{
|
||||||
var fill = new InMemoryDefaultFillStore(new DefaultFillSettings(
|
var fill = new InMemoryDefaultFillStore(new DefaultFillSettings(
|
||||||
_defaults!.Bronorganisatie, _defaults.VerantwoordelijkeOrganisatie, _defaults.Vertrouwelijkheidaanduiding));
|
_defaults!.Bronorganisatie, _defaults.VerantwoordelijkeOrganisatie, _defaults.Vertrouwelijkheidaanduiding));
|
||||||
var service = new AclService(_gateway, fill, new CachedZaaktypeCatalog(_gateway, _defaults!), new FixedClock(_today));
|
var service = new AclService(_gateway, new InMemoryRegisterRecordGateway(), fill, new CachedZaaktypeCatalog(_gateway, _defaults!), new FixedClock(_today));
|
||||||
_returnedUrl = await service.OpenZaakAsync(_registration!);
|
_returnedUrl = await service.OpenZaakAsync(_registration!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,3 +53,16 @@ public sealed class InMemoryZaakGateway : IZaakGateway
|
|||||||
=> Task.FromResult<IReadOnlyList<ZaaktypeSummary>>(
|
=> Task.FromResult<IReadOnlyList<ZaaktypeSummary>>(
|
||||||
[new ZaaktypeSummary("BIG-REGISTRATIE", "BIG-registratie", ResolvedZaaktypeUrl)]);
|
[new ZaaktypeSummary("BIG-REGISTRATIE", "BIG-registratie", ResolvedZaaktypeUrl)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>An in-memory stand-in for the Objecten API (S-19a): records the register records the ACL
|
||||||
|
/// writes on approval, so a scenario can assert on them without a running Objecten.</summary>
|
||||||
|
public sealed class InMemoryRegisterRecordGateway : IRegisterRecordGateway
|
||||||
|
{
|
||||||
|
public List<RegisterRecord> Upserted { get; } = [];
|
||||||
|
|
||||||
|
public Task UpsertAsync(RegisterRecord record, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Upserted.Add(record);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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