Compare commits
10 Commits
1c185e6686
...
feat/13-be
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cb3ce6956 | |||
| 4ebc263bdf | |||
| d50a60c98b | |||
| 94d5feb6e0 | |||
| c005b0d627 | |||
| 9c3da48d8e | |||
| 4085bdead7 | |||
| d4ed0ffc22 | |||
| 3023bb6fbe | |||
| 9997da8beb |
113
CHANGELOG.md
113
CHANGELOG.md
@@ -2,19 +2,130 @@
|
||||
|
||||
All notable changes to this project. Generated from Conventional Commits by git-cliff.
|
||||
|
||||
## Unreleased
|
||||
## v2026.07.0 — 2026-07-14
|
||||
|
||||
### Architecture
|
||||
- ADR-0005 adopt Stryker.NET for mutation testing (refs #47)
|
||||
- ADR-0006 — provision the ACL integration test against the compose stack (refs #46)
|
||||
- ADR-0007 + runbooks for the OZ→NRC notification wiring (refs #56)
|
||||
- ADR-0009 external-task job-worker pattern (refs #6, #60)
|
||||
- ADR-0010 BFF OIDC validation + downstream boundaries (refs #8, #63)
|
||||
|
||||
### Bug Fixes
|
||||
- Pin OpenZaak/NRC image tags; add smoke log capture on failure (refs #30)
|
||||
- Harden oz-db healthcheck and raise compose-up timeout (refs #30)
|
||||
- Bake config into images so compose-smoke passes on CI (refs #30)
|
||||
- Nrc-init runs migrations only, not setup_configuration (refs #30)
|
||||
- Smoke waits on durable services, not the whole project (refs #30)
|
||||
- Portable health poll instead of compose --wait (refs #30)
|
||||
- Pin upload-artifact to @v3 — @v4 refuses to run on Gitea (refs #47)
|
||||
- Buffer the zaak POST body so OpenZaak accepts it (refs #46)
|
||||
- Keep dotnet format green under the shared .editorconfig (refs #65)
|
||||
- Re-export the full Utrecht package from libs/ui (refs #67)
|
||||
- Run checkAuth() at startup to end the login redirect loop (refs #67)
|
||||
- Health-check nginx over IPv4 (127.0.0.1) (refs #68)
|
||||
- Treat the http portal origin as secure so DigiD PKCE login works (refs #68)
|
||||
- Attach the DigiD token to relative BFF calls (refs #68)
|
||||
|
||||
### Build
|
||||
- Pin Stryker.NET as a local dotnet tool (refs #47)
|
||||
|
||||
### CI
|
||||
- Gitea Actions pipeline + runner runbook (refs #30) (#37)
|
||||
- ACL Dockerfile + full compose stack for smoke test (refs #30)
|
||||
- Switch runner label to ubuntu-latest (refs #30)
|
||||
- Run the mutation ratchet as a parallel CI job (refs #47)
|
||||
- Publish the Stryker HTML report as a CI artifact (refs #47)
|
||||
- Run the ACL integration test as a Gitea Actions job (refs #46)
|
||||
- Keep the integration lane local-only; document the runner gap (refs #46)
|
||||
- Run the ACL integration test in CI inside the compose network (closes #55) (refs #46)
|
||||
- Run the Event Subscriber + projection-api in compose and verify end-to-end (refs #7)
|
||||
- Containerize, wire into compose, and verify end-to-end (refs #6)
|
||||
- Make Stryker report upload best-effort (refs #62)
|
||||
- Retrigger after runner cleanup (refs #6)
|
||||
- Retrigger CI (refs #6)
|
||||
- Retrigger CI after gitea restart (refs #6)
|
||||
- Compose wiring, verify-bff live check, mutation baseline (refs #8)
|
||||
- Nx frontend lane (lint/test/build) (refs #65)
|
||||
- Serve the self-service app in compose (refs #68)
|
||||
- Run Vitest ahead of the production build to stop worker-start timeout (refs #68)
|
||||
- Cache the NuGet package store across the .NET jobs (refs #73)
|
||||
- Run Playwright from the prebuilt image instead of downloading browsers (refs #73)
|
||||
|
||||
### Chores
|
||||
- Add idempotent Gitea backlog seeder
|
||||
- Remove bootstrap scripts from main (#35)
|
||||
- Contributor workflow — templates, git-cliff, gitea-workflow doc (closes #31) (#38)
|
||||
|
||||
### Documentation
|
||||
- Split S-00 into sub-slices (refs #1) (#33)
|
||||
- MkDocs scaffold + ADR-0001 + README quickstart (closes #32) (#39)
|
||||
- Tighten gitea-actions-gotchas, add local compose (refs #30)
|
||||
- ADR-0008 read projection store + demo note for the event path (refs #7)
|
||||
- Demo note for submitting a registration (S-05) (refs #6)
|
||||
- Demo note for the BFF front door (S-07) (refs #8)
|
||||
- Split S-08 into S-08a-d (refs #65)
|
||||
- Frontend-decisions + demo note for S-08a (refs #65)
|
||||
- Record the orval generator choice (refs #66)
|
||||
- Record NL DS + DigiD decisions and demo note (refs #67)
|
||||
- Serving/e2e decisions + walking-skeleton demo note (refs #68)
|
||||
|
||||
### Features
|
||||
- Placeholder BFF + /health endpoint (closes #28) (#34)
|
||||
- Containerize BFF + compose-up smoke (closes #29) (#36)
|
||||
- OpenZaak + Postgres + Redis up in compose (refs #10) (#40)
|
||||
- Seed BIG catalogus + JWT client for OpenZaak (refs #2) (#41)
|
||||
- Open Notificaties up + shared network (closes #2) (#42)
|
||||
- Keycloak with four mock realms (closes #3) (#43)
|
||||
- Flowable + registratie.bpmn external task (closes #4) (#44)
|
||||
- ACL skeleton — OpenZaak default-fill (refs #5) (#45)
|
||||
- Add bind-mount local compose for no-make/Windows dev (refs #30)
|
||||
- Publish the BIG zaaktype on demand via OZ_PUBLISH (refs #46)
|
||||
- Wire OpenZaak → Open Notificaties notifications (refs #56)
|
||||
- Project zaak-created notifications into the read projection (refs #7)
|
||||
- Persist the read projection and expose webhook + read APIs (refs #7)
|
||||
- Enforce the callback bearer before reading the body (refs #7)
|
||||
- Implement the Registration aggregate invariants (refs #6)
|
||||
- Implement SubmitRegistration and OpenZaakWorker (refs #6)
|
||||
- Implement the Flowable Workflow Client and ACL client (refs #6)
|
||||
- Expose POST /registrations and the read endpoint (refs #6)
|
||||
- Implement self-service submit and openbaar lookup (refs #8)
|
||||
- Committed OpenAPI contract + drift guard (refs #8)
|
||||
- Self-service portal placeholder page (refs #65)
|
||||
- Expose the generated BFF client + repeatable generate target (refs #66)
|
||||
- Implement the DigiD registration submit page (refs #67)
|
||||
- Runtime config + nginx serve/proxy image (refs #68)
|
||||
- Surface submit failures with a retryable alert (refs #68)
|
||||
- One citizen reference across self-service and the openbaar register (#79)
|
||||
|
||||
### Other
|
||||
- Openbaar Register portal — public lookup (#76)
|
||||
- Approval flow — temp admin endpoint + status transition to projection (#77)
|
||||
|
||||
### Refactor
|
||||
- Bake config via dockerfile_inline, drop Dockerfile files (refs #30)
|
||||
- Use upstream images verbatim, seed config via docker cp (refs #30)
|
||||
- One verify-stack stage for all live-stack checks (closes #58) (refs #46 #56)
|
||||
|
||||
### Tests
|
||||
- BDD acceptance scenario for opening a zaak (closes #5) (#49)
|
||||
- Kill surviving mutants — assert CRS headers, guards, error paths, JWT claims (refs #47)
|
||||
- Add Stryker config + mutation make target recording the 95% baseline (refs #47)
|
||||
- Integration test opens a real zaak against OpenZaak (refs #46)
|
||||
- Verify-notifications smoke + CI job for the OZ→NRC path (refs #56)
|
||||
- Project zaak-created notifications into the read projection (refs #7)
|
||||
- Ratchet projector mutation baseline to 100% (refs #7)
|
||||
- Registration aggregate invariants (refs #6)
|
||||
- SubmitRegistration + OpenZaakWorker use cases (refs #6)
|
||||
- Workflow Client, ACL client, store and job processor (refs #6)
|
||||
- Acceptance scenario for submitting a registration (refs #6)
|
||||
- Mutation baseline 90 (achieved 97.7%) + CI/Makefile wiring (refs #6)
|
||||
- Endpoints, JWT auth and public-safe projection (refs #8)
|
||||
- Acceptance scenario for BFF access (valid/invalid tokens) (refs #8)
|
||||
- Self-service portal placeholder renders (refs #65)
|
||||
- Generated BFF client is exposed and calls the endpoints (refs #66)
|
||||
- DigiD-guarded registration submit page (refs #67)
|
||||
- Walking-skeleton Playwright happy path + verify-e2e lane (refs #68)
|
||||
- Submit surfaces BFF failures instead of swallowing them (refs #68)
|
||||
- Guard that the DigiD token attaches to relative BFF calls (refs #68)
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<tbody>
|
||||
@for (entry of entries(); track entry.id) {
|
||||
<tr>
|
||||
<td>{{ entry.id }}</td>
|
||||
<td>{{ entry.reference }}</td>
|
||||
<td>{{ entry.status }}</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import { axe } from 'vitest-axe';
|
||||
import { RegisterPage } from './register-page';
|
||||
|
||||
const sample: OpenbaarEntry[] = [
|
||||
{ id: 'zaak-abc', status: 'INGEDIEND' },
|
||||
{ id: 'zaak-def', status: 'INGESCHREVEN' },
|
||||
{ id: 'zaak-abc', status: 'INGEDIEND', reference: 'REG-abc' },
|
||||
{ id: 'zaak-def', status: 'INGESCHREVEN', reference: 'REG-def' },
|
||||
];
|
||||
|
||||
function providers(get = vi.fn().mockReturnValue(of(sample))) {
|
||||
@@ -22,9 +22,11 @@ describe('RegisterPage', () => {
|
||||
await render(RegisterPage, { providers: providers(get).providers });
|
||||
|
||||
expect(get).toHaveBeenCalled();
|
||||
expect(await screen.findByText(/zaak-abc/)).toBeTruthy();
|
||||
// The Referentie column shows the citizen's reference (matches the submit confirmation, #78),
|
||||
// not the internal zaak id.
|
||||
expect(await screen.findByText(/REG-abc/)).toBeTruthy();
|
||||
expect(screen.getByText(/INGEDIEND/)).toBeTruthy();
|
||||
expect(screen.getByText(/zaak-def/)).toBeTruthy();
|
||||
expect(screen.getByText(/REG-def/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('searches by the entered term', async () => {
|
||||
|
||||
104
docs/architecture/adr-0012-citizen-reference-correlation.md
Normal file
104
docs/architecture/adr-0012-citizen-reference-correlation.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# ADR-0012: One citizen-facing reference across self-service and the openbaar register
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-14
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** #78 (adr-proposal); builds on ADR-0008 (read projection), ADR-0001 (loose coupling), ADR-0009 (external-task worker / zaak creation)
|
||||
|
||||
## Context
|
||||
|
||||
A citizen submits through the self-service portal and is shown a confirmation with a
|
||||
**reference** so they can find their registration back in the public register. But the two
|
||||
sides showed **different identifiers**:
|
||||
|
||||
- The self-service confirmation shows the **domain `registrationId`** — a GUID minted by the
|
||||
domain aggregate (`RegistrationId.New()`) when the registration is created, before any zaak
|
||||
exists.
|
||||
- The openbaar register showed the **zaak id** — the UUID from the NRC `hoofdObject` URL,
|
||||
assigned by OpenZaak when the ACL opens the zaak.
|
||||
|
||||
These never match, so the reference on the confirmation was useless for looking the entry up.
|
||||
The two identifiers live on opposite sides of the ACL boundary and are generated by different
|
||||
systems at different times, so there is no way to reconcile them after the fact without a
|
||||
correlating value carried across the boundary.
|
||||
|
||||
The NRC notification the Event Subscriber consumes carries only the zaak URL plus the fixed
|
||||
`kenmerken` (`bronorganisatie`, `zaaktype`, `vertrouwelijkheidaanduiding`) — **not** the
|
||||
`registrationId`, the bsn, or the `identificatie`. ADR-0008 already recorded that filling any
|
||||
such field means reading the zaak **through the ACL** (§8.1) and deferred it as a follow-up.
|
||||
This is that follow-up, scoped to the one field the citizen actually needs.
|
||||
|
||||
## Decision
|
||||
|
||||
**Use the domain `registrationId` as the zaak's `identificatie`, and surface that single value
|
||||
as the citizen-facing `reference` on both portals. The Event Subscriber enriches the projection
|
||||
with the reference by reading the zaak through the ACL, and stores it in the replay log so
|
||||
rebuild stays log-only.**
|
||||
|
||||
Concretely, following the request path:
|
||||
|
||||
1. **Domain → ACL (write).** When the OpenZaak worker opens a zaak, it passes
|
||||
`registration.Id` to the ACL (`IAclClient.OpenZaakAsync(bsn, reference, …)`). The ACL sets
|
||||
it as the zaak's `identificatie` on `POST /zaken`. OpenZaak's `identificatie` is unique per
|
||||
`bronorganisatie` and ≤ 40 chars — a GUID string fits. The ACL remains the only code that
|
||||
constructs ZGW payloads (§8.1); the domain never sees a ZGW URL.
|
||||
2. **Event Subscriber → ACL (read).** On a notification, the subscriber asks the ACL for the
|
||||
zaak's reference via a new `POST /zaken/reference` endpoint (`{ zaakUrl } → { reference }`),
|
||||
which reads the zaak's `identificatie` through the ACL's OpenZaak gateway. The subscriber
|
||||
still never talks to ZGW itself (§8.1) — it depends only on the ACL, over HTTP.
|
||||
3. **Projection + replay log.** The reference is written both to the `register_projection` row
|
||||
**and** to the `processed_notifications` replay log (a new nullable `reference` column on
|
||||
each). Storing it in the log is what keeps ADR-0008's "**rebuild replays the log, not
|
||||
OpenZaak**" invariant true: `POST /admin/rebuild` reproduces the reference from the log
|
||||
without re-reading the ACL.
|
||||
4. **BFF + openbaar.** The public view (`OpenbaarProjection.PublicView`) exposes
|
||||
`id`, `status`, and `reference` (never bsn/naam), and the openbaar search matches on either
|
||||
`id` or `reference`. The openbaar register's "Referentie" column now renders `reference`.
|
||||
|
||||
The end-to-end guarantee is asserted in the Playwright walking-skeleton: the reference captured
|
||||
from the submit confirmation must appear as a cell in the public register.
|
||||
|
||||
### Why HTTP to the ACL, not the ACL as a library
|
||||
|
||||
ADR-0008 floated "extend the ACL with a zaak-read operation, consumed as a library." We instead
|
||||
call the ACL **over HTTP**, consistent with every other cross-service hop in this system
|
||||
(portals→BFF, domain→ACL). Sharing the ACL as a library would couple the subscriber to the
|
||||
ACL's infrastructure assembly and its ZGW client configuration, defeating the anti-corruption
|
||||
boundary. The HTTP endpoint keeps the ACL the single owner of ZGW access and its config.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- One reference, end to end: the citizen's confirmation value is exactly what the public
|
||||
register shows and searches by.
|
||||
- §8.1 stays intact — only the ACL reads or writes ZGW; the subscriber depends on the ACL, not
|
||||
OpenZaak.
|
||||
- Rebuild stays log-only (ADR-0008): the reference is replayed from `processed_notifications`,
|
||||
so `/admin/rebuild` needs no ACL/ZGW access.
|
||||
- The column additions are nullable and additive; older rows without a reference are tolerated.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- A new coupling: the Event Subscriber now depends on the ACL being reachable
|
||||
(`Acl__BaseUrl`, compose `depends_on: acl`). A registration whose reference read fails will
|
||||
need the notification redelivered (NRC already redelivers; the projection upsert is
|
||||
idempotent).
|
||||
- One extra HTTP hop per notification (subscriber→ACL→OpenZaak) on the projection path. Bounded:
|
||||
one small GET per zaak, off the citizen's request path.
|
||||
- `identificatie` now carries semantic meaning (it equals the `registrationId`). If OpenZaak
|
||||
were ever configured to auto-generate `identificatie`, the correlation would break; the ACL
|
||||
setting it explicitly is now load-bearing.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Carry the `registrationId` in the notification** — rejected: NRC `kenmerken` are fixed and
|
||||
the notification content is not ours to extend; it would also couple the projection to a
|
||||
bespoke notification shape.
|
||||
- **Show the zaak id on the confirmation instead** — rejected: the zaak does not exist yet when
|
||||
the confirmation is returned (the worker opens it asynchronously, ADR-0009), so the domain has
|
||||
no zaak id to show at submit time.
|
||||
- **Store only on the projection row, re-read the ACL on rebuild** — rejected: it would make
|
||||
rebuild depend on the ACL/ZGW, breaking ADR-0008's log-only rebuild invariant.
|
||||
- **Reconcile the two ids in a lookup table** — rejected: adds write-only state and a second
|
||||
source of truth for a value that can simply be the same on both sides.
|
||||
76
docs/architecture/adr-0013-behandel-portal-wiring.md
Normal file
76
docs/architecture/adr-0013-behandel-portal-wiring.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# ADR-0013: Behandel-portal wiring — multi-realm BFF auth, werkbak from Flowable tasks, decision completes the task
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-15
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** #84 (adr-proposal), S-12 (#13); builds on ADR-0010 (BFF OIDC), ADR-0011 (approval status flow), ADR-0009 (external-task worker), ADR-0008 (read projection)
|
||||
|
||||
## Context
|
||||
|
||||
S-12 adds the behandel-portal: a behandelaar logs in, sees a **werkbak** of registrations awaiting
|
||||
beoordeling, and decides each (goedkeuren/afwijzen). Three questions had no obvious answer and shape
|
||||
the whole slice.
|
||||
|
||||
1. **Which realm authenticates behandelaars, and how does the BFF accept it?** Citizens use the
|
||||
`digid` realm (ADR-0010); staff use a separate `medewerker` realm with roles (`behandelaar`,
|
||||
`teamlead`). Keycloak realms are distinct issuers with distinct signing keys, so the BFF's single
|
||||
`digid`-realm JWT validation rejects a medewerker token outright.
|
||||
2. **Where does the werkbak get its data?** The registrations awaiting beoordeling could come from
|
||||
the read projection (status-filtered rows) or from the Flowable `Beoordelen` user tasks (S-12b).
|
||||
3. **How does a decision correlate to the workflow?** The process parks at the `Beoordelen` user
|
||||
task; the decision must advance it, and also apply the domain transition (ADR-0011).
|
||||
|
||||
## Decision
|
||||
|
||||
**The BFF validates a second realm for behandel endpoints; the werkbak is the set of open Flowable
|
||||
`Beoordelen` tasks (read through the domain); and a decision both applies the domain transition and
|
||||
completes the Flowable task.**
|
||||
|
||||
- **Multi-realm BFF auth.** The BFF registers a second JWT bearer scheme (`medewerker`, authority =
|
||||
the medewerker realm) alongside the default `digid` scheme. `/behandel/*` endpoints require an
|
||||
authorization policy bound to the `medewerker` scheme **and** the `behandelaar` role. Keycloak puts
|
||||
realm roles in the nested `realm_access.roles` claim, which ASP.NET does not map automatically, so
|
||||
the scheme's `OnTokenValidated` lifts those roles onto the principal as role claims. Self-service
|
||||
keeps the `digid` scheme. Audience validation stays off (ADR-0010's deferred hardening).
|
||||
- **Werkbak = Flowable user tasks (via the domain).** The domain's `Werkbak` query reads the open
|
||||
`Beoordelen` tasks from the Workflow Client (§8.2, `IUserTaskClient`) and enriches each with its
|
||||
aggregate's bsn + status; `GET /behandel/werkbak` exposes it and the BFF proxies it behind the
|
||||
behandelaar policy. The list **is** the authoritative set of claimable/decidable work items, so a
|
||||
decision acts on a real task with no separate correlation store. The read projection stays the
|
||||
anonymous openbaar model — we do **not** project `IN_BEHANDELING` or populate staff-only personal
|
||||
data (both deferred in ADR-0008) just to render a staff view.
|
||||
- **Decision completes the task (S-12c-2).** A behandelaar decision applies the domain transition
|
||||
(aggregate + ACL for approval, per ADR-0011) **and** completes the Flowable `Beoordelen` task
|
||||
(looked up by registrationId), so the process advances. Implemented in the next sub-slice; recorded
|
||||
here so the boundary is decided up front.
|
||||
|
||||
Delivery is split: **S-12c-1** (this PR) = multi-realm auth + werkbak read; **S-12c-2** = the decide
|
||||
endpoint + task completion.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- Staff and citizens are cleanly separated by realm; the `behandelaar` role gates the behandel API.
|
||||
- The werkbak reflects exactly what a behandelaar can act on; claim/decide need no extra correlation.
|
||||
- No premature projection changes — the openbaar read model stays focused and personal-data-free.
|
||||
- Only the ACL/Workflow Client talk to their peers; the BFF still fans out only to domain/projection
|
||||
(§8.3).
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- The BFF now depends on two Keycloak realms being reachable (`Keycloak:MedewerkerAuthority`).
|
||||
- Rendering the werkbak fans out to Flowable (one task query) plus a store read per task — acceptable
|
||||
for the caseload sizes here; a denormalized staff read model is an additive follow-up if needed.
|
||||
- Realm separation (distinct issuers/keys) is validated live, not in the BFF unit tests, where issuer
|
||||
validation is off and one test key signs both realms; the tests exercise the role-based authorization.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Werkbak from the read projection** — rejected for now: needs new plumbing to project
|
||||
`IN_BEHANDELING` and to populate staff-only bsn/naam (deferred, ADR-0008), plus a separate way to
|
||||
find the Flowable task at decide-time. Revisit if a high-volume denormalized staff view is needed.
|
||||
- **One JWT scheme accepting both realms (issuer validation off)** — rejected: trusting multiple
|
||||
issuers without validation is a security regression; two schemes keep each realm's issuer/key checked.
|
||||
- **A dedicated behandel BFF/service** — rejected as premature; one BFF with per-endpoint policies is
|
||||
enough at this size and keeps §8.3 simple.
|
||||
@@ -227,3 +227,24 @@ curl -fsS http://localhost:8140/openbaar/register | jq
|
||||
> **End of walking skeleton** (S-09 + S-09b): submit → process → projection → public visibility, from
|
||||
> INGEDIEND through approval to INGESCHREVEN. The subscriber takes any post-creation status-set as the
|
||||
> approval (ADR-0011) — a walking-skeleton assumption that tightens when more transitions arrive (S-12+).
|
||||
|
||||
## #78 — One reference across both portals (ADR-0012)
|
||||
|
||||
Before this change the self-service confirmation and the openbaar register showed **different**
|
||||
identifiers, so a citizen could not look their registration back up. Now both show the same
|
||||
**reference**: the domain `registrationId` is set as the zaak's `identificatie` by the ACL, and the
|
||||
Event Subscriber enriches the projection with it by reading the zaak through the ACL (§8.1) — storing
|
||||
it in the replay log so rebuild stays log-only (ADR-0008).
|
||||
|
||||
**The path:** domain passes `registrationId` → ACL sets it as `zaak.identificatie` → NRC →
|
||||
Event Subscriber asks the ACL for the reference → projection row + replay log → openbaar register.
|
||||
|
||||
```bash
|
||||
# Submit as in S-09 and note the reference on the confirmation, then find it in the public register:
|
||||
ref="<registration-reference-from-the-confirmation>"
|
||||
curl -fsS "http://localhost:8140/openbaar/register?q=$ref" | jq
|
||||
# → [ { "id": "<zaak-uuid>", "status": "INGEDIEND", "reference": "<same-ref-as-confirmation>" } ]
|
||||
```
|
||||
|
||||
> The openbaar register's "Referentie" column and its search now use this reference — the exact value
|
||||
> the citizen saw on submit. Asserted end-to-end by the Playwright happy path.
|
||||
|
||||
@@ -349,6 +349,8 @@ services:
|
||||
# Keycloak (start-dev) derives the issuer from the request host, so the BFF authority and the
|
||||
# verify token request both use keycloak:8080 to keep the issuer consistent.
|
||||
Keycloak__Authority: http://keycloak:8080/realms/digid
|
||||
# Behandelaars authenticate against the medewerker realm; the BFF validates it for /behandel/* (S-12c).
|
||||
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
|
||||
Downstream__Domain__BaseUrl: http://domain:8080/
|
||||
Downstream__Projection__BaseUrl: http://projection-api:8080/
|
||||
ports:
|
||||
@@ -396,6 +398,9 @@ services:
|
||||
image: register-referentie/event-subscriber:dev
|
||||
environment:
|
||||
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
|
||||
# The subscriber enriches the projection with each zaak's reference (identificatie) by asking
|
||||
# the ACL — the only code allowed to read ZGW (§8.1, #78).
|
||||
Acl__BaseUrl: http://acl:8080/
|
||||
# The bearer Open Notificaties must present on the abonnement callback. NRC's
|
||||
# registration probe expects a 401 without it (ADR-0007). Dev-only token.
|
||||
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
|
||||
@@ -410,6 +415,8 @@ services:
|
||||
depends_on:
|
||||
projection-db:
|
||||
condition: service_healthy
|
||||
acl:
|
||||
condition: service_healthy
|
||||
networks: [cg]
|
||||
|
||||
# The read side of the projection. Shares Projection.ReadModel, so build context is root.
|
||||
|
||||
@@ -51,18 +51,71 @@ loc="$(docker run --rm --network "$net" curlimages/curl:latest \
|
||||
echo ">> registration accepted at $loc"
|
||||
|
||||
echo ">> polling the domain until the worker records the opened zaak"
|
||||
zaak_ok=""
|
||||
for _ in $(seq 1 30); do
|
||||
body="$(docker run --rm --network "$net" curlimages/curl:latest \
|
||||
-fsS "http://$dom_ip:8080$loc" 2>/dev/null || true)"
|
||||
if echo "$body" | grep -q '/zaken/api/v1/zaken/'; then
|
||||
echo "OK — the domain opened a zaak and recorded it on the registration:"
|
||||
echo "$body" | cut -c1-300
|
||||
exit 0
|
||||
zaak_ok=1
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ -z "$zaak_ok" ]; then
|
||||
echo "FAIL — the registration never received a zaak URL" >&2
|
||||
echo "--- domain log ---" >&2; docker logs "$dom" 2>&1 | tail -15 >&2
|
||||
acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)"
|
||||
[ -n "$acl" ] && { echo "--- acl log ---" >&2; docker logs "$acl" 2>&1 | tail -15 >&2; }
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── S-12b: the process now parks at the Beoordelen user task. Exercise the exact Flowable REST
|
||||
# contract the Workflow Client uses (query/claim/complete) against the live engine, reaching
|
||||
# flowable-rest by container IP (same in-network constraint as above). ──────────────────────────
|
||||
fl="$(docker ps -q --filter 'name=flowable-rest' | head -1)"
|
||||
[ -n "$fl" ] || { echo "ERROR: no running flowable-rest container" >&2; exit 1; }
|
||||
fl_base="http://$(ip "$fl"):8080/flowable-rest/service"
|
||||
reg_id="${loc##*/}"
|
||||
|
||||
# Extracts the Beoordelen task id for our registration from a Flowable task-query response on stdin.
|
||||
# Tolerates an empty/non-JSON body (a transient failure during the poll) by printing nothing.
|
||||
task_for_reg() { REG_ID="$reg_id" python3 -c "import os,sys,json
|
||||
try:
|
||||
d=json.load(sys.stdin)
|
||||
except Exception:
|
||||
d={}
|
||||
rid=os.environ['REG_ID']
|
||||
# Flowable's task-query returns the included process variables under 'variables'.
|
||||
print(next((t['id'] for t in (d.get('data') or [])
|
||||
if any(v.get('name')=='registrationId' and v.get('value')==rid for v in (t.get('variables') or []))), ''))"; }
|
||||
|
||||
flcurl() { docker run --rm --network "$net" curlimages/curl:latest -fsS -u rest-admin:test "$@"; }
|
||||
query='{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen","includeProcessVariables":true}'
|
||||
|
||||
echo ">> polling Flowable for the Beoordelen user task (werkbak)"
|
||||
task_id=""
|
||||
for _ in $(seq 1 30); do
|
||||
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
|
||||
task_id="$(printf '%s' "$resp" | task_for_reg)"
|
||||
[ -n "$task_id" ] && break
|
||||
sleep 2
|
||||
done
|
||||
[ -n "$task_id" ] || { echo "FAIL — no Beoordelen task appeared for registration $reg_id" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
|
||||
echo ">> Beoordelen task $task_id is waiting"
|
||||
|
||||
echo ">> claiming the task as merel-behandelaar"
|
||||
flcurl -X POST "$fl_base/runtime/tasks/$task_id" -H 'Content-Type: application/json' \
|
||||
-d '{"action":"claim","assignee":"merel-behandelaar"}' >/dev/null
|
||||
|
||||
echo ">> completing the beoordeling (goedkeuren)"
|
||||
flcurl -X POST "$fl_base/runtime/tasks/$task_id" -H 'Content-Type: application/json' \
|
||||
-d '{"action":"complete","variables":[{"name":"besluit","type":"string","value":"goedkeuren"}]}' >/dev/null
|
||||
|
||||
echo ">> asserting the process finished (no Beoordelen task remains for the registration)"
|
||||
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query")"
|
||||
still="$(printf '%s' "$resp" | task_for_reg)"
|
||||
[ -z "$still" ] || { echo "FAIL — Beoordelen task $still still active after completion" >&2; exit 1; }
|
||||
echo "OK — behandelaar claimed and completed the Beoordelen task; the registratie process finished"
|
||||
exit 0
|
||||
|
||||
@@ -24,9 +24,15 @@ import {
|
||||
Observable
|
||||
} from 'rxjs';
|
||||
|
||||
export interface DecideRequest {
|
||||
besluit: string;
|
||||
}
|
||||
|
||||
export interface OpenbaarEntry {
|
||||
id: string;
|
||||
status: string;
|
||||
/** @nullable */
|
||||
reference: string | null;
|
||||
}
|
||||
|
||||
export interface SubmitAccepted {
|
||||
@@ -34,6 +40,12 @@ export interface SubmitAccepted {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface WerkbakItem {
|
||||
registrationId: string;
|
||||
bsn: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export type GetOpenbaarRegisterParams = {
|
||||
q?: string;
|
||||
};
|
||||
@@ -213,4 +225,73 @@ export class BffApiV1Service {
|
||||
);
|
||||
}
|
||||
|
||||
getBehandelWerkbak<TData = WerkbakItem[]>( options?: HttpClientBodyOptions): Observable<TData>;
|
||||
getBehandelWerkbak<TData = WerkbakItem[]>( options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||
getBehandelWerkbak<TData = WerkbakItem[]>( options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||
getBehandelWerkbak<TData = WerkbakItem[]>(
|
||||
options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
||||
if (options?.observe === 'events') {
|
||||
return this.http.get<TData>(
|
||||
`/behandel/werkbak`,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'events',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (options?.observe === 'response') {
|
||||
return this.http.get<TData>(
|
||||
`/behandel/werkbak`,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'response',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return this.http.get<TData>(
|
||||
`/behandel/werkbak`,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'body',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
postBehandelRegistrationsIdDecide<TData = void>(id: string,
|
||||
decideRequest: DecideRequest, options?: HttpClientBodyOptions): Observable<TData>;
|
||||
postBehandelRegistrationsIdDecide<TData = void>(id: string,
|
||||
decideRequest: DecideRequest, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||
postBehandelRegistrationsIdDecide<TData = void>(id: string,
|
||||
decideRequest: DecideRequest, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||
postBehandelRegistrationsIdDecide<TData = void>(
|
||||
id: string,
|
||||
decideRequest: DecideRequest, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
||||
if (options?.observe === 'events') {
|
||||
return this.http.post<TData>(
|
||||
`/behandel/registrations/${id}/decide`,
|
||||
decideRequest,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'events',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (options?.observe === 'response') {
|
||||
return this.http.post<TData>(
|
||||
`/behandel/registrations/${id}/decide`,
|
||||
decideRequest,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'response',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return this.http.post<TData>(
|
||||
`/behandel/registrations/${id}/decide`,
|
||||
decideRequest,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'body',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ app.MapGet("/health", () => "Healthy");
|
||||
// The ACL's single operation, exposed as a service endpoint.
|
||||
app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationToken ct) =>
|
||||
{
|
||||
var zaakUrl = await acl.OpenZaakAsync(new DomainRegistration(body.Bsn), ct);
|
||||
var zaakUrl = await acl.OpenZaakAsync(new DomainRegistration(body.Bsn, body.Reference), ct);
|
||||
return Results.Ok(new { zaakUrl = zaakUrl.ToString() });
|
||||
});
|
||||
|
||||
@@ -32,10 +32,20 @@ app.MapPost("/statussen", async (SetStatusRequest body, AclService acl, Cancella
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
// Read a zaak's public-safe reference (its identificatie). The Event Subscriber calls this to enrich
|
||||
// the read projection without reading ZGW itself (§8.1, #78).
|
||||
app.MapPost("/zaken/reference", async (ZaakReferenceRequest body, AclService acl, CancellationToken ct) =>
|
||||
{
|
||||
var reference = await acl.GetZaakReferenceAsync(new Uri(body.ZaakUrl), ct);
|
||||
return Results.Ok(new { reference });
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
||||
public sealed record OpenZaakRequest(string Bsn);
|
||||
public sealed record OpenZaakRequest(string Bsn, string Reference);
|
||||
|
||||
public sealed record SetStatusRequest(string ZaakUrl);
|
||||
|
||||
public sealed record ZaakReferenceRequest(string ZaakUrl);
|
||||
|
||||
public partial class Program;
|
||||
|
||||
@@ -13,7 +13,8 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
|
||||
defaults.VerantwoordelijkeOrganisatie,
|
||||
defaults.Vertrouwelijkheidaanduiding,
|
||||
defaults.ZaaktypeUrl,
|
||||
clock.Today);
|
||||
clock.Today,
|
||||
registration.Reference);
|
||||
|
||||
return gateway.OpenZaakAsync(request, ct);
|
||||
}
|
||||
@@ -28,4 +29,12 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
|
||||
|
||||
return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
|
||||
}
|
||||
|
||||
/// <summary>The zaak's reference (its ZGW identificatie), for the read projection (#78).</summary>
|
||||
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||
|
||||
return gateway.GetZaakIdentificatieAsync(zaakUrl, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
namespace Acl.Application;
|
||||
|
||||
/// <summary>Domain-language payload handed to the ACL. No ZGW concepts here.</summary>
|
||||
public sealed record DomainRegistration(string Bsn);
|
||||
/// <summary>Domain-language payload handed to the ACL. No ZGW concepts here. <see cref="Reference"/>
|
||||
/// is the registration's own id; the ACL records it as the zaak identificatie (adr-proposal #78).</summary>
|
||||
public sealed record DomainRegistration(string Bsn, string Reference);
|
||||
|
||||
@@ -12,4 +12,8 @@ public interface IZaakGateway
|
||||
/// the catalogus and POSTs a status against the zaak, dated <paramref name="datumStatusGezet"/>.
|
||||
/// </summary>
|
||||
Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Read the zaak's <c>identificatie</c> — the public-safe reference the register shows.
|
||||
/// The Event Subscriber calls this through the ACL rather than reading ZGW itself (§8.1, #78).</summary>
|
||||
Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -6,4 +6,5 @@ public sealed record ZaakRequest(
|
||||
string VerantwoordelijkeOrganisatie,
|
||||
string Vertrouwelijkheidaanduiding,
|
||||
Uri Zaaktype,
|
||||
DateOnly Startdatum);
|
||||
DateOnly Startdatum,
|
||||
string Identificatie);
|
||||
|
||||
@@ -20,7 +20,8 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
||||
request.Zaaktype.ToString(),
|
||||
request.VerantwoordelijkeOrganisatie,
|
||||
request.Startdatum.ToString("yyyy-MM-dd"),
|
||||
request.Vertrouwelijkheidaanduiding)),
|
||||
request.Vertrouwelijkheidaanduiding,
|
||||
request.Identificatie)),
|
||||
};
|
||||
message.Headers.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
|
||||
@@ -61,6 +62,24 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
||||
"Setting the zaak status", ct);
|
||||
}
|
||||
|
||||
public async Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||
|
||||
using var message = new HttpRequestMessage(HttpMethod.Get, zaakUrl);
|
||||
message.Headers.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
|
||||
// The zaak is a geo resource; the Zaken API requires the CRS header even on GET.
|
||||
message.Headers.Add("Accept-Crs", "EPSG:4326");
|
||||
|
||||
using var response = await http.SendAsync(message, ct);
|
||||
await EnsureSuccessAsync(response, "Reading the zaak", ct);
|
||||
|
||||
var zaak = await response.Content.ReadFromJsonAsync<ZaakReadDto>(ct)
|
||||
?? throw new InvalidOperationException("OpenZaak returned an empty zaak response");
|
||||
return zaak.Identificatie;
|
||||
}
|
||||
|
||||
// POSTs a non-geo ZGW resource (resultaat/status — no CRS headers). Buffers the body so uwsgi gets
|
||||
// a Content-Length instead of a chunked body (as with zaak-create).
|
||||
private async Task PostAsync(string path, object dto, string action, CancellationToken ct)
|
||||
@@ -132,11 +151,15 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
||||
[property: JsonPropertyName("zaaktype")] string Zaaktype,
|
||||
[property: JsonPropertyName("verantwoordelijkeOrganisatie")] string VerantwoordelijkeOrganisatie,
|
||||
[property: JsonPropertyName("startdatum")] string Startdatum,
|
||||
[property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding);
|
||||
[property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding,
|
||||
[property: JsonPropertyName("identificatie")] string Identificatie);
|
||||
|
||||
private sealed record ZaakCreatedDto(
|
||||
[property: JsonPropertyName("url")] string Url);
|
||||
|
||||
private sealed record ZaakReadDto(
|
||||
[property: JsonPropertyName("identificatie")] string Identificatie);
|
||||
|
||||
private sealed record StatusDto(
|
||||
[property: JsonPropertyName("zaak")] string Zaak,
|
||||
[property: JsonPropertyName("statustype")] string Statustype,
|
||||
|
||||
@@ -22,12 +22,14 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
|
||||
"seed it with OZ_PUBLISH=1 (`make integration` does this).");
|
||||
|
||||
var gateway = new OpenZaakGateway(stack.Http, stack.Options);
|
||||
var reference = Guid.NewGuid().ToString(); // zaak identificatie must be unique per bronorganisatie
|
||||
var request = new ZaakRequest(
|
||||
Bronorganisatie: "517439943",
|
||||
VerantwoordelijkeOrganisatie: "517439943",
|
||||
Vertrouwelijkheidaanduiding: "openbaar",
|
||||
Zaaktype: zaaktype!,
|
||||
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow),
|
||||
Identificatie: reference);
|
||||
|
||||
var zaakUrl = await gateway.OpenZaakAsync(request);
|
||||
|
||||
@@ -36,11 +38,12 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
|
||||
new Uri(stack.BaseUrl, "/zaken/api/v1/zaken/").ToString(),
|
||||
zaakUrl.ToString());
|
||||
|
||||
// ...and that zaak is really persisted with the default-filled fields.
|
||||
// ...and that zaak is really persisted with the default-filled fields + the reference identificatie.
|
||||
var zaak = await stack.GetZaakAsync(zaakUrl);
|
||||
Assert.Equal(zaaktype.ToString(), zaak.GetProperty("zaaktype").GetString());
|
||||
Assert.Equal("517439943", zaak.GetProperty("bronorganisatie").GetString());
|
||||
Assert.Equal("openbaar", zaak.GetProperty("vertrouwelijkheidaanduiding").GetString());
|
||||
Assert.Equal(reference, zaak.GetProperty("identificatie").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -57,7 +60,8 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
|
||||
VerantwoordelijkeOrganisatie: "517439943",
|
||||
Vertrouwelijkheidaanduiding: "openbaar",
|
||||
Zaaktype: zaaktype!,
|
||||
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow)));
|
||||
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow),
|
||||
Identificatie: Guid.NewGuid().ToString()));
|
||||
|
||||
await gateway.SetZaakToEindstatusAsync(zaakUrl, zaaktype!, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
|
||||
|
||||
@@ -22,6 +22,14 @@ public class AclServiceTests
|
||||
Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Uri? ReadReferenceFor;
|
||||
|
||||
public Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||
{
|
||||
ReadReferenceFor = zaakUrl;
|
||||
return Task.FromResult("REG-FROM-ZAAK");
|
||||
}
|
||||
}
|
||||
|
||||
private static AclDefaults Defaults() => new()
|
||||
@@ -50,7 +58,7 @@ public class AclServiceTests
|
||||
};
|
||||
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
|
||||
|
||||
var url = await service.OpenZaakAsync(new DomainRegistration("123456782"));
|
||||
var url = await service.OpenZaakAsync(new DomainRegistration("123456782", "reg-77"));
|
||||
|
||||
Assert.Equal(gateway.Result, url);
|
||||
var req = Assert.IsType<ZaakRequest>(gateway.Captured);
|
||||
@@ -59,6 +67,8 @@ public class AclServiceTests
|
||||
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding);
|
||||
Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype);
|
||||
Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum);
|
||||
// The registration reference becomes the zaak identificatie (#78).
|
||||
Assert.Equal("reg-77", req.Identificatie);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -103,4 +113,27 @@ public class AclServiceTests
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!));
|
||||
Assert.Null(gateway.Approved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reading_a_zaak_reference_returns_the_zaaks_identificatie()
|
||||
{
|
||||
var gateway = new FakeGateway();
|
||||
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
||||
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
|
||||
|
||||
var reference = await service.GetZaakReferenceAsync(zaak);
|
||||
|
||||
Assert.Equal("REG-FROM-ZAAK", reference);
|
||||
Assert.Equal(zaak, gateway.ReadReferenceFor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reading_a_null_zaak_reference_is_rejected()
|
||||
{
|
||||
var gateway = new FakeGateway();
|
||||
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.GetZaakReferenceAsync(null!));
|
||||
Assert.Null(gateway.ReadReferenceFor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ public class OpenZaakGatewayTests
|
||||
|
||||
private static ZaakRequest SampleRequest() => new(
|
||||
"517439943", "517439943", "openbaar",
|
||||
new("http://openzaak/catalogi/api/v1/zaaktypen/big"), new DateOnly(2026, 6, 4));
|
||||
new("http://openzaak/catalogi/api/v1/zaaktypen/big"), new DateOnly(2026, 6, 4), "REG-REF-1");
|
||||
|
||||
private static StubHandler Created(out RequestCapture capture)
|
||||
{
|
||||
@@ -67,6 +67,8 @@ public class OpenZaakGatewayTests
|
||||
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", capture.Body);
|
||||
Assert.Contains("\"startdatum\":\"2026-06-04\"", capture.Body);
|
||||
Assert.Contains("\"zaaktype\":\"http://openzaak/catalogi/api/v1/zaaktypen/big\"", capture.Body);
|
||||
// The registration reference is set as the zaak identificatie (#78).
|
||||
Assert.Contains("\"identificatie\":\"REG-REF-1\"", capture.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -357,6 +359,61 @@ public class OpenZaakGatewayTests
|
||||
Assert.Equal(4, rec.Requests.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reading_a_zaak_returns_its_identificatie_with_bearer_and_crs()
|
||||
{
|
||||
RequestCapture? capture = null;
|
||||
var handler = new StubHandler(req =>
|
||||
{
|
||||
capture = new RequestCapture { Seen = req };
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = JsonContent.Create(new { identificatie = "REG-XYZ", url = ZaakUrl }),
|
||||
});
|
||||
});
|
||||
|
||||
var reference = await Gateway(handler).GetZaakIdentificatieAsync(new Uri(ZaakUrl));
|
||||
|
||||
Assert.Equal("REG-XYZ", reference);
|
||||
Assert.Equal(HttpMethod.Get, capture!.Seen!.Method);
|
||||
Assert.Equal(ZaakUrl, capture.Seen.RequestUri!.ToString());
|
||||
Assert.Equal("Bearer", capture.Seen.Headers.Authorization!.Scheme);
|
||||
Assert.Equal("EPSG:4326", Assert.Single(capture.Seen.Headers.GetValues("Accept-Crs")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reading_a_zaak_throws_when_openzaak_rejects_it()
|
||||
{
|
||||
var handler = new StubHandler(_ =>
|
||||
Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("nope") }));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => Gateway(handler).GetZaakIdentificatieAsync(new Uri(ZaakUrl)));
|
||||
Assert.Contains("Reading the zaak", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reading_a_zaak_throws_when_openzaak_returns_an_empty_body()
|
||||
{
|
||||
var handler = new StubHandler(_ =>
|
||||
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("null", System.Text.Encoding.UTF8, "application/json"),
|
||||
}));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => Gateway(handler).GetZaakIdentificatieAsync(new Uri(ZaakUrl)));
|
||||
Assert.Contains("empty zaak response", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reading_a_null_zaak_is_rejected()
|
||||
{
|
||||
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => Gateway(handler).GetZaakIdentificatieAsync(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Approving_rejects_a_null_zaak_or_zaaktype()
|
||||
{
|
||||
|
||||
@@ -6,16 +6,27 @@ namespace Bff.Api;
|
||||
public sealed record SubmitAccepted(string RegistrationId, string Status);
|
||||
|
||||
/// <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).</summary>
|
||||
public sealed record ProjectionEntry(string Id, string Status, string? Bsn, string? NaamPlaceholder);
|
||||
/// 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>
|
||||
public sealed record ProjectionEntry(string Id, string Status, string? Reference, string? Bsn, string? NaamPlaceholder);
|
||||
|
||||
/// <summary>A public-safe openbaar register row — only non-sensitive fields leave the BFF.</summary>
|
||||
public sealed record OpenbaarEntry(string Id, string Status);
|
||||
public sealed record OpenbaarEntry(string Id, string Status, string? Reference);
|
||||
|
||||
/// <summary>A behandelaar's werkbak row: a registration awaiting beoordeling, with the bsn + status a
|
||||
/// behandelaar sees (staff view — reached only behind medewerker/behandelaar authorization, S-12c).</summary>
|
||||
public sealed record WerkbakItem(string RegistrationId, string Bsn, string Status);
|
||||
|
||||
/// <summary>Port to the Domain Service (§8.3: the BFF is the portals' only backend; it fans out).</summary>
|
||||
public interface IDomainClient
|
||||
{
|
||||
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary>
|
||||
Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Apply a behandelaar's decision (<c>goedkeuren</c>/<c>afwijzen</c>) to a registration.</summary>
|
||||
Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Port to the read projection.</summary>
|
||||
@@ -36,6 +47,16 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
|
||||
return new SubmitAccepted(dto.RegistrationId, dto.Status);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||
=> await http.GetFromJsonAsync<List<WerkbakItem>>("behandel/werkbak", ct) ?? [];
|
||||
|
||||
public async Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default)
|
||||
{
|
||||
using var response = await http.PostAsJsonAsync(
|
||||
$"registrations/{registrationId}/decide", new { besluit }, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private sealed record DomainResponse(string RegistrationId, string Status, string? ZaakUrl);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,10 @@ public static class OpenbaarProjection
|
||||
{
|
||||
var filtered = string.IsNullOrWhiteSpace(q)
|
||||
? entries
|
||||
: entries.Where(e => e.Id.Contains(q, StringComparison.OrdinalIgnoreCase));
|
||||
: entries.Where(e =>
|
||||
e.Id.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
(e.Reference?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false));
|
||||
|
||||
return [.. filtered.Select(e => new OpenbaarEntry(e.Id, e.Status))];
|
||||
return [.. filtered.Select(e => new OpenbaarEntry(e.Id, e.Status, e.Reference))];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bff.Api;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
|
||||
@@ -6,6 +8,10 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
var keycloakAuthority = builder.Configuration["Keycloak:Authority"]
|
||||
?? throw new InvalidOperationException("Missing configuration 'Keycloak:Authority'");
|
||||
// Behandelaars authenticate against a *different* Keycloak realm (medewerker) than citizens (digid),
|
||||
// so the BFF validates a second issuer for the behandel endpoints (ADR-0013).
|
||||
var medewerkerAuthority = builder.Configuration["Keycloak:MedewerkerAuthority"]
|
||||
?? throw new InvalidOperationException("Missing configuration 'Keycloak:MedewerkerAuthority'");
|
||||
var domainBaseUrl = builder.Configuration["Downstream:Domain:BaseUrl"]
|
||||
?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'");
|
||||
var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"]
|
||||
@@ -19,8 +25,28 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
options.Authority = keycloakAuthority;
|
||||
options.RequireHttpsMetadata = false;
|
||||
options.TokenValidationParameters.ValidateAudience = false;
|
||||
})
|
||||
// The medewerker realm — behandel endpoints only. On validation we lift Keycloak's realm roles
|
||||
// (the nested realm_access.roles claim) into role claims so authorization policies can require them.
|
||||
.AddJwtBearer(BehandelAuth.Scheme, options =>
|
||||
{
|
||||
options.Authority = medewerkerAuthority;
|
||||
options.RequireHttpsMetadata = false;
|
||||
options.TokenValidationParameters.ValidateAudience = false;
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnTokenValidated = context =>
|
||||
{
|
||||
BehandelAuth.AddRealmRoles(context.Principal);
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
};
|
||||
});
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddAuthorization(options =>
|
||||
options.AddPolicy(BehandelAuth.Policy, policy => policy
|
||||
.AddAuthenticationSchemes(BehandelAuth.Scheme)
|
||||
.RequireAuthenticatedUser()
|
||||
.RequireRole(BehandelAuth.BehandelaarRole)));
|
||||
|
||||
// The BFF is the portals' only backend; it fans out to the domain and projection (§8.3).
|
||||
builder.Services.AddHttpClient<IDomainClient, DomainClient>(c => c.BaseAddress = new Uri(domainBaseUrl));
|
||||
@@ -68,7 +94,79 @@ app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection,
|
||||
})
|
||||
.Produces<IReadOnlyList<OpenbaarEntry>>(StatusCodes.Status200OK);
|
||||
|
||||
// Behandelaar's werkbak: registrations awaiting beoordeling. Reached only with a medewerker-realm
|
||||
// token carrying the behandelaar role; the BFF proxies the domain's werkbak (staff view, ADR-0013).
|
||||
app.MapGet("/behandel/werkbak", async (IDomainClient domain, CancellationToken ct) =>
|
||||
Results.Ok(await domain.GetWerkbakAsync(ct)))
|
||||
.RequireAuthorization(BehandelAuth.Policy)
|
||||
.Produces<IReadOnlyList<WerkbakItem>>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden);
|
||||
|
||||
// A behandelaar's beoordeling on a registration (goedkeuren/afwijzen). Forwarded to the domain, which
|
||||
// applies the decision and completes the workflow task (ADR-0013). Same medewerker/behandelaar gate.
|
||||
app.MapPost("/behandel/registrations/{id}/decide",
|
||||
async (string id, DecideRequest body, IDomainClient domain, CancellationToken ct) =>
|
||||
{
|
||||
if (!BehandelAuth.IsKnownBesluit(body.Besluit))
|
||||
return Results.BadRequest(new { error = $"Unknown besluit '{body.Besluit}'. Expected 'goedkeuren' or 'afwijzen'." });
|
||||
|
||||
await domain.DecideAsync(id, body.Besluit, ct);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.RequireAuthorization(BehandelAuth.Policy)
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden);
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>The behandelaar's decision on a registration.</summary>
|
||||
public sealed record DecideRequest(string Besluit);
|
||||
|
||||
// Behandel (medewerker-realm) authentication + authorization wiring (ADR-0013).
|
||||
internal static class BehandelAuth
|
||||
{
|
||||
public const string Scheme = "medewerker";
|
||||
public const string Policy = "behandelaar";
|
||||
public const string BehandelaarRole = "behandelaar";
|
||||
|
||||
/// <summary>The beoordeling vocabulary the BFF accepts (case-insensitive); an unknown besluit is a
|
||||
/// 400 without troubling the domain. Mirrors the domain's <c>BeoordelingsBesluit</c>.</summary>
|
||||
public static bool IsKnownBesluit(string? besluit) =>
|
||||
string.Equals(besluit, "goedkeuren", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(besluit, "afwijzen", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Lift Keycloak's realm roles (the nested <c>realm_access.roles</c> claim) onto the
|
||||
/// principal as role claims, so <c>RequireRole</c> can authorize on them.</summary>
|
||||
public static void AddRealmRoles(ClaimsPrincipal? principal)
|
||||
{
|
||||
if (principal?.Identity is not ClaimsIdentity identity)
|
||||
return;
|
||||
|
||||
var realmAccess = principal.FindFirst("realm_access")?.Value;
|
||||
if (string.IsNullOrWhiteSpace(realmAccess))
|
||||
return;
|
||||
|
||||
// A malformed realm_access claim must not fail authentication (a throw here becomes a 401);
|
||||
// it simply yields no roles, so the authorization policy answers 403.
|
||||
string[] roles;
|
||||
try
|
||||
{
|
||||
roles = JsonSerializer.Deserialize<RealmAccess>(realmAccess)?.Roles ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var role in roles)
|
||||
identity.AddClaim(new Claim(identity.RoleClaimType, role));
|
||||
}
|
||||
|
||||
private sealed record RealmAccess([property: JsonPropertyName("roles")] string[] Roles);
|
||||
}
|
||||
|
||||
// Exposed so the test host (WebApplicationFactory<Program>) can boot the app.
|
||||
public partial class Program;
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Keycloak": {
|
||||
"Authority": "http://localhost:8180/realms/digid"
|
||||
"Authority": "http://localhost:8180/realms/digid",
|
||||
"MedewerkerAuthority": "http://localhost:8180/realms/medewerker"
|
||||
},
|
||||
"Downstream": {
|
||||
"Domain": { "BaseUrl": "http://localhost:8130/" },
|
||||
|
||||
114
services/bff/Bff.Tests/BehandelEndpointTests.cs
Normal file
114
services/bff/Bff.Tests/BehandelEndpointTests.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Bff.Api;
|
||||
|
||||
namespace Bff.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The behandel werkbak endpoint (S-12c): reached only with a medewerker-realm token that carries the
|
||||
/// <c>behandelaar</c> role. A missing token is 401; an authenticated medewerker without the role is
|
||||
/// 403; a behandelaar gets the werkbak (staff view, incl. bsn).
|
||||
/// </summary>
|
||||
public class BehandelEndpointTests
|
||||
{
|
||||
private static HttpRequestMessage Werkbak(string? bearer)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, "/behandel/werkbak");
|
||||
if (bearer is not null)
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
|
||||
return request;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_the_werkbak_without_a_token()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
|
||||
var response = await factory.CreateClient().SendAsync(Werkbak(bearer: null));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_a_medewerker_without_the_behandelaar_role()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
|
||||
var response = await factory.CreateClient().SendAsync(Werkbak(TestTokens.Medewerker("teamlead")));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Serves_the_werkbak_to_a_behandelaar()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
factory.Domain.Werkbak.Add(new WerkbakItem("reg-1", "123456782", "InBehandeling"));
|
||||
|
||||
var response = await factory.CreateClient().SendAsync(Werkbak(TestTokens.Medewerker("behandelaar")));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var items = await response.Content.ReadFromJsonAsync<List<WerkbakItem>>();
|
||||
var item = Assert.Single(items!);
|
||||
Assert.Equal("reg-1", item.RegistrationId);
|
||||
Assert.Equal("123456782", item.Bsn);
|
||||
}
|
||||
|
||||
private static HttpRequestMessage Decide(string? bearer, string id = "reg-1", string besluit = "goedkeuren")
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, $"/behandel/registrations/{id}/decide")
|
||||
{
|
||||
Content = JsonContent.Create(new { besluit }),
|
||||
};
|
||||
if (bearer is not null)
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
|
||||
return request;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_a_decision_without_a_token()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
|
||||
var response = await factory.CreateClient().SendAsync(Decide(bearer: null));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
Assert.Null(factory.Domain.Decided);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_a_decision_from_a_medewerker_without_the_behandelaar_role()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
|
||||
var response = await factory.CreateClient().SendAsync(Decide(TestTokens.Medewerker("teamlead")));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Null(factory.Domain.Decided);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Forwards_a_behandelaar_decision_to_the_domain()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
|
||||
var response = await factory.CreateClient()
|
||||
.SendAsync(Decide(TestTokens.Medewerker("behandelaar"), id: "reg-42", besluit: "afwijzen"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
Assert.Equal(("reg-42", "afwijzen"), factory.Domain.Decided);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_an_unknown_besluit_without_calling_the_domain()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
|
||||
var response = await factory.CreateClient()
|
||||
.SendAsync(Decide(TestTokens.Medewerker("behandelaar"), besluit: "misschien"));
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Null(factory.Domain.Decided);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
@@ -23,24 +24,19 @@ internal sealed class BffFactory : WebApplicationFactory<Program>
|
||||
public FakeDomainClient Domain { get; } = new();
|
||||
public FakeProjectionClient Projection { get; } = new();
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
private static void ValidateWithTestKey(IServiceCollection services, string scheme) =>
|
||||
services.Configure<JwtBearerOptions>(scheme, options =>
|
||||
{
|
||||
builder.UseSetting("Keycloak:Authority", "https://keycloak.invalid/realms/digid");
|
||||
builder.UseSetting("Downstream:Domain:BaseUrl", "http://domain.invalid/");
|
||||
builder.UseSetting("Downstream:Projection:BaseUrl", "http://projection.invalid/");
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.AddSingleton<IDomainClient>(Domain);
|
||||
services.AddSingleton<IProjectionClient>(Projection);
|
||||
|
||||
services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
// Validate locally against the test key; never reach out for OIDC metadata.
|
||||
// Validate locally against the test key and NEVER reach out for OIDC metadata. A static
|
||||
// configuration manager guarantees this regardless of Configure/PostConfigure ordering —
|
||||
// clearing Authority alone left the medewerker scheme fetching metadata under CI timing
|
||||
// (2s hang → 401), because JwtBearer's PostConfigure could still build a ConfigurationManager.
|
||||
options.Authority = null;
|
||||
options.MetadataAddress = null!;
|
||||
options.RequireHttpsMetadata = false;
|
||||
options.Configuration = new OpenIdConnectConfiguration();
|
||||
options.ConfigurationManager =
|
||||
new StaticConfigurationManager<OpenIdConnectConfiguration>(new OpenIdConnectConfiguration());
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = false,
|
||||
@@ -51,6 +47,24 @@ internal sealed class BffFactory : WebApplicationFactory<Program>
|
||||
ClockSkew = TimeSpan.Zero,
|
||||
};
|
||||
});
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseSetting("Keycloak:Authority", "https://keycloak.invalid/realms/digid");
|
||||
builder.UseSetting("Keycloak:MedewerkerAuthority", "https://keycloak.invalid/realms/medewerker");
|
||||
builder.UseSetting("Downstream:Domain:BaseUrl", "http://domain.invalid/");
|
||||
builder.UseSetting("Downstream:Projection:BaseUrl", "http://projection.invalid/");
|
||||
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.AddSingleton<IDomainClient>(Domain);
|
||||
services.AddSingleton<IProjectionClient>(Projection);
|
||||
|
||||
// Both realms validate locally against the test key (no live Keycloak). The medewerker
|
||||
// scheme keeps its OnTokenValidated role-lifting from Program.cs — only the validation
|
||||
// parameters are swapped here.
|
||||
ValidateWithTestKey(services, JwtBearerDefaults.AuthenticationScheme);
|
||||
ValidateWithTestKey(services, "medewerker");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -60,12 +74,24 @@ internal sealed class FakeDomainClient : IDomainClient
|
||||
{
|
||||
public string? SubmittedBsn { get; private set; }
|
||||
public SubmitAccepted Result { get; set; } = new("reg-123", "Ingediend");
|
||||
public List<WerkbakItem> Werkbak { get; } = [];
|
||||
|
||||
public Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default)
|
||||
{
|
||||
SubmittedBsn = bsn;
|
||||
return Task.FromResult(Result);
|
||||
}
|
||||
|
||||
public (string RegistrationId, string Besluit)? Decided { get; private set; }
|
||||
|
||||
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<WerkbakItem>>(Werkbak);
|
||||
|
||||
public Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default)
|
||||
{
|
||||
Decided = (registrationId, besluit);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serves a configurable set of projection rows.</summary>
|
||||
|
||||
@@ -10,7 +10,7 @@ public class OpenbaarEndpointTests
|
||||
public async Task Serves_public_safe_rows_anonymously()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", "123456782", "Jan"));
|
||||
factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", "REG-abc", "123456782", "Jan"));
|
||||
|
||||
// No Authorization header — the openbaar register is a public lookup (ADR-0010/S-09).
|
||||
var response = await factory.CreateClient().GetAsync("/openbaar/register");
|
||||
@@ -19,7 +19,9 @@ public class OpenbaarEndpointTests
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("abc-111", body);
|
||||
Assert.Contains("INGEDIEND", body);
|
||||
// The bsn must never appear in a public response.
|
||||
// The public reference is surfaced (matches the submit confirmation, #78)...
|
||||
Assert.Contains("REG-abc", body);
|
||||
// ...but the bsn must never appear in a public response.
|
||||
Assert.DoesNotContain("123456782", body);
|
||||
}
|
||||
|
||||
@@ -27,8 +29,8 @@ public class OpenbaarEndpointTests
|
||||
public async Task Filters_by_the_query_parameter()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", null, null));
|
||||
factory.Projection.Entries.Add(new ProjectionEntry("def-222", "INGEDIEND", null, null));
|
||||
factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", "REG-abc", null, null));
|
||||
factory.Projection.Entries.Add(new ProjectionEntry("def-222", "INGEDIEND", "REG-def", null, null));
|
||||
|
||||
var rows = await factory.CreateClient()
|
||||
.GetFromJsonAsync<List<OpenbaarEntry>>("/openbaar/register?q=abc");
|
||||
|
||||
@@ -6,8 +6,8 @@ public class OpenbaarProjectionTests
|
||||
{
|
||||
private static readonly ProjectionEntry[] Sample =
|
||||
[
|
||||
new("abc-111", "INGEDIEND", "123456782", "Jan"),
|
||||
new("def-222", "INGEDIEND", "987654321", "Piet"),
|
||||
new("abc-111", "INGEDIEND", Reference: "REG-A", Bsn: "123456782", NaamPlaceholder: "Jan"),
|
||||
new("def-222", "INGESCHREVEN", Reference: "REG-B", Bsn: "987654321", NaamPlaceholder: "Piet"),
|
||||
];
|
||||
|
||||
[Fact]
|
||||
@@ -18,31 +18,47 @@ public class OpenbaarProjectionTests
|
||||
Assert.Equal(2, view.Count);
|
||||
Assert.Equal("abc-111", view[0].Id);
|
||||
Assert.Equal("INGEDIEND", view[0].Status);
|
||||
// The public reference (zaak identificatie) is surfaced so it matches the submit confirmation (#78).
|
||||
Assert.Equal("REG-A", view[0].Reference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Public_view_exposes_only_id_and_status()
|
||||
public void Public_view_exposes_only_id_status_and_reference()
|
||||
{
|
||||
// OpenbaarEntry structurally carries only Id + Status — bsn/naam can never leak.
|
||||
// OpenbaarEntry structurally carries only public-safe fields — bsn/naam can never leak.
|
||||
var props = typeof(OpenbaarEntry).GetProperties().Select(p => p.Name).ToArray();
|
||||
Assert.Equal(["Id", "Status"], props);
|
||||
Assert.Equal(["Id", "Status", "Reference"], props);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("abc", 1)]
|
||||
[InlineData("ABC", 1)]
|
||||
[InlineData("2", 1)]
|
||||
[InlineData("zzz", 0)]
|
||||
[InlineData("abc", 1)] // by id
|
||||
[InlineData("ABC", 1)] // by id, case-insensitive
|
||||
[InlineData("def", 1)] // by id
|
||||
[InlineData("zzz", 0)] // no match
|
||||
public void Filters_by_id_containing_the_query_case_insensitively(string q, int expected)
|
||||
{
|
||||
var view = OpenbaarProjection.PublicView(Sample, q);
|
||||
=> Assert.Equal(expected, OpenbaarProjection.PublicView(Sample, q).Count);
|
||||
|
||||
Assert.Equal(expected, view.Count);
|
||||
}
|
||||
[Theory]
|
||||
[InlineData("REG-A", 1)] // by reference — the citizen's confirmation reference
|
||||
[InlineData("reg-a", 1)] // by reference, case-insensitive
|
||||
[InlineData("REG", 2)] // both share the prefix
|
||||
public void Filters_by_reference_containing_the_query_case_insensitively(string q, int expected)
|
||||
=> Assert.Equal(expected, OpenbaarProjection.PublicView(Sample, q).Count);
|
||||
|
||||
[Fact]
|
||||
public void Blank_query_is_treated_as_no_filter()
|
||||
{
|
||||
Assert.Equal(2, OpenbaarProjection.PublicView(Sample, " ").Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_row_without_a_reference_never_matches_a_query()
|
||||
{
|
||||
// Older rows can have a null reference (the column is additive, #78/ADR-0012). A search must
|
||||
// simply not match them — it must not throw and must not treat "no reference" as a match.
|
||||
var entries = new[] { new ProjectionEntry("xyz-999", "INGEDIEND", Reference: null, Bsn: null, NaamPlaceholder: null) };
|
||||
|
||||
Assert.Empty(OpenbaarProjection.PublicView(entries, "REG"));
|
||||
Assert.Equal("xyz-999", Assert.Single(OpenbaarProjection.PublicView(entries, "xyz")).Id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,22 @@ internal static class TestTokens
|
||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes("a-different-signing-key-256-bits-long-indeed-yes!")),
|
||||
expired: false);
|
||||
|
||||
/// <summary>A medewerker-realm token carrying the given realm roles under <c>realm_access.roles</c>
|
||||
/// (Keycloak's shape), signed with the valid test key. Used to exercise behandel authorization.</summary>
|
||||
public static string Medewerker(params string[] roles)
|
||||
{
|
||||
var handler = new JsonWebTokenHandler();
|
||||
return handler.CreateToken(new SecurityTokenDescriptor
|
||||
{
|
||||
Claims = new Dictionary<string, object>
|
||||
{
|
||||
["realm_access"] = new Dictionary<string, object> { ["roles"] = roles },
|
||||
},
|
||||
Expires = DateTime.UtcNow.AddMinutes(30),
|
||||
SigningCredentials = new SigningCredentials(BffFactory.TestSigningKey, SecurityAlgorithms.HmacSha256),
|
||||
});
|
||||
}
|
||||
|
||||
private static string Create(string bsn, SymmetricSecurityKey key, bool expired)
|
||||
{
|
||||
var handler = new JsonWebTokenHandler();
|
||||
|
||||
@@ -60,14 +60,95 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/behandel/werkbak": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Bff.Api"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/WerkbakItem"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/behandel/registrations/{id}/decide": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Bff.Api"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/DecideRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"DecideRequest": {
|
||||
"required": [
|
||||
"besluit"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"besluit": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"OpenbaarEntry": {
|
||||
"required": [
|
||||
"id",
|
||||
"status"
|
||||
"status",
|
||||
"reference"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -76,6 +157,12 @@
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"reference": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -93,6 +180,25 @@
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"WerkbakItem": {
|
||||
"required": [
|
||||
"registrationId",
|
||||
"bsn",
|
||||
"status"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"registrationId": {
|
||||
"type": "string"
|
||||
},
|
||||
"bsn": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -20,10 +20,13 @@ builder.Services.AddSingleton<IRegistrationStore, InMemoryRegistrationStore>();
|
||||
builder.Services.AddHttpClient<FlowableWorkflowClient>();
|
||||
builder.Services.AddTransient<IWorkflowClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
||||
builder.Services.AddTransient<IExternalWorkerClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
||||
builder.Services.AddTransient<IUserTaskClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
||||
builder.Services.AddHttpClient<IAclClient, AclHttpClient>();
|
||||
|
||||
builder.Services.AddScoped<SubmitRegistration>();
|
||||
builder.Services.AddScoped<ApproveRegistration>();
|
||||
builder.Services.AddScoped<BeoordeelRegistratie>();
|
||||
builder.Services.AddScoped<Werkbak>();
|
||||
builder.Services.AddScoped<OpenZaakWorker>();
|
||||
builder.Services.AddScoped<OpenZaakJobProcessor>();
|
||||
|
||||
@@ -55,6 +58,28 @@ app.MapPost("/registrations/{id}/approve", async (string id, ApproveRegistration
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
// The behandelaar's beoordeling (S-12): decide a registration goedkeuren (→ INGESCHREVEN, sets the
|
||||
// zaak's final status via the ACL) or afwijzen (→ AFGEWEZEN). Idempotent. This is the domain contract
|
||||
// the behandel-portal's decision reaches through the BFF; it supersedes the temporary /approve above,
|
||||
// which is retired once the portal lands.
|
||||
app.MapPost("/registrations/{id}/decide", async (string id, DecideRequest body, BeoordeelRegistratie beoordeel, CancellationToken ct) =>
|
||||
{
|
||||
if (!Guid.TryParse(id, out var guid))
|
||||
return Results.NotFound();
|
||||
|
||||
if (!Enum.TryParse<BeoordelingsBesluit>(body.Besluit, ignoreCase: true, out var besluit))
|
||||
return Results.BadRequest(new { error = $"Unknown besluit '{body.Besluit}'. Expected 'goedkeuren' or 'afwijzen'." });
|
||||
|
||||
await beoordeel.HandleAsync(new BeoordeelRegistratieCommand(new RegistrationId(guid), besluit), ct);
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
// The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open
|
||||
// Beoordelen user tasks (§8.2) and enriched with bsn + status. The BFF proxies this behind
|
||||
// medewerker-realm + behandelaar-role authorization; the domain trusts its callers (§8.3).
|
||||
app.MapGet("/behandel/werkbak", async (Werkbak werkbak, CancellationToken ct) =>
|
||||
Results.Ok(await werkbak.GetAsync(ct)));
|
||||
|
||||
// Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually).
|
||||
app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) =>
|
||||
{
|
||||
@@ -72,6 +97,8 @@ await app.RunAsync();
|
||||
|
||||
public sealed record SubmitRegistrationRequest(string Bsn);
|
||||
|
||||
public sealed record DecideRequest(string Besluit);
|
||||
|
||||
public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl);
|
||||
|
||||
public partial class Program;
|
||||
|
||||
71
services/domain/Big.Application/BeoordeelRegistratie.cs
Normal file
71
services/domain/Big.Application/BeoordeelRegistratie.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using Big.Domain;
|
||||
|
||||
namespace Big.Application;
|
||||
|
||||
/// <summary>A behandelaar's beoordeling outcome, in domain language.</summary>
|
||||
public enum BeoordelingsBesluit
|
||||
{
|
||||
/// <summary>Approve — enter the registration in the register.</summary>
|
||||
Goedkeuren,
|
||||
|
||||
/// <summary>Reject — turn the registration down.</summary>
|
||||
Afwijzen,
|
||||
}
|
||||
|
||||
/// <summary>A behandelaar's decision on a registration.</summary>
|
||||
public sealed record BeoordeelRegistratieCommand(RegistrationId RegistrationId, BeoordelingsBesluit Besluit);
|
||||
|
||||
/// <summary>
|
||||
/// The beoordeling use case (S-12): apply a behandelaar's decision to a registration.
|
||||
/// <see cref="BeoordelingsBesluit.Goedkeuren"/> sets the zaak's final status via the ACL (§8.1) and
|
||||
/// advances the aggregate to INGESCHREVEN; <see cref="BeoordelingsBesluit.Afwijzen"/> advances it to
|
||||
/// AFGEWEZEN in the domain (propagating a rejection to the zaak, so the openbaar projection reflects
|
||||
/// it, is a later sub-slice of S-12). After applying the decision it completes the Flowable
|
||||
/// <c>Beoordelen</c> task (found by registrationId) so the workflow advances (ADR-0013). Both
|
||||
/// decisions are idempotent — a repeated or redelivered decision that matches the current terminal
|
||||
/// state is a no-op, so the ACL is not called and the task not completed twice.
|
||||
/// </summary>
|
||||
public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient acl, IUserTaskClient tasks)
|
||||
{
|
||||
public async Task HandleAsync(BeoordeelRegistratieCommand command, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
|
||||
var registration = await store.GetAsync(command.RegistrationId, ct)
|
||||
?? throw new InvalidOperationException($"No registration {command.RegistrationId} to decide.");
|
||||
|
||||
switch (command.Besluit)
|
||||
{
|
||||
case BeoordelingsBesluit.Goedkeuren:
|
||||
// A repeated approval is a no-op: don't set the zaak status a second time.
|
||||
if (registration.Status == RegistrationStatus.Ingeschreven)
|
||||
return;
|
||||
if (registration.ZaakUrl is null)
|
||||
throw new InvalidOperationException(
|
||||
$"Registration {command.RegistrationId} has no zaak yet; it cannot be approved.");
|
||||
await acl.ApproveZaakAsync(registration.ZaakUrl, ct);
|
||||
registration.Approve();
|
||||
break;
|
||||
|
||||
case BeoordelingsBesluit.Afwijzen:
|
||||
if (registration.Status == RegistrationStatus.Afgewezen)
|
||||
return;
|
||||
registration.Reject();
|
||||
break;
|
||||
}
|
||||
|
||||
await store.SaveAsync(registration, ct);
|
||||
await CompleteWorkflowTaskAsync(command.RegistrationId, command.Besluit, ct);
|
||||
}
|
||||
|
||||
// Advance the workflow: complete the open Beoordelen task for this registration. If none is open
|
||||
// (already completed, or the process hasn't parked yet) the decision still stands — we complete
|
||||
// nothing rather than fail.
|
||||
private async Task CompleteWorkflowTaskAsync(RegistrationId registrationId, BeoordelingsBesluit besluit, CancellationToken ct)
|
||||
{
|
||||
var open = await tasks.GetOpenBeoordelingenAsync(ct);
|
||||
var task = open.FirstOrDefault(t => t.RegistrationId == registrationId);
|
||||
if (task is not null)
|
||||
await tasks.CompleteBeoordelingAsync(task.TaskId, besluit, ct);
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ public sealed class OpenZaakWorker(IRegistrationStore store, IAclClient acl)
|
||||
if (registration.ZaakUrl is not null)
|
||||
return registration.ZaakUrl;
|
||||
|
||||
var zaakUrl = await acl.OpenZaakAsync(registration.Bsn, ct);
|
||||
var zaakUrl = await acl.OpenZaakAsync(registration.Bsn, registration.Id.ToString(), ct);
|
||||
registration.AttachZaak(zaakUrl);
|
||||
await store.SaveAsync(registration, ct);
|
||||
return zaakUrl;
|
||||
|
||||
@@ -24,7 +24,10 @@ public interface IWorkflowClient
|
||||
/// </summary>
|
||||
public interface IAclClient
|
||||
{
|
||||
Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default);
|
||||
/// <summary>Open a zaak for the registration. <paramref name="reference"/> is the registration's
|
||||
/// own reference (its id); the ACL records it as the zaak's identificatie so the openbaar register
|
||||
/// can show the same reference the zorgprofessional was given (S-09b follow-up, adr-proposal #78).</summary>
|
||||
Task<Uri> OpenZaakAsync(string bsn, string reference, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Record the approval on the given zaak. The ACL translates this to the ZGW concept — setting
|
||||
@@ -33,6 +36,30 @@ public interface IAclClient
|
||||
Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The port to the behandelaar's user tasks in the workflow engine (S-12). Implemented by the
|
||||
/// Workflow Client — the only code that talks to Flowable (§8.2). The application lists the open
|
||||
/// <c>Beoordelen</c> tasks (the werkbak), claims one for a behandelaar, and completes it with the
|
||||
/// decision; it never names Flowable's REST shapes.
|
||||
/// </summary>
|
||||
public interface IUserTaskClient
|
||||
{
|
||||
/// <summary>The werkbak: the <c>Beoordelen</c> tasks awaiting a behandelaar, each with the
|
||||
/// registration it belongs to.</summary>
|
||||
Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Claim a beoordeling task for a behandelaar (assigns it to them).</summary>
|
||||
Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Complete a beoordeling task, carrying the decision into the process as the
|
||||
/// <c>besluit</c> variable so the workflow can continue on the chosen branch.</summary>
|
||||
Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>A <c>Beoordelen</c> user task in the werkbak: the Flowable task id (needed to claim and
|
||||
/// complete it) and the registration it carries as a process variable.</summary>
|
||||
public sealed record BeoordelingTask(string TaskId, RegistrationId RegistrationId);
|
||||
|
||||
/// <summary>
|
||||
/// Persistence port for the <see cref="Registration"/> aggregate. In-memory for the minimal slice
|
||||
/// (ADR-0009); an EF-backed store is a documented follow-up, and this port keeps that change additive.
|
||||
|
||||
32
services/domain/Big.Application/Werkbak.cs
Normal file
32
services/domain/Big.Application/Werkbak.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
namespace Big.Application;
|
||||
|
||||
/// <summary>One row of the behandelaar's werkbak: a registration awaiting beoordeling, with the
|
||||
/// public-facing reference (its id) plus the bsn and status a behandelaar needs to triage it.</summary>
|
||||
public sealed record WerkbakItem(string RegistrationId, string Bsn, string Status);
|
||||
|
||||
/// <summary>
|
||||
/// The werkbak query (S-12c): the registrations awaiting a behandelaar's beoordeling. It reads the
|
||||
/// open <c>Beoordelen</c> tasks from the workflow engine (§8.2, via <see cref="IUserTaskClient"/>) —
|
||||
/// the authoritative set of work items — and enriches each with its aggregate (bsn + status). A task
|
||||
/// whose registration the domain doesn't know is skipped rather than invented.
|
||||
/// </summary>
|
||||
public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store)
|
||||
{
|
||||
public async Task<IReadOnlyList<WerkbakItem>> GetAsync(CancellationToken ct = default)
|
||||
{
|
||||
var open = await tasks.GetOpenBeoordelingenAsync(ct);
|
||||
|
||||
var items = new List<WerkbakItem>(open.Count);
|
||||
foreach (var task in open)
|
||||
{
|
||||
var registration = await store.GetAsync(task.RegistrationId, ct);
|
||||
if (registration is null)
|
||||
continue;
|
||||
|
||||
items.Add(new WerkbakItem(
|
||||
registration.Id.ToString(), registration.Bsn, registration.Status.ToString()));
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
@@ -66,10 +66,28 @@ public sealed class Registration
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Approve the registration — the behandelaar's decision to enter it in the register. Advances
|
||||
/// <see cref="RegistrationStatus.Ingediend"/> → <see cref="RegistrationStatus.Ingeschreven"/>.
|
||||
/// Requires an opened zaak (the approval sets that zaak's status via the ACL), and only a
|
||||
/// submitted registration can be approved: re-approving is rejected as an invalid transition.
|
||||
/// A behandelaar picks the registration up for beoordeling. Advances
|
||||
/// <see cref="RegistrationStatus.Ingediend"/> → <see cref="RegistrationStatus.InBehandeling"/>.
|
||||
/// Re-taking one already <see cref="RegistrationStatus.InBehandeling"/> is a no-op (the same or
|
||||
/// another behandelaar re-opens it); a decided registration can no longer be taken into behandeling.
|
||||
/// </summary>
|
||||
public void TakeIntoBehandeling()
|
||||
{
|
||||
if (Status == RegistrationStatus.InBehandeling)
|
||||
return;
|
||||
|
||||
if (Status != RegistrationStatus.Ingediend)
|
||||
throw new InvalidOperationException(
|
||||
$"Registration {Id} is {Status}; only an INGEDIEND registration can be taken into behandeling.");
|
||||
|
||||
Status = RegistrationStatus.InBehandeling;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Approve the registration — the behandelaar's decision to enter it in the register. Advances a
|
||||
/// submitted or in-behandeling registration to <see cref="RegistrationStatus.Ingeschreven"/>.
|
||||
/// Requires an opened zaak (the approval sets that zaak's status via the ACL); a registration that
|
||||
/// has already been decided cannot be approved again.
|
||||
/// </summary>
|
||||
public void Approve()
|
||||
{
|
||||
@@ -77,10 +95,27 @@ public sealed class Registration
|
||||
throw new InvalidOperationException(
|
||||
$"Registration {Id} has no zaak yet; it cannot be approved before its zaak is opened.");
|
||||
|
||||
if (Status != RegistrationStatus.Ingediend)
|
||||
throw new InvalidOperationException(
|
||||
$"Registration {Id} is {Status}; only an INGEDIEND registration can be approved.");
|
||||
|
||||
RequireOpenForDecision(nameof(Approve));
|
||||
Status = RegistrationStatus.Ingeschreven;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reject the registration — the behandelaar's decision not to enter it in the register. Advances a
|
||||
/// submitted or in-behandeling registration to <see cref="RegistrationStatus.Afgewezen"/>. Unlike
|
||||
/// approval this needs no zaak: a registration can be rejected before or after its zaak is opened.
|
||||
/// A registration that has already been decided cannot be rejected again.
|
||||
/// </summary>
|
||||
public void Reject()
|
||||
{
|
||||
RequireOpenForDecision(nameof(Reject));
|
||||
Status = RegistrationStatus.Afgewezen;
|
||||
}
|
||||
|
||||
// A decision is only valid while the registration is still open (INGEDIEND or IN_BEHANDELING).
|
||||
private void RequireOpenForDecision(string decision)
|
||||
{
|
||||
if (Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling))
|
||||
throw new InvalidOperationException(
|
||||
$"Registration {Id} is {Status}; only an INGEDIEND or IN_BEHANDELING registration can be decided ({decision}).");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
namespace Big.Domain;
|
||||
|
||||
/// <summary>The lifecycle states a <see cref="Registration"/> moves through. The walking
|
||||
/// skeleton knows <see cref="Ingediend"/> and the terminal <see cref="Ingeschreven"/>; withdrawal,
|
||||
/// beoordeling and herregistratie states arrive in their own slices (Iteration 2+).</summary>
|
||||
/// <summary>The lifecycle states a <see cref="Registration"/> moves through. Submission starts in
|
||||
/// <see cref="Ingediend"/>; a behandelaar takes it <see cref="InBehandeling"/> and decides it into one
|
||||
/// of the terminal states <see cref="Ingeschreven"/> (approved) or <see cref="Afgewezen"/> (rejected).
|
||||
/// Withdrawal and herregistratie states arrive in their own slices (S-11+).</summary>
|
||||
public enum RegistrationStatus
|
||||
{
|
||||
/// <summary>Submitted by the zorgprofessional; the registratie process has been started.</summary>
|
||||
Ingediend,
|
||||
|
||||
/// <summary>Approved: entered in the register. Terminal in the walking skeleton (S-09b).</summary>
|
||||
/// <summary>Picked up by a behandelaar for beoordeling (S-12).</summary>
|
||||
InBehandeling,
|
||||
|
||||
/// <summary>Approved: entered in the register. Terminal.</summary>
|
||||
Ingeschreven,
|
||||
|
||||
/// <summary>Rejected by the behandelaar. Terminal.</summary>
|
||||
Afgewezen,
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ namespace Big.Infrastructure;
|
||||
/// </summary>
|
||||
public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclClient
|
||||
{
|
||||
public async Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default)
|
||||
public async Task<Uri> OpenZaakAsync(string bsn, string reference, CancellationToken ct = default)
|
||||
{
|
||||
using var response = await http.PostAsJsonAsync(
|
||||
new Uri(options.BaseUrl, "zaken"), new OpenZaakRequest(bsn), ct);
|
||||
new Uri(options.BaseUrl, "zaken"), new OpenZaakRequest(bsn, reference), ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var opened = await response.Content.ReadFromJsonAsync<OpenZaakResponse>(ct)
|
||||
@@ -31,7 +31,9 @@ public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclCli
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private sealed record OpenZaakRequest([property: JsonPropertyName("bsn")] string Bsn);
|
||||
private sealed record OpenZaakRequest(
|
||||
[property: JsonPropertyName("bsn")] string Bsn,
|
||||
[property: JsonPropertyName("reference")] string Reference);
|
||||
|
||||
private sealed record OpenZaakResponse([property: JsonPropertyName("zaakUrl")] string ZaakUrl);
|
||||
|
||||
|
||||
@@ -15,12 +15,14 @@ namespace Big.Infrastructure;
|
||||
/// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009).
|
||||
/// </summary>
|
||||
public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options)
|
||||
: IWorkflowClient, IExternalWorkerClient
|
||||
: IWorkflowClient, IExternalWorkerClient, IUserTaskClient
|
||||
{
|
||||
private const string Topic = "OpenZaakAanmaken";
|
||||
private const string ProcessDefinitionKey = "registratie";
|
||||
private const string BeoordelenTaskKey = "Beoordelen";
|
||||
private const string RegistrationIdVariable = "registrationId";
|
||||
private const string ZaakUrlVariable = "zaakUrl";
|
||||
private const string BesluitVariable = "besluit";
|
||||
|
||||
public async Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
|
||||
{
|
||||
@@ -55,6 +57,34 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default)
|
||||
{
|
||||
var request = new TaskQueryRequest(ProcessDefinitionKey, BeoordelenTaskKey, IncludeProcessVariables: true);
|
||||
|
||||
var page = await PostAsync<TaskQueryRequest, TaskQueryResult>(
|
||||
"service/query/tasks", request, ct);
|
||||
|
||||
var tasks = page?.Data ?? [];
|
||||
return [.. tasks.Select(t => new BeoordelingTask(t.Id, RegistrationId.Parse(t.RegistrationId())))];
|
||||
}
|
||||
|
||||
public async Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default)
|
||||
{
|
||||
using var response = await SendAsync(
|
||||
$"service/runtime/tasks/{taskId}", new ClaimTaskRequest("claim", behandelaar), ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default)
|
||||
{
|
||||
var request = new CompleteTaskRequest(
|
||||
"complete",
|
||||
[new Variable(BesluitVariable, "string", besluit.ToString().ToLowerInvariant())]);
|
||||
|
||||
using var response = await SendAsync($"service/runtime/tasks/{taskId}", request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct)
|
||||
{
|
||||
using var response = await SendAsync(path, body, ct);
|
||||
@@ -89,6 +119,34 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
||||
[property: JsonPropertyName("workerId")] string WorkerId,
|
||||
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables);
|
||||
|
||||
private sealed record TaskQueryRequest(
|
||||
[property: JsonPropertyName("processDefinitionKey")] string ProcessDefinitionKey,
|
||||
[property: JsonPropertyName("taskDefinitionKey")] string TaskDefinitionKey,
|
||||
[property: JsonPropertyName("includeProcessVariables")] bool IncludeProcessVariables);
|
||||
|
||||
private sealed record ClaimTaskRequest(
|
||||
[property: JsonPropertyName("action")] string Action,
|
||||
[property: JsonPropertyName("assignee")] string Assignee);
|
||||
|
||||
private sealed record CompleteTaskRequest(
|
||||
[property: JsonPropertyName("action")] string Action,
|
||||
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables);
|
||||
|
||||
private sealed record TaskQueryResult(
|
||||
[property: JsonPropertyName("data")] IReadOnlyList<UserTaskDto>? Data);
|
||||
|
||||
private sealed record UserTaskDto(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
// Flowable's task-query returns the (included) process variables under "variables", not
|
||||
// "processVariables"; the request opts in via includeProcessVariables.
|
||||
[property: JsonPropertyName("variables")] IReadOnlyList<Variable>? Variables)
|
||||
{
|
||||
/// <summary>The registration id this task carries as a process variable.</summary>
|
||||
public string RegistrationId() =>
|
||||
Variables?.SingleOrDefault(v => v.Name == "registrationId")?.Value
|
||||
?? throw new InvalidOperationException($"Beoordelen task {Id} carries no registrationId variable.");
|
||||
}
|
||||
|
||||
private sealed record Variable(
|
||||
[property: JsonPropertyName("name")] string Name,
|
||||
[property: JsonPropertyName("type")] string Type,
|
||||
|
||||
@@ -15,12 +15,13 @@ public class AclHttpClientTests
|
||||
var client = Client(capture.Responds(HttpStatusCode.OK,
|
||||
"""{"zaakUrl":"http://openzaak/zaken/api/v1/zaken/abc"}"""));
|
||||
|
||||
var url = await client.OpenZaakAsync("123456782");
|
||||
var url = await client.OpenZaakAsync("123456782", "reg-42");
|
||||
|
||||
Assert.Equal("http://openzaak/zaken/api/v1/zaken/abc", url.ToString());
|
||||
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
|
||||
Assert.Equal("http://acl/zaken", capture.Seen.RequestUri!.ToString());
|
||||
Assert.Contains("\"bsn\":\"123456782\"", capture.Body);
|
||||
Assert.Contains("\"reference\":\"reg-42\"", capture.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -29,7 +30,7 @@ public class AclHttpClientTests
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.BadGateway));
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => client.OpenZaakAsync("123456782"));
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => client.OpenZaakAsync("123456782", "reg-1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -38,7 +39,7 @@ public class AclHttpClientTests
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.OK, "null"));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => client.OpenZaakAsync("123456782"));
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => client.OpenZaakAsync("123456782", "reg-1"));
|
||||
Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
|
||||
168
services/domain/Big.Tests/BeoordeelRegistratieTests.cs
Normal file
168
services/domain/Big.Tests/BeoordeelRegistratieTests.cs
Normal file
@@ -0,0 +1,168 @@
|
||||
using Big.Application;
|
||||
using Big.Domain;
|
||||
|
||||
namespace Big.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The beoordeling use case (S-12): a behandelaar's decision on a registration. Goedkeuren sets the
|
||||
/// zaak's final status via the ACL (§8.1) and marks the aggregate INGESCHREVEN; Afwijzen marks it
|
||||
/// AFGEWEZEN. Either way the decision also completes the Flowable Beoordelen task (found by
|
||||
/// registrationId) so the process advances. All idempotent — a repeated decision is a no-op.
|
||||
/// </summary>
|
||||
public class BeoordeelRegistratieTests
|
||||
{
|
||||
private static Registration WithZaak(string bsn = "123456782")
|
||||
{
|
||||
var registration = Registration.Submit(bsn);
|
||||
registration.AttachZaak(FakeAclClient.DefaultZaakUrl);
|
||||
return registration;
|
||||
}
|
||||
|
||||
private static FakeUserTaskClient TaskFor(Registration registration) =>
|
||||
new([new BeoordelingTask("task-1", registration.Id)]);
|
||||
|
||||
[Fact]
|
||||
public async Task Goedkeuren_sets_the_zaak_status_via_the_acl_and_marks_the_registration_ingeschreven()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var registration = WithZaak();
|
||||
store.Seed(registration);
|
||||
var tasks = TaskFor(registration);
|
||||
var handler = new BeoordeelRegistratie(store, acl, tasks);
|
||||
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
||||
|
||||
var saved = await store.GetAsync(registration.Id);
|
||||
Assert.Equal(RegistrationStatus.Ingeschreven, saved!.Status);
|
||||
Assert.Equal(FakeAclClient.DefaultZaakUrl, acl.ApprovedZaakUrl);
|
||||
Assert.Equal(1, acl.ApproveCallCount);
|
||||
Assert.Equal(1, store.SaveCount);
|
||||
// The behandelaar's decision advances the workflow: the Beoordelen task is completed.
|
||||
Assert.Equal(("task-1", BeoordelingsBesluit.Goedkeuren), tasks.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Afwijzen_marks_the_registration_afgewezen_without_calling_the_acl()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var registration = WithZaak();
|
||||
store.Seed(registration);
|
||||
var tasks = TaskFor(registration);
|
||||
var handler = new BeoordeelRegistratie(store, acl, tasks);
|
||||
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
|
||||
|
||||
var saved = await store.GetAsync(registration.Id);
|
||||
Assert.Equal(RegistrationStatus.Afgewezen, saved!.Status);
|
||||
Assert.Equal(0, acl.ApproveCallCount);
|
||||
Assert.Equal(1, store.SaveCount);
|
||||
Assert.Equal(("task-1", BeoordelingsBesluit.Afwijzen), tasks.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Goedkeuren_an_in_behandeling_registration_marks_it_ingeschreven()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var registration = WithZaak();
|
||||
registration.TakeIntoBehandeling();
|
||||
store.Seed(registration);
|
||||
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
|
||||
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
||||
|
||||
Assert.Equal(RegistrationStatus.Ingeschreven, (await store.GetAsync(registration.Id))!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_a_null_command_without_touching_the_store_or_acl()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
|
||||
Assert.Equal(0, acl.ApproveCallCount);
|
||||
Assert.Equal(0, store.SaveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deciding_an_unknown_registration_throws_and_does_not_call_the_acl()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
handler.HandleAsync(new BeoordeelRegistratieCommand(RegistrationId.New(), BeoordelingsBesluit.Goedkeuren)));
|
||||
Assert.Contains("No registration", ex.Message);
|
||||
Assert.Equal(0, acl.ApproveCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Goedkeuren_before_the_zaak_is_opened_throws_without_calling_the_acl()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var registration = Registration.Submit("123456782"); // no zaak yet
|
||||
store.Seed(registration);
|
||||
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren)));
|
||||
Assert.Contains("no zaak", ex.Message);
|
||||
Assert.Equal(0, acl.ApproveCallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Re_goedkeuren_an_already_ingeschreven_registration_is_idempotent()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var registration = WithZaak();
|
||||
store.Seed(registration);
|
||||
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
|
||||
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
||||
|
||||
Assert.Equal(1, acl.ApproveCallCount);
|
||||
Assert.Equal(RegistrationStatus.Ingeschreven, (await store.GetAsync(registration.Id))!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Re_afwijzen_an_already_afgewezen_registration_is_idempotent()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var registration = WithZaak();
|
||||
store.Seed(registration);
|
||||
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
|
||||
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
|
||||
|
||||
Assert.Equal(1, store.SaveCount);
|
||||
Assert.Equal(RegistrationStatus.Afgewezen, (await store.GetAsync(registration.Id))!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deciding_completes_no_task_when_none_is_open_for_the_registration()
|
||||
{
|
||||
// The task may already be gone (redelivery / manual completion). The decision still applies
|
||||
// and simply completes nothing rather than failing.
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var registration = WithZaak();
|
||||
store.Seed(registration);
|
||||
var tasks = new FakeUserTaskClient([]); // no open task for this registration
|
||||
var handler = new BeoordeelRegistratie(store, acl, tasks);
|
||||
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
||||
|
||||
Assert.Equal(RegistrationStatus.Ingeschreven, (await store.GetAsync(registration.Id))!.Status);
|
||||
Assert.Null(tasks.Completed);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,29 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A fake user-task client for the werkbak/decision use cases: returns a scripted set of
|
||||
/// open beoordeling tasks and records claim/complete calls.</summary>
|
||||
internal sealed class FakeUserTaskClient(IReadOnlyList<BeoordelingTask> open) : IUserTaskClient
|
||||
{
|
||||
public (string TaskId, string Behandelaar)? Claimed { get; private set; }
|
||||
public (string TaskId, BeoordelingsBesluit Besluit)? Completed { get; private set; }
|
||||
|
||||
public Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult(open);
|
||||
|
||||
public Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default)
|
||||
{
|
||||
Claimed = (taskId, behandelaar);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default)
|
||||
{
|
||||
Completed = (taskId, besluit);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A fake ACL client that records the bsn it was asked to open a zaak for and returns a
|
||||
/// fixed zaak URL.</summary>
|
||||
internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient
|
||||
@@ -50,15 +73,17 @@ internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient
|
||||
private readonly Uri _zaakUrl = zaakUrl ?? DefaultZaakUrl;
|
||||
|
||||
public string? OpenedForBsn { get; private set; }
|
||||
public string? OpenedWithReference { get; private set; }
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public Uri? ApprovedZaakUrl { get; private set; }
|
||||
public int ApproveCallCount { get; private set; }
|
||||
|
||||
public Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default)
|
||||
public Task<Uri> OpenZaakAsync(string bsn, string reference, CancellationToken ct = default)
|
||||
{
|
||||
CallCount++;
|
||||
OpenedForBsn = bsn;
|
||||
OpenedWithReference = reference;
|
||||
return Task.FromResult(_zaakUrl);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Big.Application;
|
||||
using Big.Domain;
|
||||
using Big.Infrastructure;
|
||||
|
||||
@@ -127,6 +128,17 @@ public class FlowableWorkflowClientTests
|
||||
() => client.StartRegistrationProcessAsync(RegistrationId.New()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Start_throws_when_flowable_returns_an_empty_body()
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.Created, "null"));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => client.StartRegistrationProcessAsync(RegistrationId.New()));
|
||||
Assert.Contains("empty process-instance", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Complete_throws_when_flowable_rejects_the_request()
|
||||
{
|
||||
@@ -136,4 +148,107 @@ public class FlowableWorkflowClientTests
|
||||
await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => client.CompleteOpenZaakJobAsync("job-1", new Uri("http://openzaak/zaken/api/v1/zaken/abc")));
|
||||
}
|
||||
|
||||
// ── S-12b: behandelaar user tasks (werkbak / claim / complete) ────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Open_beoordelingen_queries_beoordelen_tasks_with_their_registration_id()
|
||||
{
|
||||
var rid = RegistrationId.New();
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.OK,
|
||||
$$"""
|
||||
{"data":[{"id":"task-1","variables":[{"name":"registrationId","type":"string","value":"{{rid}}"}]}],"total":1}
|
||||
"""));
|
||||
|
||||
var tasks = await client.GetOpenBeoordelingenAsync();
|
||||
|
||||
var task = Assert.Single(tasks);
|
||||
Assert.Equal("task-1", task.TaskId);
|
||||
Assert.Equal(rid, task.RegistrationId);
|
||||
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
|
||||
Assert.Equal("http://flowable/flowable-rest/service/query/tasks",
|
||||
capture.Seen.RequestUri!.ToString());
|
||||
Assert.Equal("rest-admin:test", DecodeBasic(capture.Seen));
|
||||
Assert.Contains("\"processDefinitionKey\":\"registratie\"", capture.Body);
|
||||
Assert.Contains("\"taskDefinitionKey\":\"Beoordelen\"", capture.Body);
|
||||
Assert.Contains("\"includeProcessVariables\":true", capture.Body);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("""{"data":[],"total":0}""")]
|
||||
[InlineData("""{"data":null,"total":0}""")]
|
||||
public async Task Open_beoordelingen_is_empty_when_no_tasks_are_waiting(string body)
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.OK, body));
|
||||
|
||||
Assert.Empty(await client.GetOpenBeoordelingenAsync());
|
||||
Assert.NotNull(capture.Seen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Open_beoordelingen_throws_when_a_task_carries_no_registration_id()
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.OK,
|
||||
"""{"data":[{"id":"task-1","variables":[]}],"total":1}"""));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => client.GetOpenBeoordelingenAsync());
|
||||
Assert.Contains("task-1", ex.Message);
|
||||
Assert.Contains("registrationId", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Claim_assigns_the_task_to_the_behandelaar()
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.OK));
|
||||
|
||||
await client.ClaimAsync("task-1", "merel-behandelaar");
|
||||
|
||||
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
|
||||
Assert.Equal("http://flowable/flowable-rest/service/runtime/tasks/task-1",
|
||||
capture.Seen.RequestUri!.ToString());
|
||||
Assert.Contains("\"action\":\"claim\"", capture.Body);
|
||||
Assert.Contains("\"assignee\":\"merel-behandelaar\"", capture.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Claim_throws_when_flowable_rejects_the_request()
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.Conflict));
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(() => client.ClaimAsync("task-1", "merel-behandelaar"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(BeoordelingsBesluit.Goedkeuren, "goedkeuren")]
|
||||
[InlineData(BeoordelingsBesluit.Afwijzen, "afwijzen")]
|
||||
public async Task Complete_posts_the_besluit_variable_to_the_task(BeoordelingsBesluit besluit, string expected)
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.OK));
|
||||
|
||||
await client.CompleteBeoordelingAsync("task-1", besluit);
|
||||
|
||||
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
|
||||
Assert.Equal("http://flowable/flowable-rest/service/runtime/tasks/task-1",
|
||||
capture.Seen.RequestUri!.ToString());
|
||||
Assert.Contains("\"action\":\"complete\"", capture.Body);
|
||||
Assert.Contains("\"name\":\"besluit\"", capture.Body);
|
||||
Assert.Contains("\"type\":\"string\"", capture.Body);
|
||||
Assert.Contains($"\"value\":\"{expected}\"", capture.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Complete_beoordeling_throws_when_flowable_rejects_the_request()
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.InternalServerError));
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => client.CompleteBeoordelingAsync("task-1", BeoordelingsBesluit.Goedkeuren));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ public class OpenZaakWorkerTests
|
||||
|
||||
Assert.Equal(FakeAclClient.DefaultZaakUrl, zaakUrl);
|
||||
Assert.Equal("123456782", acl.OpenedForBsn);
|
||||
// The registration's own id is handed to the ACL as the zaak reference (identificatie),
|
||||
// so the openbaar register can show the reference the zorgprofessional received (#78).
|
||||
Assert.Equal(registration.Id.ToString(), acl.OpenedWithReference);
|
||||
var saved = await store.GetAsync(registration.Id);
|
||||
Assert.Equal(FakeAclClient.DefaultZaakUrl, saved!.ZaakUrl);
|
||||
Assert.Equal(1, store.SaveCount);
|
||||
|
||||
@@ -121,4 +121,100 @@ public class RegistrationTests
|
||||
Assert.Contains("only an INGEDIEND", ex.Message);
|
||||
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Taking_a_registration_into_behandeling_moves_it_to_in_behandeling()
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
|
||||
registration.TakeIntoBehandeling();
|
||||
|
||||
Assert.Equal(RegistrationStatus.InBehandeling, registration.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Re_taking_an_in_behandeling_registration_is_idempotent()
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.TakeIntoBehandeling();
|
||||
|
||||
registration.TakeIntoBehandeling();
|
||||
|
||||
Assert.Equal(RegistrationStatus.InBehandeling, registration.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Taking_a_decided_registration_into_behandeling_is_rejected()
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
||||
registration.Approve();
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => registration.TakeIntoBehandeling());
|
||||
Assert.Contains("only an INGEDIEND", ex.Message);
|
||||
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Approving_an_in_behandeling_registration_with_a_zaak_sets_it_ingeschreven()
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
||||
registration.TakeIntoBehandeling();
|
||||
|
||||
registration.Approve();
|
||||
|
||||
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejecting_a_registration_sets_it_afgewezen()
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
|
||||
registration.Reject();
|
||||
|
||||
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejecting_needs_no_zaak()
|
||||
{
|
||||
// A registration can be turned down before its zaak is ever opened, so Reject must not require one.
|
||||
var registration = Registration.Submit("123456782");
|
||||
|
||||
registration.Reject();
|
||||
|
||||
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
|
||||
Assert.Null(registration.ZaakUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejecting_an_in_behandeling_registration_sets_it_afgewezen()
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.TakeIntoBehandeling();
|
||||
|
||||
registration.Reject();
|
||||
|
||||
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deciding_an_already_decided_registration_is_rejected()
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.Reject();
|
||||
|
||||
var approveEx = Assert.Throws<InvalidOperationException>(() =>
|
||||
{
|
||||
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
||||
registration.Approve();
|
||||
});
|
||||
Assert.Contains("IN_BEHANDELING", approveEx.Message);
|
||||
|
||||
var rejectEx = Assert.Throws<InvalidOperationException>(() => registration.Reject());
|
||||
Assert.Contains("Afgewezen", rejectEx.Message);
|
||||
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
|
||||
}
|
||||
}
|
||||
|
||||
49
services/domain/Big.Tests/WerkbakTests.cs
Normal file
49
services/domain/Big.Tests/WerkbakTests.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Big.Application;
|
||||
using Big.Domain;
|
||||
|
||||
namespace Big.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The werkbak query (S-12c): the behandelaar's list of registrations awaiting beoordeling. It reads
|
||||
/// the open Beoordelen tasks from the workflow engine (§8.2) and enriches each with its registration
|
||||
/// (bsn + status) from the store. A task whose registration is unknown is skipped defensively.
|
||||
/// </summary>
|
||||
public class WerkbakTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Lists_an_item_per_open_beoordeling_enriched_from_the_registration()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.AttachZaak(FakeAclClient.DefaultZaakUrl);
|
||||
registration.TakeIntoBehandeling();
|
||||
store.Seed(registration);
|
||||
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", registration.Id)]);
|
||||
var werkbak = new Werkbak(tasks, store);
|
||||
|
||||
var items = await werkbak.GetAsync();
|
||||
|
||||
var item = Assert.Single(items);
|
||||
Assert.Equal(registration.Id.ToString(), item.RegistrationId);
|
||||
Assert.Equal("123456782", item.Bsn);
|
||||
Assert.Equal("InBehandeling", item.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Is_empty_when_no_beoordelingen_are_open()
|
||||
{
|
||||
var werkbak = new Werkbak(new FakeUserTaskClient([]), new FakeRegistrationStore());
|
||||
|
||||
Assert.Empty(await werkbak.GetAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Skips_a_task_whose_registration_is_unknown()
|
||||
{
|
||||
// Defensive: the werkbak never invents an item for a task the domain has no aggregate for.
|
||||
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", RegistrationId.New())]);
|
||||
var werkbak = new Werkbak(tasks, new FakeRegistrationStore());
|
||||
|
||||
Assert.Empty(await werkbak.GetAsync());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using EventSubscriber.Application;
|
||||
|
||||
namespace EventSubscriber.Api;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client to the ACL service. The subscriber enriches the projection with the zaak's reference
|
||||
/// (identificatie) by asking the ACL — the only code that may read ZGW (§8.1) — rather than reading
|
||||
/// OpenZaak itself (adr-proposal #78).
|
||||
/// </summary>
|
||||
public sealed class AclHttpClient(HttpClient http) : IAclClient
|
||||
{
|
||||
public async Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||
|
||||
using var response = await http.PostAsJsonAsync(
|
||||
new Uri(http.BaseAddress!, "zaken/reference"), new ReferenceRequest(zaakUrl.ToString()), ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var body = await response.Content.ReadFromJsonAsync<ReferenceResponse>(ct)
|
||||
?? throw new InvalidOperationException("The ACL returned an empty reference response.");
|
||||
return body.Reference;
|
||||
}
|
||||
|
||||
private sealed record ReferenceRequest([property: JsonPropertyName("zaakUrl")] string ZaakUrl);
|
||||
|
||||
private sealed record ReferenceResponse([property: JsonPropertyName("reference")] string Reference);
|
||||
}
|
||||
@@ -11,9 +11,15 @@ var connectionString = builder.Configuration.GetConnectionString("Projection")
|
||||
// this value (ADR-0007), so the webhook enforces it.
|
||||
var webhookToken = builder.Configuration["EventSubscriber:Webhook:AuthToken"]
|
||||
?? throw new InvalidOperationException("Missing configuration 'EventSubscriber:Webhook:AuthToken'");
|
||||
// The ACL is the only code that may read ZGW (§8.1); the subscriber enriches the projection with the
|
||||
// zaak reference through it (adr-proposal #78).
|
||||
var aclBaseUrl = builder.Configuration["Acl:BaseUrl"]
|
||||
?? throw new InvalidOperationException("Missing configuration 'Acl:BaseUrl'");
|
||||
|
||||
builder.Services.AddProjectionReadModel(connectionString);
|
||||
builder.Services.AddProjectionWriteSide();
|
||||
builder.Services.AddHttpClient<EventSubscriber.Application.IAclClient, EventSubscriber.Api.AclHttpClient>(
|
||||
c => c.BaseAddress = new Uri(aclBaseUrl));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
|
||||
@@ -24,10 +24,12 @@ public sealed record Notification(
|
||||
public bool IsZaakStatusSet =>
|
||||
Kanaal == "zaken" && Resource == "status" && Actie == "create";
|
||||
|
||||
/// <summary>The zaak UUID used as the projection key. For a status notification the resource URL is
|
||||
/// the status, so the zaak comes from <c>hoofdObject</c> (which, for a zaak-create, equals the
|
||||
/// resource URL) — see the NRC payload note.</summary>
|
||||
public string ZaakId => (HoofdObject ?? ResourceUrl).Segments[^1].Trim('/');
|
||||
/// <summary>The zaak URL this notification concerns — <c>hoofdObject</c> (the zaak) for a status
|
||||
/// notification, else the resource URL (which, for a zaak-create, is the zaak).</summary>
|
||||
public Uri ZaakUrl => HoofdObject ?? ResourceUrl;
|
||||
|
||||
/// <summary>The zaak UUID used as the projection key — the trailing segment of <see cref="ZaakUrl"/>.</summary>
|
||||
public string ZaakId => ZaakUrl.Segments[^1].Trim('/');
|
||||
|
||||
/// <summary>
|
||||
/// A deterministic dedup key. Open Notificaties carries no notification id and may
|
||||
|
||||
@@ -5,17 +5,19 @@ namespace EventSubscriber.Application;
|
||||
/// out-of-order deliveries (CLAUDE.md §8.6): the notification log dedups, and the projection
|
||||
/// upsert is idempotent on the zaak id. Rebuilds the projection by replaying the log.
|
||||
/// </summary>
|
||||
public sealed class NotificationProjector(INotificationLog log, IProjectionStore store)
|
||||
public sealed class NotificationProjector(INotificationLog log, IProjectionStore store, IAclClient acl)
|
||||
{
|
||||
/// <summary>Handle one inbound notification. Reacts to a zaak being created (INGEDIEND) and a
|
||||
/// status being set (INGESCHREVEN); ignores everything else.</summary>
|
||||
/// status being set (INGESCHREVEN); ignores everything else. Enriches the row with the zaak's
|
||||
/// reference via the ACL (§8.1) and records it so a rebuild needs no ZGW access (#78).</summary>
|
||||
public async Task HandleAsync(Notification notification, CancellationToken ct = default)
|
||||
{
|
||||
if (!notification.IsZaakCreated && !notification.IsZaakStatusSet)
|
||||
return;
|
||||
|
||||
var reference = await acl.GetZaakReferenceAsync(notification.ZaakUrl, ct);
|
||||
var recorded = new RecordedNotification(
|
||||
notification.IdempotencyKey, notification.Actie, notification.ZaakId, notification.Resource);
|
||||
notification.IdempotencyKey, notification.Actie, notification.ZaakId, notification.Resource, reference);
|
||||
|
||||
// Atomic record-or-skip: a duplicate (or concurrent) delivery is recognised and dropped
|
||||
// before it touches the projection, so the projection stays a faithful derived artefact.
|
||||
@@ -36,7 +38,8 @@ public sealed class NotificationProjector(INotificationLog log, IProjectionStore
|
||||
/// <summary>The projection row for an accepted notification: a status-set maps to INGESCHREVEN,
|
||||
/// a zaak-create to INGEDIEND. bsn/naam are deferred (ADR-0008).</summary>
|
||||
private static RegisterEntry ToEntry(RecordedNotification recorded)
|
||||
=> new(recorded.ZaakId, recorded.Resource == "status"
|
||||
? RegistrationStatus.Ingeschreven
|
||||
: RegistrationStatus.Ingediend);
|
||||
=> new(
|
||||
recorded.ZaakId,
|
||||
recorded.Resource == "status" ? RegistrationStatus.Ingeschreven : RegistrationStatus.Ingediend,
|
||||
Reference: recorded.Reference);
|
||||
}
|
||||
|
||||
@@ -20,9 +20,20 @@ public interface INotificationLog
|
||||
}
|
||||
|
||||
/// <summary>A notification that has been accepted, retaining what a rebuild needs to recompute its
|
||||
/// projection row — including the ZGW <c>resource</c>, which distinguishes a zaak-create (INGEDIEND)
|
||||
/// from a status-set (INGESCHREVEN) so a rebuild reproduces the right status.</summary>
|
||||
public sealed record RecordedNotification(string Key, string Actie, string ZaakId, string Resource);
|
||||
/// projection row — the ZGW <c>resource</c> (zaak-create → INGEDIEND vs status-set → INGESCHREVEN) and
|
||||
/// the zaak <c>reference</c> (identificatie), so a rebuild reproduces the row without re-reading ZGW (#78).</summary>
|
||||
public sealed record RecordedNotification(string Key, string Actie, string ZaakId, string Resource, string? Reference);
|
||||
|
||||
/// <summary>
|
||||
/// Port to the Anti-Corruption Layer. The subscriber enriches the projection with the zaak's
|
||||
/// public-safe reference (its identificatie) by asking the ACL — the only code that may read ZGW
|
||||
/// (§8.1) — rather than reading OpenZaak itself (adr-proposal #78).
|
||||
/// </summary>
|
||||
public interface IAclClient
|
||||
{
|
||||
/// <summary>The zaak's reference (identificatie) for the read projection.</summary>
|
||||
Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>The read projection store. Owned by the projection bounded context (ADR-0008); the
|
||||
/// subscriber writes to it and the projection-api reads it.</summary>
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace EventSubscriber.Application;
|
||||
public sealed record RegisterEntry(
|
||||
string Id,
|
||||
string Status,
|
||||
string? Reference = null,
|
||||
string? Bsn = null,
|
||||
string? NaamPlaceholder = null);
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using EventSubscriber.Api;
|
||||
|
||||
namespace EventSubscriber.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the subscriber's ACL client, which reads a zaak's reference (identificatie) through
|
||||
/// the ACL — the only code allowed to talk to ZGW (§8.1, #78). Uses a scripted message handler so no
|
||||
/// real ACL is required.
|
||||
/// </summary>
|
||||
public class AclHttpClientTests
|
||||
{
|
||||
private static AclHttpClient Client(StubHandler handler) =>
|
||||
new(new HttpClient(handler) { BaseAddress = new Uri("http://acl/") });
|
||||
|
||||
[Fact]
|
||||
public async Task Reads_a_zaak_reference_by_posting_the_zaak_url_and_returns_it()
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.OK, """{"reference":"REG-42"}"""));
|
||||
|
||||
var reference = await client.GetZaakReferenceAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
||||
|
||||
Assert.Equal("REG-42", reference);
|
||||
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
|
||||
Assert.Equal("http://acl/zaken/reference", capture.Seen.RequestUri!.ToString());
|
||||
Assert.Contains("\"zaakUrl\":\"http://openzaak/zaken/api/v1/zaken/abc\"", capture.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Throws_when_the_acl_rejects_the_request()
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.BadGateway));
|
||||
|
||||
await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => client.GetZaakReferenceAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Throws_when_the_acl_returns_an_empty_body()
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.OK, "null"));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => client.GetZaakReferenceAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc")));
|
||||
Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_a_null_zaak_url_without_sending_a_request()
|
||||
{
|
||||
var capture = new RequestCapture();
|
||||
var client = Client(capture.Responds(HttpStatusCode.OK, """{"reference":"REG-1"}"""));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => client.GetZaakReferenceAsync(null!));
|
||||
Assert.Null(capture.Seen);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A test double for <see cref="HttpMessageHandler"/> that records the last request and
|
||||
/// returns a scripted response — mirrors the ACL/domain gateway tests.</summary>
|
||||
internal sealed class StubHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> onSend) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
=> onSend(request);
|
||||
}
|
||||
|
||||
/// <summary>Captures the request a client sent, including the (buffered) body.</summary>
|
||||
internal sealed class RequestCapture
|
||||
{
|
||||
public HttpRequestMessage? Seen { get; private set; }
|
||||
public string? Body { get; private set; }
|
||||
|
||||
public StubHandler Responds(HttpStatusCode status, string? json = null) => new(async req =>
|
||||
{
|
||||
Seen = req;
|
||||
Body = req.Content is null ? null : await req.Content.ReadAsStringAsync();
|
||||
var response = new HttpResponseMessage(status);
|
||||
if (json is not null)
|
||||
response.Content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
return response;
|
||||
});
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\EventSubscriber.Api\EventSubscriber.Api.csproj" />
|
||||
<ProjectReference Include="..\EventSubscriber.Application\EventSubscriber.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -5,6 +5,19 @@ namespace EventSubscriber.Tests;
|
||||
/// <summary>In-memory stand-ins for the projection store and notification log, so the
|
||||
/// projector's behaviour is exercised without Postgres (hand-written stubs, the repo's
|
||||
/// convention — no mocking library).</summary>
|
||||
/// <summary>A fake ACL client that returns a fixed reference derived from the zaak, and records
|
||||
/// how many times it was called (to prove a rebuild does not re-read via the ACL).</summary>
|
||||
internal sealed class FakeAclClient : IAclClient
|
||||
{
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||
{
|
||||
CallCount++;
|
||||
return Task.FromResult("REG-" + zaakUrl.Segments[^1].Trim('/'));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class InMemoryNotificationLog : INotificationLog
|
||||
{
|
||||
private readonly Dictionary<string, RecordedNotification> _byKey = [];
|
||||
|
||||
@@ -12,8 +12,9 @@ public sealed class NotificationProjectorTests
|
||||
|
||||
private readonly InMemoryNotificationLog _log = new();
|
||||
private readonly InMemoryProjectionStore _store = new();
|
||||
private readonly FakeAclClient _acl = new();
|
||||
|
||||
private NotificationProjector Projector() => new(_log, _store);
|
||||
private NotificationProjector Projector() => new(_log, _store, _acl);
|
||||
|
||||
private static Notification ZaakCreated(string url = ZaakUrl)
|
||||
=> new("zaken", "zaak", "create", new Uri(url));
|
||||
@@ -30,6 +31,23 @@ public sealed class NotificationProjectorTests
|
||||
var entry = Assert.Single(await _store.AllAsync());
|
||||
Assert.Equal("11111111-1111-1111-1111-111111111111", entry.Id);
|
||||
Assert.Equal(RegistrationStatus.Ingediend, entry.Status);
|
||||
// Enriched with the zaak's reference (identificatie), fetched via the ACL (#78).
|
||||
Assert.Equal("REG-11111111-1111-1111-1111-111111111111", entry.Reference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task rebuild_reproduces_the_reference_without_re_reading_via_the_acl()
|
||||
{
|
||||
var projector = Projector();
|
||||
await projector.HandleAsync(ZaakCreated());
|
||||
var callsAfterProjection = _acl.CallCount;
|
||||
|
||||
await projector.RebuildAsync();
|
||||
|
||||
var entry = Assert.Single(await _store.AllAsync());
|
||||
Assert.Equal("REG-11111111-1111-1111-1111-111111111111", entry.Reference);
|
||||
// Rebuild replays the log (which stored the reference) — no extra ACL calls (#78, ADR-0008).
|
||||
Assert.Equal(callsAfterProjection, _acl.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog
|
||||
Actie = notification.Actie,
|
||||
ZaakId = notification.ZaakId,
|
||||
Resource = notification.Resource,
|
||||
Reference = notification.Reference,
|
||||
ReceivedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
|
||||
@@ -35,6 +36,6 @@ public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog
|
||||
public async Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default)
|
||||
=> await db.ProcessedNotifications
|
||||
.OrderBy(r => r.ReceivedAt)
|
||||
.Select(r => new RecordedNotification(r.Key, r.Actie, r.ZaakId, r.Resource))
|
||||
.Select(r => new RecordedNotification(r.Key, r.Actie, r.ZaakId, r.Resource, r.Reference))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ public sealed class EfProjectionStore(ProjectionDbContext db) : IProjectionStore
|
||||
{
|
||||
Id = entry.Id,
|
||||
Status = entry.Status,
|
||||
Reference = entry.Reference,
|
||||
Bsn = entry.Bsn,
|
||||
NaamPlaceholder = entry.NaamPlaceholder,
|
||||
});
|
||||
@@ -22,6 +23,7 @@ public sealed class EfProjectionStore(ProjectionDbContext db) : IProjectionStore
|
||||
else
|
||||
{
|
||||
row.Status = entry.Status;
|
||||
row.Reference = entry.Reference;
|
||||
row.Bsn = entry.Bsn;
|
||||
row.NaamPlaceholder = entry.NaamPlaceholder;
|
||||
}
|
||||
@@ -35,6 +37,6 @@ public sealed class EfProjectionStore(ProjectionDbContext db) : IProjectionStore
|
||||
public async Task<IReadOnlyList<RegisterEntry>> AllAsync(CancellationToken ct = default)
|
||||
=> await db.RegisterEntries
|
||||
.OrderBy(r => r.Id)
|
||||
.Select(r => new RegisterEntry(r.Id, r.Status, r.Bsn, r.NaamPlaceholder))
|
||||
.Select(r => new RegisterEntry(r.Id, r.Status, Reference: r.Reference, Bsn: r.Bsn, NaamPlaceholder: r.NaamPlaceholder))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Projection.ReadModel;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Projection.ReadModel.Migrations
|
||||
{
|
||||
[DbContext(typeof(ProjectionDbContext))]
|
||||
[Migration("20260714101642_AddReferenceColumns")]
|
||||
partial class AddReferenceColumns
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.0")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Projection.ReadModel.ProcessedNotificationRow", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("key");
|
||||
|
||||
b.Property<string>("Actie")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("actie");
|
||||
|
||||
b.Property<DateTimeOffset>("ReceivedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("received_at");
|
||||
|
||||
b.Property<string>("Reference")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("reference");
|
||||
|
||||
b.Property<string>("Resource")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("resource");
|
||||
|
||||
b.Property<string>("ZaakId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("zaak_id");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("processed_notifications", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Projection.ReadModel.RegisterEntryRow", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Bsn")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("bsn");
|
||||
|
||||
b.Property<string>("NaamPlaceholder")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("naam_placeholder");
|
||||
|
||||
b.Property<string>("Reference")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("reference");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("register_projection", (string)null);
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Projection.ReadModel.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddReferenceColumns : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "reference",
|
||||
table: "register_projection",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "reference",
|
||||
table: "processed_notifications",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "reference",
|
||||
table: "register_projection");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "reference",
|
||||
table: "processed_notifications");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,10 @@ namespace Projection.ReadModel.Migrations
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("received_at");
|
||||
|
||||
b.Property<string>("Reference")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("reference");
|
||||
|
||||
b.Property<string>("Resource")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
@@ -66,6 +70,10 @@ namespace Projection.ReadModel.Migrations
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("naam_placeholder");
|
||||
|
||||
b.Property<string>("Reference")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("reference");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
|
||||
@@ -24,6 +24,7 @@ public sealed class ProjectionDbContext(DbContextOptions<ProjectionDbContext> op
|
||||
e.HasKey(r => r.Id);
|
||||
e.Property(r => r.Id).HasColumnName("id");
|
||||
e.Property(r => r.Status).HasColumnName("status").IsRequired();
|
||||
e.Property(r => r.Reference).HasColumnName("reference");
|
||||
e.Property(r => r.Bsn).HasColumnName("bsn");
|
||||
e.Property(r => r.NaamPlaceholder).HasColumnName("naam_placeholder");
|
||||
});
|
||||
@@ -36,6 +37,7 @@ public sealed class ProjectionDbContext(DbContextOptions<ProjectionDbContext> op
|
||||
e.Property(r => r.Actie).HasColumnName("actie").IsRequired();
|
||||
e.Property(r => r.ZaakId).HasColumnName("zaak_id").IsRequired();
|
||||
e.Property(r => r.Resource).HasColumnName("resource").IsRequired();
|
||||
e.Property(r => r.Reference).HasColumnName("reference");
|
||||
e.Property(r => r.ReceivedAt).HasColumnName("received_at");
|
||||
});
|
||||
}
|
||||
@@ -46,6 +48,10 @@ public sealed class RegisterEntryRow
|
||||
{
|
||||
public required string Id { get; set; }
|
||||
public required string Status { get; set; }
|
||||
|
||||
/// <summary>The citizen-facing reference (the zaak's identificatie) — matches the submit confirmation (#78).</summary>
|
||||
public string? Reference { get; set; }
|
||||
|
||||
public string? Bsn { get; set; }
|
||||
public string? NaamPlaceholder { get; set; }
|
||||
}
|
||||
@@ -61,5 +67,8 @@ public sealed class ProcessedNotificationRow
|
||||
/// the right status without reading OpenZaak (S-09b).</summary>
|
||||
public required string Resource { get; set; }
|
||||
|
||||
/// <summary>The zaak reference (identificatie), retained so a rebuild reprojects it without the ACL (#78).</summary>
|
||||
public string? Reference { get; set; }
|
||||
|
||||
public DateTimeOffset ReceivedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ app.MapGet("/register", async (ProjectionDbContext db, CancellationToken ct) =>
|
||||
{
|
||||
var rows = await db.RegisterEntries
|
||||
.OrderBy(r => r.Id)
|
||||
.Select(r => new RegisterEntryDto(r.Id, r.Status, r.Bsn, r.NaamPlaceholder))
|
||||
.Select(r => new RegisterEntryDto(r.Id, r.Status, r.Reference, r.Bsn, r.NaamPlaceholder))
|
||||
.ToListAsync(ct);
|
||||
return Results.Ok(rows);
|
||||
});
|
||||
@@ -32,13 +32,13 @@ app.MapGet("/register/{id}", async (string id, ProjectionDbContext db, Cancellat
|
||||
{
|
||||
var row = await db.RegisterEntries
|
||||
.Where(r => r.Id == id)
|
||||
.Select(r => new RegisterEntryDto(r.Id, r.Status, r.Bsn, r.NaamPlaceholder))
|
||||
.Select(r => new RegisterEntryDto(r.Id, r.Status, r.Reference, r.Bsn, r.NaamPlaceholder))
|
||||
.SingleOrDefaultAsync(ct);
|
||||
return row is null ? Results.NotFound() : Results.Ok(row);
|
||||
});
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
public sealed record RegisterEntryDto(string Id, string Status, string? Bsn, string? NaamPlaceholder);
|
||||
public sealed record RegisterEntryDto(string Id, string Status, string? Reference, string? Bsn, string? NaamPlaceholder);
|
||||
|
||||
public partial class Program;
|
||||
|
||||
26
tests/acceptance/Features/EenRegistratieBeoordelen.feature
Normal file
26
tests/acceptance/Features/EenRegistratieBeoordelen.feature
Normal file
@@ -0,0 +1,26 @@
|
||||
# language: en
|
||||
# Drives S-12 (#13). A behandelaar picks up a submitted registration for beoordeling and decides it:
|
||||
# goedkeuren enters it in the register (INGESCHREVEN, the zaak's final status set via the ACL, §8.1),
|
||||
# afwijzen turns it down (AFGEWEZEN). This scenario exercises the use cases against in-memory
|
||||
# stand-ins for the ACL and the store; real Flowable user-task claim/complete arrives in a later
|
||||
# sub-slice and is verified live.
|
||||
Feature: Een registratie beoordelen
|
||||
Als behandelaar wil ik een ingediende registratie beoordelen
|
||||
zodat deze wordt ingeschreven of afgewezen.
|
||||
|
||||
Scenario: Goedkeuren schrijft de registratie in via de ACL
|
||||
Given a submitted registration with an opened zaak
|
||||
When the behandelaar takes it into behandeling
|
||||
Then the registration has status "INBEHANDELING"
|
||||
When the behandelaar decides "goedkeuren"
|
||||
Then the registration has status "INGESCHREVEN"
|
||||
And the zaak's final status is set via the ACL
|
||||
And the beoordeling task is completed with "goedkeuren"
|
||||
|
||||
Scenario: Afwijzen wijst de registratie af zonder de ACL
|
||||
Given a submitted registration with an opened zaak
|
||||
When the behandelaar takes it into behandeling
|
||||
And the behandelaar decides "afwijzen"
|
||||
Then the registration has status "AFGEWEZEN"
|
||||
And the ACL is not asked to set the zaak status
|
||||
And the beoordeling task is completed with "afwijzen"
|
||||
@@ -31,7 +31,7 @@ public sealed class BffToegangSteps : IDisposable
|
||||
[Given("the projection contains a zaak \"(.*)\" for BSN \"(.*)\"")]
|
||||
public void GivenTheProjectionContains(string zaakId, string bsn)
|
||||
{
|
||||
_host.Projection.Entries.Add(new ProjectionEntry(zaakId, "INGEDIEND", bsn, null));
|
||||
_host.Projection.Entries.Add(new ProjectionEntry(zaakId, "INGEDIEND", "REG-" + zaakId, bsn, null));
|
||||
_client = _host.CreateClient();
|
||||
}
|
||||
|
||||
|
||||
65
tests/acceptance/Steps/EenRegistratieBeoordelenSteps.cs
Normal file
65
tests/acceptance/Steps/EenRegistratieBeoordelenSteps.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using Acceptance.Support;
|
||||
using Big.Application;
|
||||
using Big.Domain;
|
||||
using Reqnroll;
|
||||
using Xunit;
|
||||
|
||||
namespace Acceptance.Steps;
|
||||
|
||||
/// <summary>Bindings for <c>EenRegistratieBeoordelen.feature</c> (S-12). Drives the TakeIntoBehandeling
|
||||
/// transition and the BeoordeelRegistratie use case against in-memory ports; one instance per scenario.
|
||||
/// Scoped to this feature so its "the registration has status" step does not clash with the identically
|
||||
/// phrased (but differently-asserting) step in <see cref="RegistratieIndienenSteps"/>.</summary>
|
||||
[Binding]
|
||||
[Scope(Feature = "Een registratie beoordelen")]
|
||||
public sealed class EenRegistratieBeoordelenSteps
|
||||
{
|
||||
private readonly InMemoryAclClient _acl = new();
|
||||
private readonly InMemoryRegistrationStore _store = new();
|
||||
private readonly InMemoryUserTaskClient _tasks = new();
|
||||
private RegistrationId _id;
|
||||
|
||||
[Given("a submitted registration with an opened zaak")]
|
||||
public async Task GivenASubmittedRegistrationWithAnOpenedZaak()
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.AttachZaak(InMemoryAclClient.OpenedZaakUrl);
|
||||
await _store.SaveAsync(registration);
|
||||
_id = registration.Id;
|
||||
// The process has parked at the Beoordelen user task awaiting the behandelaar.
|
||||
_tasks.Open(_id);
|
||||
}
|
||||
|
||||
[When("the behandelaar takes it into behandeling")]
|
||||
public async Task WhenTheBehandelaarTakesItIntoBehandeling()
|
||||
{
|
||||
var registration = await _store.GetAsync(_id);
|
||||
registration!.TakeIntoBehandeling();
|
||||
await _store.SaveAsync(registration);
|
||||
}
|
||||
|
||||
[When("the behandelaar decides \"(.*)\"")]
|
||||
public async Task WhenTheBehandelaarDecides(string besluit)
|
||||
=> await new BeoordeelRegistratie(_store, _acl, _tasks).HandleAsync(
|
||||
new BeoordeelRegistratieCommand(_id, Enum.Parse<BeoordelingsBesluit>(besluit, ignoreCase: true)));
|
||||
|
||||
[Then("the registration has status \"(.*)\"")]
|
||||
public async Task ThenTheRegistrationHasStatus(string expected)
|
||||
{
|
||||
var registration = await _store.GetAsync(_id);
|
||||
Assert.NotNull(registration);
|
||||
Assert.Equal(expected, registration.Status.ToString().ToUpperInvariant());
|
||||
}
|
||||
|
||||
[Then("the zaak's final status is set via the ACL")]
|
||||
public void ThenTheZaakFinalStatusIsSetViaTheAcl()
|
||||
=> Assert.Equal(InMemoryAclClient.OpenedZaakUrl, _acl.ApprovedZaakUrl);
|
||||
|
||||
[Then("the ACL is not asked to set the zaak status")]
|
||||
public void ThenTheAclIsNotAskedToSetTheZaakStatus()
|
||||
=> Assert.Null(_acl.ApprovedZaakUrl);
|
||||
|
||||
[Then("the beoordeling task is completed with \"(.*)\"")]
|
||||
public void ThenTheBeoordelingTaskIsCompletedWith(string besluit)
|
||||
=> Assert.Equal(Enum.Parse<BeoordelingsBesluit>(besluit, ignoreCase: true), _tasks.Completed?.Besluit);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ public sealed class EenZaakOpenenSteps
|
||||
|
||||
[Given("a domain registration for BSN \"(.*)\"")]
|
||||
public void GivenADomainRegistrationForBsn(string bsn)
|
||||
=> _registration = new DomainRegistration(bsn);
|
||||
=> _registration = new DomainRegistration(bsn, "ACC-REF-1");
|
||||
|
||||
[Given("the ACL is configured with these defaults:")]
|
||||
public void GivenTheAclIsConfiguredWithTheseDefaults(DataTable defaults)
|
||||
|
||||
@@ -17,7 +17,8 @@ public sealed class RegisterProjectieBijwerkenSteps
|
||||
private readonly NotificationProjector _projector;
|
||||
private Notification? _notification;
|
||||
|
||||
public RegisterProjectieBijwerkenSteps() => _projector = new NotificationProjector(_log, _store);
|
||||
public RegisterProjectieBijwerkenSteps()
|
||||
=> _projector = new NotificationProjector(_log, _store, new InMemoryAclReferenceClient());
|
||||
|
||||
[Given("a zaak is created in OpenZaak with id \"(.*)\"")]
|
||||
public void GivenAZaakIsCreatedInOpenZaakWithId(string id)
|
||||
|
||||
@@ -68,6 +68,12 @@ public sealed class CapturingDomainClient : IDomainClient
|
||||
SubmittedBsn = bsn;
|
||||
return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend"));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<WerkbakItem>>([]);
|
||||
|
||||
public Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Serves configurable projection rows.</summary>
|
||||
|
||||
@@ -26,11 +26,13 @@ public sealed class InMemoryAclClient : IAclClient
|
||||
public static readonly Uri OpenedZaakUrl = new("http://openzaak/zaken/api/v1/zaken/acc-zaak");
|
||||
|
||||
public string? OpenedForBsn { get; private set; }
|
||||
public string? OpenedWithReference { get; private set; }
|
||||
public Uri? ApprovedZaakUrl { get; private set; }
|
||||
|
||||
public Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default)
|
||||
public Task<Uri> OpenZaakAsync(string bsn, string reference, CancellationToken ct = default)
|
||||
{
|
||||
OpenedForBsn = bsn;
|
||||
OpenedWithReference = reference;
|
||||
return Task.FromResult(OpenedZaakUrl);
|
||||
}
|
||||
|
||||
@@ -41,6 +43,29 @@ public sealed class InMemoryAclClient : IAclClient
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An in-memory user-task client for the beoordeling acceptance scenario: it holds one open
|
||||
/// Beoordelen task per registration and records the besluit each is completed with.</summary>
|
||||
public sealed class InMemoryUserTaskClient : IUserTaskClient
|
||||
{
|
||||
private readonly List<BeoordelingTask> _open = [];
|
||||
|
||||
public (string TaskId, BeoordelingsBesluit Besluit)? Completed { get; private set; }
|
||||
|
||||
public void Open(RegistrationId registrationId) => _open.Add(new BeoordelingTask($"task-{registrationId}", registrationId));
|
||||
|
||||
public Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<BeoordelingTask>>(_open);
|
||||
|
||||
public Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default) => Task.CompletedTask;
|
||||
|
||||
public Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default)
|
||||
{
|
||||
Completed = (taskId, besluit);
|
||||
_open.RemoveAll(t => t.TaskId == taskId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An in-memory registration store for the domain acceptance scenario.</summary>
|
||||
public sealed class InMemoryRegistrationStore : IRegistrationStore
|
||||
{
|
||||
|
||||
@@ -38,3 +38,11 @@ public sealed class InMemoryProjectionStore : IProjectionStore
|
||||
public IReadOnlyList<RegisterEntry> RowsFor(string id)
|
||||
=> [.. _byId.Values.Where(e => e.Id == id)];
|
||||
}
|
||||
|
||||
/// <summary>A fake ACL client for the projection acceptance scenario: returns a reference derived
|
||||
/// from the zaak, so the projector can enrich rows without a running ACL (#78).</summary>
|
||||
public sealed class InMemoryAclReferenceClient : IAclClient
|
||||
{
|
||||
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||
=> Task.FromResult("REG-" + zaakUrl.Segments[^1].Trim('/'));
|
||||
}
|
||||
|
||||
@@ -24,4 +24,7 @@ public sealed class InMemoryZaakGateway : IZaakGateway
|
||||
Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||
=> Task.FromResult("ACC-REF-1");
|
||||
}
|
||||
|
||||
@@ -26,15 +26,22 @@ test('DigiD login → submit → public INGEDIEND → approve → public INGESCH
|
||||
|
||||
// The openbaar register (anonymous, its own origin) shows the submitted entry once the projection
|
||||
// catches up. The projection updates asynchronously (NRC → event-subscriber), and the register loads
|
||||
// on open, so reload until a submitted (INGEDIEND) row appears.
|
||||
// on open, so reload until *this* submission's row appears. We poll on the reference cell (not a
|
||||
// generic INGEDIEND cell): the shared verify stack already holds INGEDIEND rows from earlier checks,
|
||||
// so a status-only poll would short-circuit on a stale row before our row is projected.
|
||||
await page.goto('http://openbaar/');
|
||||
await expect(page.getByRole('heading', { name: /Openbaar BIG-register/i })).toBeVisible();
|
||||
|
||||
// #78: the reference shown in the public register must be the exact one the citizen saw on the
|
||||
// submit confirmation — no mismatch between the two portals.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
await page.reload();
|
||||
return page.getByRole('cell', { name: 'INGEDIEND' }).count();
|
||||
return page.getByRole('cell', { name: reference }).count();
|
||||
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
||||
.toBeGreaterThan(0);
|
||||
await expect(page.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' }))
|
||||
.toBeVisible();
|
||||
|
||||
// Approve via the temporary admin endpoint (reached directly on the compose network, as a
|
||||
// behandelaar would until the behandel-portal exists — S-12). The zaak is opened off the request
|
||||
@@ -49,11 +56,12 @@ test('DigiD login → submit → public INGEDIEND → approve → public INGESCH
|
||||
const approve = await request.post(`http://domain:8080/registrations/${reference}/approve`);
|
||||
expect(approve.status()).toBe(204);
|
||||
|
||||
// The approval flows back to the projection; the openbaar register now shows it as INGESCHREVEN.
|
||||
// The approval flows back to the projection; the openbaar register now shows *our* row (matched by
|
||||
// its reference) as INGESCHREVEN.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
await page.reload();
|
||||
return page.getByRole('cell', { name: 'INGESCHREVEN' }).count();
|
||||
return page.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGESCHREVEN' }).count();
|
||||
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -6,9 +6,12 @@
|
||||
xmlns:omgdi="http://www.omg.org/spec/DD/20100524/DI"
|
||||
targetNamespace="http://respellion.nl/big">
|
||||
|
||||
<!-- S-03: minimal "Registratie ontvangen" flow.
|
||||
start -> external-worker task (OpenZaakAanmaken) -> end.
|
||||
The external task is handled by the Workflow Client / ACL in later slices. -->
|
||||
<!-- Registratie ontvangen flow.
|
||||
start -> external-worker task (OpenZaakAanmaken) -> Beoordelen (behandelaar user task) -> end.
|
||||
S-03 added the external task (Workflow Client / ACL). S-12 adds the behandelaar's beoordeling
|
||||
as a user task: the process parks here until a behandelaar claims and completes it with a
|
||||
`besluit` (goedkeuren/afwijzen). The registrationId set at start rides along as a process
|
||||
variable so the werkbak can correlate each task back to its aggregate. -->
|
||||
<process id="registratie" name="Registratie ontvangen" isExecutable="true">
|
||||
|
||||
<startEvent id="start" name="Registratie ontvangen"/>
|
||||
@@ -19,9 +22,13 @@
|
||||
flowable:type="external-worker"
|
||||
flowable:topic="OpenZaakAanmaken"/>
|
||||
|
||||
<sequenceFlow id="flow2" sourceRef="OpenZaakAanmaken" targetRef="end"/>
|
||||
<sequenceFlow id="flow2" sourceRef="OpenZaakAanmaken" targetRef="Beoordelen"/>
|
||||
|
||||
<endEvent id="end" name="Zaak aangemaakt"/>
|
||||
<userTask id="Beoordelen" name="Beoordelen" flowable:candidateGroups="behandelaar"/>
|
||||
|
||||
<sequenceFlow id="flow3" sourceRef="Beoordelen" targetRef="end"/>
|
||||
|
||||
<endEvent id="end" name="Registratie beoordeeld"/>
|
||||
</process>
|
||||
|
||||
<bpmndi:BPMNDiagram id="diagram">
|
||||
@@ -32,8 +39,11 @@
|
||||
<bpmndi:BPMNShape id="s_task" bpmnElement="OpenZaakAanmaken">
|
||||
<omgdc:Bounds x="200" y="85" width="120" height="60"/>
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="s_beoordelen" bpmnElement="Beoordelen">
|
||||
<omgdc:Bounds x="390" y="85" width="120" height="60"/>
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="s_end" bpmnElement="end">
|
||||
<omgdc:Bounds x="400" y="100" width="30" height="30"/>
|
||||
<omgdc:Bounds x="580" y="100" width="30" height="30"/>
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNEdge id="e_flow1" bpmnElement="flow1">
|
||||
<omgdi:waypoint x="130" y="115"/>
|
||||
@@ -41,7 +51,11 @@
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="e_flow2" bpmnElement="flow2">
|
||||
<omgdi:waypoint x="320" y="115"/>
|
||||
<omgdi:waypoint x="400" y="115"/>
|
||||
<omgdi:waypoint x="390" y="115"/>
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNEdge id="e_flow3" bpmnElement="flow3">
|
||||
<omgdi:waypoint x="510" y="115"/>
|
||||
<omgdi:waypoint x="580" y="115"/>
|
||||
</bpmndi:BPMNEdge>
|
||||
</bpmndi:BPMNPlane>
|
||||
</bpmndi:BPMNDiagram>
|
||||
|
||||
Reference in New Issue
Block a user