Compare commits

..
Author SHA1 Message Date
notandClaude Opus 4.8 25b593ec3e test(e2e): isolate the self-service specs with dedicated DigiD users (refs #111)
CI / frontend (pull_request) Successful in 2m37s
CI / lint (pull_request) Successful in 1m17s
CI / build (pull_request) Successful in 59s
CI / unit (pull_request) Successful in 1m13s
CI / mutation (pull_request) Successful in 5m50s
CI / verify-stack (pull_request) Successful in 11m50s
Resume-on-load (S-26) restores any open registration for the logged-in bsn, so on the
shared verify stack the specs can no longer share jan-burger: verify-domain submits as
jan-burger (123456782) before the e2e, and that open registration was being resumed on
login. Each self-service spec now uses its own citizen — registration→emma-burger,
resume→sanne-burger, withdrawal→lars-burger — none touched by the verify-* checks or
each other. jan-burger stays the documented citizen for the API-level verify checks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 18:05:33 +02:00
notandClaude Opus 4.8 c25ec24c73 feat(self-service): resume an existing registration after refresh (refs #111)
Frontend half of S-26. registration-page asks the BFF for the caller's current open
registration on init (regenerated api-client → getSelfServiceRegistrations) and
restores the submitted view — reference + the documenten/withdraw actions — instead
of dropping back to the blank form after a refresh; 204 (none) shows the form as
before. Component test covers resume-on-load; a Playwright e2e submits, reloads, and
asserts the reference + actions persist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 17:33:28 +02:00
notandClaude Opus 4.8 24927b1e80 feat(domain,bff): current-registration lookup for self-service resume (refs #111)
Backend half of S-26. The domain gains IRegistrationStore.FindOpenByBsnAsync (the
citizen's non-terminal INGEDIEND/IN_BEHANDELING registration) + GET
/registrations/current?bsn=; the BFF adds owner-scoped GET /self-service/registrations
(bsn from the DigiD token) returning that registration or 204 when none. Regenerated
services/bff/openapi.json for the new endpoint (the api-client is generated from it).
Store + BFF endpoint unit tests; acceptance/BFF fakes updated for the new members.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 17:04:35 +02:00
not 5de8c1e292 feat(acl): resolve the zaaktype by identificatie, not a pinned URL (S-27, closes #113) (#118)
CI / lint (push) Successful in 1m21s
CI / build (push) Successful in 58s
CI / unit (push) Successful in 1m7s
CI / frontend (push) Successful in 2m36s
CI / mutation (push) Successful in 5m36s
CI / verify-stack (push) Successful in 8m4s
## What & why

The ACL was handed a **pinned zaaktype URL** (`Acl__Defaults__ZaaktypeUrl`) + informatieobjecttype URL. OpenZaak assigns those UUIDs at creation, so every stack had to seed the catalogus and then capture + inject the resulting URLs out of band (CI's `run-domain-check.sh`; the local `local-seed`→`acl.env` bootstrap from ADR-0020). Brittle, and a stale/placeholder URL failed opaquely (OpenZaak 400).

Now **the ACL resolves them itself** from OpenZaak's Catalogi API by stable business key:
- config `ZaaktypeIdentificatie` (`BIG-REGISTRATIE`) / `InformatieobjecttypeOmschrijving` (`Diploma`);
- a `CachedZaaktypeCatalog` resolves **lazily on first use** and caches (success only, so a pre-publish miss is retried — no startup ordering coupling);
- a clear "No published … found" error replaces the opaque placeholder 400.

Design in **ADR-0021** (proposed in #117).

Closes #113
Closes #117

## Consequences (the payoff)

No stack captures/injects a server-assigned URL any more — `docker-compose.yml`/`.local.yml`, `run-domain-check.sh` and `local-seed` all drop it; the local `acl.env` shrinks to a single line.

**One thing S-27 can't remove** (confirmed empirically during this work): OpenZaak validates the `zaaktype` field on zaak-create with Django's URLValidator and **rejects a single-label host** (`http://openzaak:8000/…` → `zaaktype: bad-url`). So the ACL's **base URL** must still point at a URL-valid host (a container IP); that base-URL injection from ADR-0020 stays (local `acl.env` now carries only it; CI keeps `ACL_OPENZAAK_BASEURL`). ADR-0021 records this.

## Definition of Done

- [x] Linked issues (#113 slice, #117 adr-proposal).
- [x] TDD — resolver + gateway-lookup unit tests, updated `AclService` tests (50 unit tests green).
- [x] Implementation makes them pass; refactor of both compose stacks + verify scripts follows.
- [x] Conventional Commits referencing #113.
- [ ] CI green — see below.
- [x] `docker compose up` reaches green health — verified: fresh `make local` + `make verify-local` green with **no zaaktype-URL injection**; `acl.env` is base-URL-only.
- [x] Docs — ADR-0021 + demo-script S-27 note.
- [x] ADR added (ADR-0021).
- [x] Demo note appended.

## Verification done locally

- **50 unit tests** pass (resolver resolve/cache/retry-on-failure; gateway match/miss/blank-key; all `AclService` paths).
- **6 ACL integration tests** pass against a live seeded OpenZaak — incl. resolving the zaaktype + Diploma iot by business key, and a clear error for an unknown identificatie.
- **Fresh `make local` + `make verify-local`**: full flow (submit → werkbak → openbaar) green; `acl.env` = `Acl__OpenZaak__BaseUrl` only.
- `make lint` clean; ACL mutation ratchet run locally (see checks).

## Notes for reviewers

- `IZaakGateway` gains two resolve methods; `AclService` depends on the new `IZaaktypeCatalog` (singleton, so the cache persists).
- Supersedes the pinned-URL mechanism; ADR-0021 documents that ADR-0020's `seed-env`/entrypoint shim are **simplified** (base-URL only), not deleted, because of the URLValidator constraint above.

Reviewed-on: #118
2026-07-22 14:49:25 +00:00
not 183d0bce31 fix(infra): docker-compose.local self-seeds zaaktype, DMN + NRC abonnement (closes #110) (#114)
CI / lint (push) Successful in 1m20s
CI / build (push) Successful in 59s
CI / unit (push) Successful in 1m12s
CI / frontend (push) Successful in 2m42s
CI / mutation (push) Successful in 5m42s
CI / verify-stack (push) Successful in 9m21s
## What & why

The host-browser stack (`make local`) had drifted behind three slices, so a fresh bring-up couldn't complete the flow: registrations stuck at `OpenZaakAanmaken`, the behandel werkbak stayed empty, and the openbaar register showed nothing. The `verify-*` scripts do this setup for CI at test time; `make local` had no equivalent.

This makes the local stack **self-seed at bring-up** so it just works in a browser:

- **DMN** — `flowable-init` now also deploys `diploma-eligibility.dmn` (was BPMN-only), so completing `WachtOpDocumenten` routes through the DMN to `Beoordelen` instead of 404ing.
- **Zaaktype + ACL** — a `local-seed` one-shot publishes the BIG zaaktype (whose UUID is server-assigned, hence not static in the compose file) and writes the real URLs to `seed-env:/acl.env`; the ACL sources it on startup via an entrypoint override.
- **NRC abonnement** — an `nrc-subscribe` one-shot registers the `zaken` subscription at the event-subscriber callback, so notifications reach the projection/openbaar register.

Both one-shots reach OpenZaak/NRC by **container IP** (a single-label host fails their Django URLValidator), mirroring the CI verify scripts. Design + trade-offs in **ADR-0020**.

Closes #110

## Definition of Done

- [x] Linked Gitea issue (#110).
- [x] Failing test committed before the implementation — `test(infra): …` adds `infra/run-local-flow-check.sh` / `make verify-local`; the three gaps' failures were observed live on a fresh `make local` (red), and the fix turns it green.
- [x] Implementation makes the test pass; docs commit follows.
- [x] Conventional Commits referencing the issue (`refs #110`).
- [ ] CI green — running on the restored runner. Infra-only change; the CI `verify-stack` job uses `docker-compose.yml` (untouched). Also validated locally: `make verify-local` passes against a fresh `make local` (see below).
- [x] `docker compose up` from a fresh clone reaches green health checks — verified: `make local` healthy in ~2m20s, then `make verify-local` green.
- [x] Docs updated — ADR-0020 + demo-script note.
- [x] ADR added in `docs/architecture/` — ADR-0020.
- [x] Demo note in `docs/demo-script.md`.

## Notes for reviewers

- **Infra-only** — no service code changes; the ACL image and the CI stack (`docker-compose.yml`) are untouched.
- **Verified end-to-end on a fresh stack** (`make local-down && make local && make verify-local`):
  ```
  >> 2. zaak opened            (zaaktype seeded + wired)
  >> 3. documents accepted 204 (DMN deployed)
  >> 4. in the werkbak         (DMN routing → Beoordelen)
  >> 5. visible in the openbaar register (NRC abonnement)
  OK — a fresh local stack completed the flow with no manual seeding
  ```
- **Follow-up:** the cleaner design — ACL resolving its zaaktype by `identificatie` instead of a pinned server-assigned URL — is split out as **S-27 (#113)**; landing it would remove the `acl.env` injection here. ADR-0020 records this.
- The `seed-env` volume carries the generated `acl.env` from `local-seed` to the ACL; a `down --volumes` (as `make local-down` does) resets it cleanly.

Reviewed-on: #114
2026-07-22 12:44:29 +00:00
not d5e5fa254c fix(e2e): run Playwright single-worker to stop OOM page-crash in verify-stack (closes #115) (#116)
CI / lint (push) Successful in 1m22s
CI / build (push) Successful in 1m1s
CI / unit (push) Successful in 1m14s
CI / frontend (push) Successful in 2m43s
CI / mutation (push) Successful in 5m53s
CI / verify-stack (push) Has been cancelled
## What & why

`verify-stack` was failing intermittently on the Playwright e2e with `Page crashed` mid-action (`locator.fill`) and 90s timeouts — the run logged **"2 workers"**, i.e. two full `channel: 'chromium'` browsers running alongside the entire compose stack on the 8 GB self-hosted runner. The renderer gets OOM-killed. Tests passed only when a retry happened to run alone.

Fix: pin `workers: 1` in `tests/e2e/playwright.config.ts` (there are only two long-running happy-path specs, so serial costs little) and add `--disable-dev-shm-usage`. This removes the memory contention at the source rather than leaning on `retries` (CLAUDE.md §15 — flaky tests are fixed, not retried).

Closes #115

## Definition of Done

- [x] Linked Gitea issue (#115).
- [ ] Failing test committed first — N/A: the "red" is the observed `verify-stack` e2e crash (`Page crashed`, 2 workers); this changes test-harness config to fix it. Verified green by re-running the e2e (see notes).
- [x] Conventional Commit referencing the issue (`refs #115`).
- [ ] CI green — the point of the change; `verify-stack` e2e should stop OOM-crashing.
- [x] Docs — none needed (test-config only; rationale in an inline comment).
- [ ] ADR — N/A.

## Notes for reviewers

- One-line-of-behaviour change: `workers: 1` + `--disable-dev-shm-usage`; no product or spec changes.
- `Page crashed` is a renderer OOM, not a product defect — the happy path passes when a browser runs alone (the flaky retries already showed this). Single-worker makes that the normal case.
- Independent of #110 (that PR fixes `docker-compose.local.yml`; this fixes the CI `verify-stack` e2e). Landing this first unblocks #110's `verify-stack`.

Reviewed-on: #116
2026-07-22 12:03:59 +00:00
not bf234e1322 docs(backlog): add S-26 self-service resume slice (refs #111) (#112)
CI / verify-stack (push) Successful in 11m16s
CI / lint (push) Successful in 1m22s
CI / build (push) Successful in 1m6s
CI / unit (push) Successful in 1m18s
CI / frontend (push) Successful in 3m7s
CI / mutation (push) Successful in 5m58s
## What & why

Mirror the new self-service **"resume after refresh"** slice into the Iteration 2 section of the curated backlog (`BACKLOG.md`), keeping it in sync with Gitea. Tracked as #111 (S-26).

Refs #111 — **does not close it**: the backlog is the curated mirror, the slice itself stays open for implementation.

## Definition of Done

- [x] Linked Gitea issue (#111).
- [ ] Failing test committed before the implementation — N/A (docs-only backlog mirror).
- [ ] Implementation makes the test pass; refactor commit if structure improved — N/A.
- [x] Conventional Commits referencing the issue (`refs #111`).
- [ ] CI green — no code paths touched; only `BACKLOG.md`.
- [ ] `docker compose up` reaches green health checks — N/A.
- [x] Docs updated (this IS the docs change).
- [ ] ADR added — N/A.
- [ ] Demo note in `docs/demo-script.md` — N/A (backlog entry, not a shipped user-visible change).

## Notes for reviewers

Single-file change: adds the `S-26` entry (Outcome + Acceptance) after S-14 in Iteration 2, matching the surrounding slice format. The `S-B04` local-stack bug (#110) is intentionally **not** added — the `S-B0N` bug-slices have never been mirrored in `BACKLOG.md` (they live only in Gitea).

Reviewed-on: #112
2026-07-22 09:12:19 +00:00
not c8fdfbb699 feat(acl,domain): cancel the ZGW zaak on document-timeout expiry (S-10c, closes #106) (#109)
CI / lint (push) Successful in 1m22s
CI / build (push) Successful in 1m1s
CI / frontend (push) Successful in 2m27s
CI / mutation (push) Successful in 5m34s
CI / verify-stack (push) Successful in 8m0s
CI / unit (push) Successful in 1m17s
## S-10c · Close the ZGW zaak on document-timeout expiry (closes #106)

Completes the S-10a/S-10b boundary flagged in ADR-0017: when a registration's 30-day document term lapses, the domain now cancels the **ZGW zaak** as well as marking the aggregate `Verlopen`, so OpenZaak and the register no longer diverge.

### What it does
On expiry the `ExpireRegistrationWorker` calls the ACL to set the zaak to a distinct, non-terminal **`Geannuleerd`** status with a **`Vervallen`** resultaat (vs the approval `Afgehandeld` + `Geregistreerd`), resolved **by omschrijving** in the ACL — the ACL-first ordering mirrors approval so a failed ZGW call leaves the job for redelivery rather than diverging the two.

**Path:** Flowable P30D timer → `RegistratieVerlopen` job → domain `ExpireRegistrationWorker` → ACL `POST /annuleringen` → ZGW `resultaten` + `statussen` (Geannuleerd) → aggregate `Verlopen`.

### Layers touched (each red→green)
- **ACL gateway** — `SetZaakToCancellationStatusAsync` (Geannuleerd + Vervallen by name); approval now resolves its `Geregistreerd` resultaat by name too (a second resultaattype now exists).
- **ACL service/API** — `AclService.CancelZaakAsync` + `POST /annuleringen`.
- **Domain** — `IAclClient.CancelZaakAsync` + client; expiry worker cancels the zaak before advancing to `Verlopen`, guarded against redelivery double-cancel.
- **Seed** — non-terminal `Geannuleerd` statustype (volgnummer 2; `Afgehandeld` → 3) + `Vervallen` resultaattype, both idempotent by omschrijving and sharing the zaaktype's procestype.
- **Verify/integration** — ACL↔OpenZaak integration test (live `Geannuleerd` + resultaat); `run-domain-check.sh` fires the real P30D timer and asserts the zaak reaches `Geannuleerd` end-to-end; BDD scenario asserts cancel-on-timeout vs untouched-when-in-time.
- **Docs** — ADR-0019 (cancellation modelling decision), demo-script, BACKLOG.

### Design note (ADR-0019)
ZGW allows only one eindstatus per zaaktype, so `Geannuleerd` is modelled as a **non-terminal** status (it records a cancellation status + resultaat but does not set `einddatum`). This follows the issue's explicit "distinct statustype + resultaat" outcome; the shared-eindstatus alternative is recorded in the ADR.

### Tests
Unit + acceptance all green locally (Acl 38, Big 134, Acceptance 17, Bff 33, EventSubscriber 19). Integration + verify-stack run in CI (need live OpenZaak + selectielijst egress).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #109
2026-07-21 13:58:15 +00:00
46 changed files with 1368 additions and 97 deletions
+6
View File
@@ -239,6 +239,12 @@ Split (issue #11 closed) into two independently-demoable slices per §13 — the
**Outcome:** Boundary timer on beoordeling user task — 14 days. On timeout, reassigns to a teamlead role. **Outcome:** Boundary timer on beoordeling user task — 14 days. On timeout, reassigns to a teamlead role.
### S-26 · Self-service — resume an existing registration after refresh — #111
**Outcome:** a signed-in zorgprofessional who reloads the self-service portal (or returns later) gets back to their in-flight registration and its actions (Documenten aanleveren, Trek aanvraag in), instead of a blank submit form with the reference lost. Today all post-submit state lives in in-memory signals, the reference is not in the URL, and there is no self-service read endpoint — so a reload strands the registration. Adds an owner-scoped (DigiD bsn) `GET /self-service/registrations` on the BFF/domain and a load-on-init/route restore in the portal.
**Acceptance:** BDD — resume after refresh shows the existing registration; lookup is owner-scoped (never another citizen's); a user with no in-flight registration still sees the submit form. Playwright e2e reloads mid-flow and asserts the actions remain reachable.
--- ---
## Iteration 3 — Maintenance portal and observability *(milestone: `Iteration 3 — Beheer & Observability`)* ## Iteration 3 — Maintenance portal and observability *(milestone: `Iteration 3 — Beheer & Observability`)*
+6 -1
View File
@@ -43,7 +43,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
endif endif
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) ## 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). ## `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 docker compose -f $(LOCAL_COMPOSE) up -d --build
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS) 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: stop and remove the bind-mount stack
local-down: local-down:
docker compose -f $(LOCAL_COMPOSE) down --volumes docker compose -f $(LOCAL_COMPOSE) down --volumes
@@ -21,16 +21,20 @@ function providers(
post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })), post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
withdraw = vi.fn().mockReturnValue(of(undefined)), withdraw = vi.fn().mockReturnValue(of(undefined)),
provideDocuments = vi.fn().mockReturnValue(of(undefined)), provideDocuments = vi.fn().mockReturnValue(of(undefined)),
// Resume lookup (S-26): default to 204/empty — no in-flight registration, so the submit form shows.
getCurrent = vi.fn().mockReturnValue(of(undefined)),
) { ) {
return { return {
post, post,
withdraw, withdraw,
provideDocuments, provideDocuments,
getCurrent,
providers: [ providers: [
{ provide: AuthService, useClass: FakeAuth }, { provide: AuthService, useClass: FakeAuth },
{ {
provide: BffApiV1Service, provide: BffApiV1Service,
useValue: { useValue: {
getSelfServiceRegistrations: getCurrent,
postSelfServiceRegistrations: post, postSelfServiceRegistrations: post,
postSelfServiceRegistrationsIdWithdraw: withdraw, postSelfServiceRegistrationsIdWithdraw: withdraw,
postSelfServiceRegistrationsIdDocuments: provideDocuments, postSelfServiceRegistrationsIdDocuments: provideDocuments,
@@ -56,6 +60,21 @@ describe('RegistrationPage', () => {
expect(await screen.findByText(/ontvangen/i)).toBeTruthy(); expect(await screen.findByText(/ontvangen/i)).toBeTruthy();
}); });
it('resumes an existing registration on load, without submitting again (S-26)', async () => {
const { post, providers: p } = providers(
undefined,
undefined,
undefined,
vi.fn().mockReturnValue(of({ registrationId: 'reg-77', status: 'Ingediend' })),
);
await render(RegistrationPage, { providers: p });
// The confirmation view is restored from the in-flight registration — no submit click.
expect(await screen.findByText(/ontvangen/i)).toBeTruthy();
expect(screen.getByText(/reg-77/)).toBeTruthy();
expect(post).not.toHaveBeenCalled();
});
it('shows an error and keeps the submit available when the BFF call fails', async () => { it('shows an error and keeps the submit available when the BFF call fails', async () => {
const { post, providers: p } = providers(vi.fn().mockReturnValue(throwError(() => new Error('BFF rejected')))); const { post, providers: p } = providers(vi.fn().mockReturnValue(throwError(() => new Error('BFF rejected'))));
await render(RegistrationPage, { providers: p }); await render(RegistrationPage, { providers: p });
@@ -1,5 +1,5 @@
import { Component, inject, signal } from '@angular/core'; import { Component, inject, type OnInit, signal } from '@angular/core';
import { BffApiV1Service, type SubmitAccepted } from 'api-client'; import { BffApiV1Service, type CurrentRegistration, type SubmitAccepted } from 'api-client';
import { AuthService } from 'auth'; import { AuthService } from 'auth';
import { UtrechtComponentsModule } from 'ui'; import { UtrechtComponentsModule } from 'ui';
@@ -8,13 +8,16 @@ import { UtrechtComponentsModule } from 'ui';
* registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and- * registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and-
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c). After * submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c). After
* submitting they can withdraw it — "trek aanvraag in" — keyed by that reference (S-11c). * submitting they can withdraw it — "trek aanvraag in" — keyed by that reference (S-11c).
*
* On load it asks the BFF for the caller's current open registration and restores the submitted view
* if there is one, so a page refresh no longer strands an in-flight registration (S-26).
*/ */
@Component({ @Component({
selector: 'app-registration-page', selector: 'app-registration-page',
imports: [UtrechtComponentsModule], imports: [UtrechtComponentsModule],
templateUrl: './registration-page.html', templateUrl: './registration-page.html',
}) })
export class RegistrationPage { export class RegistrationPage implements OnInit {
private readonly auth = inject(AuthService); private readonly auth = inject(AuthService);
private readonly bff = inject(BffApiV1Service); private readonly bff = inject(BffApiV1Service);
@@ -31,6 +34,23 @@ export class RegistrationPage {
protected readonly provideDocumentsFailed = signal(false); protected readonly provideDocumentsFailed = signal(false);
protected readonly selectedFile = signal<File | undefined>(undefined); protected readonly selectedFile = signal<File | undefined>(undefined);
/** Resume an existing in-flight registration after a refresh (S-26): the BFF returns the caller's
* current open registration, or 204 (empty body) when there is none — in which case we show the
* submit form as before. Failures are non-fatal for the same reason. */
ngOnInit(): void {
this.bff.getSelfServiceRegistrations().subscribe({
next: (current: CurrentRegistration | void) => {
if (current && current.registrationId) {
this.reference.set(current.registrationId);
this.submitted.set(true);
}
},
error: () => {
// No resumable registration (or the lookup failed) — fall back to the submit form.
},
});
}
submit(): void { submit(): void {
this.submitting.set(true); this.submitting.set(true);
this.failed.set(false); this.failed.set(false);
@@ -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.
+49
View File
@@ -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 ## 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 **Outcome:** the self-service portal is served in the stack and the full front-of-house happy path
+1
View File
@@ -14,6 +14,7 @@ All test users share the password **`test123`**.
| Realm | Mimics | User | Identifying claim | | Realm | Mimics | User | Identifying claim |
|---|---|---|---| |---|---|---|---|
| `digid` | DigiD (burgers) | `jan-burger` | `bsn` = `123456782` | | `digid` | DigiD (burgers) | `jan-burger` | `bsn` = `123456782` |
| `digid` | DigiD (burgers) | `sanne-burger` | `bsn` = `231477813` (S-26 resume e2e — its own user so it can leave an open registration) |
| `eherkenning` | eHerkenning (bedrijven) | `acme-ondernemer` | `kvk` = `12345678` | | `eherkenning` | eHerkenning (bedrijven) | `acme-ondernemer` | `kvk` = `12345678` |
| `eidas` | eIDAS (EU) | `pierre-dupont` | `eidas_id` = `FR/NL/AB-1234-5678` | | `eidas` | eIDAS (EU) | `pierre-dupont` | `eidas_id` = `FR/NL/AB-1234-5678` |
| `medewerker` | Internal staff | `merel-behandelaar` | role `behandelaar` | | `medewerker` | Internal staff | `merel-behandelaar` | role `behandelaar` |
+83 -8
View File
@@ -1,7 +1,12 @@
# LOCAL development stack — runs with a plain `docker compose up`, no make / no # 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). # 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 # podman
# docker compose -f infra/docker-compose.local.yml up -d --build --wait # Docker Desktop # docker compose -f infra/docker-compose.local.yml up -d --build --wait # Docker Desktop
# docker compose -f infra/docker-compose.local.yml down --volumes # docker compose -f infra/docker-compose.local.yml down --volumes
@@ -257,38 +262,79 @@ services:
restart: "no" restart: "no"
volumes: volumes:
- ../workflows/registratie.bpmn:/work/registratie.bpmn:ro,z - ../workflows/registratie.bpmn:/work/registratie.bpmn:ro,z
- ../workflows/diploma-eligibility.dmn:/work/diploma-eligibility.dmn:ro,z
command: command:
- sh - sh
- -c - -c
- | - |
base=http://flowable-rest:8080/flowable-rest/service/repository/deployments svc=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 dmn=http://flowable-rest:8080/flowable-rest/dmn-api/dmn-repository/deployments
if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then until curl -sf -u rest-admin:test "$$svc" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
echo "registratie already deployed; skip" # 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 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 fi
depends_on: depends_on:
flowable-rest: flowable-rest:
condition: service_started condition: service_started
networks: [cg] 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 ──────────────────────────────────────────────────────────────────
acl: acl:
build: build:
context: ../services/acl context: ../services/acl
dockerfile: Dockerfile dockerfile: Dockerfile
image: register-referentie/acl:dev 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: 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__ClientId: big-reference-seed
Acl__OpenZaak__Secret: insecure-dev-secret-change-me Acl__OpenZaak__Secret: insecure-dev-secret-change-me
Acl__Defaults__Bronorganisatie: "517439943" Acl__Defaults__Bronorganisatie: "517439943"
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943" Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar 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: ports:
- "8100:8080" - "8100:8080"
volumes:
- seed-env:/seed:ro
healthcheck: healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s interval: 5s
@@ -298,6 +344,8 @@ services:
depends_on: depends_on:
openzaak: openzaak:
condition: service_healthy condition: service_healthy
local-seed:
condition: service_completed_successfully
networks: [cg] networks: [cg]
# ── BFF ────────────────────────────────────────────────────────────────── # ── BFF ──────────────────────────────────────────────────────────────────
@@ -400,6 +448,31 @@ services:
condition: service_healthy condition: service_healthy
networks: [cg] 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: projection-api:
build: build:
context: .. context: ..
@@ -492,6 +565,8 @@ volumes:
nrc-db: nrc-db:
flowable-db: flowable-db:
projection-db: projection-db:
# Carries the seed-generated acl.env (server-assigned zaaktype URLs) from local-seed to the ACL.
seed-env:
networks: networks:
cg: cg:
+12 -11
View File
@@ -15,12 +15,12 @@
# #
# docker compose -f infra/docker-compose.yml up -d --build --wait # docker compose -f infra/docker-compose.yml up -d --build --wait
# #
# After first boot, seed the BIG catalogus and note the zaaktype URL: # After first boot, seed + publish the BIG catalogus:
# python infra/openzaak/seed_catalogus.py # OZ_PUBLISH=1 python infra/openzaak/seed_catalogus.py
# Then set ACL_ZAAKTYPE_URL in a .env file or your shell and re-up the acl # The ACL discovers the zaaktype by identificatie (S-27, ADR-0021), so there is no URL to inject —
# service: # just point its BaseUrl at an OpenZaak host OpenZaak accepts on zaak-create (a container IP; a
# export ACL_ZAAKTYPE_URL=http://openzaak:8000/catalogi/api/v1/zaaktypen/<uuid> # single-label host is rejected):
# docker compose -f infra/docker-compose.yml up -d acl # ACL_OPENZAAK_BASEURL=http://<openzaak-ip>:8000/ docker compose -f infra/docker-compose.yml up -d acl
services: services:
@@ -304,11 +304,12 @@ services:
Acl__Defaults__Bronorganisatie: "517439943" Acl__Defaults__Bronorganisatie: "517439943"
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943" Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
# Override with the real zaaktype URL after running seed_catalogus.py. # The ACL resolves the (server-assigned) zaaktype + diploma informatieobjecttype URLs from the
Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000} # Catalogi API by these stable business keys (S-27, ADR-0021) — no URL to capture and inject.
# The informatieobjecttype a diploma is filed under (S-10b). Placeholder until seed_catalogus.py # BaseUrl above stays overridable because OpenZaak rejects a single-label host on zaak creation,
# (OZ_PUBLISH=1) reports the real URL, which verify-domain injects like the zaaktype URL. # so verify-domain still points the ACL at OpenZaak's container IP.
Acl__Defaults__InformatieobjecttypeUrl: ${ACL_INFORMATIEOBJECTTYPE_URL:-http://openzaak:8000/catalogi/api/v1/informatieobjecttypen/00000000-0000-0000-0000-000000000000} Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
ports: ports:
- "8100:8080" - "8100:8080"
healthcheck: healthcheck:
+30
View File
@@ -38,6 +38,36 @@
"emailVerified": true, "emailVerified": true,
"credentials": [{ "type": "password", "value": "test123", "temporary": false }], "credentials": [{ "type": "password", "value": "test123", "temporary": false }],
"attributes": { "bsn": ["123456782"] } "attributes": { "bsn": ["123456782"] }
},
{
"username": "sanne-burger",
"enabled": true,
"firstName": "Sanne",
"lastName": "Burger",
"email": "sanne.burger@example.nl",
"emailVerified": true,
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
"attributes": { "bsn": ["231477813"] }
},
{
"username": "emma-burger",
"enabled": true,
"firstName": "Emma",
"lastName": "Burger",
"email": "emma.burger@example.nl",
"emailVerified": true,
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
"attributes": { "bsn": ["231477805"] }
},
{
"username": "lars-burger",
"enabled": true,
"firstName": "Lars",
"lastName": "Burger",
"email": "lars.burger@example.nl",
"emailVerified": true,
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
"attributes": { "bsn": ["231477821"] }
} }
] ]
} }
+78
View File
@@ -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()
+33
View File
@@ -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}/)"
+7 -10
View File
@@ -30,21 +30,18 @@ oz_ip="$(ip "$oz")"; dom_ip="$(ip "$dom")"
oz_base="http://$oz_ip:8000" oz_base="http://$oz_ip:8000"
echo ">> openzaak=$oz_ip domain=$dom_ip network=$net" 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)" 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 docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null
seed_out="$(docker start -a "$sid")" 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 docker rm -f "$sid" >/dev/null
[ -n "$zt_url" ] || { echo "ERROR: seed did not report a ZAAKTYPE_URL" >&2; exit 1; } printf '%s\n' "$seed_out" | grep -q '^ZAAKTYPE_URL ' || { echo "ERROR: seed did not publish the zaaktype" >&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"
echo ">> recreating the acl service pointed at the seeded zaaktype + informatieobjecttype (host-consistent)" # The ACL resolves the zaaktype + informatieobjecttype by identificatie/omschrijving (S-27, ADR-0021),
ACL_ZAAKTYPE_URL="$zt_url" ACL_INFORMATIEOBJECTTYPE_URL="$iot_url" ACL_OPENZAAK_BASEURL="$oz_base/" \ # so there is no URL to inject — only the OpenZaak base URL, pointed at the same host's container IP
docker compose -f "$compose" up -d acl # (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 WAIT_TIMEOUT="${WAIT_TIMEOUT:-120}" bash "$here/wait-healthy.sh" acl
echo ">> submitting a registration to the domain" echo ">> submitting a registration to the domain"
+67
View File
@@ -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)"
@@ -24,6 +24,11 @@ import {
Observable Observable
} from 'rxjs'; } from 'rxjs';
export interface CurrentRegistration {
registrationId: string;
status: string;
}
export interface DecideRequest { export interface DecideRequest {
besluit: string; besluit: string;
} }
@@ -200,6 +205,37 @@ export class BffApiV1Service {
); );
} }
getSelfServiceRegistrations<TData = CurrentRegistration | void>( options?: HttpClientBodyOptions): Observable<TData>;
getSelfServiceRegistrations<TData = CurrentRegistration | void>( options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
getSelfServiceRegistrations<TData = CurrentRegistration | void>( options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
getSelfServiceRegistrations<TData = CurrentRegistration | void>(
options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
if (options?.observe === 'events') {
return this.http.get<TData>(
`/self-service/registrations`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
}
);
}
if (options?.observe === 'response') {
return this.http.get<TData>(
`/self-service/registrations`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
}
);
}
return this.http.get<TData>(
`/self-service/registrations`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'body',
}
);
}
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientBodyOptions): Observable<TData>; postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientBodyOptions): Observable<TData>;
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>; postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>; postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
+2
View File
@@ -11,6 +11,8 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
.GetSection("Acl:OpenZaak").Get<OpenZaakOptions>() .GetSection("Acl:OpenZaak").Get<OpenZaakOptions>()
?? throw new InvalidOperationException("Missing configuration section 'Acl:OpenZaak'")); ?? throw new InvalidOperationException("Missing configuration section 'Acl:OpenZaak'"));
builder.Services.AddHttpClient<IZaakGateway, OpenZaakGateway>(); 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>(); builder.Services.AddScoped<AclService>();
var app = builder.Build(); var app = builder.Build();
+7 -4
View File
@@ -6,9 +6,12 @@ public sealed class AclDefaults
public required string Bronorganisatie { get; init; } public required string Bronorganisatie { get; init; }
public required string VerantwoordelijkeOrganisatie { get; init; } public required string VerantwoordelijkeOrganisatie { get; init; }
public required string Vertrouwelijkheidaanduiding { 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 /// <summary>The BIG zaaktype's stable business key. The ACL resolves the (server-assigned) zaaktype
/// catalogus and injected like <see cref="ZaaktypeUrl"/>.</summary> /// URL from this via the Catalogi API instead of being handed a pinned URL (S-27, ADR-0021).</summary>
public required Uri InformatieobjecttypeUrl { get; init; } 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; }
} }
+15 -15
View File
@@ -2,9 +2,9 @@ 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, 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); ArgumentNullException.ThrowIfNull(registration);
@@ -12,34 +12,34 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
defaults.Bronorganisatie, defaults.Bronorganisatie,
defaults.VerantwoordelijkeOrganisatie, defaults.VerantwoordelijkeOrganisatie,
defaults.Vertrouwelijkheidaanduiding, defaults.Vertrouwelijkheidaanduiding,
defaults.ZaaktypeUrl, await catalog.GetZaaktypeUrlAsync(ct),
clock.Today, clock.Today,
registration.Reference); registration.Reference);
return gateway.OpenZaakAsync(request, ct); return await gateway.OpenZaakAsync(request, ct);
} }
/// <summary> /// <summary>
/// Approve a zaak: set it to the eindstatus of the configured BIG zaaktype (ADR-0003 default). The /// Approve a zaak: set it to the eindstatus of the BIG zaaktype (resolved by identificatie, S-27).
/// domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1). /// The domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1).
/// </summary> /// </summary>
public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default) public async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
{ {
ArgumentNullException.ThrowIfNull(zaakUrl); ArgumentNullException.ThrowIfNull(zaakUrl);
return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct); await gateway.SetZaakToEindstatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct);
} }
/// <summary> /// <summary>
/// Cancel a zaak on document-timeout expiry (S-10c): set it to the configured BIG zaaktype's /// Cancel a zaak on document-timeout expiry (S-10c): set it to the BIG zaaktype's cancellation
/// 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
/// statustype/resultaat means "cancelled" (§8.1). /// statustype/resultaat means "cancelled" (§8.1).
/// </summary> /// </summary>
public Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default) public async Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
{ {
ArgumentNullException.ThrowIfNull(zaakUrl); 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> /// <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. /// 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). /// The domain supplies only the zaak, the bytes, and the file's name/type (§8.1).
/// </summary> /// </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(zaakUrl);
ArgumentNullException.ThrowIfNull(content); ArgumentNullException.ThrowIfNull(content);
@@ -65,7 +65,7 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
var request = new DocumentRequest( var request = new DocumentRequest(
defaults.Bronorganisatie, defaults.Bronorganisatie,
defaults.InformatieobjecttypeUrl, await catalog.GetInformatieobjecttypeUrlAsync(ct),
defaults.Vertrouwelijkheidaanduiding, defaults.Vertrouwelijkheidaanduiding,
zaakUrl, zaakUrl,
clock.Today, clock.Today,
@@ -76,6 +76,6 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
Formaat: contentType, Formaat: contentType,
Inhoud: content); 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. /// the created informatieobject.
/// </summary> /// </summary>
Task<Uri> StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default); 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; 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 // 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). // a Content-Length instead of a chunked body (as with zaak-create).
private async Task PostAsync(string path, object dto, string action, CancellationToken ct) 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( private sealed record ZaakInformatieobjectDto(
[property: JsonPropertyName("zaak")] string Zaak, [property: JsonPropertyName("zaak")] string Zaak,
[property: JsonPropertyName("informatieobject")] string Informatieobject); [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(), Assert.Contains(relations.EnumerateArray(),
r => r.GetProperty("zaak").GetString() == zaakUrl.ToString()); 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);
}
} }
+44 -38
View File
@@ -6,6 +6,12 @@ public class AclServiceTests
{ {
private sealed class FakeGateway : IZaakGateway 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 ZaakRequest? Captured;
public Uri Result { get; } = new("http://openzaak/zaken/api/v1/zaken/abc"); public Uri Result { get; } = new("http://openzaak/zaken/api/v1/zaken/abc");
@@ -47,6 +53,18 @@ public class AclServiceTests
StoredDocument = request; StoredDocument = request;
return Task.FromResult(DocumentResult); 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() private static AclDefaults Defaults() => new()
@@ -54,28 +72,23 @@ public class AclServiceTests
Bronorganisatie = "517439943", Bronorganisatie = "517439943",
VerantwoordelijkeOrganisatie = "517439943", VerantwoordelijkeOrganisatie = "517439943",
Vertrouwelijkheidaanduiding = "openbaar", Vertrouwelijkheidaanduiding = "openbaar",
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"), ZaaktypeIdentificatie = "BIG-REGISTRATIE",
InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"), 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 private sealed class FixedClock(DateOnly today) : IClock
{ {
public DateOnly Today { get; } = today; public DateOnly Today { get; } = today;
} }
[Fact] [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 gateway = new FakeGateway();
var defaults = new AclDefaults var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
{
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 url = await service.OpenZaakAsync(new DomainRegistration("123456782", "reg-77")); 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.Bronorganisatie);
Assert.Equal("517439943", req.VerantwoordelijkeOrganisatie); Assert.Equal("517439943", req.VerantwoordelijkeOrganisatie);
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding); 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); Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum);
// The registration reference becomes the zaak identificatie (#78). // The registration reference becomes the zaak identificatie (#78).
Assert.Equal("reg-77", req.Identificatie); Assert.Equal("reg-77", req.Identificatie);
@@ -94,33 +109,24 @@ public class AclServiceTests
public async Task Rejects_a_null_registration_without_calling_the_gateway() public async Task Rejects_a_null_registration_without_calling_the_gateway()
{ {
var gateway = new FakeGateway(); var gateway = new FakeGateway();
var defaults = new AclDefaults var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
{
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)));
await Assert.ThrowsAsync<ArgumentNullException>(() => service.OpenZaakAsync(null!)); await Assert.ThrowsAsync<ArgumentNullException>(() => service.OpenZaakAsync(null!));
Assert.Null(gateway.Captured); Assert.Null(gateway.Captured);
} }
[Fact] [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 gateway = new FakeGateway();
var defaults = Defaults(); var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc"); var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
await service.ApproveZaakAsync(zaak); await service.ApproveZaakAsync(zaak);
Assert.NotNull(gateway.Approved); Assert.NotNull(gateway.Approved);
Assert.Equal(zaak, gateway.Approved!.Value.Zaak); 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); 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() public async Task Approving_a_null_zaak_is_rejected_without_touching_the_gateway()
{ {
var gateway = new FakeGateway(); 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!)); await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!));
Assert.Null(gateway.Approved); 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() public async Task Cancelling_a_zaak_sets_it_to_the_cancellation_status_dated_today()
{ {
var gateway = new FakeGateway(); var gateway = new FakeGateway();
var defaults = Defaults(); var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc"); var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
await service.CancelZaakAsync(zaak); await service.CancelZaakAsync(zaak);
Assert.NotNull(gateway.Cancelled); Assert.NotNull(gateway.Cancelled);
Assert.Equal(zaak, gateway.Cancelled!.Value.Zaak); 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); Assert.Equal(new DateOnly(2026, 6, 4), gateway.Cancelled.Value.Datum);
// Cancellation must not touch the approval path. // Cancellation must not touch the approval path.
Assert.Null(gateway.Approved); Assert.Null(gateway.Approved);
@@ -156,18 +161,17 @@ public class AclServiceTests
public async Task Cancelling_a_null_zaak_is_rejected_without_touching_the_gateway() public async Task Cancelling_a_null_zaak_is_rejected_without_touching_the_gateway()
{ {
var gateway = new FakeGateway(); 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!)); await Assert.ThrowsAsync<ArgumentNullException>(() => service.CancelZaakAsync(null!));
Assert.Null(gateway.Cancelled); Assert.Null(gateway.Cancelled);
} }
[Fact] [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 gateway = new FakeGateway();
var defaults = Defaults(); var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc"); var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
var url = await service.StoreDiplomaAsync(zaak, [1, 2, 3], "diploma.pdf", "application/pdf"); 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); Assert.Equal(gateway.DocumentResult, url);
var req = gateway.StoredDocument!; var req = gateway.StoredDocument!;
Assert.Equal(zaak, req.Zaak); 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("517439943", req.Bronorganisatie);
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding); Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding);
Assert.Equal(new DateOnly(2026, 6, 4), req.Creatiedatum); Assert.Equal(new DateOnly(2026, 6, 4), req.Creatiedatum);
@@ -188,7 +194,7 @@ public class AclServiceTests
[Fact] [Fact]
public async Task Storing_a_diploma_rejects_null_or_blank_arguments() 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"); var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
await Assert.ThrowsAsync<ArgumentNullException>(() => service.StoreDiplomaAsync(null!, [1], "d.pdf", "application/pdf")); 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() public async Task Reading_a_zaak_reference_returns_the_zaaks_identificatie()
{ {
var gateway = new FakeGateway(); 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 zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
var reference = await service.GetZaakReferenceAsync(zaak); var reference = await service.GetZaakReferenceAsync(zaak);
@@ -214,7 +220,7 @@ public class AclServiceTests
public async Task Reading_a_null_zaak_reference_is_rejected() public async Task Reading_a_null_zaak_reference_is_rejected()
{ {
var gateway = new FakeGateway(); 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!)); await Assert.ThrowsAsync<ArgumentNullException>(() => service.GetZaakReferenceAsync(null!));
Assert.Null(gateway.ReadReferenceFor); Assert.Null(gateway.ReadReferenceFor);
@@ -675,4 +675,153 @@ public class OpenZaakGatewayTests
await Assert.ThrowsAsync<ArgumentNullException>(() => Gateway(handler).StoreDocumentAsync(null!)); 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);
}
}
+20
View File
@@ -5,6 +5,10 @@ namespace Bff.Api;
/// <summary>What the self-service submit returns to the portal (the domain's registration id + status).</summary> /// <summary>What the self-service submit returns to the portal (the domain's registration id + status).</summary>
public sealed record SubmitAccepted(string RegistrationId, string Status); public sealed record SubmitAccepted(string RegistrationId, string Status);
/// <summary>The caller's current open registration, for resuming the self-service portal after a
/// refresh (S-26): the reference (registration id) + its status.</summary>
public sealed record CurrentRegistration(string RegistrationId, string Status);
/// <summary>A projection row as the projection-api serves it. <c>Bsn</c>/<c>NaamPlaceholder</c> are /// <summary>A projection row as the projection-api serves it. <c>Bsn</c>/<c>NaamPlaceholder</c> are
/// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09). /// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09).
/// <c>Reference</c> is the public-safe citizen reference (the zaak identificatie, #78).</summary> /// <c>Reference</c> is the public-safe citizen reference (the zaak identificatie, #78).</summary>
@@ -22,6 +26,10 @@ public interface IDomainClient
{ {
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default); Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
/// <summary>The caller's current open registration (resume after refresh, S-26), or <c>null</c>
/// when they have none in flight. Owner-scoped by <paramref name="bsn"/>.</summary>
Task<CurrentRegistration?> GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default);
/// <summary>Withdraw the caller's own registration ("trek aanvraag in"). Owner-scoped by /// <summary>Withdraw the caller's own registration ("trek aanvraag in"). Owner-scoped by
/// <paramref name="bsn"/>. Returns <c>false</c> when the domain reports the registration is /// <paramref name="bsn"/>. Returns <c>false</c> when the domain reports the registration is
/// unknown or not the caller's (404), so the BFF can relay a 404 rather than a 500.</summary> /// unknown or not the caller's (404), so the BFF can relay a 404 rather than a 500.</summary>
@@ -58,6 +66,18 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
return new SubmitAccepted(dto.RegistrationId, dto.Status); return new SubmitAccepted(dto.RegistrationId, dto.Status);
} }
public async Task<CurrentRegistration?> GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default)
{
using var response = await http.GetAsync($"registrations/current?bsn={Uri.EscapeDataString(bsn)}", ct);
// The domain 404s when the citizen has no open registration — that's "none", not an error.
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
var dto = await response.Content.ReadFromJsonAsync<DomainResponse>(ct)
?? throw new InvalidOperationException("The Domain Service returned an empty registration response.");
return new CurrentRegistration(dto.RegistrationId, dto.Status);
}
public async Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default) public async Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
{ {
using var response = await http.PostAsJsonAsync( using var response = await http.PostAsJsonAsync(
+18
View File
@@ -86,6 +86,24 @@ app.MapPost("/self-service/registrations", async (ClaimsPrincipal user, IDomainC
.Produces(StatusCodes.Status400BadRequest) .Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized); .Produces(StatusCodes.Status401Unauthorized);
// Self-service resume (S-26): the signed-in zorgprofessional's current open registration, so the
// portal can restore its reference + actions after a page refresh. The bsn comes from the DigiD token;
// 204 when the citizen has none in flight (so the portal shows the submit form).
app.MapGet("/self-service/registrations", async (ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) =>
{
var bsn = user.FindFirstValue("bsn");
if (string.IsNullOrWhiteSpace(bsn))
return Results.BadRequest("The token carries no bsn claim.");
var current = await domain.GetCurrentRegistrationAsync(bsn, ct);
return current is null ? Results.NoContent() : Results.Ok(current);
})
.RequireAuthorization()
.Produces<CurrentRegistration>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized);
// Self-service withdrawal (S-11): the signed-in zorgprofessional withdraws their own registration. // Self-service withdrawal (S-11): the signed-in zorgprofessional withdraws their own registration.
// The bsn comes from the DigiD token and is forwarded to the domain, which owner-scopes the action; // The bsn comes from the DigiD token and is forwarded to the domain, which owner-scopes the action;
// a registration that is unknown or not the caller's comes back 404 (ownership is not revealed). // a registration that is unknown or not the caller's comes back 404 (ownership is not revealed).
+12
View File
@@ -82,6 +82,18 @@ internal sealed class FakeDomainClient : IDomainClient
return Task.FromResult(Result); return Task.FromResult(Result);
} }
public string? CurrentQueriedBsn { get; private set; }
/// <summary>The current open registration the fake domain returns (null → the citizen has none in
/// flight, so the BFF replies 204). Tests set this to exercise resume.</summary>
public CurrentRegistration? Current { get; set; }
public Task<CurrentRegistration?> GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default)
{
CurrentQueriedBsn = bsn;
return Task.FromResult(Current);
}
public (string RegistrationId, string Bsn)? Withdrawn { get; private set; } public (string RegistrationId, string Bsn)? Withdrawn { get; private set; }
/// <summary>Whether the fake domain reports the withdrawal as done (true → 204) or not-found/not-owned /// <summary>Whether the fake domain reports the withdrawal as done (true → 204) or not-found/not-owned
@@ -1,6 +1,7 @@
using System.Net; using System.Net;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using Bff.Api;
namespace Bff.Tests; namespace Bff.Tests;
@@ -168,5 +169,52 @@ public class SelfServiceEndpointTests
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
} }
private static HttpRequestMessage Current(string? bearer)
{
var request = new HttpRequestMessage(HttpMethod.Get, "/self-service/registrations");
if (bearer is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
return request;
}
[Fact]
public async Task Rejects_the_current_registration_lookup_without_a_token()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Current(bearer: null));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Returns_no_content_when_the_caller_has_no_open_registration()
{
using var factory = new BffFactory();
factory.Domain.Current = null;
var response = await factory.CreateClient().SendAsync(Current(TestTokens.Valid("123456782")));
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
Assert.Equal("123456782", factory.Domain.CurrentQueriedBsn);
}
[Fact]
public async Task Returns_the_callers_current_registration_when_one_is_open()
{
using var factory = new BffFactory();
factory.Domain.Current = new CurrentRegistration("reg-77", "Ingediend");
var response = await factory.CreateClient().SendAsync(Current(TestTokens.Valid("123456782")));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("123456782", factory.Domain.CurrentQueriedBsn);
var body = await response.Content.ReadFromJsonAsync<CurrentRegistrationDto>();
Assert.Equal("reg-77", body!.RegistrationId);
Assert.Equal("Ingediend", body.Status);
}
private sealed record SubmitAcceptedDto(string RegistrationId, string Status); private sealed record SubmitAcceptedDto(string RegistrationId, string Status);
private sealed record CurrentRegistrationDto(string RegistrationId, string Status);
} }
+41
View File
@@ -28,6 +28,32 @@
"description": "Unauthorized" "description": "Unauthorized"
} }
} }
},
"get": {
"tags": [
"Bff.Api"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CurrentRegistration"
}
}
}
},
"204": {
"description": "No Content"
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
}
}
} }
}, },
"/self-service/registrations/{id}/withdraw": { "/self-service/registrations/{id}/withdraw": {
@@ -205,6 +231,21 @@
}, },
"components": { "components": {
"schemas": { "schemas": {
"CurrentRegistration": {
"required": [
"registrationId",
"status"
],
"type": "object",
"properties": {
"registrationId": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"DecideRequest": { "DecideRequest": {
"required": [ "required": [
"besluit" "besluit"
+15
View File
@@ -141,6 +141,21 @@ app.MapGet("/behandel/werkbak", async (Werkbak werkbak, CancellationToken ct) =>
Results.Ok(await werkbak.GetAsync(ct))); Results.Ok(await werkbak.GetAsync(ct)));
// Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually). // Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually).
// The citizen's current open registration, looked up by bsn — lets the self-service portal resume
// after a refresh (S-26). The BFF forwards the bsn from the DigiD token; the domain trusts its
// callers (§8.3). 404 when the citizen has none in flight.
app.MapGet("/registrations/current", async (string bsn, IRegistrationStore store, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(bsn))
return Results.BadRequest("A bsn is required.");
var registration = await store.FindOpenByBsnAsync(bsn, ct);
return registration is null
? Results.NotFound()
: Results.Ok(new RegistrationResponse(
registration.Id.ToString(), registration.Status.ToString(), registration.ZaakUrl?.ToString()));
});
app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) => app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) =>
{ {
if (!Guid.TryParse(id, out var guid)) if (!Guid.TryParse(id, out var guid))
+5
View File
@@ -102,6 +102,11 @@ public interface IRegistrationStore
/// <summary>Load a registration by id, or <c>null</c> if none exists.</summary> /// <summary>Load a registration by id, or <c>null</c> if none exists.</summary>
Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default); Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default);
/// <summary>The citizen's current <em>open</em> (non-terminal: INGEDIEND/IN_BEHANDELING)
/// registration, or <c>null</c> if they have none in flight. Lets the self-service portal resume
/// an existing registration after a refresh (S-26); terminal registrations are not resumed.</summary>
Task<Registration?> FindOpenByBsnAsync(string bsn, CancellationToken ct = default);
} }
/// <summary> /// <summary>
@@ -22,4 +22,8 @@ public sealed class InMemoryRegistrationStore : IRegistrationStore
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default) public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
=> Task.FromResult(_byId.GetValueOrDefault(id)); => Task.FromResult(_byId.GetValueOrDefault(id));
public Task<Registration?> FindOpenByBsnAsync(string bsn, CancellationToken ct = default)
=> Task.FromResult(_byId.Values.FirstOrDefault(r =>
r.Bsn == bsn && r.Status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling));
} }
+4
View File
@@ -22,6 +22,10 @@ internal sealed class FakeRegistrationStore : IRegistrationStore
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default) public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
=> Task.FromResult(_byId.GetValueOrDefault(id)); => Task.FromResult(_byId.GetValueOrDefault(id));
public Task<Registration?> FindOpenByBsnAsync(string bsn, CancellationToken ct = default)
=> Task.FromResult(_byId.Values.FirstOrDefault(r =>
r.Bsn == bsn && r.Status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling));
public void Seed(Registration registration) => _byId[registration.Id] = registration; public void Seed(Registration registration) => _byId[registration.Id] = registration;
} }
@@ -41,4 +41,59 @@ public class InMemoryRegistrationStoreTests
await Assert.ThrowsAsync<ArgumentNullException>(() => store.SaveAsync(null!)); await Assert.ThrowsAsync<ArgumentNullException>(() => store.SaveAsync(null!));
} }
[Fact]
public async Task Finds_the_open_registration_for_a_bsn()
{
var store = new InMemoryRegistrationStore();
var open = Registration.Submit("123456782");
await store.SaveAsync(open);
var found = await store.FindOpenByBsnAsync("123456782");
Assert.NotNull(found);
Assert.Equal(open.Id, found.Id);
}
[Fact]
public async Task An_in_behandeling_registration_is_still_open()
{
var store = new InMemoryRegistrationStore();
var registration = Registration.Submit("123456782");
registration.TakeIntoBehandeling();
await store.SaveAsync(registration);
Assert.NotNull(await store.FindOpenByBsnAsync("123456782"));
}
[Theory]
[InlineData(nameof(Registration.Withdraw))]
[InlineData(nameof(Registration.Approve))]
[InlineData(nameof(Registration.Reject))]
[InlineData(nameof(Registration.Expire))]
public async Task A_terminal_registration_is_not_returned_as_open(string transition)
{
var store = new InMemoryRegistrationStore();
var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); // Approve requires an opened zaak
switch (transition)
{
case nameof(Registration.Withdraw): registration.Withdraw(); break;
case nameof(Registration.Approve): registration.Approve(); break;
case nameof(Registration.Reject): registration.Reject(); break;
case nameof(Registration.Expire): registration.Expire(); break;
}
await store.SaveAsync(registration);
Assert.Null(await store.FindOpenByBsnAsync("123456782"));
}
[Fact]
public async Task Does_not_return_another_bsns_registration_or_an_unknown_bsn()
{
var store = new InMemoryRegistrationStore();
await store.SaveAsync(Registration.Submit("111111110"));
Assert.Null(await store.FindOpenByBsnAsync("123456782"));
}
} }
+6 -3
View File
@@ -29,9 +29,12 @@ public sealed class EenZaakOpenenSteps
Bronorganisatie = values["bronorganisatie"], Bronorganisatie = values["bronorganisatie"],
VerantwoordelijkeOrganisatie = values["verantwoordelijkeOrganisatie"], VerantwoordelijkeOrganisatie = values["verantwoordelijkeOrganisatie"],
Vertrouwelijkheidaanduiding = values["vertrouwelijkheidaanduiding"], Vertrouwelijkheidaanduiding = values["vertrouwelijkheidaanduiding"],
ZaaktypeUrl = new Uri(values["zaaktype"]), ZaaktypeIdentificatie = "BIG-REGISTRATIE",
InformatieobjecttypeUrl = new Uri("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"), 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 \"(.*)\"")] [Given("today is \"(.*)\"")]
@@ -41,7 +44,7 @@ public sealed class EenZaakOpenenSteps
[When("the domain asks the ACL to open a zaak")] [When("the domain asks the ACL to open a zaak")]
public async Task WhenTheDomainAsksTheAclToOpenAZaak() 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!); _returnedUrl = await service.OpenZaakAsync(_registration!);
} }
@@ -69,6 +69,9 @@ public sealed class CapturingDomainClient : IDomainClient
return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend")); return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend"));
} }
public Task<CurrentRegistration?> GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default)
=> Task.FromResult<CurrentRegistration?>(null);
public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default) public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
=> Task.FromResult(true); => Task.FromResult(true);
@@ -217,4 +217,8 @@ public sealed class InMemoryRegistrationStore : IRegistrationStore
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default) public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
=> Task.FromResult(_byId.GetValueOrDefault(id)); => Task.FromResult(_byId.GetValueOrDefault(id));
public Task<Registration?> FindOpenByBsnAsync(string bsn, CancellationToken ct = default)
=> Task.FromResult(_byId.Values.FirstOrDefault(r =>
r.Bsn == bsn && r.Status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling));
} }
@@ -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)? Approved { get; private set; }
public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Cancelled { 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) public Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
{ {
Captured = request; Captured = request;
@@ -37,4 +42,10 @@ public sealed class InMemoryZaakGateway : IZaakGateway
public Task<Uri> StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default) public Task<Uri> StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default)
=> Task.FromResult(new Uri("http://openzaak/documenten/api/v1/enkelvoudiginformatieobjecten/acc-doc")); => 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);
} }
+13 -1
View File
@@ -12,6 +12,12 @@ export default defineConfig({
timeout: 90_000, timeout: 90_000,
expect: { timeout: 15_000 }, expect: { timeout: 15_000 },
retries: 1, retries: 1,
// Run the specs serially. Each spec drives a full `channel: 'chromium'` browser, and the e2e
// shares an 8 GB runner with the entire compose stack (OpenZaak, NRC, Keycloak, Flowable, 4×
// Postgres, every service + 3 portals). Two parallel browsers exhaust memory and the renderer is
// OOM-killed mid-action ("Page crashed") — fixing the flakiness at its source rather than leaning
// on `retries` (CLAUDE.md §15). Only two long-running happy-path specs, so serial costs little.
workers: 1,
reporter: [['list']], reporter: [['list']],
use: { use: {
baseURL, baseURL,
@@ -26,7 +32,13 @@ export default defineConfig({
// headless), not Playwright's default headless-shell, so pin `channel: 'chromium'`. // headless), not Playwright's default headless-shell, so pin `channel: 'chromium'`.
channel: 'chromium', channel: 'chromium',
launchOptions: { launchOptions: {
args: [`--unsafely-treat-insecure-origin-as-secure=${baseURL},${behandelURL}`], args: [
`--unsafely-treat-insecure-origin-as-secure=${baseURL},${behandelURL}`,
// Write Chromium's shared memory to /tmp instead of the container's small /dev/shm, so a
// large DOM/heap can't crash the renderer on the memory-constrained runner (belt-and-braces
// alongside the single worker above).
'--disable-dev-shm-usage',
],
}, },
}, },
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
+5 -2
View File
@@ -13,8 +13,11 @@ test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login. // Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
await page.goto('/'); await page.goto('/');
// Keycloak's default login form (stable ids across themes). // Keycloak's default login form (stable ids across themes). Its own DigiD user: the verify-* API
await page.locator('#username').fill('jan-burger'); // checks submit as jan-burger (bsn 123456782) before the e2e runs on the shared stack, and
// resume-on-load (S-26) would otherwise restore one of those on login — so each self-service spec
// uses a dedicated citizen no other actor touches.
await page.locator('#username').fill('emma-burger');
await page.locator('#password').fill('test123'); await page.locator('#password').fill('test123');
await page.locator('#kc-login').click(); await page.locator('#kc-login').click();
+33
View File
@@ -0,0 +1,33 @@
import { expect, test } from '@playwright/test';
// S-26: a zorgprofessional submits, then reloads the self-service portal. On load the portal asks the
// BFF for the caller's current open registration (owner-scoped by the DigiD token's bsn) and restores
// the submitted view — so a refresh no longer strands the in-flight registration and its actions.
test('DigiD submit → reload → self-service restores the existing registration', async ({ page }) => {
await page.goto('/');
// Its own DigiD user (like every self-service spec): on the shared verify stack, resume-on-load
// (S-26) restores any open registration for the bsn, so each spec uses a dedicated citizen that no
// other spec or verify-* check touches. This one in particular leaves an open registration.
await page.locator('#username').fill('sanne-burger');
await page.locator('#password').fill('test123');
await page.locator('#kc-login').click();
await expect(page.getByRole('heading', { name: /Zelfservice/i })).toBeVisible();
await page.getByRole('button', { name: /indienen/i }).click();
const confirmation = page.getByText(/ontvangen/i);
await expect(confirmation).toBeVisible();
const reference = (await confirmation.textContent())?.match(/Referentie:\s*([0-9a-fA-F-]+)/)?.[1];
expect(reference, 'the confirmation shows a registration reference').toBeTruthy();
// Reload: the component's in-memory submitted state is gone, but the DigiD session persists and the
// portal resumes from the BFF instead of dropping back to the blank submit form.
await page.reload();
await expect(page.getByText(/ontvangen/i)).toBeVisible();
// The same reference the citizen saw before the reload is restored...
await expect(page.getByText(new RegExp(reference!))).toBeVisible();
// ...and its actions are reachable again (e.g. "trek aanvraag in").
await expect(page.getByRole('button', { name: /trek aanvraag in/i })).toBeVisible();
});
+3 -1
View File
@@ -8,7 +8,9 @@ test('DigiD submit → trek aanvraag in → self-service confirms ingetrokken',
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login. // Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
await page.goto('/'); await page.goto('/');
await page.locator('#username').fill('jan-burger'); // Its own DigiD user — isolated from the verify-* checks (jan-burger/123456782) so resume-on-load
// (S-26) can't restore someone else's registration on the shared stack.
await page.locator('#username').fill('lars-burger');
await page.locator('#password').fill('test123'); await page.locator('#password').fill('test123');
await page.locator('#kc-login').click(); await page.locator('#kc-login').click();