Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f363cb1663 | ||
|
|
2fe187cbdd | ||
|
|
734dfb21dd | ||
|
|
24c7ebe8cb | ||
|
|
0ba410ad78 | ||
|
|
183d0bce31 | ||
|
|
d5e5fa254c |
@@ -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-notifications smoke up down 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-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).
|
||||
@@ -114,6 +114,11 @@ local:
|
||||
docker compose -f $(LOCAL_COMPOSE) up -d --build
|
||||
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
|
||||
|
||||
## verify-local: acceptance check for the local stack (S-B04) — a fresh `make local` completes the
|
||||
## whole flow (zaaktype seeded + DMN deployed + NRC abonnement) with NO manual seeding.
|
||||
verify-local:
|
||||
bash infra/run-local-flow-check.sh
|
||||
|
||||
## local-down: stop and remove the bind-mount stack
|
||||
local-down:
|
||||
docker compose -f $(LOCAL_COMPOSE) down --volumes
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# ADR-0020: The local stack self-seeds the zaaktype, DMN, and NRC abonnement at bring-up
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-22
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-B04 (#110). Local-stack twin of the seeding the verify-* scripts do for CI
|
||||
(`infra/run-domain-check.sh`, `infra/verify-notification-driver.py`). Superseded in part by S-27
|
||||
(#113), which would let the ACL resolve its zaaktype by identificatie and remove the URL injection.
|
||||
|
||||
## Context
|
||||
|
||||
`infra/docker-compose.local.yml` is the host-browser-friendly stack (`make local`) — the one a
|
||||
developer clicks through the portals with. It had drifted behind three slices, so a fresh bring-up
|
||||
could not complete the flow:
|
||||
|
||||
1. The ACL pointed at a placeholder zaaktype (`…/00000000-…`), so zaak creation failed with OpenZaak
|
||||
`400` and the registratie process stuck at `OpenZaakAanmaken` (S-05).
|
||||
2. `flowable-init` deployed only `registratie.bpmn`, not `diploma-eligibility.dmn`, so completing
|
||||
`WachtOpDocumenten` 404'd on the missing decision and never reached `Beoordelen` (S-10a/S-13).
|
||||
3. No NRC abonnement was registered, so notifications reached NRC and went nowhere — the projection
|
||||
and the openbaar register stayed empty (S-06).
|
||||
|
||||
The CI stack (`infra/docker-compose.yml`) does not hit this because its `verify-*` scripts seed the
|
||||
zaaktype, deploy the DMN, and register the abonnement at *test* time. The local stack has no such
|
||||
harness — a developer just runs `make local` and browses. The non-obvious wrinkle is (1): the
|
||||
zaaktype **UUID is assigned by OpenZaak at creation**, so the ACL's zaaktype URL is not knowable when
|
||||
the compose file is written and cannot be a static value.
|
||||
|
||||
## Decision
|
||||
|
||||
**Make the local stack self-seed at bring-up via one-shot init containers, and hand the ACL its
|
||||
server-assigned zaaktype URL through a shared-volume env file it sources on startup.**
|
||||
|
||||
- **DMN (gap 2).** `flowable-init` now deploys `diploma-eligibility.dmn` to the DMN engine
|
||||
(`/flowable-rest/dmn-api/dmn-repository/deployments`) as a separate deployment alongside the BPMN —
|
||||
identical to the CI `flowable-init`. Idempotent.
|
||||
- **Zaaktype + ACL wiring (gap 1).** A `local-seed` one-shot runs the existing
|
||||
`infra/openzaak/seed_catalogus.py` (`OZ_PUBLISH=1`) against OpenZaak and writes the resulting
|
||||
`Acl__Defaults__ZaaktypeUrl` / `…InformatieobjecttypeUrl` / `Acl__OpenZaak__BaseUrl` into
|
||||
`seed-env:/out/acl.env`. The ACL mounts that volume read-only and overrides its entrypoint to
|
||||
`sh -c 'set -a; . /seed/acl.env; set +a; exec dotnet Acl.Api.dll'`, so the real values override the
|
||||
compose placeholders before the app reads config. The ACL `depends_on: local-seed
|
||||
(service_completed_successfully)`.
|
||||
- **Abonnement (gap 3).** A `nrc-subscribe` one-shot registers an abonnement on the `zaken` kanaal
|
||||
pointing at the event-subscriber's `/notifications` callback (`infra/local/register-abonnement.py`).
|
||||
It is a leaf — nothing depends on it — so it can wait for the event-subscriber without forming a
|
||||
cycle with the ACL bootstrap.
|
||||
- **Reach OpenZaak/NRC by container IP, not service name.** Both the seed's ZTC calls and the
|
||||
abonnement's `callbackUrl` are validated by Django's URLValidator, which rejects a single-label host
|
||||
like `openzaak` / `event-subscriber`. The scripts resolve the target's container IP at runtime (as
|
||||
`infra/run-domain-check.sh` does), keeping the seeded URLs valid **and** host-consistent — the ACL's
|
||||
base URL is set to the same OpenZaak IP that owns the zaaktype URL.
|
||||
- **Acceptance.** `make verify-local` (`infra/run-local-flow-check.sh`) submits against a fresh stack
|
||||
and asserts the zaak opens, the case reaches the werkbak after documents, and the reference appears
|
||||
in the openbaar register — the red-to-green test for all three gaps.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- A fresh `make local` completes the full demo (submit → werkbak → openbaar) with no manual seeding —
|
||||
the slice's stated outcome.
|
||||
- Reuses the proven CI mechanisms (`seed_catalogus.py`, the DMN deploy, the abonnement driver) rather
|
||||
than inventing new ones; the only genuinely new piece is the entrypoint-sourced env file.
|
||||
- No service code changes — the fix is entirely in `infra/` (compose + two small scripts), so the ACL
|
||||
image and the CI stack are untouched.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- The two compose files diverge further: the CI stack seeds at test time, the local stack at bring-up.
|
||||
Mitigated by reusing the same underlying scripts and cross-referencing them.
|
||||
- The ACL entrypoint override couples the local ACL to the seed-written file path (`/seed/acl.env`);
|
||||
if the seed fails, the ACL fails to start (loud, healthcheck-visible — preferred over silently
|
||||
running with a placeholder).
|
||||
- Container-IP-based URLs are re-derived on each bring-up; a keep-volumes restart with a changed
|
||||
OpenZaak IP relies on OpenZaak rebuilding hyperlinked URLs from the request host (it does) so the
|
||||
idempotent re-seed reports current-IP URLs.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **ACL resolves its zaaktype by identificatie (`BIG-REGISTRATIE`) at startup.** The cleaner,
|
||||
less-brittle design — no server-assigned URL to capture — and it would help the CI stack too. But it
|
||||
changes a service's runtime behaviour and its config contract, needs new ACL tests + mutation
|
||||
coverage, and still needs a seed step to *create* the zaaktype. Deliberately split out as its own
|
||||
slice with its own ADR (S-27 / #113) rather than folded into this infra-only fix.
|
||||
- **A documented `make local-seed` step run after `make local`.** Smallest change, but it fails the
|
||||
slice's "no manual seeding" outcome — the local stack is exactly the one meant to just work in a
|
||||
browser. Rejected.
|
||||
- **Fixed zaaktype UUID via OpenZaak `setup_configuration`/fixtures.** OpenZaak assigns UUIDs on POST;
|
||||
declaratively creating a fully *published* zaaktype (statustypen + resultaattypen validated against
|
||||
the Selectielijst + roltypen + iot relations) is not something `setup_configuration` supports
|
||||
cleanly in 1.28.2. Rejected as more fragile than reusing `seed_catalogus.py`.
|
||||
@@ -0,0 +1,67 @@
|
||||
# ADR-0021: The ACL resolves its zaaktype by identificatie, not a pinned URL
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-22
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-27 (#113), proposed in #117. The cleaner design deliberately split out of S-B04
|
||||
(#110, ADR-0020), which fixed the local stack with an infra-only bootstrap.
|
||||
|
||||
## Context
|
||||
|
||||
The ACL was handed a **pinned zaaktype URL** (`Acl__Defaults__ZaaktypeUrl`) and diploma
|
||||
informatieobjecttype URL. OpenZaak assigns those UUIDs at creation, so the URL is not knowable when
|
||||
the compose file is written — every stack had to seed the catalogus and then capture + inject the
|
||||
resulting URLs out of band: `run-domain-check.sh` for CI, and the `local-seed` → `acl.env` bootstrap
|
||||
(ADR-0020) for `make local`. Brittle, and a stale/placeholder URL failed opaquely (OpenZaak 400).
|
||||
|
||||
## Decision
|
||||
|
||||
**The ACL resolves its zaaktype (by `identificatie`) and diploma informatieobjecttype (by
|
||||
`omschrijving`) from OpenZaak's Catalogi API, instead of being handed the URLs.**
|
||||
|
||||
- **Config:** `AclDefaults.ZaaktypeUrl`/`InformatieobjecttypeUrl` → `ZaaktypeIdentificatie`
|
||||
(`BIG-REGISTRATIE`) / `InformatieobjecttypeOmschrijving` (`Diploma`).
|
||||
- **Lookup (gateway, §8.1):** `GET /catalogi/api/v1/zaaktypen?status=definitief&identificatie=…` →
|
||||
the published zaaktype URL; `GET /catalogi/api/v1/informatieobjecttypen?status=definitief` matched
|
||||
on `omschrijving`. Reuses the gateway's existing catalogus-query machinery.
|
||||
- **Timing = lazy + cached (`CachedZaaktypeCatalog`).** Resolve on first use (first zaak open /
|
||||
document store) and cache for the process lifetime. Lazy avoids a startup ordering coupling — the
|
||||
ACL never crash-loops when it boots before the catalogus is published. A **failed** resolution is
|
||||
not cached, so it is retried on the next call (e.g. once the zaaktype is published); a restart
|
||||
re-resolves.
|
||||
- **Failure mode:** no published match → a clear "No published zaaktype with identificatie '…' found
|
||||
in OpenZaak — is the BIG catalogus seeded and published?" error, replacing the opaque placeholder
|
||||
400.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- No stack captures or injects a server-assigned URL any more: `run-domain-check.sh` drops the
|
||||
`ACL_ZAAKTYPE_URL`/`ACL_INFORMATIEOBJECTTYPE_URL` capture+inject, `docker-compose.yml`/`.local.yml`
|
||||
drop the placeholder URL env, and `local-seed`/`acl.env` shrink to a single line. The ACL
|
||||
self-configures from the catalogus it already talks to.
|
||||
- The failure mode is legible (a named error instead of a 400 on a zeros-UUID).
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- The ACL still needs its OpenZaak **BaseUrl** pointed at a **URL-valid host (a container IP)**, so
|
||||
the base-URL injection from ADR-0020 stays (the local `acl.env` now carries only that; CI keeps
|
||||
`ACL_OPENZAAK_BASEURL`). This is **not** something S-27 can remove: OpenZaak validates the
|
||||
`zaaktype` field on zaak-create with Django's URLValidator and **rejects a single-label host**
|
||||
(`http://openzaak:8000/…` → `zaaktype: bad-url, "Voer een geldige URL in."`, confirmed empirically).
|
||||
So ADR-0020's `seed-env` volume + ACL entrypoint shim are **simplified, not deleted**.
|
||||
- New branching in the gateway/resolver → unit + integration test surface; the mutation ratchet
|
||||
covers it (§5).
|
||||
- A seed step still **creates + publishes** the zaaktype (this ADR changes only discovery). Reaching
|
||||
OpenZaak's Catalogi API to *seed* likewise needs the IP host (its query params hit the same
|
||||
URLValidator) — unchanged from before.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Resolve at startup** (eager). Simpler cache, but reintroduces the ordering coupling (crash-loop
|
||||
if the catalogus isn't published yet). Rejected in favour of lazy.
|
||||
- **Per-request resolution** (no cache). No stale-cache risk, but a Catalogi lookup on every ACL
|
||||
operation. Rejected; a process-lifetime cache with restart-to-refresh is enough here.
|
||||
- **Keep the pinned URL** (status quo / ADR-0020 only). Rejected — the brittleness this ADR removes is
|
||||
exactly what S-27 was carved out to fix.
|
||||
@@ -5,6 +5,55 @@ copy-pasteable walkthrough against a local `make up` stack.
|
||||
|
||||
---
|
||||
|
||||
## S-B04 — `make local` completes the whole flow with no manual seeding (#110, ADR-0020)
|
||||
|
||||
**Outcome:** the host-browser stack (`make local`) now self-seeds at bring-up — it publishes the BIG
|
||||
zaaktype and wires the ACL to it, deploys the `diploma-eligibility` DMN, and registers the NRC
|
||||
abonnement — so a fresh bring-up runs submit → werkbak → openbaar without the manual seeding the
|
||||
`verify-*` scripts do for CI. (Previously the process stuck at `OpenZaakAanmaken`, the werkbak stayed
|
||||
empty, and the openbaar register showed nothing.)
|
||||
|
||||
```bash
|
||||
# 1. Fresh bring-up (self-seeding init containers: local-seed, nrc-subscribe; DMN in flowable-init).
|
||||
make local
|
||||
|
||||
# 2. Assert the whole flow works with no manual seeding — submit opens a zaak, documents route it to
|
||||
# the werkbak, and the reference appears in the openbaar register:
|
||||
make verify-local # → "OK — a fresh local stack completed the flow with no manual seeding ..."
|
||||
|
||||
# 3. Or by hand in the browser: log in at http://localhost:8140 (jan-burger / test123), submit +
|
||||
# upload a PDF, then approve it in the werkbak at http://localhost:8142 (merel-behandelaar /
|
||||
# test123); it shows as INGESCHREVEN in the openbaar register at http://localhost:8141.
|
||||
```
|
||||
|
||||
> The zaaktype is discovered by the ACL itself since S-27 (below); `local-seed`'s `acl.env` now
|
||||
> carries only OpenZaak's IP base URL, which the ACL still needs because OpenZaak rejects a
|
||||
> single-label host on zaak-create (ADR-0020 + ADR-0021).
|
||||
|
||||
---
|
||||
|
||||
## S-27 — ACL resolves its zaaktype by identificatie, not a pinned URL (#113, ADR-0021)
|
||||
|
||||
**Outcome:** the ACL discovers its BIG zaaktype (by `identificatie`) and diploma informatieobjecttype
|
||||
(by `omschrijving`) from OpenZaak's Catalogi API, instead of being handed the server-assigned URLs.
|
||||
No user-visible behaviour change — the flow runs exactly as before — but no stack captures/injects a
|
||||
zaaktype URL any more, and a missing catalogus now fails with a clear message instead of an opaque 400.
|
||||
|
||||
```bash
|
||||
# The live ACL↔OpenZaak integration test proves resolution against a real seeded OpenZaak:
|
||||
make verify-acl # → "resolves the published BIG-REGISTRATIE zaaktype + Diploma informatieobjecttype by business key"
|
||||
|
||||
# End-to-end unchanged (the ACL self-discovers the zaaktype during the flow):
|
||||
make verify-local # local stack — still green, now with no zaaktype-URL injection
|
||||
make verify-domain # CI stack — recreates the ACL pointed only at OpenZaak's IP (no URL to inject)
|
||||
```
|
||||
|
||||
> The ACL still needs its OpenZaak base URL at a URL-valid host (a container IP): OpenZaak's
|
||||
> URLValidator rejects a single-label host like `openzaak:8000` on zaak-create. So ADR-0020's base-URL
|
||||
> injection stays; only the zaaktype/informatieobjecttype **URL** injection is gone (ADR-0021).
|
||||
|
||||
---
|
||||
|
||||
## S-08d — Walking skeleton complete: browser → submit, end-to-end
|
||||
|
||||
**Outcome:** the self-service portal is served in the stack and the full front-of-house happy path
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
# LOCAL development stack — runs with a plain `docker compose up`, no make / no
|
||||
# seed step / no bash. Use this on a local engine (Docker Desktop on Windows or
|
||||
# external seed step / no bash. Use this on a local engine (Docker Desktop on Windows or
|
||||
# macOS, or rootless Podman on Linux).
|
||||
#
|
||||
# Self-seeding (S-B04, #110, ADR-0020): unlike the CI stack — where the verify-* scripts seed the
|
||||
# zaaktype and register the NRC abonnement at test time — this stack does that itself, via one-shot
|
||||
# init containers (local-seed, nrc-subscribe) + a DMN deploy in flowable-init, so a fresh bring-up
|
||||
# completes the whole flow with no manual steps. `make verify-local` asserts it.
|
||||
#
|
||||
# docker compose -f infra/docker-compose.local.yml up -d --build # podman
|
||||
# docker compose -f infra/docker-compose.local.yml up -d --build --wait # Docker Desktop
|
||||
# docker compose -f infra/docker-compose.local.yml down --volumes
|
||||
@@ -257,38 +262,79 @@ services:
|
||||
restart: "no"
|
||||
volumes:
|
||||
- ../workflows/registratie.bpmn:/work/registratie.bpmn:ro,z
|
||||
- ../workflows/diploma-eligibility.dmn:/work/diploma-eligibility.dmn:ro,z
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
base=http://flowable-rest:8080/flowable-rest/service/repository/deployments
|
||||
until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
|
||||
if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then
|
||||
echo "registratie already deployed; skip"
|
||||
svc=http://flowable-rest:8080/flowable-rest/service/repository/deployments
|
||||
dmn=http://flowable-rest:8080/flowable-rest/dmn-api/dmn-repository/deployments
|
||||
until curl -sf -u rest-admin:test "$$svc" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
|
||||
# Deploy the DMN to the DMN engine and the BPMN to the process engine as SEPARATE deployments:
|
||||
# flowable-rest does NOT cascade a .dmn bundled in a process .bar into the DMN engine, so the DMN
|
||||
# must go via dmn-api. The registratie process's DMN service task then resolves the decision across
|
||||
# deployments by key (S-13, ADR-0016). Without this the WachtOpDocumenten completion 404s on the
|
||||
# missing decision and the case never reaches Beoordelen (S-B04). Both steps are idempotent.
|
||||
if curl -s -u rest-admin:test "$$dmn" | grep -q '"name":"diploma-eligibility.dmn"'; then
|
||||
echo "diploma-eligibility DMN already deployed; skip"
|
||||
else
|
||||
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie"
|
||||
curl -sf -u rest-admin:test -F 'file=@/work/diploma-eligibility.dmn;filename=diploma-eligibility.dmn' "$$dmn" >/dev/null && echo "deployed diploma-eligibility DMN"
|
||||
fi
|
||||
if curl -s -u rest-admin:test "$$svc?name=registratie" | grep -q '"name":"registratie"'; then
|
||||
echo "registratie BPMN already deployed; skip"
|
||||
else
|
||||
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$svc" >/dev/null && echo "deployed registratie BPMN"
|
||||
fi
|
||||
depends_on:
|
||||
flowable-rest:
|
||||
condition: service_started
|
||||
networks: [cg]
|
||||
|
||||
# ── Local bootstrap: seed the zaaktype + wire the ACL (S-B04, #110, ADR-0020) ─────────────────
|
||||
# The zaaktype UUID is assigned by OpenZaak at creation, so it can't be a static value in this
|
||||
# file. This one-shot seeds + publishes the BIG zaaktype (and the Diploma informatieobjecttype)
|
||||
# and writes their server-assigned URLs into a shared volume as acl.env, which the ACL sources on
|
||||
# startup (below). It is the local-stack equivalent of what infra/run-domain-check.sh does for CI.
|
||||
# Reaches OpenZaak by its container IP because a single-label host fails OpenZaak's URLValidator.
|
||||
local-seed:
|
||||
image: docker.io/library/python:3-slim
|
||||
restart: "no"
|
||||
volumes:
|
||||
- ./openzaak/seed_catalogus.py:/work/seed_catalogus.py:ro,z
|
||||
- ./local/seed-zaaktype.sh:/work/seed-zaaktype.sh:ro,z
|
||||
- seed-env:/out
|
||||
command: ["sh", "/work/seed-zaaktype.sh"]
|
||||
depends_on:
|
||||
openzaak:
|
||||
condition: service_healthy
|
||||
networks: [cg]
|
||||
|
||||
# ── ACL ──────────────────────────────────────────────────────────────────
|
||||
acl:
|
||||
build:
|
||||
context: ../services/acl
|
||||
dockerfile: Dockerfile
|
||||
image: register-referentie/acl:dev
|
||||
# The ACL discovers its zaaktype + informatieobjecttype URLs from the Catalogi API by the business
|
||||
# keys below (S-27, ADR-0021), so no URL is injected. It still needs its OpenZaak BaseUrl pointed at
|
||||
# a URL-valid host (OpenZaak rejects a single-label host like `openzaak` on zaak-create), so the
|
||||
# local-seed one-shot writes that IP base into seed-env:/seed/acl.env, which the entrypoint sources
|
||||
# (set -a) before the app starts. A runtime-generated env file is why we override the entrypoint here
|
||||
# rather than use `env_file:` (which compose reads at parse time, before the seed has run).
|
||||
entrypoint: ["/bin/sh", "-c", "set -a; . /seed/acl.env; set +a; exec dotnet Acl.Api.dll"]
|
||||
environment:
|
||||
Acl__OpenZaak__BaseUrl: http://openzaak:8000/
|
||||
Acl__OpenZaak__BaseUrl: http://openzaak:8000/ # placeholder; seed-env/acl.env supplies the IP base
|
||||
Acl__OpenZaak__ClientId: big-reference-seed
|
||||
Acl__OpenZaak__Secret: insecure-dev-secret-change-me
|
||||
Acl__Defaults__Bronorganisatie: "517439943"
|
||||
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
|
||||
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
|
||||
Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000}
|
||||
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
|
||||
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
|
||||
ports:
|
||||
- "8100:8080"
|
||||
volumes:
|
||||
- seed-env:/seed:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
||||
interval: 5s
|
||||
@@ -298,6 +344,8 @@ services:
|
||||
depends_on:
|
||||
openzaak:
|
||||
condition: service_healthy
|
||||
local-seed:
|
||||
condition: service_completed_successfully
|
||||
networks: [cg]
|
||||
|
||||
# ── BFF ──────────────────────────────────────────────────────────────────
|
||||
@@ -400,6 +448,31 @@ services:
|
||||
condition: service_healthy
|
||||
networks: [cg]
|
||||
|
||||
# ── Local bootstrap: register the NRC abonnement (S-B04, #110, ADR-0020) ──────────────────────
|
||||
# Without a subscription, OpenZaak's notifications reach NRC and are delivered nowhere, so the
|
||||
# projection (and the openbaar register) stay empty. This one-shot registers an abonnement on the
|
||||
# `zaken` kanaal pointing at the event-subscriber's /notifications callback — the CI equivalent is
|
||||
# infra/verify-notification-driver.py. The callback uses the event-subscriber's container IP (a
|
||||
# single-label host fails NRC's URLValidator). It is a leaf (nothing depends on it), so it can wait
|
||||
# for the event-subscriber without creating a cycle with the ACL bootstrap.
|
||||
nrc-subscribe:
|
||||
image: docker.io/library/python:3-slim
|
||||
restart: "no"
|
||||
volumes:
|
||||
- ./local/register-abonnement.py:/work/register-abonnement.py:ro,z
|
||||
environment:
|
||||
NRC_BASE: http://nrc-web:8000
|
||||
SINK_HOST: event-subscriber
|
||||
SINK_PORT: "8080"
|
||||
SINK_AUTH: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
|
||||
command: ["python", "/work/register-abonnement.py"]
|
||||
depends_on:
|
||||
nrc-web:
|
||||
condition: service_healthy
|
||||
event-subscriber:
|
||||
condition: service_started
|
||||
networks: [cg]
|
||||
|
||||
projection-api:
|
||||
build:
|
||||
context: ..
|
||||
@@ -492,6 +565,8 @@ volumes:
|
||||
nrc-db:
|
||||
flowable-db:
|
||||
projection-db:
|
||||
# Carries the seed-generated acl.env (server-assigned zaaktype URLs) from local-seed to the ACL.
|
||||
seed-env:
|
||||
|
||||
networks:
|
||||
cg:
|
||||
|
||||
+12
-11
@@ -15,12 +15,12 @@
|
||||
#
|
||||
# docker compose -f infra/docker-compose.yml up -d --build --wait
|
||||
#
|
||||
# After first boot, seed the BIG catalogus and note the zaaktype URL:
|
||||
# python infra/openzaak/seed_catalogus.py
|
||||
# Then set ACL_ZAAKTYPE_URL in a .env file or your shell and re-up the acl
|
||||
# service:
|
||||
# export ACL_ZAAKTYPE_URL=http://openzaak:8000/catalogi/api/v1/zaaktypen/<uuid>
|
||||
# docker compose -f infra/docker-compose.yml up -d acl
|
||||
# After first boot, seed + publish the BIG catalogus:
|
||||
# OZ_PUBLISH=1 python infra/openzaak/seed_catalogus.py
|
||||
# The ACL discovers the zaaktype by identificatie (S-27, ADR-0021), so there is no URL to inject —
|
||||
# just point its BaseUrl at an OpenZaak host OpenZaak accepts on zaak-create (a container IP; a
|
||||
# single-label host is rejected):
|
||||
# ACL_OPENZAAK_BASEURL=http://<openzaak-ip>:8000/ docker compose -f infra/docker-compose.yml up -d acl
|
||||
|
||||
services:
|
||||
|
||||
@@ -304,11 +304,12 @@ services:
|
||||
Acl__Defaults__Bronorganisatie: "517439943"
|
||||
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
|
||||
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
|
||||
# Override with the real zaaktype URL after running seed_catalogus.py.
|
||||
Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000}
|
||||
# The informatieobjecttype a diploma is filed under (S-10b). Placeholder until seed_catalogus.py
|
||||
# (OZ_PUBLISH=1) reports the real URL, which verify-domain injects like the zaaktype URL.
|
||||
Acl__Defaults__InformatieobjecttypeUrl: ${ACL_INFORMATIEOBJECTTYPE_URL:-http://openzaak:8000/catalogi/api/v1/informatieobjecttypen/00000000-0000-0000-0000-000000000000}
|
||||
# The ACL resolves the (server-assigned) zaaktype + diploma informatieobjecttype URLs from the
|
||||
# Catalogi API by these stable business keys (S-27, ADR-0021) — no URL to capture and inject.
|
||||
# BaseUrl above stays overridable because OpenZaak rejects a single-label host on zaak creation,
|
||||
# so verify-domain still points the ACL at OpenZaak's container IP.
|
||||
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
|
||||
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
|
||||
ports:
|
||||
- "8100:8080"
|
||||
healthcheck:
|
||||
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Local-stack bootstrap (S-B04, #110, ADR-0020) — register the NRC abonnement.
|
||||
|
||||
Runs as the `nrc-subscribe` init container of infra/docker-compose.local.yml. Registers an
|
||||
abonnement on the `zaken` kanaal pointing at the event-subscriber's /notifications callback, so
|
||||
OpenZaak's notifications (zaak create + status set) reach the projection — without this the openbaar
|
||||
(public) register stays empty. This is what infra/verify-notification-driver.py does for CI (minus
|
||||
the test zaak it also creates).
|
||||
|
||||
The callback host is the event-subscriber's resolved **container IP**, not `event-subscriber`, because
|
||||
NRC validates callbackUrl with Django's URLValidator (a single-label host is rejected — same reason the
|
||||
zaaktype seed uses OpenZaak's IP). Idempotent + restart-safe: it removes any stale /notifications
|
||||
abonnement first, then registers one for the current IP. Stdlib only.
|
||||
|
||||
Env: NRC_BASE, SINK_HOST, SINK_PORT, SINK_AUTH, OZ_CLIENT_ID, OZ_SECRET.
|
||||
"""
|
||||
import base64, hashlib, hmac, json, os, socket, sys, time, urllib.error, urllib.request
|
||||
|
||||
NRC = os.environ.get("NRC_BASE", "http://nrc-web:8000").rstrip("/")
|
||||
SINK_HOST = os.environ.get("SINK_HOST", "event-subscriber")
|
||||
SINK_PORT = os.environ.get("SINK_PORT", "8080")
|
||||
SINK_AUTH = os.environ.get("SINK_AUTH", "Bearer big-reference-notifications")
|
||||
CID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed")
|
||||
SECRET = os.environ.get("OZ_SECRET", "insecure-dev-secret-change-me")
|
||||
|
||||
|
||||
def token():
|
||||
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=")
|
||||
seg = (
|
||||
b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
|
||||
+ b"."
|
||||
+ b64(json.dumps(
|
||||
{"iss": CID, "iat": int(time.time()), "client_id": CID,
|
||||
"user_id": "local-seed", "user_representation": "local-seed"},
|
||||
separators=(",", ":")).encode())
|
||||
)
|
||||
return (seg + b"." + b64(hmac.new(SECRET.encode(), seg, hashlib.sha256).digest())).decode()
|
||||
|
||||
|
||||
def call(method, url, body=None):
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method, headers={
|
||||
"Authorization": "Bearer " + token(),
|
||||
"Content-Type": "application/json", "Accept": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
raw = r.read()
|
||||
return r.status, (json.loads(raw) if raw else None)
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read()
|
||||
return e.code, (json.loads(raw) if raw else None)
|
||||
|
||||
|
||||
def main():
|
||||
ip = socket.gethostbyname(SINK_HOST)
|
||||
callback = f"http://{ip}:{SINK_PORT}/notifications"
|
||||
|
||||
# Restart-safe: drop any prior /notifications abonnement (its IP may be stale) before creating a
|
||||
# fresh one for the current event-subscriber IP.
|
||||
status, body = call("GET", f"{NRC}/api/v1/abonnement")
|
||||
for ab in (body or []) if status == 200 else []:
|
||||
if str(ab.get("callbackUrl", "")).endswith("/notifications"):
|
||||
if ab.get("callbackUrl") == callback:
|
||||
print(f"abonnement already current: {ab['url']}")
|
||||
return
|
||||
call("DELETE", ab["url"])
|
||||
print(f"removed stale abonnement {ab['url']}")
|
||||
|
||||
status, ab = call("POST", f"{NRC}/api/v1/abonnement", {
|
||||
"callbackUrl": callback, "auth": SINK_AUTH,
|
||||
"kanalen": [{"naam": "zaken", "filters": {}}]})
|
||||
if status != 201:
|
||||
sys.exit(f"create abonnement -> {status}: {json.dumps(ab)}")
|
||||
print(f"abonnement registered: {ab['url']} -> {callback}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/bin/sh
|
||||
# Local-stack bootstrap (S-B04, #110, ADR-0020) — the "seed zaaktype + wire the ACL" step.
|
||||
#
|
||||
# Runs as the `local-seed` init container of infra/docker-compose.local.yml. It seeds + publishes
|
||||
# the BIG zaaktype (and the Diploma informatieobjecttype) into OpenZaak, then writes the resulting
|
||||
# **server-assigned** URLs into /out/acl.env, which the ACL entrypoint sources before starting. This
|
||||
# is the local-stack equivalent of what infra/run-domain-check.sh does for CI: the zaaktype UUID is
|
||||
# assigned by OpenZaak at creation, so it can't be a static value in the compose file.
|
||||
#
|
||||
# Why the container IP and not the `openzaak` service name: OpenZaak validates URL query params
|
||||
# (e.g. ?catalogus=) with Django's URLValidator, which rejects a single-label host like `openzaak`.
|
||||
# Seeding against the resolved IP keeps the seeded URLs valid AND host-consistent with the ACL, which
|
||||
# we point at the same IP below. See docs/runbooks/gitea-actions-gotchas.md and ADR-0020.
|
||||
set -eu
|
||||
|
||||
oz_ip="$(python3 -c "import socket;print(socket.gethostbyname('openzaak'))")"
|
||||
OZ_BASE="http://${oz_ip}:8000"
|
||||
export OZ_BASE OZ_PUBLISH=1
|
||||
|
||||
echo ">> seeding + publishing the BIG zaaktype at ${OZ_BASE} (idempotent)"
|
||||
out="$(python3 /work/seed_catalogus.py)"
|
||||
echo "$out"
|
||||
|
||||
# Sanity-check that the zaaktype was actually published (the ACL discovers it by identificatie, S-27).
|
||||
printf '%s\n' "$out" | grep -q '^ZAAKTYPE_URL ' || { echo "ERROR: seed did not publish the zaaktype" >&2; exit 1; }
|
||||
|
||||
# The ACL resolves the zaaktype/informatieobjecttype URLs itself (S-27, ADR-0021); the only value it
|
||||
# still needs injected is the OpenZaak base URL at a URL-valid host (the container IP), because OpenZaak
|
||||
# rejects a single-label host on zaak-create. The ACL entrypoint sources this.
|
||||
cat > /out/acl.env <<EOF
|
||||
Acl__OpenZaak__BaseUrl=${OZ_BASE}/
|
||||
EOF
|
||||
echo ">> wrote /out/acl.env (base=${OZ_BASE}/)"
|
||||
Binary file not shown.
@@ -30,21 +30,18 @@ oz_ip="$(ip "$oz")"; dom_ip="$(ip "$dom")"
|
||||
oz_base="http://$oz_ip:8000"
|
||||
echo ">> openzaak=$oz_ip domain=$dom_ip network=$net"
|
||||
|
||||
echo ">> seeding a published BIG zaaktype (idempotent) and capturing its URL"
|
||||
echo ">> seeding + publishing a BIG zaaktype (idempotent)"
|
||||
sid="$(docker create --network "$net" -e "OZ_BASE=$oz_base" -e OZ_PUBLISH=1 python:3-slim python /seed.py)"
|
||||
docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null
|
||||
seed_out="$(docker start -a "$sid")"
|
||||
zt_url="$(printf '%s\n' "$seed_out" | sed -n 's/^ZAAKTYPE_URL //p' | head -1)"
|
||||
iot_url="$(printf '%s\n' "$seed_out" | sed -n 's/^INFORMATIEOBJECTTYPE_URL //p' | head -1)"
|
||||
docker rm -f "$sid" >/dev/null
|
||||
[ -n "$zt_url" ] || { echo "ERROR: seed did not report a ZAAKTYPE_URL" >&2; exit 1; }
|
||||
[ -n "$iot_url" ] || { echo "ERROR: seed did not report an INFORMATIEOBJECTTYPE_URL" >&2; exit 1; }
|
||||
echo ">> zaaktype: $zt_url"
|
||||
echo ">> informatieobjecttype: $iot_url"
|
||||
printf '%s\n' "$seed_out" | grep -q '^ZAAKTYPE_URL ' || { echo "ERROR: seed did not publish the zaaktype" >&2; exit 1; }
|
||||
|
||||
echo ">> recreating the acl service pointed at the seeded zaaktype + informatieobjecttype (host-consistent)"
|
||||
ACL_ZAAKTYPE_URL="$zt_url" ACL_INFORMATIEOBJECTTYPE_URL="$iot_url" ACL_OPENZAAK_BASEURL="$oz_base/" \
|
||||
docker compose -f "$compose" up -d acl
|
||||
# The ACL resolves the zaaktype + informatieobjecttype by identificatie/omschrijving (S-27, ADR-0021),
|
||||
# so there is no URL to inject — only the OpenZaak base URL, pointed at the same host's container IP
|
||||
# (OpenZaak rejects a single-label host on zaak-create).
|
||||
echo ">> recreating the acl service pointed at OpenZaak's IP (it resolves the zaaktype itself, S-27)"
|
||||
ACL_OPENZAAK_BASEURL="$oz_base/" docker compose -f "$compose" up -d acl
|
||||
WAIT_TIMEOUT="${WAIT_TIMEOUT:-120}" bash "$here/wait-healthy.sh" acl
|
||||
|
||||
echo ">> submitting a registration to the domain"
|
||||
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Acceptance check for the local stack (S-B04, #110): a fresh `make local` must complete the whole
|
||||
# flow with NO manual seeding. Run against an already-up local stack (infra/docker-compose.local.yml)
|
||||
# via the host-published ports. It exercises, and thereby covers, the three bring-up gaps the slice
|
||||
# fixes:
|
||||
#
|
||||
# 1. zaaktype seeded + ACL wired -> a submitted registration opens a zaak (zaakUrl gets filled).
|
||||
# 2. diploma-eligibility DMN deployed -> providing documents completes WachtOpDocumenten, routes
|
||||
# through the DMN, and the case lands on Beoordelen (visible in the behandel werkbak).
|
||||
# 3. NRC abonnement registered -> the zaak shows up in the openbaar (public) register.
|
||||
#
|
||||
# Before the fix this fails at step 1 (ACL points at a placeholder zaaktype -> OpenZaak 400).
|
||||
set -euo pipefail
|
||||
|
||||
DOM=${DOM:-http://localhost:8130} # domain
|
||||
BFF=${BFF:-http://localhost:8080} # bff (openbaar register)
|
||||
BSN=${BSN:-123456782}
|
||||
# A minimal, valid PDF, base64-encoded (the diploma upload).
|
||||
PDF_B64="$(printf '%%PDF-1.4\n1 0 obj<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>\n%%%%EOF\n' | base64 | tr -d '\n')"
|
||||
|
||||
echo ">> 1. submit a registration (no manual seeding expected)"
|
||||
loc="$(curl -fsS -D - -o /dev/null -X POST "$DOM/registrations" \
|
||||
-H 'Content-Type: application/json' -d "{\"bsn\":\"$BSN\"}" \
|
||||
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
|
||||
[ -n "$loc" ] || { echo "FAIL: POST /registrations returned no Location" >&2; exit 1; }
|
||||
id="${loc##*/}"
|
||||
echo " accepted: $id"
|
||||
|
||||
echo ">> 2. poll until the ACL opens the zaak (proves the zaaktype is seeded + wired)"
|
||||
zaak=""
|
||||
for _ in $(seq 1 30); do
|
||||
zaak="$(curl -fsS "$DOM$loc" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("zaakUrl") or "")' 2>/dev/null || true)"
|
||||
[ -n "$zaak" ] && break
|
||||
sleep 3
|
||||
done
|
||||
[ -n "$zaak" ] || { echo "FAIL: zaak never opened — ACL zaaktype not wired (gap 1)" >&2; exit 1; }
|
||||
echo " zaak opened: $zaak"
|
||||
|
||||
echo ">> 3. provide documents (proves the diploma-eligibility DMN is deployed)"
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$DOM/registrations/$id/documents" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"bsn\":\"$BSN\",\"contentBase64\":\"$PDF_B64\",\"fileName\":\"diploma.pdf\",\"contentType\":\"application/pdf\"}")"
|
||||
[ "$code" = "204" ] || { echo "FAIL: provide documents -> $code (DMN missing routes WachtOpDocumenten to a 404 — gap 2)" >&2; exit 1; }
|
||||
echo " documents accepted (204)"
|
||||
|
||||
echo ">> 4. poll the werkbak until the registration awaits beoordeling (reached Beoordelen)"
|
||||
in_werkbak=""
|
||||
for _ in $(seq 1 20); do
|
||||
in_werkbak="$(curl -fsS "$DOM/behandel/werkbak" | python3 -c "import sys,json;print(any(r.get('registrationId')=='$id' for r in json.load(sys.stdin)))" 2>/dev/null || true)"
|
||||
[ "$in_werkbak" = "True" ] && break
|
||||
sleep 3
|
||||
done
|
||||
[ "$in_werkbak" = "True" ] || { echo "FAIL: registration never reached the werkbak (gap 2)" >&2; exit 1; }
|
||||
echo " in the werkbak"
|
||||
|
||||
echo ">> 5. poll the openbaar register until the reference is publicly visible (proves NRC abonnement)"
|
||||
public=""
|
||||
for _ in $(seq 1 30); do
|
||||
public="$(curl -fsS "$BFF/openbaar/register" | python3 -c "import sys,json;print(any(r.get('reference')=='$id' for r in json.load(sys.stdin)))" 2>/dev/null || true)"
|
||||
[ "$public" = "True" ] && break
|
||||
sleep 3
|
||||
done
|
||||
[ "$public" = "True" ] || { echo "FAIL: reference never appeared in the openbaar register — NRC abonnement not registered (gap 3)" >&2; exit 1; }
|
||||
echo " visible in the openbaar register"
|
||||
|
||||
echo "OK — a fresh local stack completed the flow with no manual seeding (zaaktype + DMN + abonnement)"
|
||||
@@ -11,6 +11,8 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
|
||||
.GetSection("Acl:OpenZaak").Get<OpenZaakOptions>()
|
||||
?? throw new InvalidOperationException("Missing configuration section 'Acl:OpenZaak'"));
|
||||
builder.Services.AddHttpClient<IZaakGateway, OpenZaakGateway>();
|
||||
// Singleton so the resolved zaaktype/informatieobjecttype URLs are cached across requests (S-27).
|
||||
builder.Services.AddSingleton<IZaaktypeCatalog, CachedZaaktypeCatalog>();
|
||||
builder.Services.AddScoped<AclService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -6,9 +6,12 @@ public sealed class AclDefaults
|
||||
public required string Bronorganisatie { get; init; }
|
||||
public required string VerantwoordelijkeOrganisatie { get; init; }
|
||||
public required string Vertrouwelijkheidaanduiding { get; init; }
|
||||
public required Uri ZaaktypeUrl { get; init; }
|
||||
|
||||
/// <summary>The informatieobjecttype an uploaded diploma is filed under (S-10b). Seeded in the
|
||||
/// catalogus and injected like <see cref="ZaaktypeUrl"/>.</summary>
|
||||
public required Uri InformatieobjecttypeUrl { get; init; }
|
||||
/// <summary>The BIG zaaktype's stable business key. The ACL resolves the (server-assigned) zaaktype
|
||||
/// URL from this via the Catalogi API instead of being handed a pinned URL (S-27, ADR-0021).</summary>
|
||||
public required string ZaaktypeIdentificatie { get; init; }
|
||||
|
||||
/// <summary>The omschrijving of the informatieobjecttype an uploaded diploma is filed under (S-10b);
|
||||
/// resolved to a URL by the Catalogi API, like <see cref="ZaaktypeIdentificatie"/>.</summary>
|
||||
public required string InformatieobjecttypeOmschrijving { get; init; }
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ namespace Acl.Application;
|
||||
|
||||
/// <summary>The ACL's single operation: open a zaak from a domain payload,
|
||||
/// default-filling the ZGW-mandatory fields (ADR-0003).</summary>
|
||||
public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IClock clock)
|
||||
public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaaktypeCatalog catalog, IClock clock)
|
||||
{
|
||||
public Task<Uri> OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default)
|
||||
public async Task<Uri> OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registration);
|
||||
|
||||
@@ -12,34 +12,34 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
|
||||
defaults.Bronorganisatie,
|
||||
defaults.VerantwoordelijkeOrganisatie,
|
||||
defaults.Vertrouwelijkheidaanduiding,
|
||||
defaults.ZaaktypeUrl,
|
||||
await catalog.GetZaaktypeUrlAsync(ct),
|
||||
clock.Today,
|
||||
registration.Reference);
|
||||
|
||||
return gateway.OpenZaakAsync(request, ct);
|
||||
return await gateway.OpenZaakAsync(request, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Approve a zaak: set it to the eindstatus of the configured BIG zaaktype (ADR-0003 default). The
|
||||
/// domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1).
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||
public async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||
|
||||
return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
|
||||
await gateway.SetZaakToEindstatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancel a zaak on document-timeout expiry (S-10c): set it to the configured BIG zaaktype's
|
||||
/// cancellation statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which
|
||||
/// 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 means "cancelled" (§8.1).
|
||||
/// </summary>
|
||||
public Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||
public async Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||
|
||||
return gateway.SetZaakToCancellationStatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
|
||||
await gateway.SetZaakToCancellationStatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct);
|
||||
}
|
||||
|
||||
/// <summary>The zaak's reference (its ZGW identificatie), for the read projection (#78).</summary>
|
||||
@@ -56,7 +56,7 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
|
||||
/// and hand the file to the gateway, which creates the informatieobject and relates it to the zaak.
|
||||
/// The domain supplies only the zaak, the bytes, and the file's name/type (§8.1).
|
||||
/// </summary>
|
||||
public Task<Uri> StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default)
|
||||
public async Task<Uri> StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
@@ -65,7 +65,7 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
|
||||
|
||||
var request = new DocumentRequest(
|
||||
defaults.Bronorganisatie,
|
||||
defaults.InformatieobjecttypeUrl,
|
||||
await catalog.GetInformatieobjecttypeUrlAsync(ct),
|
||||
defaults.Vertrouwelijkheidaanduiding,
|
||||
zaakUrl,
|
||||
clock.Today,
|
||||
@@ -76,6 +76,6 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
|
||||
Formaat: contentType,
|
||||
Inhoud: content);
|
||||
|
||||
return gateway.StoreDocumentAsync(request, ct);
|
||||
return await gateway.StoreDocumentAsync(request, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace Acl.Application;
|
||||
|
||||
/// <summary>Resolves the zaaktype + diploma-informatieobjecttype URLs from the Catalogi API on first
|
||||
/// use and caches them for the process lifetime (S-27, ADR-0021). Lazy (not at startup) so the ACL
|
||||
/// never crash-loops when it boots before the catalogus is seeded/published; a <em>failed</em>
|
||||
/// resolution is not cached, so it is retried on the next call (e.g. once the zaaktype is published).
|
||||
/// A process restart re-resolves.</summary>
|
||||
public sealed class CachedZaaktypeCatalog(IZaakGateway gateway, AclDefaults defaults) : IZaaktypeCatalog
|
||||
{
|
||||
private readonly SemaphoreSlim gate = new(1, 1);
|
||||
private Uri? zaaktype;
|
||||
private Uri? informatieobjecttype;
|
||||
|
||||
public Task<Uri> GetZaaktypeUrlAsync(CancellationToken ct = default) =>
|
||||
ResolveOnceAsync(
|
||||
() => zaaktype, value => zaaktype = value,
|
||||
() => gateway.ResolveZaaktypeUrlAsync(defaults.ZaaktypeIdentificatie, ct), ct);
|
||||
|
||||
public Task<Uri> GetInformatieobjecttypeUrlAsync(CancellationToken ct = default) =>
|
||||
ResolveOnceAsync(
|
||||
() => informatieobjecttype, value => informatieobjecttype = value,
|
||||
() => gateway.ResolveInformatieobjecttypeUrlAsync(defaults.InformatieobjecttypeOmschrijving, ct), ct);
|
||||
|
||||
// Double-checked, single-flight resolution: return the cache if set; otherwise resolve under the
|
||||
// gate and cache only on success (a throw leaves the cache empty so the next call retries).
|
||||
private async Task<Uri> ResolveOnceAsync(Func<Uri?> read, Action<Uri> store, Func<Task<Uri>> resolve, CancellationToken ct)
|
||||
{
|
||||
if (read() is { } cached)
|
||||
return cached;
|
||||
|
||||
await gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (read() is { } existing)
|
||||
return existing;
|
||||
|
||||
var resolved = await resolve();
|
||||
store(resolved);
|
||||
return resolved;
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,4 +32,12 @@ public interface IZaakGateway
|
||||
/// the created informatieobject.
|
||||
/// </summary>
|
||||
Task<Uri> StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Resolve the URL of the published zaaktype with the given <paramref name="identificatie"/>
|
||||
/// from the Catalogi API (S-27). Throws if no published zaaktype matches.</summary>
|
||||
Task<Uri> ResolveZaaktypeUrlAsync(string identificatie, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Resolve the URL of the published informatieobjecttype with the given
|
||||
/// <paramref name="omschrijving"/> from the Catalogi API (S-27). Throws if none matches.</summary>
|
||||
Task<Uri> ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Acl.Application;
|
||||
|
||||
/// <summary>Supplies the ACL's zaaktype + diploma-informatieobjecttype URLs, resolved from OpenZaak's
|
||||
/// Catalogi API by their stable business keys (<see cref="AclDefaults.ZaaktypeIdentificatie"/> /
|
||||
/// <see cref="AclDefaults.InformatieobjecttypeOmschrijving"/>) rather than pinned in config (S-27,
|
||||
/// ADR-0021). Implementations resolve lazily on first use and cache the result.</summary>
|
||||
public interface IZaaktypeCatalog
|
||||
{
|
||||
Task<Uri> GetZaaktypeUrlAsync(CancellationToken ct = default);
|
||||
|
||||
Task<Uri> GetInformatieobjecttypeUrlAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -142,6 +142,48 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
||||
return created;
|
||||
}
|
||||
|
||||
public async Task<Uri> ResolveZaaktypeUrlAsync(string identificatie, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(identificatie);
|
||||
|
||||
// The published zaaktype with this identificatie; status=definitief excludes concepts.
|
||||
var page = await GetAsync<ZaaktypePage>(
|
||||
"/catalogi/api/v1/zaaktypen?status=definitief&identificatie=" + Uri.EscapeDataString(identificatie),
|
||||
"zaaktypen", ct);
|
||||
var match = (page.Results ?? []).FirstOrDefault()
|
||||
?? throw new InvalidOperationException(
|
||||
$"No published zaaktype with identificatie '{identificatie}' found in OpenZaak — is the BIG catalogus seeded and published?");
|
||||
return new Uri(match.Url);
|
||||
}
|
||||
|
||||
public async Task<Uri> ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(omschrijving);
|
||||
|
||||
// The informatieobjecttypen collection has no omschrijving filter, so match client-side over the
|
||||
// published ones.
|
||||
var page = await GetAsync<InformatieobjecttypePage>(
|
||||
"/catalogi/api/v1/informatieobjecttypen?status=definitief", "informatieobjecttypen", ct);
|
||||
var match = (page.Results ?? []).FirstOrDefault(i => i.Omschrijving == omschrijving)
|
||||
?? throw new InvalidOperationException(
|
||||
$"No published informatieobjecttype '{omschrijving}' found in OpenZaak — is the BIG catalogus seeded and published?");
|
||||
return new Uri(match.Url);
|
||||
}
|
||||
|
||||
// GETs an absolute-by-path ZGW resource with auth (no CRS — catalogi is not a geo API).
|
||||
private async Task<T> GetAsync<T>(string pathAndQuery, string label, CancellationToken ct)
|
||||
{
|
||||
using var message = new HttpRequestMessage(HttpMethod.Get, new Uri(options.BaseUrl, pathAndQuery));
|
||||
message.Headers.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
|
||||
|
||||
using var response = await http.SendAsync(message, ct);
|
||||
await EnsureSuccessAsync(response, $"Querying {label}", ct);
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<T>(ct)
|
||||
?? throw new InvalidOperationException($"OpenZaak returned an empty {label} response");
|
||||
}
|
||||
|
||||
// POSTs a non-geo ZGW resource (resultaat/status — no CRS headers). Buffers the body so uwsgi gets
|
||||
// a Content-Length instead of a chunked body (as with zaak-create).
|
||||
private async Task PostAsync(string path, object dto, string action, CancellationToken ct)
|
||||
@@ -298,4 +340,18 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
||||
private sealed record ZaakInformatieobjectDto(
|
||||
[property: JsonPropertyName("zaak")] string Zaak,
|
||||
[property: JsonPropertyName("informatieobject")] string Informatieobject);
|
||||
|
||||
private sealed record ZaaktypePage(
|
||||
[property: JsonPropertyName("results")] IReadOnlyList<ZaaktypeDto>? Results);
|
||||
|
||||
private sealed record ZaaktypeDto(
|
||||
[property: JsonPropertyName("url")] string Url,
|
||||
[property: JsonPropertyName("identificatie")] string? Identificatie);
|
||||
|
||||
private sealed record InformatieobjecttypePage(
|
||||
[property: JsonPropertyName("results")] IReadOnlyList<InformatieobjecttypeDto>? Results);
|
||||
|
||||
private sealed record InformatieobjecttypeDto(
|
||||
[property: JsonPropertyName("url")] string Url,
|
||||
[property: JsonPropertyName("omschrijving")] string? Omschrijving);
|
||||
}
|
||||
|
||||
@@ -161,4 +161,32 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
|
||||
Assert.Contains(relations.EnumerateArray(),
|
||||
r => r.GetProperty("zaak").GetString() == zaakUrl.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolves_the_published_zaaktype_and_diploma_informatieobjecttype_by_business_key()
|
||||
{
|
||||
var expectedZaaktype = await stack.FindPublishedBigZaaktypeAsync();
|
||||
Assert.True(expectedZaaktype is not null,
|
||||
"No published BIG-REGISTRATIE zaaktype found — seed the stack with OZ_PUBLISH=1.");
|
||||
var expectedInformatieobjecttype = await stack.FindPublishedDiplomaInformatieobjecttypeAsync();
|
||||
Assert.True(expectedInformatieobjecttype is not null,
|
||||
"No published Diploma informatieobjecttype found — seed the stack with OZ_PUBLISH=1.");
|
||||
|
||||
var gateway = new OpenZaakGateway(stack.Http, stack.Options);
|
||||
|
||||
// The ACL discovers both URLs from the live Catalogi API by their stable business keys (S-27),
|
||||
// matching what the fixture found independently — no pinned URL needed.
|
||||
Assert.Equal(expectedZaaktype, await gateway.ResolveZaaktypeUrlAsync("BIG-REGISTRATIE"));
|
||||
Assert.Equal(expectedInformatieobjecttype, await gateway.ResolveInformatieobjecttypeUrlAsync("Diploma"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolving_an_unknown_zaaktype_identificatie_throws_a_clear_error()
|
||||
{
|
||||
var gateway = new OpenZaakGateway(stack.Http, stack.Options);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => gateway.ResolveZaaktypeUrlAsync("NO-SUCH-ZAAKTYPE"));
|
||||
Assert.Contains("NO-SUCH-ZAAKTYPE", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,12 @@ public class AclServiceTests
|
||||
{
|
||||
private sealed class FakeGateway : IZaakGateway
|
||||
{
|
||||
// The URLs the catalogus resolves the configured identificatie/omschrijving to (S-27).
|
||||
public Uri ResolvedZaaktype { get; } = new("http://openzaak/catalogi/api/v1/zaaktypen/big");
|
||||
public Uri ResolvedInformatieobjecttype { get; } = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip");
|
||||
public string? ResolvedByIdentificatie;
|
||||
public string? ResolvedByOmschrijving;
|
||||
|
||||
public ZaakRequest? Captured;
|
||||
public Uri Result { get; } = new("http://openzaak/zaken/api/v1/zaken/abc");
|
||||
|
||||
@@ -47,6 +53,18 @@ public class AclServiceTests
|
||||
StoredDocument = request;
|
||||
return Task.FromResult(DocumentResult);
|
||||
}
|
||||
|
||||
public Task<Uri> ResolveZaaktypeUrlAsync(string identificatie, CancellationToken ct = default)
|
||||
{
|
||||
ResolvedByIdentificatie = identificatie;
|
||||
return Task.FromResult(ResolvedZaaktype);
|
||||
}
|
||||
|
||||
public Task<Uri> ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default)
|
||||
{
|
||||
ResolvedByOmschrijving = omschrijving;
|
||||
return Task.FromResult(ResolvedInformatieobjecttype);
|
||||
}
|
||||
}
|
||||
|
||||
private static AclDefaults Defaults() => new()
|
||||
@@ -54,28 +72,23 @@ public class AclServiceTests
|
||||
Bronorganisatie = "517439943",
|
||||
VerantwoordelijkeOrganisatie = "517439943",
|
||||
Vertrouwelijkheidaanduiding = "openbaar",
|
||||
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
|
||||
InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"),
|
||||
ZaaktypeIdentificatie = "BIG-REGISTRATIE",
|
||||
InformatieobjecttypeOmschrijving = "Diploma",
|
||||
};
|
||||
|
||||
private static AclService ServiceWith(FakeGateway gateway, AclDefaults defaults, DateOnly today) =>
|
||||
new(gateway, defaults, new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today));
|
||||
|
||||
private sealed class FixedClock(DateOnly today) : IClock
|
||||
{
|
||||
public DateOnly Today { get; } = today;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Opening_a_zaak_default_fills_zgw_fields_and_returns_the_zaak_url()
|
||||
public async Task Opening_a_zaak_default_fills_zgw_fields_and_uses_the_resolved_zaaktype()
|
||||
{
|
||||
var gateway = new FakeGateway();
|
||||
var defaults = new AclDefaults
|
||||
{
|
||||
Bronorganisatie = "517439943",
|
||||
VerantwoordelijkeOrganisatie = "517439943",
|
||||
Vertrouwelijkheidaanduiding = "openbaar",
|
||||
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
|
||||
InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"),
|
||||
};
|
||||
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
|
||||
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||
|
||||
var url = await service.OpenZaakAsync(new DomainRegistration("123456782", "reg-77"));
|
||||
|
||||
@@ -84,7 +97,9 @@ public class AclServiceTests
|
||||
Assert.Equal("517439943", req.Bronorganisatie);
|
||||
Assert.Equal("517439943", req.VerantwoordelijkeOrganisatie);
|
||||
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding);
|
||||
Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype);
|
||||
// The zaaktype is resolved from the configured identificatie, not a pinned URL (S-27).
|
||||
Assert.Equal("BIG-REGISTRATIE", gateway.ResolvedByIdentificatie);
|
||||
Assert.Equal(gateway.ResolvedZaaktype, req.Zaaktype);
|
||||
Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum);
|
||||
// The registration reference becomes the zaak identificatie (#78).
|
||||
Assert.Equal("reg-77", req.Identificatie);
|
||||
@@ -94,33 +109,24 @@ public class AclServiceTests
|
||||
public async Task Rejects_a_null_registration_without_calling_the_gateway()
|
||||
{
|
||||
var gateway = new FakeGateway();
|
||||
var defaults = new AclDefaults
|
||||
{
|
||||
Bronorganisatie = "517439943",
|
||||
VerantwoordelijkeOrganisatie = "517439943",
|
||||
Vertrouwelijkheidaanduiding = "openbaar",
|
||||
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
|
||||
InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"),
|
||||
};
|
||||
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
|
||||
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.OpenZaakAsync(null!));
|
||||
Assert.Null(gateway.Captured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Approving_a_zaak_sets_it_to_its_zaaktypes_eindstatus_dated_today()
|
||||
public async Task Approving_a_zaak_sets_it_to_its_resolved_zaaktypes_eindstatus_dated_today()
|
||||
{
|
||||
var gateway = new FakeGateway();
|
||||
var defaults = Defaults();
|
||||
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
|
||||
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
|
||||
|
||||
await service.ApproveZaakAsync(zaak);
|
||||
|
||||
Assert.NotNull(gateway.Approved);
|
||||
Assert.Equal(zaak, gateway.Approved!.Value.Zaak);
|
||||
Assert.Equal(defaults.ZaaktypeUrl, gateway.Approved.Value.Zaaktype);
|
||||
Assert.Equal(gateway.ResolvedZaaktype, gateway.Approved.Value.Zaaktype);
|
||||
Assert.Equal(new DateOnly(2026, 6, 4), gateway.Approved.Value.Datum);
|
||||
}
|
||||
|
||||
@@ -128,7 +134,7 @@ public class AclServiceTests
|
||||
public async Task Approving_a_null_zaak_is_rejected_without_touching_the_gateway()
|
||||
{
|
||||
var gateway = new FakeGateway();
|
||||
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
||||
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!));
|
||||
Assert.Null(gateway.Approved);
|
||||
@@ -138,15 +144,14 @@ public class AclServiceTests
|
||||
public async Task Cancelling_a_zaak_sets_it_to_the_cancellation_status_dated_today()
|
||||
{
|
||||
var gateway = new FakeGateway();
|
||||
var defaults = Defaults();
|
||||
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
|
||||
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
|
||||
|
||||
await service.CancelZaakAsync(zaak);
|
||||
|
||||
Assert.NotNull(gateway.Cancelled);
|
||||
Assert.Equal(zaak, gateway.Cancelled!.Value.Zaak);
|
||||
Assert.Equal(defaults.ZaaktypeUrl, gateway.Cancelled.Value.Zaaktype);
|
||||
Assert.Equal(gateway.ResolvedZaaktype, gateway.Cancelled.Value.Zaaktype);
|
||||
Assert.Equal(new DateOnly(2026, 6, 4), gateway.Cancelled.Value.Datum);
|
||||
// Cancellation must not touch the approval path.
|
||||
Assert.Null(gateway.Approved);
|
||||
@@ -156,18 +161,17 @@ public class AclServiceTests
|
||||
public async Task Cancelling_a_null_zaak_is_rejected_without_touching_the_gateway()
|
||||
{
|
||||
var gateway = new FakeGateway();
|
||||
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
||||
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.CancelZaakAsync(null!));
|
||||
Assert.Null(gateway.Cancelled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Storing_a_diploma_default_fills_the_document_fields_and_returns_its_url()
|
||||
public async Task Storing_a_diploma_default_fills_the_document_fields_and_uses_the_resolved_informatieobjecttype()
|
||||
{
|
||||
var gateway = new FakeGateway();
|
||||
var defaults = Defaults();
|
||||
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
|
||||
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
|
||||
|
||||
var url = await service.StoreDiplomaAsync(zaak, [1, 2, 3], "diploma.pdf", "application/pdf");
|
||||
@@ -175,7 +179,9 @@ public class AclServiceTests
|
||||
Assert.Equal(gateway.DocumentResult, url);
|
||||
var req = gateway.StoredDocument!;
|
||||
Assert.Equal(zaak, req.Zaak);
|
||||
Assert.Equal(defaults.InformatieobjecttypeUrl, req.Informatieobjecttype);
|
||||
// The informatieobjecttype is resolved from the configured omschrijving (S-27).
|
||||
Assert.Equal("Diploma", gateway.ResolvedByOmschrijving);
|
||||
Assert.Equal(gateway.ResolvedInformatieobjecttype, req.Informatieobjecttype);
|
||||
Assert.Equal("517439943", req.Bronorganisatie);
|
||||
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding);
|
||||
Assert.Equal(new DateOnly(2026, 6, 4), req.Creatiedatum);
|
||||
@@ -188,7 +194,7 @@ public class AclServiceTests
|
||||
[Fact]
|
||||
public async Task Storing_a_diploma_rejects_null_or_blank_arguments()
|
||||
{
|
||||
var service = new AclService(new FakeGateway(), Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
||||
var service = ServiceWith(new FakeGateway(), Defaults(), new DateOnly(2026, 6, 4));
|
||||
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.StoreDiplomaAsync(null!, [1], "d.pdf", "application/pdf"));
|
||||
@@ -201,7 +207,7 @@ public class AclServiceTests
|
||||
public async Task Reading_a_zaak_reference_returns_the_zaaks_identificatie()
|
||||
{
|
||||
var gateway = new FakeGateway();
|
||||
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
||||
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
|
||||
|
||||
var reference = await service.GetZaakReferenceAsync(zaak);
|
||||
@@ -214,7 +220,7 @@ public class AclServiceTests
|
||||
public async Task Reading_a_null_zaak_reference_is_rejected()
|
||||
{
|
||||
var gateway = new FakeGateway();
|
||||
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
||||
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.GetZaakReferenceAsync(null!));
|
||||
Assert.Null(gateway.ReadReferenceFor);
|
||||
|
||||
@@ -675,4 +675,153 @@ public class OpenZaakGatewayTests
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => Gateway(handler).StoreDocumentAsync(null!));
|
||||
}
|
||||
|
||||
// ── Catalogi resolution by business key (S-27) ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Resolves_the_published_zaaktype_url_by_identificatie()
|
||||
{
|
||||
HttpRequestMessage? seen = null;
|
||||
var handler = new StubHandler(req =>
|
||||
{
|
||||
seen = req;
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = JsonContent.Create(new
|
||||
{
|
||||
results = new[] { new { url = "http://openzaak/catalogi/api/v1/zaaktypen/big", identificatie = "BIG-REGISTRATIE" } },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
var url = await Gateway(handler).ResolveZaaktypeUrlAsync("BIG-REGISTRATIE");
|
||||
|
||||
Assert.Equal("http://openzaak/catalogi/api/v1/zaaktypen/big", url.ToString());
|
||||
Assert.Equal(HttpMethod.Get, seen!.Method);
|
||||
// Filters to the published zaaktype with that identificatie, and authenticates.
|
||||
Assert.Contains("/catalogi/api/v1/zaaktypen", seen.RequestUri!.ToString());
|
||||
Assert.Contains("status=definitief", seen.RequestUri!.Query);
|
||||
Assert.Contains("identificatie=BIG-REGISTRATIE", seen.RequestUri!.Query);
|
||||
Assert.Equal("Bearer", seen.Headers.Authorization!.Scheme);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolving_a_zaaktype_throws_a_clear_error_when_none_is_published()
|
||||
{
|
||||
var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = JsonContent.Create(new { results = Array.Empty<object>() }),
|
||||
}));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => Gateway(handler).ResolveZaaktypeUrlAsync("BIG-REGISTRATIE"));
|
||||
Assert.Contains("BIG-REGISTRATIE", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolves_the_informatieobjecttype_url_by_omschrijving()
|
||||
{
|
||||
HttpRequestMessage? seen = null;
|
||||
var handler = new StubHandler(req =>
|
||||
{
|
||||
seen = req;
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = JsonContent.Create(new
|
||||
{
|
||||
results = new[]
|
||||
{
|
||||
new { url = "http://openzaak/catalogi/api/v1/informatieobjecttypen/other", omschrijving = "Overig" },
|
||||
new { url = "http://openzaak/catalogi/api/v1/informatieobjecttypen/dip", omschrijving = "Diploma" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
var url = await Gateway(handler).ResolveInformatieobjecttypeUrlAsync("Diploma");
|
||||
|
||||
// Queries the published informatieobjecttypen collection, and matches on omschrijving (not position).
|
||||
Assert.Contains("/catalogi/api/v1/informatieobjecttypen", seen!.RequestUri!.ToString());
|
||||
Assert.Contains("status=definitief", seen.RequestUri!.Query);
|
||||
Assert.Equal("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip", url.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolving_a_zaaktype_throws_when_the_response_carries_no_results()
|
||||
{
|
||||
// No "results" property → the page's Results is null; the gateway must treat that as "none
|
||||
// found" (not dereference null).
|
||||
var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = JsonContent.Create(new { count = 0 }),
|
||||
}));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => Gateway(handler).ResolveZaaktypeUrlAsync("BIG-REGISTRATIE"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolving_an_informatieobjecttype_throws_when_the_response_carries_no_results()
|
||||
{
|
||||
var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = JsonContent.Create(new { count = 0 }),
|
||||
}));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => Gateway(handler).ResolveInformatieobjecttypeUrlAsync("Diploma"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolving_a_zaaktype_surfaces_a_non_success_catalogi_response()
|
||||
{
|
||||
var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
{
|
||||
Content = new StringContent("boom"),
|
||||
}));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => Gateway(handler).ResolveZaaktypeUrlAsync("BIG-REGISTRATIE"));
|
||||
// The error names the resource being queried and includes OpenZaak's body.
|
||||
Assert.Contains("zaaktypen", ex.Message);
|
||||
Assert.Contains("boom", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolving_an_informatieobjecttype_surfaces_a_non_success_catalogi_response()
|
||||
{
|
||||
var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
{
|
||||
Content = new StringContent("boom"),
|
||||
}));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => Gateway(handler).ResolveInformatieobjecttypeUrlAsync("Diploma"));
|
||||
Assert.Contains("informatieobjecttypen", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolving_an_informatieobjecttype_throws_when_no_omschrijving_matches()
|
||||
{
|
||||
var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = JsonContent.Create(new
|
||||
{
|
||||
results = new[] { new { url = "http://openzaak/catalogi/api/v1/informatieobjecttypen/other", omschrijving = "Overig" } },
|
||||
}),
|
||||
}));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => Gateway(handler).ResolveInformatieobjecttypeUrlAsync("Diploma"));
|
||||
Assert.Contains("Diploma", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolving_rejects_a_blank_business_key_without_calling_openzaak()
|
||||
{
|
||||
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
|
||||
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(() => Gateway(handler).ResolveZaaktypeUrlAsync(" "));
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(() => Gateway(handler).ResolveInformatieobjecttypeUrlAsync(" "));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using Acl.Application;
|
||||
|
||||
namespace Acl.Tests;
|
||||
|
||||
public class ZaaktypeCatalogTests
|
||||
{
|
||||
// A gateway that only supports resolution; the other members are unused here.
|
||||
private sealed class ResolvingGateway : IZaakGateway
|
||||
{
|
||||
public int ZaaktypeCalls;
|
||||
public int InformatieobjecttypeCalls;
|
||||
public string? LastIdentificatie;
|
||||
public string? LastOmschrijving;
|
||||
public int ThrowZaaktypeTimes;
|
||||
public Uri ZaaktypeUrl { get; } = new("http://openzaak/catalogi/api/v1/zaaktypen/big");
|
||||
public Uri InformatieobjecttypeUrl { get; } = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip");
|
||||
|
||||
public Task<Uri> ResolveZaaktypeUrlAsync(string identificatie, CancellationToken ct = default)
|
||||
{
|
||||
ZaaktypeCalls++;
|
||||
LastIdentificatie = identificatie;
|
||||
if (ZaaktypeCalls <= ThrowZaaktypeTimes)
|
||||
throw new InvalidOperationException("no published zaaktype yet");
|
||||
return Task.FromResult(ZaaktypeUrl);
|
||||
}
|
||||
|
||||
public Task<Uri> ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default)
|
||||
{
|
||||
InformatieobjecttypeCalls++;
|
||||
LastOmschrijving = omschrijving;
|
||||
return Task.FromResult(InformatieobjecttypeUrl);
|
||||
}
|
||||
|
||||
public Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task SetZaakToEindstatusAsync(Uri z, Uri zt, DateOnly d, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task SetZaakToCancellationStatusAsync(Uri z, Uri zt, DateOnly d, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<string> GetZaakIdentificatieAsync(Uri z, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<Uri> StoreDocumentAsync(DocumentRequest r, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private static AclDefaults Defaults() => new()
|
||||
{
|
||||
Bronorganisatie = "517439943",
|
||||
VerantwoordelijkeOrganisatie = "517439943",
|
||||
Vertrouwelijkheidaanduiding = "openbaar",
|
||||
ZaaktypeIdentificatie = "BIG-REGISTRATIE",
|
||||
InformatieobjecttypeOmschrijving = "Diploma",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Resolves_the_zaaktype_and_informatieobjecttype_by_their_configured_business_keys()
|
||||
{
|
||||
var gateway = new ResolvingGateway();
|
||||
var catalog = new CachedZaaktypeCatalog(gateway, Defaults());
|
||||
|
||||
Assert.Equal(gateway.ZaaktypeUrl, await catalog.GetZaaktypeUrlAsync());
|
||||
Assert.Equal(gateway.InformatieobjecttypeUrl, await catalog.GetInformatieobjecttypeUrlAsync());
|
||||
Assert.Equal("BIG-REGISTRATIE", gateway.LastIdentificatie);
|
||||
Assert.Equal("Diploma", gateway.LastOmschrijving);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Caches_the_resolved_urls_so_the_gateway_is_hit_once()
|
||||
{
|
||||
var gateway = new ResolvingGateway();
|
||||
var catalog = new CachedZaaktypeCatalog(gateway, Defaults());
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
await catalog.GetZaaktypeUrlAsync();
|
||||
await catalog.GetInformatieobjecttypeUrlAsync();
|
||||
}
|
||||
|
||||
Assert.Equal(1, gateway.ZaaktypeCalls);
|
||||
Assert.Equal(1, gateway.InformatieobjecttypeCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Does_not_cache_a_failed_resolution_so_it_is_retried()
|
||||
{
|
||||
// The zaaktype is not published yet on the first call; the catalog must retry (not cache the
|
||||
// failure) so a later call succeeds once it is published.
|
||||
var gateway = new ResolvingGateway { ThrowZaaktypeTimes = 1 };
|
||||
var catalog = new CachedZaaktypeCatalog(gateway, Defaults());
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => catalog.GetZaaktypeUrlAsync());
|
||||
var url = await catalog.GetZaaktypeUrlAsync();
|
||||
|
||||
Assert.Equal(gateway.ZaaktypeUrl, url);
|
||||
Assert.Equal(2, gateway.ZaaktypeCalls);
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,12 @@ public sealed class EenZaakOpenenSteps
|
||||
Bronorganisatie = values["bronorganisatie"],
|
||||
VerantwoordelijkeOrganisatie = values["verantwoordelijkeOrganisatie"],
|
||||
Vertrouwelijkheidaanduiding = values["vertrouwelijkheidaanduiding"],
|
||||
ZaaktypeUrl = new Uri(values["zaaktype"]),
|
||||
InformatieobjecttypeUrl = new Uri("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"),
|
||||
ZaaktypeIdentificatie = "BIG-REGISTRATIE",
|
||||
InformatieobjecttypeOmschrijving = "Diploma",
|
||||
};
|
||||
// The ACL resolves the zaaktype by identificatie (S-27); the scenario's zaaktype URL is what
|
||||
// the catalogus resolves it to, so the created zaak still carries that URL.
|
||||
_gateway.ResolvedZaaktypeUrl = new Uri(values["zaaktype"]);
|
||||
}
|
||||
|
||||
[Given("today is \"(.*)\"")]
|
||||
@@ -41,7 +44,7 @@ public sealed class EenZaakOpenenSteps
|
||||
[When("the domain asks the ACL to open a zaak")]
|
||||
public async Task WhenTheDomainAsksTheAclToOpenAZaak()
|
||||
{
|
||||
var service = new AclService(_gateway, _defaults!, new FixedClock(_today));
|
||||
var service = new AclService(_gateway, _defaults!, new CachedZaaktypeCatalog(_gateway, _defaults!), new FixedClock(_today));
|
||||
_returnedUrl = await service.OpenZaakAsync(_registration!);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ public sealed class InMemoryZaakGateway : IZaakGateway
|
||||
public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Approved { get; private set; }
|
||||
public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Cancelled { get; private set; }
|
||||
|
||||
// The URLs the catalogus resolves the configured identificatie/omschrijving to (S-27); settable so
|
||||
// a scenario can pin the zaaktype the ACL should default-fill.
|
||||
public Uri ResolvedZaaktypeUrl { get; set; } = new("http://openzaak/catalogi/api/v1/zaaktypen/big");
|
||||
public Uri ResolvedInformatieobjecttypeUrl { get; set; } = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip");
|
||||
|
||||
public Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Captured = request;
|
||||
@@ -37,4 +42,10 @@ public sealed class InMemoryZaakGateway : IZaakGateway
|
||||
|
||||
public Task<Uri> StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default)
|
||||
=> Task.FromResult(new Uri("http://openzaak/documenten/api/v1/enkelvoudiginformatieobjecten/acc-doc"));
|
||||
|
||||
public Task<Uri> ResolveZaaktypeUrlAsync(string identificatie, CancellationToken ct = default)
|
||||
=> Task.FromResult(ResolvedZaaktypeUrl);
|
||||
|
||||
public Task<Uri> ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default)
|
||||
=> Task.FromResult(ResolvedInformatieobjecttypeUrl);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user