Compare commits
11 Commits
8badef02af
...
feat/13-be
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cb3ce6956 | |||
| 4ebc263bdf | |||
| d50a60c98b | |||
| 94d5feb6e0 | |||
| c005b0d627 | |||
| 9c3da48d8e | |||
| 4085bdead7 | |||
| d4ed0ffc22 | |||
| 3023bb6fbe | |||
| 9997da8beb | |||
| 1c185e6686 |
113
CHANGELOG.md
113
CHANGELOG.md
@@ -2,19 +2,130 @@
|
|||||||
|
|
||||||
All notable changes to this project. Generated from Conventional Commits by git-cliff.
|
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
|
### CI
|
||||||
- Gitea Actions pipeline + runner runbook (refs #30) (#37)
|
- 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
|
### Chores
|
||||||
- Add idempotent Gitea backlog seeder
|
- Add idempotent Gitea backlog seeder
|
||||||
- Remove bootstrap scripts from main (#35)
|
- Remove bootstrap scripts from main (#35)
|
||||||
|
- Contributor workflow — templates, git-cliff, gitea-workflow doc (closes #31) (#38)
|
||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
- Split S-00 into sub-slices (refs #1) (#33)
|
- 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
|
### Features
|
||||||
- Placeholder BFF + /health endpoint (closes #28) (#34)
|
- Placeholder BFF + /health endpoint (closes #28) (#34)
|
||||||
- Containerize BFF + compose-up smoke (closes #29) (#36)
|
- 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>
|
<tbody>
|
||||||
@for (entry of entries(); track entry.id) {
|
@for (entry of entries(); track entry.id) {
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ entry.id }}</td>
|
<td>{{ entry.reference }}</td>
|
||||||
<td>{{ entry.status }}</td>
|
<td>{{ entry.status }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { axe } from 'vitest-axe';
|
|||||||
import { RegisterPage } from './register-page';
|
import { RegisterPage } from './register-page';
|
||||||
|
|
||||||
const sample: OpenbaarEntry[] = [
|
const sample: OpenbaarEntry[] = [
|
||||||
{ id: 'zaak-abc', status: 'INGEDIEND' },
|
{ id: 'zaak-abc', status: 'INGEDIEND', reference: 'REG-abc' },
|
||||||
{ id: 'zaak-def', status: 'INGESCHREVEN' },
|
{ id: 'zaak-def', status: 'INGESCHREVEN', reference: 'REG-def' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function providers(get = vi.fn().mockReturnValue(of(sample))) {
|
function providers(get = vi.fn().mockReturnValue(of(sample))) {
|
||||||
@@ -22,9 +22,11 @@ describe('RegisterPage', () => {
|
|||||||
await render(RegisterPage, { providers: providers(get).providers });
|
await render(RegisterPage, { providers: providers(get).providers });
|
||||||
|
|
||||||
expect(get).toHaveBeenCalled();
|
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(/INGEDIEND/)).toBeTruthy();
|
||||||
expect(screen.getByText(/zaak-def/)).toBeTruthy();
|
expect(screen.getByText(/REG-def/)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('searches by the entered term', async () => {
|
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
|
> **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
|
> 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+).
|
> 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
|
# 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.
|
# verify token request both use keycloak:8080 to keep the issuer consistent.
|
||||||
Keycloak__Authority: http://keycloak:8080/realms/digid
|
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__Domain__BaseUrl: http://domain:8080/
|
||||||
Downstream__Projection__BaseUrl: http://projection-api:8080/
|
Downstream__Projection__BaseUrl: http://projection-api:8080/
|
||||||
ports:
|
ports:
|
||||||
@@ -396,6 +398,9 @@ services:
|
|||||||
image: register-referentie/event-subscriber:dev
|
image: register-referentie/event-subscriber:dev
|
||||||
environment:
|
environment:
|
||||||
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
|
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
|
# The bearer Open Notificaties must present on the abonnement callback. NRC's
|
||||||
# registration probe expects a 401 without it (ADR-0007). Dev-only token.
|
# registration probe expects a 401 without it (ADR-0007). Dev-only token.
|
||||||
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
|
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
|
||||||
@@ -410,6 +415,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
projection-db:
|
projection-db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
acl:
|
||||||
|
condition: service_healthy
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
# The read side of the projection. Shares Projection.ReadModel, so build context is root.
|
# 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 ">> registration accepted at $loc"
|
||||||
|
|
||||||
echo ">> polling the domain until the worker records the opened zaak"
|
echo ">> polling the domain until the worker records the opened zaak"
|
||||||
|
zaak_ok=""
|
||||||
for _ in $(seq 1 30); do
|
for _ in $(seq 1 30); do
|
||||||
body="$(docker run --rm --network "$net" curlimages/curl:latest \
|
body="$(docker run --rm --network "$net" curlimages/curl:latest \
|
||||||
-fsS "http://$dom_ip:8080$loc" 2>/dev/null || true)"
|
-fsS "http://$dom_ip:8080$loc" 2>/dev/null || true)"
|
||||||
if echo "$body" | grep -q '/zaken/api/v1/zaken/'; then
|
if echo "$body" | grep -q '/zaken/api/v1/zaken/'; then
|
||||||
echo "OK — the domain opened a zaak and recorded it on the registration:"
|
echo "OK — the domain opened a zaak and recorded it on the registration:"
|
||||||
echo "$body" | cut -c1-300
|
echo "$body" | cut -c1-300
|
||||||
exit 0
|
zaak_ok=1
|
||||||
|
break
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
if [ -z "$zaak_ok" ]; then
|
||||||
echo "FAIL — the registration never received a zaak URL" >&2
|
echo "FAIL — the registration never received a zaak URL" >&2
|
||||||
echo "--- domain log ---" >&2; docker logs "$dom" 2>&1 | tail -15 >&2
|
echo "--- domain log ---" >&2; docker logs "$dom" 2>&1 | tail -15 >&2
|
||||||
acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)"
|
acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)"
|
||||||
[ -n "$acl" ] && { echo "--- acl log ---" >&2; docker logs "$acl" 2>&1 | tail -15 >&2; }
|
[ -n "$acl" ] && { echo "--- acl log ---" >&2; docker logs "$acl" 2>&1 | tail -15 >&2; }
|
||||||
exit 1
|
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
|
Observable
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
|
|
||||||
|
export interface DecideRequest {
|
||||||
|
besluit: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OpenbaarEntry {
|
export interface OpenbaarEntry {
|
||||||
id: string;
|
id: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
/** @nullable */
|
||||||
|
reference: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubmitAccepted {
|
export interface SubmitAccepted {
|
||||||
@@ -34,6 +40,12 @@ export interface SubmitAccepted {
|
|||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WerkbakItem {
|
||||||
|
registrationId: string;
|
||||||
|
bsn: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type GetOpenbaarRegisterParams = {
|
export type GetOpenbaarRegisterParams = {
|
||||||
q?: string;
|
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.
|
// The ACL's single operation, exposed as a service endpoint.
|
||||||
app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationToken ct) =>
|
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() });
|
return Results.Ok(new { zaakUrl = zaakUrl.ToString() });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,10 +32,20 @@ app.MapPost("/statussen", async (SetStatusRequest body, AclService acl, Cancella
|
|||||||
return Results.NoContent();
|
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();
|
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 SetStatusRequest(string ZaakUrl);
|
||||||
|
|
||||||
|
public sealed record ZaakReferenceRequest(string ZaakUrl);
|
||||||
|
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
|
|||||||
defaults.VerantwoordelijkeOrganisatie,
|
defaults.VerantwoordelijkeOrganisatie,
|
||||||
defaults.Vertrouwelijkheidaanduiding,
|
defaults.Vertrouwelijkheidaanduiding,
|
||||||
defaults.ZaaktypeUrl,
|
defaults.ZaaktypeUrl,
|
||||||
clock.Today);
|
clock.Today,
|
||||||
|
registration.Reference);
|
||||||
|
|
||||||
return gateway.OpenZaakAsync(request, ct);
|
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);
|
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;
|
namespace Acl.Application;
|
||||||
|
|
||||||
/// <summary>Domain-language payload handed to the ACL. No ZGW concepts here.</summary>
|
/// <summary>Domain-language payload handed to the ACL. No ZGW concepts here. <see cref="Reference"/>
|
||||||
public sealed record DomainRegistration(string Bsn);
|
/// 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"/>.
|
/// the catalogus and POSTs a status against the zaak, dated <paramref name="datumStatusGezet"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default);
|
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 VerantwoordelijkeOrganisatie,
|
||||||
string Vertrouwelijkheidaanduiding,
|
string Vertrouwelijkheidaanduiding,
|
||||||
Uri Zaaktype,
|
Uri Zaaktype,
|
||||||
DateOnly Startdatum);
|
DateOnly Startdatum,
|
||||||
|
string Identificatie);
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
|||||||
request.Zaaktype.ToString(),
|
request.Zaaktype.ToString(),
|
||||||
request.VerantwoordelijkeOrganisatie,
|
request.VerantwoordelijkeOrganisatie,
|
||||||
request.Startdatum.ToString("yyyy-MM-dd"),
|
request.Startdatum.ToString("yyyy-MM-dd"),
|
||||||
request.Vertrouwelijkheidaanduiding)),
|
request.Vertrouwelijkheidaanduiding,
|
||||||
|
request.Identificatie)),
|
||||||
};
|
};
|
||||||
message.Headers.Authorization =
|
message.Headers.Authorization =
|
||||||
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
|
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
|
||||||
@@ -47,40 +48,69 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
|||||||
ArgumentNullException.ThrowIfNull(zaaktypeUrl);
|
ArgumentNullException.ThrowIfNull(zaaktypeUrl);
|
||||||
|
|
||||||
var eindstatus = await ResolveEindstatusAsync(zaaktypeUrl, ct);
|
var eindstatus = await ResolveEindstatusAsync(zaaktypeUrl, ct);
|
||||||
|
var resultaattype = await ResolveResultaattypeAsync(zaaktypeUrl, ct);
|
||||||
|
|
||||||
using var message = new HttpRequestMessage(
|
// OpenZaak refuses to set a zaak's eindstatus unless the zaak has a resultaat
|
||||||
HttpMethod.Post, new Uri(options.BaseUrl, "/zaken/api/v1/statussen"))
|
// ("resultaat-does-not-exist"), so record the resultaat first, then the status.
|
||||||
{
|
await PostAsync("/zaken/api/v1/resultaten",
|
||||||
Content = JsonContent.Create(new StatusDto(
|
new ResultaatDto(zaakUrl.ToString(), resultaattype.ToString()), "Setting the zaak resultaat", ct);
|
||||||
zaakUrl.ToString(),
|
|
||||||
eindstatus.ToString(),
|
await PostAsync("/zaken/api/v1/statussen",
|
||||||
// datumStatusGezet is a ZGW date-time; set it at the start of the given day (UTC).
|
// datumStatusGezet is a ZGW date-time; set it at the start of the given day (UTC).
|
||||||
datumStatusGezet.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ"))),
|
new StatusDto(zaakUrl.ToString(), eindstatus.ToString(),
|
||||||
|
datumStatusGezet.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ")),
|
||||||
|
"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)
|
||||||
|
{
|
||||||
|
using var message = new HttpRequestMessage(HttpMethod.Post, new Uri(options.BaseUrl, path))
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(dto),
|
||||||
};
|
};
|
||||||
message.Headers.Authorization =
|
message.Headers.Authorization =
|
||||||
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
|
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
|
||||||
// The status resource carries no geometry, so no CRS headers. As with zaak-create, buffer the
|
|
||||||
// body so uwsgi gets a Content-Length instead of a chunked body.
|
|
||||||
await message.Content.LoadIntoBufferAsync(ct);
|
await message.Content.LoadIntoBufferAsync(ct);
|
||||||
|
|
||||||
using var response = await http.SendAsync(message, ct);
|
using var response = await http.SendAsync(message, ct);
|
||||||
response.EnsureSuccessStatusCode();
|
await EnsureSuccessAsync(response, action, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureSuccessStatusCode discards the response body; ZGW returns a JSON problem detail on 400 that
|
||||||
|
// is essential for diagnosing a rejected request, so surface it in the exception.
|
||||||
|
private static async Task EnsureSuccessAsync(HttpResponseMessage response, string action, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var body = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
throw new HttpRequestException($"{action} failed: {(int)response.StatusCode} {response.ReasonPhrase}. {body}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Resolve the zaaktype's eindstatus (the terminal statustype) from the catalogus.</summary>
|
/// <summary>Resolve the zaaktype's eindstatus (the terminal statustype) from the catalogus.</summary>
|
||||||
private async Task<Uri> ResolveEindstatusAsync(Uri zaaktypeUrl, CancellationToken ct)
|
private async Task<Uri> ResolveEindstatusAsync(Uri zaaktypeUrl, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var query = new Uri(options.BaseUrl,
|
var page = await GetCatalogusAsync<StatustypePage>("statustypen", zaaktypeUrl, "statustypen", ct);
|
||||||
"/catalogi/api/v1/statustypen?status=alles&zaaktype=" + Uri.EscapeDataString(zaaktypeUrl.ToString()));
|
|
||||||
using var message = new HttpRequestMessage(HttpMethod.Get, query);
|
|
||||||
message.Headers.Authorization =
|
|
||||||
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
|
|
||||||
|
|
||||||
using var response = await http.SendAsync(message, ct);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
|
|
||||||
var page = await response.Content.ReadFromJsonAsync<StatustypePage>(ct)
|
|
||||||
?? throw new InvalidOperationException("OpenZaak returned an empty statustypen response");
|
|
||||||
var results = page.Results ?? [];
|
var results = page.Results ?? [];
|
||||||
|
|
||||||
// OpenZaak flags the terminal statustype (highest volgnummer) as isEindstatus; fall back to the
|
// OpenZaak flags the terminal statustype (highest volgnummer) as isEindstatus; fall back to the
|
||||||
@@ -91,16 +121,45 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
|||||||
return new Uri(eindstatus.Url);
|
return new Uri(eindstatus.Url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolve the zaaktype's resultaattype from the catalogus (the seed defines one).</summary>
|
||||||
|
private async Task<Uri> ResolveResultaattypeAsync(Uri zaaktypeUrl, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var page = await GetCatalogusAsync<ResultaattypePage>("resultaattypen", zaaktypeUrl, "resultaattypen", ct);
|
||||||
|
var resultaattype = (page.Results ?? []).FirstOrDefault()
|
||||||
|
?? throw new InvalidOperationException($"No resultaattypen found for zaaktype {zaaktypeUrl}");
|
||||||
|
return new Uri(resultaattype.Url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GETs a catalogus collection filtered by zaaktype (status=alles includes concept + published).
|
||||||
|
private async Task<T> GetCatalogusAsync<T>(string resource, Uri zaaktypeUrl, string label, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var query = new Uri(options.BaseUrl,
|
||||||
|
$"/catalogi/api/v1/{resource}?status=alles&zaaktype=" + Uri.EscapeDataString(zaaktypeUrl.ToString()));
|
||||||
|
using var message = new HttpRequestMessage(HttpMethod.Get, query);
|
||||||
|
message.Headers.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
|
||||||
|
|
||||||
|
using var response = await http.SendAsync(message, ct);
|
||||||
|
await EnsureSuccessAsync(response, $"Querying {label}", ct);
|
||||||
|
|
||||||
|
return await response.Content.ReadFromJsonAsync<T>(ct)
|
||||||
|
?? throw new InvalidOperationException($"OpenZaak returned an empty {label} response");
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record ZaakDto(
|
private sealed record ZaakDto(
|
||||||
[property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
|
[property: JsonPropertyName("bronorganisatie")] string Bronorganisatie,
|
||||||
[property: JsonPropertyName("zaaktype")] string Zaaktype,
|
[property: JsonPropertyName("zaaktype")] string Zaaktype,
|
||||||
[property: JsonPropertyName("verantwoordelijkeOrganisatie")] string VerantwoordelijkeOrganisatie,
|
[property: JsonPropertyName("verantwoordelijkeOrganisatie")] string VerantwoordelijkeOrganisatie,
|
||||||
[property: JsonPropertyName("startdatum")] string Startdatum,
|
[property: JsonPropertyName("startdatum")] string Startdatum,
|
||||||
[property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding);
|
[property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding,
|
||||||
|
[property: JsonPropertyName("identificatie")] string Identificatie);
|
||||||
|
|
||||||
private sealed record ZaakCreatedDto(
|
private sealed record ZaakCreatedDto(
|
||||||
[property: JsonPropertyName("url")] string Url);
|
[property: JsonPropertyName("url")] string Url);
|
||||||
|
|
||||||
|
private sealed record ZaakReadDto(
|
||||||
|
[property: JsonPropertyName("identificatie")] string Identificatie);
|
||||||
|
|
||||||
private sealed record StatusDto(
|
private sealed record StatusDto(
|
||||||
[property: JsonPropertyName("zaak")] string Zaak,
|
[property: JsonPropertyName("zaak")] string Zaak,
|
||||||
[property: JsonPropertyName("statustype")] string Statustype,
|
[property: JsonPropertyName("statustype")] string Statustype,
|
||||||
@@ -113,4 +172,14 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
|||||||
[property: JsonPropertyName("url")] string Url,
|
[property: JsonPropertyName("url")] string Url,
|
||||||
[property: JsonPropertyName("volgnummer")] int Volgnummer,
|
[property: JsonPropertyName("volgnummer")] int Volgnummer,
|
||||||
[property: JsonPropertyName("isEindstatus")] bool IsEindstatus);
|
[property: JsonPropertyName("isEindstatus")] bool IsEindstatus);
|
||||||
|
|
||||||
|
private sealed record ResultaatDto(
|
||||||
|
[property: JsonPropertyName("zaak")] string Zaak,
|
||||||
|
[property: JsonPropertyName("resultaattype")] string Resultaattype);
|
||||||
|
|
||||||
|
private sealed record ResultaattypePage(
|
||||||
|
[property: JsonPropertyName("results")] IReadOnlyList<ResultaattypeDto>? Results);
|
||||||
|
|
||||||
|
private sealed record ResultaattypeDto(
|
||||||
|
[property: JsonPropertyName("url")] string Url);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,14 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
|
|||||||
"seed it with OZ_PUBLISH=1 (`make integration` does this).");
|
"seed it with OZ_PUBLISH=1 (`make integration` does this).");
|
||||||
|
|
||||||
var gateway = new OpenZaakGateway(stack.Http, stack.Options);
|
var gateway = new OpenZaakGateway(stack.Http, stack.Options);
|
||||||
|
var reference = Guid.NewGuid().ToString(); // zaak identificatie must be unique per bronorganisatie
|
||||||
var request = new ZaakRequest(
|
var request = new ZaakRequest(
|
||||||
Bronorganisatie: "517439943",
|
Bronorganisatie: "517439943",
|
||||||
VerantwoordelijkeOrganisatie: "517439943",
|
VerantwoordelijkeOrganisatie: "517439943",
|
||||||
Vertrouwelijkheidaanduiding: "openbaar",
|
Vertrouwelijkheidaanduiding: "openbaar",
|
||||||
Zaaktype: zaaktype!,
|
Zaaktype: zaaktype!,
|
||||||
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow));
|
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow),
|
||||||
|
Identificatie: reference);
|
||||||
|
|
||||||
var zaakUrl = await gateway.OpenZaakAsync(request);
|
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(),
|
new Uri(stack.BaseUrl, "/zaken/api/v1/zaken/").ToString(),
|
||||||
zaakUrl.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);
|
var zaak = await stack.GetZaakAsync(zaakUrl);
|
||||||
Assert.Equal(zaaktype.ToString(), zaak.GetProperty("zaaktype").GetString());
|
Assert.Equal(zaaktype.ToString(), zaak.GetProperty("zaaktype").GetString());
|
||||||
Assert.Equal("517439943", zaak.GetProperty("bronorganisatie").GetString());
|
Assert.Equal("517439943", zaak.GetProperty("bronorganisatie").GetString());
|
||||||
Assert.Equal("openbaar", zaak.GetProperty("vertrouwelijkheidaanduiding").GetString());
|
Assert.Equal("openbaar", zaak.GetProperty("vertrouwelijkheidaanduiding").GetString());
|
||||||
|
Assert.Equal(reference, zaak.GetProperty("identificatie").GetString());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -57,7 +60,8 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
|
|||||||
VerantwoordelijkeOrganisatie: "517439943",
|
VerantwoordelijkeOrganisatie: "517439943",
|
||||||
Vertrouwelijkheidaanduiding: "openbaar",
|
Vertrouwelijkheidaanduiding: "openbaar",
|
||||||
Zaaktype: zaaktype!,
|
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));
|
await gateway.SetZaakToEindstatusAsync(zaakUrl, zaaktype!, DateOnly.FromDateTime(DateTime.UtcNow));
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ public class AclServiceTests
|
|||||||
Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet);
|
Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet);
|
||||||
return Task.CompletedTask;
|
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()
|
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 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);
|
Assert.Equal(gateway.Result, url);
|
||||||
var req = Assert.IsType<ZaakRequest>(gateway.Captured);
|
var req = Assert.IsType<ZaakRequest>(gateway.Captured);
|
||||||
@@ -59,6 +67,8 @@ public class AclServiceTests
|
|||||||
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding);
|
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding);
|
||||||
Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype);
|
Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype);
|
||||||
Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum);
|
Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum);
|
||||||
|
// The registration reference becomes the zaak identificatie (#78).
|
||||||
|
Assert.Equal("reg-77", req.Identificatie);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -103,4 +113,27 @@ public class AclServiceTests
|
|||||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!));
|
await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!));
|
||||||
Assert.Null(gateway.Approved);
|
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(
|
private static ZaakRequest SampleRequest() => new(
|
||||||
"517439943", "517439943", "openbaar",
|
"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)
|
private static StubHandler Created(out RequestCapture capture)
|
||||||
{
|
{
|
||||||
@@ -67,6 +67,8 @@ public class OpenZaakGatewayTests
|
|||||||
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", capture.Body);
|
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", capture.Body);
|
||||||
Assert.Contains("\"startdatum\":\"2026-06-04\"", capture.Body);
|
Assert.Contains("\"startdatum\":\"2026-06-04\"", capture.Body);
|
||||||
Assert.Contains("\"zaaktype\":\"http://openzaak/catalogi/api/v1/zaaktypen/big\"", 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]
|
[Fact]
|
||||||
@@ -131,8 +133,9 @@ public class OpenZaakGatewayTests
|
|||||||
Content = new StringContent("null", Encoding.UTF8, "application/json"),
|
Content = new StringContent("null", Encoding.UTF8, "application/json"),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
() => Gateway(handler).OpenZaakAsync(SampleRequest()));
|
() => Gateway(handler).OpenZaakAsync(SampleRequest()));
|
||||||
|
Assert.Contains("empty zaak response", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -144,6 +147,284 @@ public class OpenZaakGatewayTests
|
|||||||
() => Gateway(handler).OpenZaakAsync(null!));
|
() => Gateway(handler).OpenZaakAsync(null!));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- SetZaakToEindstatusAsync (approval / S-09b) ---
|
||||||
|
|
||||||
|
private const string ZaakUrl = "http://openzaak/zaken/api/v1/zaken/xyz";
|
||||||
|
private static readonly Uri Zaaktype = new("http://openzaak/catalogi/api/v1/zaaktypen/big");
|
||||||
|
|
||||||
|
private sealed class Recorder
|
||||||
|
{
|
||||||
|
public List<HttpRequestMessage> Requests { get; } = [];
|
||||||
|
public List<string?> Bodies { get; } = [];
|
||||||
|
public List<long?> ContentLengths { get; } = [];
|
||||||
|
|
||||||
|
public int IndexOf(string pathContains) =>
|
||||||
|
Requests.FindIndex(r => r.RequestUri!.ToString().Contains(pathContains));
|
||||||
|
|
||||||
|
// The (body, content-length, request) of the single request whose URL contains the segment.
|
||||||
|
public (string? Body, long? Length, HttpRequestMessage Request) Sent(string pathContains)
|
||||||
|
{
|
||||||
|
var i = IndexOf(pathContains);
|
||||||
|
return (Bodies[i], ContentLengths[i], Requests[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-route response config for the four calls the approval makes.
|
||||||
|
private sealed class OzRoutes
|
||||||
|
{
|
||||||
|
public string StatustypenJson { get; init; } = StatustypenPage(withEindstatusFlag: true);
|
||||||
|
public string ResultaattypenJson { get; init; } = """{"results":[{"url":"http://openzaak/catalogi/api/v1/resultaattypen/1"}]}""";
|
||||||
|
public HttpStatusCode StatustypenStatus { get; init; } = HttpStatusCode.OK;
|
||||||
|
public HttpStatusCode ResultaattypenStatus { get; init; } = HttpStatusCode.OK;
|
||||||
|
public HttpStatusCode ResultaatPostStatus { get; init; } = HttpStatusCode.Created;
|
||||||
|
public HttpStatusCode StatusPostStatus { get; init; } = HttpStatusCode.Created;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes the approval's four calls by URL: GET /statustypen, GET /resultaattypen (catalogus),
|
||||||
|
// then POST /resultaten and POST /statussen (zaken).
|
||||||
|
private static StubHandler ApprovalStub(Recorder rec, OzRoutes routes) => new(async req =>
|
||||||
|
{
|
||||||
|
rec.Requests.Add(req);
|
||||||
|
// Capture the length BEFORE reading the body (ReadAsStringAsync buffers as a side effect).
|
||||||
|
rec.ContentLengths.Add(req.Content?.Headers.ContentLength);
|
||||||
|
rec.Bodies.Add(req.Content is null ? null : await req.Content.ReadAsStringAsync());
|
||||||
|
|
||||||
|
var url = req.RequestUri!.ToString();
|
||||||
|
if (req.Method == HttpMethod.Get && url.Contains("/statustypen"))
|
||||||
|
return Json(routes.StatustypenStatus, routes.StatustypenJson);
|
||||||
|
if (req.Method == HttpMethod.Get && url.Contains("/resultaattypen"))
|
||||||
|
return Json(routes.ResultaattypenStatus, routes.ResultaattypenJson);
|
||||||
|
if (url.Contains("/resultaten"))
|
||||||
|
return Json(routes.ResultaatPostStatus, """{"url":"http://openzaak/zaken/api/v1/resultaten/new"}""");
|
||||||
|
return Json(routes.StatusPostStatus, """{"url":"http://openzaak/zaken/api/v1/statussen/new"}""");
|
||||||
|
});
|
||||||
|
|
||||||
|
private static HttpResponseMessage Json(HttpStatusCode status, string body) =>
|
||||||
|
new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
|
||||||
|
|
||||||
|
// Two statustypen; the eindstatus is flagged on the *lower* volgnummer so the tests prove the
|
||||||
|
// isEindstatus flag is preferred over "highest volgnummer", not coincidentally equal to it.
|
||||||
|
private static string StatustypenPage(bool withEindstatusFlag) => JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
results = new object[]
|
||||||
|
{
|
||||||
|
new { url = "http://openzaak/catalogi/api/v1/statustypen/1", volgnummer = 1, isEindstatus = withEindstatusFlag },
|
||||||
|
new { url = "http://openzaak/catalogi/api/v1/statustypen/2", volgnummer = 2, isEindstatus = false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approving_sets_a_resultaat_then_posts_the_flagged_eindstatus_against_the_zaak()
|
||||||
|
{
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
await Gateway(ApprovalStub(rec, new OzRoutes()))
|
||||||
|
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4));
|
||||||
|
|
||||||
|
Assert.Equal(4, rec.Requests.Count);
|
||||||
|
|
||||||
|
// Both catalogus queries filter by the zaaktype and carry the bearer.
|
||||||
|
var statustypenGet = rec.Sent("/statustypen").Request;
|
||||||
|
Assert.Equal(HttpMethod.Get, statustypenGet.Method);
|
||||||
|
Assert.Contains(Uri.EscapeDataString(Zaaktype.ToString()), statustypenGet.RequestUri!.ToString());
|
||||||
|
Assert.Equal("Bearer", statustypenGet.Headers.Authorization!.Scheme);
|
||||||
|
Assert.Contains(Uri.EscapeDataString(Zaaktype.ToString()), rec.Sent("/resultaattypen").Request.RequestUri!.ToString());
|
||||||
|
|
||||||
|
// OpenZaak requires a resultaat before the eindstatus, so /resultaten precedes /statussen.
|
||||||
|
Assert.True(rec.IndexOf("/resultaten") < rec.IndexOf("/statussen"));
|
||||||
|
|
||||||
|
var resultaat = rec.Sent("/resultaten");
|
||||||
|
Assert.Equal("http://openzaak/zaken/api/v1/resultaten", resultaat.Request.RequestUri!.ToString());
|
||||||
|
Assert.Equal("Bearer", resultaat.Request.Headers.Authorization!.Scheme);
|
||||||
|
Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", resultaat.Body);
|
||||||
|
Assert.Contains("\"resultaattype\":\"http://openzaak/catalogi/api/v1/resultaattypen/1\"", resultaat.Body);
|
||||||
|
Assert.True(resultaat.Length > 0);
|
||||||
|
|
||||||
|
var status = rec.Sent("/statussen");
|
||||||
|
Assert.Equal("http://openzaak/zaken/api/v1/statussen", status.Request.RequestUri!.ToString());
|
||||||
|
Assert.Equal("Bearer", status.Request.Headers.Authorization!.Scheme);
|
||||||
|
Assert.Contains("\"zaak\":\"" + ZaakUrl + "\"", status.Body);
|
||||||
|
// The isEindstatus-flagged statustype (/1) is chosen — even though /2 has a higher volgnummer.
|
||||||
|
Assert.Contains("\"statustype\":\"http://openzaak/catalogi/api/v1/statustypen/1\"", status.Body);
|
||||||
|
Assert.Contains("\"datumStatusGezet\":\"2026-06-04T00:00:00Z\"", status.Body);
|
||||||
|
// Bodies are buffered (Content-Length set), so uwsgi doesn't get a chunked body.
|
||||||
|
Assert.True(status.Length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approving_falls_back_to_the_highest_volgnummer_when_no_eindstatus_is_flagged()
|
||||||
|
{
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
await Gateway(ApprovalStub(rec, new OzRoutes { StatustypenJson = StatustypenPage(withEindstatusFlag: false) }))
|
||||||
|
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4));
|
||||||
|
|
||||||
|
// No isEindstatus flag → the highest volgnummer (/2) is chosen.
|
||||||
|
Assert.Contains("\"statustype\":\"http://openzaak/catalogi/api/v1/statustypen/2\"", rec.Sent("/statussen").Body);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approving_throws_when_the_zaaktype_has_no_statustypen()
|
||||||
|
{
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
// A page with no `results` property (Results is null) — the eindstatus cannot be resolved.
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
Gateway(ApprovalStub(rec, new OzRoutes { StatustypenJson = "{}" }))
|
||||||
|
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
|
Assert.Contains("No statustypen found", ex.Message);
|
||||||
|
// It never posts anything when it cannot resolve the eindstatus.
|
||||||
|
Assert.Single(rec.Requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approving_throws_when_the_statustypen_response_is_empty()
|
||||||
|
{
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
Gateway(ApprovalStub(rec, new OzRoutes { StatustypenJson = "null" }))
|
||||||
|
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
|
Assert.Contains("empty statustypen", ex.Message);
|
||||||
|
Assert.Single(rec.Requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approving_throws_when_the_statustypen_query_fails()
|
||||||
|
{
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
|
||||||
|
Gateway(ApprovalStub(rec, new OzRoutes { StatustypenStatus = HttpStatusCode.InternalServerError }))
|
||||||
|
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
|
Assert.Contains("Querying statustypen", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approving_throws_when_the_resultaattypen_query_fails()
|
||||||
|
{
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
|
||||||
|
Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenStatus = HttpStatusCode.InternalServerError }))
|
||||||
|
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
|
Assert.Contains("Querying resultaattypen", ex.Message);
|
||||||
|
Assert.Equal(-1, rec.IndexOf("/resultaten"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approving_throws_when_the_zaaktype_has_no_resultaattype()
|
||||||
|
{
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
|
Gateway(ApprovalStub(rec, new OzRoutes { ResultaattypenJson = "{}" }))
|
||||||
|
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
|
Assert.Contains("No resultaattypen found", ex.Message);
|
||||||
|
// Resolved the eindstatus + queried resultaattypen, but posted nothing.
|
||||||
|
Assert.Equal(-1, rec.IndexOf("/resultaten"));
|
||||||
|
Assert.Equal(-1, rec.IndexOf("/statussen"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approving_throws_when_posting_the_resultaat_fails()
|
||||||
|
{
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
|
||||||
|
Gateway(ApprovalStub(rec, new OzRoutes { ResultaatPostStatus = HttpStatusCode.BadRequest }))
|
||||||
|
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
|
Assert.Contains("Setting the zaak resultaat", ex.Message);
|
||||||
|
// The status is never posted if the resultaat could not be recorded.
|
||||||
|
Assert.Equal(-1, rec.IndexOf("/statussen"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approving_throws_when_posting_the_status_fails()
|
||||||
|
{
|
||||||
|
var rec = new Recorder();
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<HttpRequestException>(() =>
|
||||||
|
Gateway(ApprovalStub(rec, new OzRoutes { StatusPostStatus = HttpStatusCode.BadRequest }))
|
||||||
|
.SetZaakToEindstatusAsync(new Uri(ZaakUrl), Zaaktype, new DateOnly(2026, 6, 4)));
|
||||||
|
|
||||||
|
Assert.Contains("Setting the zaak status", ex.Message);
|
||||||
|
// It got as far as the resultaat + the status POST (4 calls) before failing.
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||||
|
Gateway(handler).SetZaakToEindstatusAsync(null!, Zaaktype, new DateOnly(2026, 6, 4)));
|
||||||
|
await Assert.ThrowsAsync<ArgumentNullException>(() =>
|
||||||
|
Gateway(handler).SetZaakToEindstatusAsync(new Uri(ZaakUrl), null!, new DateOnly(2026, 6, 4)));
|
||||||
|
}
|
||||||
|
|
||||||
// ZGW tokens are base64url with padding stripped (ZgwToken.B64Url); restore it to decode.
|
// ZGW tokens are base64url with padding stripped (ZgwToken.B64Url); restore it to decode.
|
||||||
private static string DecodeSegment(string segment)
|
private static string DecodeSegment(string segment)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,16 +6,27 @@ namespace Bff.Api;
|
|||||||
public sealed record SubmitAccepted(string RegistrationId, string Status);
|
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
|
/// <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>
|
/// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09).
|
||||||
public sealed record ProjectionEntry(string Id, string Status, string? Bsn, string? NaamPlaceholder);
|
/// <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>
|
/// <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>
|
/// <summary>Port to the Domain Service (§8.3: the BFF is the portals' only backend; it fans out).</summary>
|
||||||
public interface IDomainClient
|
public interface IDomainClient
|
||||||
{
|
{
|
||||||
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
|
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>
|
/// <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);
|
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);
|
private sealed record DomainResponse(string RegistrationId, string Status, string? ZaakUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ public static class OpenbaarProjection
|
|||||||
{
|
{
|
||||||
var filtered = string.IsNullOrWhiteSpace(q)
|
var filtered = string.IsNullOrWhiteSpace(q)
|
||||||
? entries
|
? 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.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using Bff.Api;
|
using Bff.Api;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
|
||||||
@@ -6,6 +8,10 @@ var builder = WebApplication.CreateBuilder(args);
|
|||||||
|
|
||||||
var keycloakAuthority = builder.Configuration["Keycloak:Authority"]
|
var keycloakAuthority = builder.Configuration["Keycloak:Authority"]
|
||||||
?? throw new InvalidOperationException("Missing 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"]
|
var domainBaseUrl = builder.Configuration["Downstream:Domain:BaseUrl"]
|
||||||
?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'");
|
?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'");
|
||||||
var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"]
|
var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"]
|
||||||
@@ -19,8 +25,28 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|||||||
options.Authority = keycloakAuthority;
|
options.Authority = keycloakAuthority;
|
||||||
options.RequireHttpsMetadata = false;
|
options.RequireHttpsMetadata = false;
|
||||||
options.TokenValidationParameters.ValidateAudience = 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).
|
// 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));
|
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);
|
.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();
|
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.
|
// Exposed so the test host (WebApplicationFactory<Program>) can boot the app.
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"Keycloak": {
|
"Keycloak": {
|
||||||
"Authority": "http://localhost:8180/realms/digid"
|
"Authority": "http://localhost:8180/realms/digid",
|
||||||
|
"MedewerkerAuthority": "http://localhost:8180/realms/medewerker"
|
||||||
},
|
},
|
||||||
"Downstream": {
|
"Downstream": {
|
||||||
"Domain": { "BaseUrl": "http://localhost:8130/" },
|
"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.Mvc.Testing;
|
||||||
using Microsoft.AspNetCore.TestHost;
|
using Microsoft.AspNetCore.TestHost;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.IdentityModel.Protocols;
|
||||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
@@ -23,24 +24,19 @@ internal sealed class BffFactory : WebApplicationFactory<Program>
|
|||||||
public FakeDomainClient Domain { get; } = new();
|
public FakeDomainClient Domain { get; } = new();
|
||||||
public FakeProjectionClient Projection { 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");
|
// Validate locally against the test key and NEVER reach out for OIDC metadata. A static
|
||||||
builder.UseSetting("Downstream:Domain:BaseUrl", "http://domain.invalid/");
|
// configuration manager guarantees this regardless of Configure/PostConfigure ordering —
|
||||||
builder.UseSetting("Downstream:Projection:BaseUrl", "http://projection.invalid/");
|
// clearing Authority alone left the medewerker scheme fetching metadata under CI timing
|
||||||
|
// (2s hang → 401), because JwtBearer's PostConfigure could still build a ConfigurationManager.
|
||||||
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.
|
|
||||||
options.Authority = null;
|
options.Authority = null;
|
||||||
options.MetadataAddress = null!;
|
options.MetadataAddress = null!;
|
||||||
options.RequireHttpsMetadata = false;
|
options.RequireHttpsMetadata = false;
|
||||||
options.Configuration = new OpenIdConnectConfiguration();
|
options.Configuration = new OpenIdConnectConfiguration();
|
||||||
|
options.ConfigurationManager =
|
||||||
|
new StaticConfigurationManager<OpenIdConnectConfiguration>(new OpenIdConnectConfiguration());
|
||||||
options.TokenValidationParameters = new TokenValidationParameters
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
{
|
{
|
||||||
ValidateIssuer = false,
|
ValidateIssuer = false,
|
||||||
@@ -51,6 +47,24 @@ internal sealed class BffFactory : WebApplicationFactory<Program>
|
|||||||
ClockSkew = TimeSpan.Zero,
|
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 string? SubmittedBsn { get; private set; }
|
||||||
public SubmitAccepted Result { get; set; } = new("reg-123", "Ingediend");
|
public SubmitAccepted Result { get; set; } = new("reg-123", "Ingediend");
|
||||||
|
public List<WerkbakItem> Werkbak { get; } = [];
|
||||||
|
|
||||||
public Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default)
|
public Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
SubmittedBsn = bsn;
|
SubmittedBsn = bsn;
|
||||||
return Task.FromResult(Result);
|
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>
|
/// <summary>Serves a configurable set of projection rows.</summary>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ public class OpenbaarEndpointTests
|
|||||||
public async Task Serves_public_safe_rows_anonymously()
|
public async Task Serves_public_safe_rows_anonymously()
|
||||||
{
|
{
|
||||||
using var factory = new BffFactory();
|
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).
|
// No Authorization header — the openbaar register is a public lookup (ADR-0010/S-09).
|
||||||
var response = await factory.CreateClient().GetAsync("/openbaar/register");
|
var response = await factory.CreateClient().GetAsync("/openbaar/register");
|
||||||
@@ -19,7 +19,9 @@ public class OpenbaarEndpointTests
|
|||||||
var body = await response.Content.ReadAsStringAsync();
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
Assert.Contains("abc-111", body);
|
Assert.Contains("abc-111", body);
|
||||||
Assert.Contains("INGEDIEND", 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);
|
Assert.DoesNotContain("123456782", body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,8 +29,8 @@ public class OpenbaarEndpointTests
|
|||||||
public async Task Filters_by_the_query_parameter()
|
public async Task Filters_by_the_query_parameter()
|
||||||
{
|
{
|
||||||
using var factory = new BffFactory();
|
using var factory = new BffFactory();
|
||||||
factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "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", null, null));
|
factory.Projection.Entries.Add(new ProjectionEntry("def-222", "INGEDIEND", "REG-def", null, null));
|
||||||
|
|
||||||
var rows = await factory.CreateClient()
|
var rows = await factory.CreateClient()
|
||||||
.GetFromJsonAsync<List<OpenbaarEntry>>("/openbaar/register?q=abc");
|
.GetFromJsonAsync<List<OpenbaarEntry>>("/openbaar/register?q=abc");
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ public class OpenbaarProjectionTests
|
|||||||
{
|
{
|
||||||
private static readonly ProjectionEntry[] Sample =
|
private static readonly ProjectionEntry[] Sample =
|
||||||
[
|
[
|
||||||
new("abc-111", "INGEDIEND", "123456782", "Jan"),
|
new("abc-111", "INGEDIEND", Reference: "REG-A", Bsn: "123456782", NaamPlaceholder: "Jan"),
|
||||||
new("def-222", "INGEDIEND", "987654321", "Piet"),
|
new("def-222", "INGESCHREVEN", Reference: "REG-B", Bsn: "987654321", NaamPlaceholder: "Piet"),
|
||||||
];
|
];
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -18,31 +18,47 @@ public class OpenbaarProjectionTests
|
|||||||
Assert.Equal(2, view.Count);
|
Assert.Equal(2, view.Count);
|
||||||
Assert.Equal("abc-111", view[0].Id);
|
Assert.Equal("abc-111", view[0].Id);
|
||||||
Assert.Equal("INGEDIEND", view[0].Status);
|
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]
|
[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();
|
var props = typeof(OpenbaarEntry).GetProperties().Select(p => p.Name).ToArray();
|
||||||
Assert.Equal(["Id", "Status"], props);
|
Assert.Equal(["Id", "Status", "Reference"], props);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("abc", 1)]
|
[InlineData("abc", 1)] // by id
|
||||||
[InlineData("ABC", 1)]
|
[InlineData("ABC", 1)] // by id, case-insensitive
|
||||||
[InlineData("2", 1)]
|
[InlineData("def", 1)] // by id
|
||||||
[InlineData("zzz", 0)]
|
[InlineData("zzz", 0)] // no match
|
||||||
public void Filters_by_id_containing_the_query_case_insensitively(string q, int expected)
|
public void Filters_by_id_containing_the_query_case_insensitively(string q, int expected)
|
||||||
{
|
=> Assert.Equal(expected, OpenbaarProjection.PublicView(Sample, q).Count);
|
||||||
var view = OpenbaarProjection.PublicView(Sample, q);
|
|
||||||
|
|
||||||
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]
|
[Fact]
|
||||||
public void Blank_query_is_treated_as_no_filter()
|
public void Blank_query_is_treated_as_no_filter()
|
||||||
{
|
{
|
||||||
Assert.Equal(2, OpenbaarProjection.PublicView(Sample, " ").Count);
|
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!")),
|
new SymmetricSecurityKey(Encoding.UTF8.GetBytes("a-different-signing-key-256-bits-long-indeed-yes!")),
|
||||||
expired: false);
|
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)
|
private static string Create(string bsn, SymmetricSecurityKey key, bool expired)
|
||||||
{
|
{
|
||||||
var handler = new JsonWebTokenHandler();
|
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": {
|
"components": {
|
||||||
"schemas": {
|
"schemas": {
|
||||||
|
"DecideRequest": {
|
||||||
|
"required": [
|
||||||
|
"besluit"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"besluit": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"OpenbaarEntry": {
|
"OpenbaarEntry": {
|
||||||
"required": [
|
"required": [
|
||||||
"id",
|
"id",
|
||||||
"status"
|
"status",
|
||||||
|
"reference"
|
||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -76,6 +157,12 @@
|
|||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
|
},
|
||||||
|
"reference": {
|
||||||
|
"type": [
|
||||||
|
"null",
|
||||||
|
"string"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -93,6 +180,25 @@
|
|||||||
"type": "string"
|
"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.AddHttpClient<FlowableWorkflowClient>();
|
||||||
builder.Services.AddTransient<IWorkflowClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
builder.Services.AddTransient<IWorkflowClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
|
||||||
builder.Services.AddTransient<IExternalWorkerClient>(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.AddHttpClient<IAclClient, AclHttpClient>();
|
||||||
|
|
||||||
builder.Services.AddScoped<SubmitRegistration>();
|
builder.Services.AddScoped<SubmitRegistration>();
|
||||||
builder.Services.AddScoped<ApproveRegistration>();
|
builder.Services.AddScoped<ApproveRegistration>();
|
||||||
|
builder.Services.AddScoped<BeoordeelRegistratie>();
|
||||||
|
builder.Services.AddScoped<Werkbak>();
|
||||||
builder.Services.AddScoped<OpenZaakWorker>();
|
builder.Services.AddScoped<OpenZaakWorker>();
|
||||||
builder.Services.AddScoped<OpenZaakJobProcessor>();
|
builder.Services.AddScoped<OpenZaakJobProcessor>();
|
||||||
|
|
||||||
@@ -55,6 +58,28 @@ app.MapPost("/registrations/{id}/approve", async (string id, ApproveRegistration
|
|||||||
return Results.NoContent();
|
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).
|
// 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) =>
|
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 SubmitRegistrationRequest(string Bsn);
|
||||||
|
|
||||||
|
public sealed record DecideRequest(string Besluit);
|
||||||
|
|
||||||
public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl);
|
public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl);
|
||||||
|
|
||||||
public partial class Program;
|
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)
|
if (registration.ZaakUrl is not null)
|
||||||
return registration.ZaakUrl;
|
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);
|
registration.AttachZaak(zaakUrl);
|
||||||
await store.SaveAsync(registration, ct);
|
await store.SaveAsync(registration, ct);
|
||||||
return zaakUrl;
|
return zaakUrl;
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ public interface IWorkflowClient
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IAclClient
|
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>
|
/// <summary>
|
||||||
/// Record the approval on the given zaak. The ACL translates this to the ZGW concept — setting
|
/// 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);
|
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>
|
/// <summary>
|
||||||
/// Persistence port for the <see cref="Registration"/> aggregate. In-memory for the minimal slice
|
/// 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.
|
/// (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>
|
/// <summary>
|
||||||
/// Approve the registration — the behandelaar's decision to enter it in the register. Advances
|
/// A behandelaar picks the registration up for beoordeling. Advances
|
||||||
/// <see cref="RegistrationStatus.Ingediend"/> → <see cref="RegistrationStatus.Ingeschreven"/>.
|
/// <see cref="RegistrationStatus.Ingediend"/> → <see cref="RegistrationStatus.InBehandeling"/>.
|
||||||
/// Requires an opened zaak (the approval sets that zaak's status via the ACL), and only a
|
/// Re-taking one already <see cref="RegistrationStatus.InBehandeling"/> is a no-op (the same or
|
||||||
/// submitted registration can be approved: re-approving is rejected as an invalid transition.
|
/// 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>
|
/// </summary>
|
||||||
public void Approve()
|
public void Approve()
|
||||||
{
|
{
|
||||||
@@ -77,10 +95,27 @@ public sealed class Registration
|
|||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Registration {Id} has no zaak yet; it cannot be approved before its zaak is opened.");
|
$"Registration {Id} has no zaak yet; it cannot be approved before its zaak is opened.");
|
||||||
|
|
||||||
if (Status != RegistrationStatus.Ingediend)
|
RequireOpenForDecision(nameof(Approve));
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Registration {Id} is {Status}; only an INGEDIEND registration can be approved.");
|
|
||||||
|
|
||||||
Status = RegistrationStatus.Ingeschreven;
|
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;
|
namespace Big.Domain;
|
||||||
|
|
||||||
/// <summary>The lifecycle states a <see cref="Registration"/> moves through. The walking
|
/// <summary>The lifecycle states a <see cref="Registration"/> moves through. Submission starts in
|
||||||
/// skeleton knows <see cref="Ingediend"/> and the terminal <see cref="Ingeschreven"/>; withdrawal,
|
/// <see cref="Ingediend"/>; a behandelaar takes it <see cref="InBehandeling"/> and decides it into one
|
||||||
/// beoordeling and herregistratie states arrive in their own slices (Iteration 2+).</summary>
|
/// 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
|
public enum RegistrationStatus
|
||||||
{
|
{
|
||||||
/// <summary>Submitted by the zorgprofessional; the registratie process has been started.</summary>
|
/// <summary>Submitted by the zorgprofessional; the registratie process has been started.</summary>
|
||||||
Ingediend,
|
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,
|
Ingeschreven,
|
||||||
|
|
||||||
|
/// <summary>Rejected by the behandelaar. Terminal.</summary>
|
||||||
|
Afgewezen,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ namespace Big.Infrastructure;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclClient
|
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(
|
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();
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
var opened = await response.Content.ReadFromJsonAsync<OpenZaakResponse>(ct)
|
var opened = await response.Content.ReadFromJsonAsync<OpenZaakResponse>(ct)
|
||||||
@@ -31,7 +31,9 @@ public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclCli
|
|||||||
response.EnsureSuccessStatusCode();
|
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);
|
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).
|
/// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options)
|
public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options)
|
||||||
: IWorkflowClient, IExternalWorkerClient
|
: IWorkflowClient, IExternalWorkerClient, IUserTaskClient
|
||||||
{
|
{
|
||||||
private const string Topic = "OpenZaakAanmaken";
|
private const string Topic = "OpenZaakAanmaken";
|
||||||
private const string ProcessDefinitionKey = "registratie";
|
private const string ProcessDefinitionKey = "registratie";
|
||||||
|
private const string BeoordelenTaskKey = "Beoordelen";
|
||||||
private const string RegistrationIdVariable = "registrationId";
|
private const string RegistrationIdVariable = "registrationId";
|
||||||
private const string ZaakUrlVariable = "zaakUrl";
|
private const string ZaakUrlVariable = "zaakUrl";
|
||||||
|
private const string BesluitVariable = "besluit";
|
||||||
|
|
||||||
public async Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
|
public async Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -55,6 +57,34 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
|
|||||||
response.EnsureSuccessStatusCode();
|
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)
|
private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct)
|
||||||
{
|
{
|
||||||
using var response = await SendAsync(path, body, 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("workerId")] string WorkerId,
|
||||||
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables);
|
[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(
|
private sealed record Variable(
|
||||||
[property: JsonPropertyName("name")] string Name,
|
[property: JsonPropertyName("name")] string Name,
|
||||||
[property: JsonPropertyName("type")] string Type,
|
[property: JsonPropertyName("type")] string Type,
|
||||||
|
|||||||
@@ -15,12 +15,13 @@ public class AclHttpClientTests
|
|||||||
var client = Client(capture.Responds(HttpStatusCode.OK,
|
var client = Client(capture.Responds(HttpStatusCode.OK,
|
||||||
"""{"zaakUrl":"http://openzaak/zaken/api/v1/zaken/abc"}"""));
|
"""{"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("http://openzaak/zaken/api/v1/zaken/abc", url.ToString());
|
||||||
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
|
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
|
||||||
Assert.Equal("http://acl/zaken", capture.Seen.RequestUri!.ToString());
|
Assert.Equal("http://acl/zaken", capture.Seen.RequestUri!.ToString());
|
||||||
Assert.Contains("\"bsn\":\"123456782\"", capture.Body);
|
Assert.Contains("\"bsn\":\"123456782\"", capture.Body);
|
||||||
|
Assert.Contains("\"reference\":\"reg-42\"", capture.Body);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -29,7 +30,7 @@ public class AclHttpClientTests
|
|||||||
var capture = new RequestCapture();
|
var capture = new RequestCapture();
|
||||||
var client = Client(capture.Responds(HttpStatusCode.BadGateway));
|
var client = Client(capture.Responds(HttpStatusCode.BadGateway));
|
||||||
|
|
||||||
await Assert.ThrowsAsync<HttpRequestException>(() => client.OpenZaakAsync("123456782"));
|
await Assert.ThrowsAsync<HttpRequestException>(() => client.OpenZaakAsync("123456782", "reg-1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -38,7 +39,7 @@ public class AclHttpClientTests
|
|||||||
var capture = new RequestCapture();
|
var capture = new RequestCapture();
|
||||||
var client = Client(capture.Responds(HttpStatusCode.OK, "null"));
|
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);
|
Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,4 +65,14 @@ public class AclHttpClientTests
|
|||||||
await Assert.ThrowsAsync<HttpRequestException>(
|
await Assert.ThrowsAsync<HttpRequestException>(
|
||||||
() => client.ApproveZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc")));
|
() => client.ApproveZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Approve_rejects_a_null_zaak_url_without_sending_a_request()
|
||||||
|
{
|
||||||
|
var capture = new RequestCapture();
|
||||||
|
var client = Client(capture.Responds(HttpStatusCode.NoContent));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ArgumentNullException>(() => client.ApproveZaakAsync(null!));
|
||||||
|
Assert.Null(capture.Seen);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,19 @@ public class ApproveRegistrationTests
|
|||||||
Assert.Equal(RegistrationStatus.Ingeschreven, saved!.Status);
|
Assert.Equal(RegistrationStatus.Ingeschreven, saved!.Status);
|
||||||
Assert.Equal(FakeAclClient.DefaultZaakUrl, acl.ApprovedZaakUrl);
|
Assert.Equal(FakeAclClient.DefaultZaakUrl, acl.ApprovedZaakUrl);
|
||||||
Assert.Equal(1, acl.ApproveCallCount);
|
Assert.Equal(1, acl.ApproveCallCount);
|
||||||
|
// The approved aggregate is persisted (not just mutated in memory).
|
||||||
|
Assert.Equal(1, store.SaveCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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 ApproveRegistration(store, acl);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
|
||||||
|
Assert.Equal(0, acl.ApproveCallCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -36,8 +49,9 @@ public class ApproveRegistrationTests
|
|||||||
var acl = new FakeAclClient();
|
var acl = new FakeAclClient();
|
||||||
var handler = new ApproveRegistration(store, acl);
|
var handler = new ApproveRegistration(store, acl);
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
() => handler.HandleAsync(new ApproveRegistrationCommand(RegistrationId.New())));
|
() => handler.HandleAsync(new ApproveRegistrationCommand(RegistrationId.New())));
|
||||||
|
Assert.Contains("No registration", ex.Message);
|
||||||
Assert.Equal(0, acl.ApproveCallCount);
|
Assert.Equal(0, acl.ApproveCallCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,8 +64,9 @@ public class ApproveRegistrationTests
|
|||||||
store.Seed(registration);
|
store.Seed(registration);
|
||||||
var handler = new ApproveRegistration(store, acl);
|
var handler = new ApproveRegistration(store, acl);
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
() => handler.HandleAsync(new ApproveRegistrationCommand(registration.Id)));
|
() => handler.HandleAsync(new ApproveRegistrationCommand(registration.Id)));
|
||||||
|
Assert.Contains("no zaak", ex.Message);
|
||||||
Assert.Equal(0, acl.ApproveCallCount);
|
Assert.Equal(0, acl.ApproveCallCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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
|
/// <summary>A fake ACL client that records the bsn it was asked to open a zaak for and returns a
|
||||||
/// fixed zaak URL.</summary>
|
/// fixed zaak URL.</summary>
|
||||||
internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient
|
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;
|
private readonly Uri _zaakUrl = zaakUrl ?? DefaultZaakUrl;
|
||||||
|
|
||||||
public string? OpenedForBsn { get; private set; }
|
public string? OpenedForBsn { get; private set; }
|
||||||
|
public string? OpenedWithReference { get; private set; }
|
||||||
public int CallCount { get; private set; }
|
public int CallCount { get; private set; }
|
||||||
|
|
||||||
public Uri? ApprovedZaakUrl { get; private set; }
|
public Uri? ApprovedZaakUrl { get; private set; }
|
||||||
public int ApproveCallCount { 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++;
|
CallCount++;
|
||||||
OpenedForBsn = bsn;
|
OpenedForBsn = bsn;
|
||||||
|
OpenedWithReference = reference;
|
||||||
return Task.FromResult(_zaakUrl);
|
return Task.FromResult(_zaakUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using Big.Application;
|
||||||
using Big.Domain;
|
using Big.Domain;
|
||||||
using Big.Infrastructure;
|
using Big.Infrastructure;
|
||||||
|
|
||||||
@@ -127,6 +128,17 @@ public class FlowableWorkflowClientTests
|
|||||||
() => client.StartRegistrationProcessAsync(RegistrationId.New()));
|
() => 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]
|
[Fact]
|
||||||
public async Task Complete_throws_when_flowable_rejects_the_request()
|
public async Task Complete_throws_when_flowable_rejects_the_request()
|
||||||
{
|
{
|
||||||
@@ -136,4 +148,107 @@ public class FlowableWorkflowClientTests
|
|||||||
await Assert.ThrowsAsync<HttpRequestException>(
|
await Assert.ThrowsAsync<HttpRequestException>(
|
||||||
() => client.CompleteOpenZaakJobAsync("job-1", new Uri("http://openzaak/zaken/api/v1/zaken/abc")));
|
() => 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(FakeAclClient.DefaultZaakUrl, zaakUrl);
|
||||||
Assert.Equal("123456782", acl.OpenedForBsn);
|
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);
|
var saved = await store.GetAsync(registration.Id);
|
||||||
Assert.Equal(FakeAclClient.DefaultZaakUrl, saved!.ZaakUrl);
|
Assert.Equal(FakeAclClient.DefaultZaakUrl, saved!.ZaakUrl);
|
||||||
Assert.Equal(1, store.SaveCount);
|
Assert.Equal(1, store.SaveCount);
|
||||||
|
|||||||
@@ -117,7 +117,104 @@ public class RegistrationTests
|
|||||||
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
||||||
registration.Approve();
|
registration.Approve();
|
||||||
|
|
||||||
Assert.Throws<InvalidOperationException>(() => registration.Approve());
|
var ex = Assert.Throws<InvalidOperationException>(() => registration.Approve());
|
||||||
|
Assert.Contains("only an INGEDIEND", ex.Message);
|
||||||
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
|
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.
|
// this value (ADR-0007), so the webhook enforces it.
|
||||||
var webhookToken = builder.Configuration["EventSubscriber:Webhook:AuthToken"]
|
var webhookToken = builder.Configuration["EventSubscriber:Webhook:AuthToken"]
|
||||||
?? throw new InvalidOperationException("Missing 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.AddProjectionReadModel(connectionString);
|
||||||
builder.Services.AddProjectionWriteSide();
|
builder.Services.AddProjectionWriteSide();
|
||||||
|
builder.Services.AddHttpClient<EventSubscriber.Application.IAclClient, EventSubscriber.Api.AclHttpClient>(
|
||||||
|
c => c.BaseAddress = new Uri(aclBaseUrl));
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ public sealed record Notification(
|
|||||||
public bool IsZaakStatusSet =>
|
public bool IsZaakStatusSet =>
|
||||||
Kanaal == "zaken" && Resource == "status" && Actie == "create";
|
Kanaal == "zaken" && Resource == "status" && Actie == "create";
|
||||||
|
|
||||||
/// <summary>The zaak UUID used as the projection key. For a status notification the resource URL is
|
/// <summary>The zaak URL this notification concerns — <c>hoofdObject</c> (the zaak) for a status
|
||||||
/// the status, so the zaak comes from <c>hoofdObject</c> (which, for a zaak-create, equals the
|
/// notification, else the resource URL (which, for a zaak-create, is the zaak).</summary>
|
||||||
/// resource URL) — see the NRC payload note.</summary>
|
public Uri ZaakUrl => HoofdObject ?? ResourceUrl;
|
||||||
public string ZaakId => (HoofdObject ?? ResourceUrl).Segments[^1].Trim('/');
|
|
||||||
|
/// <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>
|
/// <summary>
|
||||||
/// A deterministic dedup key. Open Notificaties carries no notification id and may
|
/// 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
|
/// 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.
|
/// upsert is idempotent on the zaak id. Rebuilds the projection by replaying the log.
|
||||||
/// </summary>
|
/// </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
|
/// <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)
|
public async Task HandleAsync(Notification notification, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (!notification.IsZaakCreated && !notification.IsZaakStatusSet)
|
if (!notification.IsZaakCreated && !notification.IsZaakStatusSet)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
var reference = await acl.GetZaakReferenceAsync(notification.ZaakUrl, ct);
|
||||||
var recorded = new RecordedNotification(
|
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
|
// 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.
|
// 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,
|
/// <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>
|
/// a zaak-create to INGEDIEND. bsn/naam are deferred (ADR-0008).</summary>
|
||||||
private static RegisterEntry ToEntry(RecordedNotification recorded)
|
private static RegisterEntry ToEntry(RecordedNotification recorded)
|
||||||
=> new(recorded.ZaakId, recorded.Resource == "status"
|
=> new(
|
||||||
? RegistrationStatus.Ingeschreven
|
recorded.ZaakId,
|
||||||
: RegistrationStatus.Ingediend);
|
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
|
/// <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)
|
/// projection row — the ZGW <c>resource</c> (zaak-create → INGEDIEND vs status-set → INGESCHREVEN) and
|
||||||
/// from a status-set (INGESCHREVEN) so a rebuild reproduces the right status.</summary>
|
/// 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);
|
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
|
/// <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>
|
/// subscriber writes to it and the projection-api reads it.</summary>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ namespace EventSubscriber.Application;
|
|||||||
public sealed record RegisterEntry(
|
public sealed record RegisterEntry(
|
||||||
string Id,
|
string Id,
|
||||||
string Status,
|
string Status,
|
||||||
|
string? Reference = null,
|
||||||
string? Bsn = null,
|
string? Bsn = null,
|
||||||
string? NaamPlaceholder = 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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\EventSubscriber.Api\EventSubscriber.Api.csproj" />
|
||||||
<ProjectReference Include="..\EventSubscriber.Application\EventSubscriber.Application.csproj" />
|
<ProjectReference Include="..\EventSubscriber.Application\EventSubscriber.Application.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,19 @@ namespace EventSubscriber.Tests;
|
|||||||
/// <summary>In-memory stand-ins for the projection store and notification log, so the
|
/// <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
|
/// projector's behaviour is exercised without Postgres (hand-written stubs, the repo's
|
||||||
/// convention — no mocking library).</summary>
|
/// 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
|
internal sealed class InMemoryNotificationLog : INotificationLog
|
||||||
{
|
{
|
||||||
private readonly Dictionary<string, RecordedNotification> _byKey = [];
|
private readonly Dictionary<string, RecordedNotification> _byKey = [];
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ public sealed class NotificationProjectorTests
|
|||||||
|
|
||||||
private readonly InMemoryNotificationLog _log = new();
|
private readonly InMemoryNotificationLog _log = new();
|
||||||
private readonly InMemoryProjectionStore _store = 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)
|
private static Notification ZaakCreated(string url = ZaakUrl)
|
||||||
=> new("zaken", "zaak", "create", new Uri(url));
|
=> new("zaken", "zaak", "create", new Uri(url));
|
||||||
@@ -30,6 +31,23 @@ public sealed class NotificationProjectorTests
|
|||||||
var entry = Assert.Single(await _store.AllAsync());
|
var entry = Assert.Single(await _store.AllAsync());
|
||||||
Assert.Equal("11111111-1111-1111-1111-111111111111", entry.Id);
|
Assert.Equal("11111111-1111-1111-1111-111111111111", entry.Id);
|
||||||
Assert.Equal(RegistrationStatus.Ingediend, entry.Status);
|
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]
|
[Fact]
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog
|
|||||||
Actie = notification.Actie,
|
Actie = notification.Actie,
|
||||||
ZaakId = notification.ZaakId,
|
ZaakId = notification.ZaakId,
|
||||||
Resource = notification.Resource,
|
Resource = notification.Resource,
|
||||||
|
Reference = notification.Reference,
|
||||||
ReceivedAt = DateTimeOffset.UtcNow,
|
ReceivedAt = DateTimeOffset.UtcNow,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,6 +36,6 @@ public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog
|
|||||||
public async Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default)
|
public async Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default)
|
||||||
=> await db.ProcessedNotifications
|
=> await db.ProcessedNotifications
|
||||||
.OrderBy(r => r.ReceivedAt)
|
.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);
|
.ToListAsync(ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ public sealed class EfProjectionStore(ProjectionDbContext db) : IProjectionStore
|
|||||||
{
|
{
|
||||||
Id = entry.Id,
|
Id = entry.Id,
|
||||||
Status = entry.Status,
|
Status = entry.Status,
|
||||||
|
Reference = entry.Reference,
|
||||||
Bsn = entry.Bsn,
|
Bsn = entry.Bsn,
|
||||||
NaamPlaceholder = entry.NaamPlaceholder,
|
NaamPlaceholder = entry.NaamPlaceholder,
|
||||||
});
|
});
|
||||||
@@ -22,6 +23,7 @@ public sealed class EfProjectionStore(ProjectionDbContext db) : IProjectionStore
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
row.Status = entry.Status;
|
row.Status = entry.Status;
|
||||||
|
row.Reference = entry.Reference;
|
||||||
row.Bsn = entry.Bsn;
|
row.Bsn = entry.Bsn;
|
||||||
row.NaamPlaceholder = entry.NaamPlaceholder;
|
row.NaamPlaceholder = entry.NaamPlaceholder;
|
||||||
}
|
}
|
||||||
@@ -35,6 +37,6 @@ public sealed class EfProjectionStore(ProjectionDbContext db) : IProjectionStore
|
|||||||
public async Task<IReadOnlyList<RegisterEntry>> AllAsync(CancellationToken ct = default)
|
public async Task<IReadOnlyList<RegisterEntry>> AllAsync(CancellationToken ct = default)
|
||||||
=> await db.RegisterEntries
|
=> await db.RegisterEntries
|
||||||
.OrderBy(r => r.Id)
|
.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);
|
.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")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("received_at");
|
.HasColumnName("received_at");
|
||||||
|
|
||||||
|
b.Property<string>("Reference")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("reference");
|
||||||
|
|
||||||
b.Property<string>("Resource")
|
b.Property<string>("Resource")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
@@ -66,6 +70,10 @@ namespace Projection.ReadModel.Migrations
|
|||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
.HasColumnName("naam_placeholder");
|
.HasColumnName("naam_placeholder");
|
||||||
|
|
||||||
|
b.Property<string>("Reference")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("reference");
|
||||||
|
|
||||||
b.Property<string>("Status")
|
b.Property<string>("Status")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public sealed class ProjectionDbContext(DbContextOptions<ProjectionDbContext> op
|
|||||||
e.HasKey(r => r.Id);
|
e.HasKey(r => r.Id);
|
||||||
e.Property(r => r.Id).HasColumnName("id");
|
e.Property(r => r.Id).HasColumnName("id");
|
||||||
e.Property(r => r.Status).HasColumnName("status").IsRequired();
|
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.Bsn).HasColumnName("bsn");
|
||||||
e.Property(r => r.NaamPlaceholder).HasColumnName("naam_placeholder");
|
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.Actie).HasColumnName("actie").IsRequired();
|
||||||
e.Property(r => r.ZaakId).HasColumnName("zaak_id").IsRequired();
|
e.Property(r => r.ZaakId).HasColumnName("zaak_id").IsRequired();
|
||||||
e.Property(r => r.Resource).HasColumnName("resource").IsRequired();
|
e.Property(r => r.Resource).HasColumnName("resource").IsRequired();
|
||||||
|
e.Property(r => r.Reference).HasColumnName("reference");
|
||||||
e.Property(r => r.ReceivedAt).HasColumnName("received_at");
|
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 Id { get; set; }
|
||||||
public required string Status { 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? Bsn { get; set; }
|
||||||
public string? NaamPlaceholder { get; set; }
|
public string? NaamPlaceholder { get; set; }
|
||||||
}
|
}
|
||||||
@@ -61,5 +67,8 @@ public sealed class ProcessedNotificationRow
|
|||||||
/// the right status without reading OpenZaak (S-09b).</summary>
|
/// the right status without reading OpenZaak (S-09b).</summary>
|
||||||
public required string Resource { get; set; }
|
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; }
|
public DateTimeOffset ReceivedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,46 @@ public static class ServiceCollectionExtensions
|
|||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A fixed application-scoped key for the migration advisory lock (any stable 64-bit constant).
|
||||||
|
private const long MigrationAdvisoryLockKey = 727501;
|
||||||
|
|
||||||
/// <summary>Apply any pending EF migrations. Called once on service start so a fresh stack
|
/// <summary>Apply any pending EF migrations. Called once on service start so a fresh stack
|
||||||
/// reaches a usable schema without a manual migration step (DoD: compose up reaches green).</summary>
|
/// reaches a usable schema without a manual migration step (DoD: compose up reaches green).
|
||||||
|
///
|
||||||
|
/// The Event Subscriber and the projection-api share this DB and both migrate on start. EF's
|
||||||
|
/// migrations-history lock is released between individual migrations, so with more than one pending
|
||||||
|
/// migration two migrators can interleave and one re-applies a migration the other just did
|
||||||
|
/// ("column already exists"). Hold a Postgres session <c>pg_advisory_lock</c> across the whole
|
||||||
|
/// sequence so it runs exactly once; the second migrator then finds nothing pending.</summary>
|
||||||
public static async Task MigrateProjectionAsync(this IServiceProvider services, CancellationToken ct = default)
|
public static async Task MigrateProjectionAsync(this IServiceProvider services, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var scope = services.CreateAsyncScope();
|
await using var scope = services.CreateAsyncScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<ProjectionDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<ProjectionDbContext>();
|
||||||
|
var connection = db.Database.GetDbConnection();
|
||||||
|
|
||||||
|
await connection.OpenAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ExecuteAsync(connection, $"SELECT pg_advisory_lock({MigrationAdvisoryLockKey})", ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
await db.Database.MigrateAsync(ct);
|
await db.Database.MigrateAsync(ct);
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await ExecuteAsync(connection, $"SELECT pg_advisory_unlock({MigrationAdvisoryLockKey})", ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await connection.CloseAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ExecuteAsync(System.Data.Common.DbConnection connection, string sql, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = sql;
|
||||||
|
await command.ExecuteNonQueryAsync(ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ builder.Services.AddProjectionReadModel(connectionString);
|
|||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Ensure the schema exists before serving reads. EF serialises concurrent migrators via the
|
// Ensure the schema exists before serving reads. The Event Subscriber migrates this shared DB too;
|
||||||
// migrations-history lock, so it is safe that the Event Subscriber migrates too.
|
// MigrateProjectionAsync serialises concurrent migrators with a session advisory lock so the whole
|
||||||
|
// migration sequence runs exactly once (see ServiceCollectionExtensions).
|
||||||
await app.Services.MigrateProjectionAsync();
|
await app.Services.MigrateProjectionAsync();
|
||||||
|
|
||||||
app.MapGet("/health", () => "Healthy");
|
app.MapGet("/health", () => "Healthy");
|
||||||
@@ -22,7 +23,7 @@ app.MapGet("/register", async (ProjectionDbContext db, CancellationToken ct) =>
|
|||||||
{
|
{
|
||||||
var rows = await db.RegisterEntries
|
var rows = await db.RegisterEntries
|
||||||
.OrderBy(r => r.Id)
|
.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);
|
.ToListAsync(ct);
|
||||||
return Results.Ok(rows);
|
return Results.Ok(rows);
|
||||||
});
|
});
|
||||||
@@ -31,13 +32,13 @@ app.MapGet("/register/{id}", async (string id, ProjectionDbContext db, Cancellat
|
|||||||
{
|
{
|
||||||
var row = await db.RegisterEntries
|
var row = await db.RegisterEntries
|
||||||
.Where(r => r.Id == id)
|
.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);
|
.SingleOrDefaultAsync(ct);
|
||||||
return row is null ? Results.NotFound() : Results.Ok(row);
|
return row is null ? Results.NotFound() : Results.Ok(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
await app.RunAsync();
|
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;
|
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 \"(.*)\"")]
|
[Given("the projection contains a zaak \"(.*)\" for BSN \"(.*)\"")]
|
||||||
public void GivenTheProjectionContains(string zaakId, string 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();
|
_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 \"(.*)\"")]
|
[Given("a domain registration for BSN \"(.*)\"")]
|
||||||
public void GivenADomainRegistrationForBsn(string 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:")]
|
[Given("the ACL is configured with these defaults:")]
|
||||||
public void GivenTheAclIsConfiguredWithTheseDefaults(DataTable defaults)
|
public void GivenTheAclIsConfiguredWithTheseDefaults(DataTable defaults)
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ public sealed class RegisterProjectieBijwerkenSteps
|
|||||||
private readonly NotificationProjector _projector;
|
private readonly NotificationProjector _projector;
|
||||||
private Notification? _notification;
|
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 \"(.*)\"")]
|
[Given("a zaak is created in OpenZaak with id \"(.*)\"")]
|
||||||
public void GivenAZaakIsCreatedInOpenZaakWithId(string id)
|
public void GivenAZaakIsCreatedInOpenZaakWithId(string id)
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ public sealed class CapturingDomainClient : IDomainClient
|
|||||||
SubmittedBsn = bsn;
|
SubmittedBsn = bsn;
|
||||||
return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend"));
|
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>
|
/// <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 static readonly Uri OpenedZaakUrl = new("http://openzaak/zaken/api/v1/zaken/acc-zaak");
|
||||||
|
|
||||||
public string? OpenedForBsn { get; private set; }
|
public string? OpenedForBsn { get; private set; }
|
||||||
|
public string? OpenedWithReference { get; private set; }
|
||||||
public Uri? ApprovedZaakUrl { 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;
|
OpenedForBsn = bsn;
|
||||||
|
OpenedWithReference = reference;
|
||||||
return Task.FromResult(OpenedZaakUrl);
|
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>
|
/// <summary>An in-memory registration store for the domain acceptance scenario.</summary>
|
||||||
public sealed class InMemoryRegistrationStore : IRegistrationStore
|
public sealed class InMemoryRegistrationStore : IRegistrationStore
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -38,3 +38,11 @@ public sealed class InMemoryProjectionStore : IProjectionStore
|
|||||||
public IReadOnlyList<RegisterEntry> RowsFor(string id)
|
public IReadOnlyList<RegisterEntry> RowsFor(string id)
|
||||||
=> [.. _byId.Values.Where(e => e.Id == 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);
|
Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet);
|
||||||
return Task.CompletedTask;
|
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
|
// 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
|
// 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 page.goto('http://openbaar/');
|
||||||
await expect(page.getByRole('heading', { name: /Openbaar BIG-register/i })).toBeVisible();
|
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
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
await page.reload();
|
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] })
|
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
||||||
.toBeGreaterThan(0);
|
.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
|
// 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
|
// 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`);
|
const approve = await request.post(`http://domain:8080/registrations/${reference}/approve`);
|
||||||
expect(approve.status()).toBe(204);
|
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
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
await page.reload();
|
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] })
|
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
||||||
.toBeGreaterThan(0);
|
.toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,9 +6,12 @@
|
|||||||
xmlns:omgdi="http://www.omg.org/spec/DD/20100524/DI"
|
xmlns:omgdi="http://www.omg.org/spec/DD/20100524/DI"
|
||||||
targetNamespace="http://respellion.nl/big">
|
targetNamespace="http://respellion.nl/big">
|
||||||
|
|
||||||
<!-- S-03: minimal "Registratie ontvangen" flow.
|
<!-- Registratie ontvangen flow.
|
||||||
start -> external-worker task (OpenZaakAanmaken) -> end.
|
start -> external-worker task (OpenZaakAanmaken) -> Beoordelen (behandelaar user task) -> end.
|
||||||
The external task is handled by the Workflow Client / ACL in later slices. -->
|
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">
|
<process id="registratie" name="Registratie ontvangen" isExecutable="true">
|
||||||
|
|
||||||
<startEvent id="start" name="Registratie ontvangen"/>
|
<startEvent id="start" name="Registratie ontvangen"/>
|
||||||
@@ -19,9 +22,13 @@
|
|||||||
flowable:type="external-worker"
|
flowable:type="external-worker"
|
||||||
flowable:topic="OpenZaakAanmaken"/>
|
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>
|
</process>
|
||||||
|
|
||||||
<bpmndi:BPMNDiagram id="diagram">
|
<bpmndi:BPMNDiagram id="diagram">
|
||||||
@@ -32,8 +39,11 @@
|
|||||||
<bpmndi:BPMNShape id="s_task" bpmnElement="OpenZaakAanmaken">
|
<bpmndi:BPMNShape id="s_task" bpmnElement="OpenZaakAanmaken">
|
||||||
<omgdc:Bounds x="200" y="85" width="120" height="60"/>
|
<omgdc:Bounds x="200" y="85" width="120" height="60"/>
|
||||||
</bpmndi:BPMNShape>
|
</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">
|
<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:BPMNShape>
|
||||||
<bpmndi:BPMNEdge id="e_flow1" bpmnElement="flow1">
|
<bpmndi:BPMNEdge id="e_flow1" bpmnElement="flow1">
|
||||||
<omgdi:waypoint x="130" y="115"/>
|
<omgdi:waypoint x="130" y="115"/>
|
||||||
@@ -41,7 +51,11 @@
|
|||||||
</bpmndi:BPMNEdge>
|
</bpmndi:BPMNEdge>
|
||||||
<bpmndi:BPMNEdge id="e_flow2" bpmnElement="flow2">
|
<bpmndi:BPMNEdge id="e_flow2" bpmnElement="flow2">
|
||||||
<omgdi:waypoint x="320" y="115"/>
|
<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:BPMNEdge>
|
||||||
</bpmndi:BPMNPlane>
|
</bpmndi:BPMNPlane>
|
||||||
</bpmndi:BPMNDiagram>
|
</bpmndi:BPMNDiagram>
|
||||||
|
|||||||
Reference in New Issue
Block a user