Compare commits

..

13 Commits

Author SHA1 Message Date
9089bd5e88 fix(acl): set a resultaat before the eindstatus so OpenZaak accepts the status (refs #75)
All checks were successful
CI / lint (pull_request) Successful in 1m29s
CI / build (pull_request) Successful in 1m22s
CI / unit (pull_request) Successful in 1m38s
CI / frontend (pull_request) Successful in 3m2s
CI / mutation (pull_request) Successful in 6m36s
CI / verify-stack (pull_request) Successful in 7m59s
The status-set integration test hit 400 "resultaat-does-not-exist: Zaak has no
resultaat" — OpenZaak refuses a zaak's eindstatus until the zaak has a resultaat.
Resolve the zaaktype's resultaattype and POST /resultaten before POST /statussen.
Also surface the ZGW response body in the failure exception (EnsureSuccessStatusCode
hid the 400 detail that pinpointed this). Reworked the gateway with shared
GetCatalogusAsync/PostAsync helpers and rewrote the stub-handler tests for the new
4-call flow incl. failure paths. Verified against real OpenZaak (integration test
green) and locally: acl mutation 100%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 10:39:19 +02:00
80d0308de8 fix(projection): serialise concurrent migrators with a pg advisory lock (refs #75)
Some checks failed
CI / lint (pull_request) Successful in 1m25s
CI / build (pull_request) Successful in 1m21s
CI / unit (pull_request) Successful in 1m30s
CI / frontend (pull_request) Successful in 3m11s
CI / mutation (pull_request) Successful in 5m59s
CI / verify-stack (pull_request) Failing after 5m50s
verify-up failed: the event-subscriber and projection-api both migrate the shared
projection DB on start, and EF releases its migrations-history lock between individual
migrations — harmless with one migration, but the new AddNotificationResource made a
second, so a migrator re-applied it in the window between the other's two migrations
("column resource already exists"). Hold a session pg_advisory_lock across the whole
MigrateAsync so the sequence runs exactly once; the second migrator then finds nothing
pending.

Verified by starting both images simultaneously against a fresh Postgres: both reach
health, the resource column is created once, and __EFMigrationsHistory has both rows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 10:03:14 +02:00
236e7ade9c test(acl,domain): cover the approval-flow mutants to hold the ratchet (refs #75)
Some checks failed
CI / lint (pull_request) Successful in 1m11s
CI / build (pull_request) Successful in 1m6s
CI / unit (pull_request) Successful in 1m23s
CI / frontend (pull_request) Successful in 2m45s
CI / mutation (pull_request) Successful in 6m3s
CI / verify-stack (pull_request) Failing after 13m34s
The new gateway/use-case code left surviving mutants (empty NoCoverage on the ACL
gateway status-set, plus unasserted error messages, null-guards, and the persist
call). Add stub-handler tests for SetZaakToEindstatusAsync (eindstatus resolution,
POST framing, failure paths) and assert the approve use case's messages, null
handling and SaveAsync. Local Stryker: acl 100%, domain 98.4%, event-subscriber 100%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:30:02 +02:00
8badef02af docs(architecture): ADR-0011 approval status flow + demo note (refs #75)
Some checks failed
CI / lint (pull_request) Successful in 1m7s
CI / build (pull_request) Successful in 54s
CI / unit (pull_request) Successful in 1m4s
CI / frontend (pull_request) Successful in 2m0s
CI / mutation (pull_request) Failing after 1m54s
CI / verify-stack (pull_request) Failing after 5m49s
Records the two non-obvious decisions: the ACL resolves the zaaktype eindstatus
(domain stays ZGW-ignorant), and the event-subscriber projects INGESCHREVEN from
the notification alone (hoofdObject + any status-create), never reading OpenZaak.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:05:12 +02:00
d433bdee0e test(e2e): approve a submitted registration and assert public INGESCHREVEN (refs #75)
Extend the walking-skeleton happy path: after the entry is publicly visible as
INGEDIEND, approve it via the temporary admin endpoint (waiting for the zaak to be
opened first), then poll the openbaar register until it shows INGESCHREVEN —
covering submit → BFF → domain → ACL → NRC → projection → public visibility.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:03:50 +02:00
2cacedc469 feat(event-subscriber): project a status-set as INGESCHREVEN via hoofdObject (refs #75)
Bind the NRC hoofdObject, recognise a zaken/status/create notification, and key the
projection on the zaak (hoofdObject) so the status updates the existing INGEDIEND row
to INGESCHREVEN — without reading OpenZaak (§8.1). Retain the ZGW resource in the log
(new column + migration) so a rebuild reproduces the approved status. Updates the
acceptance fakes for the new IAclClient/IZaakGateway members.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:02:39 +02:00
b672132ed4 test(event-subscriber): project INGESCHREVEN from a status-set notification (refs #75)
A zaken/status/create notification (hoofdObject = the zaak) updates the zaak's row to
INGESCHREVEN, and a rebuild reproduces it. Red: Notification has no hoofdObject/
IsZaakStatusSet and RegistrationStatus.Ingeschreven doesn't exist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:02:39 +02:00
b697b0d865 feat(acl): set a zaak to its eindstatus via ZGW + POST /statussen endpoint (refs #75)
The gateway resolves the zaaktype's eindstatus from the catalogus (isEindstatus,
falling back to the highest volgnummer) and POSTs a status against the zaak. Exposed
as POST /statussen for the domain's approve use case. Adds an integration test that
sets the eindstatus against a real OpenZaak and verifies the zaak's current status.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:54:39 +02:00
3e42999c23 test(acl): approving a zaak sets it to the zaaktype's eindstatus, dated today (refs #75)
Red: AclService.ApproveZaakAsync and IZaakGateway.SetZaakToEindstatusAsync don't exist yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:54:39 +02:00
3e0d2f9b11 feat(domain): ApproveRegistration use case + temp /registrations/{id}/approve endpoint (refs #75)
Loads the registration, asks the ACL to set its zaak's final status (new
IAclClient.ApproveZaakAsync → POST /statussen), then advances the aggregate to
INGESCHREVEN. Idempotent: a repeated approval is a no-op. Adds HTTP-adapter tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:50:45 +02:00
66fe1e5f6f test(domain): approve use case sets the zaak status via the ACL, idempotently (refs #75)
Red: ApproveRegistration and IAclClient.ApproveZaakAsync don't exist yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:50:45 +02:00
ca9964f79b feat(domain): add the Ingeschreven state and Registration.Approve() (refs #75)
Approve() advances a submitted registration with an opened zaak to INGESCHREVEN;
re-approval and approval-before-zaak are rejected as invalid transitions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:45:36 +02:00
551f6e0a0b test(domain): approving a registration advances INGEDIEND → INGESCHREVEN (refs #75)
Guards: an opened zaak is required and only a submitted registration can be
approved. Fails to compile — Registration.Approve() and RegistrationStatus.
Ingeschreven don't exist yet (red).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:45:36 +02:00
117 changed files with 159 additions and 4323 deletions

View File

@@ -157,7 +157,7 @@ jobs:
# Log dump must precede teardown (which removes the containers).
- name: Dump container logs on failure
if: failure()
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel 2>&1 || true
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service 2>&1 || true
- name: Tear down
if: always()
run: make down

View File

@@ -2,130 +2,19 @@
All notable changes to this project. Generated from Conventional Commits by git-cliff.
## 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)
## Unreleased
### CI
- Gitea Actions pipeline + runner runbook (refs #30) (#37)
- ACL Dockerfile + full compose stack for smoke test (refs #30)
- Switch runner label to ubuntu-latest (refs #30)
- Run the mutation ratchet as a parallel CI job (refs #47)
- Publish the Stryker HTML report as a CI artifact (refs #47)
- Run the ACL integration test as a Gitea Actions job (refs #46)
- Keep the integration lane local-only; document the runner gap (refs #46)
- Run the ACL integration test in CI inside the compose network (closes #55) (refs #46)
- Run the Event Subscriber + projection-api in compose and verify end-to-end (refs #7)
- Containerize, wire into compose, and verify end-to-end (refs #6)
- Make Stryker report upload best-effort (refs #62)
- Retrigger after runner cleanup (refs #6)
- Retrigger CI (refs #6)
- Retrigger CI after gitea restart (refs #6)
- Compose wiring, verify-bff live check, mutation baseline (refs #8)
- Nx frontend lane (lint/test/build) (refs #65)
- Serve the self-service app in compose (refs #68)
- Run Vitest ahead of the production build to stop worker-start timeout (refs #68)
- Cache the NuGet package store across the .NET jobs (refs #73)
- Run Playwright from the prebuilt image instead of downloading browsers (refs #73)
### Chores
- Add idempotent Gitea backlog seeder
- Remove bootstrap scripts from main (#35)
- Contributor workflow — templates, git-cliff, gitea-workflow doc (closes #31) (#38)
### Documentation
- Split S-00 into sub-slices (refs #1) (#33)
- MkDocs scaffold + ADR-0001 + README quickstart (closes #32) (#39)
- Tighten gitea-actions-gotchas, add local compose (refs #30)
- ADR-0008 read projection store + demo note for the event path (refs #7)
- Demo note for submitting a registration (S-05) (refs #6)
- Demo note for the BFF front door (S-07) (refs #8)
- Split S-08 into S-08a-d (refs #65)
- Frontend-decisions + demo note for S-08a (refs #65)
- Record the orval generator choice (refs #66)
- Record NL DS + DigiD decisions and demo note (refs #67)
- Serving/e2e decisions + walking-skeleton demo note (refs #68)
### Features
- Placeholder BFF + /health endpoint (closes #28) (#34)
- Containerize BFF + compose-up smoke (closes #29) (#36)
- OpenZaak + Postgres + Redis up in compose (refs #10) (#40)
- Seed BIG catalogus + JWT client for OpenZaak (refs #2) (#41)
- Open Notificaties up + shared network (closes #2) (#42)
- Keycloak with four mock realms (closes #3) (#43)
- Flowable + registratie.bpmn external task (closes #4) (#44)
- ACL skeleton — OpenZaak default-fill (refs #5) (#45)
- Add bind-mount local compose for no-make/Windows dev (refs #30)
- Publish the BIG zaaktype on demand via OZ_PUBLISH (refs #46)
- Wire OpenZaak → Open Notificaties notifications (refs #56)
- Project zaak-created notifications into the read projection (refs #7)
- Persist the read projection and expose webhook + read APIs (refs #7)
- Enforce the callback bearer before reading the body (refs #7)
- Implement the Registration aggregate invariants (refs #6)
- Implement SubmitRegistration and OpenZaakWorker (refs #6)
- Implement the Flowable Workflow Client and ACL client (refs #6)
- Expose POST /registrations and the read endpoint (refs #6)
- Implement self-service submit and openbaar lookup (refs #8)
- Committed OpenAPI contract + drift guard (refs #8)
- Self-service portal placeholder page (refs #65)
- Expose the generated BFF client + repeatable generate target (refs #66)
- Implement the DigiD registration submit page (refs #67)
- Runtime config + nginx serve/proxy image (refs #68)
- Surface submit failures with a retryable alert (refs #68)
- One citizen reference across self-service and the openbaar register (#79)
### Other
- Openbaar Register portal — public lookup (#76)
- Approval flow — temp admin endpoint + status transition to projection (#77)
### Refactor
- Bake config via dockerfile_inline, drop Dockerfile files (refs #30)
- Use upstream images verbatim, seed config via docker cp (refs #30)
- One verify-stack stage for all live-stack checks (closes #58) (refs #46 #56)
### Tests
- BDD acceptance scenario for opening a zaak (closes #5) (#49)
- Kill surviving mutants — assert CRS headers, guards, error paths, JWT claims (refs #47)
- Add Stryker config + mutation make target recording the 95% baseline (refs #47)
- Integration test opens a real zaak against OpenZaak (refs #46)
- Verify-notifications smoke + CI job for the OZ→NRC path (refs #56)
- Project zaak-created notifications into the read projection (refs #7)
- Ratchet projector mutation baseline to 100% (refs #7)
- Registration aggregate invariants (refs #6)
- SubmitRegistration + OpenZaakWorker use cases (refs #6)
- Workflow Client, ACL client, store and job processor (refs #6)
- Acceptance scenario for submitting a registration (refs #6)
- Mutation baseline 90 (achieved 97.7%) + CI/Makefile wiring (refs #6)
- Endpoints, JWT auth and public-safe projection (refs #8)
- Acceptance scenario for BFF access (valid/invalid tokens) (refs #8)
- Self-service portal placeholder renders (refs #65)
- Generated BFF client is exposed and calls the endpoints (refs #66)
- DigiD-guarded registration submit page (refs #67)
- Walking-skeleton Playwright happy path + verify-e2e lane (refs #68)
- Submit surfaces BFF failures instead of swallowing them (refs #68)
- Guard that the DigiD token attaches to relative BFF calls (refs #68)

View File

@@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml
# Long-running services with a healthcheck — the smoke polls these for readiness
# (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init)
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
# into external named volumes via `docker cp` (infra/seed-config.sh) instead of
# bind-mounted, because bind mounts don't reach sibling containers on the

View File

@@ -1,23 +0,0 @@
# Multi-stage build for the behandel portal (Angular → nginx).
# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
FROM node:24-slim AS build
WORKDIR /src
RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
# Restore first (cached unless the manifests change).
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml nx.json tsconfig.base.json eslint.config.mjs ./
RUN pnpm install --frozen-lockfile
# Sources (only what the app + its libs need).
COPY apps/behandel apps/behandel
COPY libs libs
RUN pnpm nx build behandel
FROM nginx:1.27-alpine AS runtime
COPY apps/behandel/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/apps/behandel/browser /usr/share/nginx/html
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
# service name, so the token issuer matches the BFF's medewerker authority (host-consistent, ADR-0013).
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/nginx/html/config.json
EXPOSE 80

View File

@@ -1,34 +0,0 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -1,24 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
# even before the BFF is up and picks up restarts — instead of failing to load the config.
resolver 127.0.0.11 ipv6=off valid=30s;
# Same-origin API: proxy the behandel endpoint group to the bff service. The api-client uses
# relative URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS, and the
# medewerker token (same-origin) is attached by the app's interceptor (ADR-0013).
location /behandel/ {
set $bff http://bff:8080;
proxy_pass $bff;
proxy_set_header Host $host;
}
# SPA fallback — Angular client-side routing.
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -1,80 +0,0 @@
{
"name": "behandel",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/behandel/src",
"tags": [],
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/apps/behandel",
"browser": "apps/behandel/src/main.ts",
"tsConfig": "apps/behandel/tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "apps/behandel/public"
}
],
"styles": ["apps/behandel/src/styles.css"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1mb",
"maximumError": "2mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "8kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
}
},
"serve": {
"continuous": true,
"executor": "@angular/build:dev-server",
"defaultConfiguration": "development",
"configurations": {
"production": {
"buildTarget": "behandel:build:production"
},
"development": {
"buildTarget": "behandel:build:development"
}
}
},
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false
}
},
"serve-static": {
"continuous": true,
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "behandel:build",
"staticFilePath": "dist/apps/behandel/browser",
"spa": true
}
}
}
}

View File

@@ -1,3 +0,0 @@
{
"authority": "http://localhost:8180/realms/medewerker"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,73 +0,0 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { BffApiV1Service } from 'api-client';
import { authInterceptor } from 'auth';
import { AbstractSecurityStorage, ConfigurationService } from 'angular-auth-oidc-client';
import { SECURE_API_ROUTES } from './app.config';
// Guards the medewerker token wiring end-to-end. The api-client calls the BFF with RELATIVE URLs, and
// the angular-auth-oidc-client interceptor attaches the token only when `req.url` starts with a
// configured secureRoute. A regression to an absolute origin makes the relative URL never match, so
// the behandel calls go out unauthenticated and the BFF answers 401. This drives the REAL interceptor
// and the REAL api-client against the REAL production route value (SECURE_API_ROUTES); only the config
// source and token storage are faked, so the assertion turns on the actual route-matching.
describe('behandel medewerker token wiring', () => {
let http: HttpTestingController;
let bff: BffApiV1Service;
const token = 'medewerker-access-token';
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([authInterceptor()])),
provideHttpClientTesting(),
{
provide: ConfigurationService,
useValue: {
hasAtLeastOneConfig: () => true,
getAllConfigurations: () => [{ configId: 'medewerker', secureRoutes: SECURE_API_ROUTES }],
},
},
{
// A signed-in session: the storage the interceptor's token lookup reads from.
provide: AbstractSecurityStorage,
useValue: {
read: () => JSON.stringify({ authzData: token, authnResult: { id_token: 'id-token' } }),
write: () => undefined,
remove: () => undefined,
clear: () => undefined,
},
},
],
});
http = TestBed.inject(HttpTestingController);
bff = TestBed.inject(BffApiV1Service);
});
afterEach(() => http.verify());
it('attaches the bearer token to the relative werkbak call', () => {
bff.getBehandelWerkbak().subscribe();
const req = http.expectOne('/behandel/werkbak');
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
req.flush([]);
});
it('attaches the bearer token to the relative decide call', () => {
bff.postBehandelRegistrationsIdDecide('reg-1', { besluit: 'goedkeuren' }).subscribe();
const req = http.expectOne('/behandel/registrations/reg-1/decide');
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
req.flush(null);
});
it('leaves the anonymous openbaar register call unauthenticated', () => {
bff.getOpenbaarRegister().subscribe();
const req = http.expectOne((r) => r.url === '/openbaar/register');
expect(req.request.headers.has('Authorization')).toBe(false);
req.flush([]);
});
});

View File

@@ -1,39 +0,0 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { authInterceptor, provideMedewerkerAuth } from 'auth';
import { appRoutes } from './app.routes';
/** Environment-specific settings fetched from /config.json at startup (see main.ts). */
export interface RuntimeConfig {
/** The Keycloak `medewerker` realm issuer as the browser reaches it (dev: localhost; compose: keycloak:8080). */
authority: string;
}
/**
* Route prefixes whose requests carry the medewerker token. These MUST match the **relative** URLs
* the api-client actually calls (same-origin via the nginx proxy) — the interceptor matches on
* `req.url`, which stays relative, so an absolute origin would never match and the token would go
* unattached. Only `/behandel/` is secured; the app calls no other endpoint group.
*/
export const SECURE_API_ROUTES = ['/behandel/'];
/**
* Build the app providers from runtime config. `redirectUrl` is the app's own origin (where Keycloak
* redirects back). `secureRoutes` uses {@link SECURE_API_ROUTES} — relative prefixes, not the origin.
*/
export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
const origin = typeof window !== 'undefined' ? window.location.origin : '/';
return {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(appRoutes),
provideHttpClient(withInterceptors([authInterceptor()])),
provideMedewerkerAuth({
authority: runtime.authority,
redirectUrl: origin,
secureRoutes: SECURE_API_ROUTES,
}),
],
};
}

View File

@@ -1 +0,0 @@
<router-outlet></router-outlet>

View File

@@ -1,7 +0,0 @@
import { Route } from '@angular/router';
import { authenticatedGuard } from 'auth';
import { WerkbakPage } from './werkbak/werkbak-page';
export const appRoutes: Route[] = [
{ path: '', component: WerkbakPage, canActivate: [authenticatedGuard] },
];

View File

@@ -1,15 +0,0 @@
import { provideRouter } from '@angular/router';
import { render, screen } from '@testing-library/angular';
import { App } from './app';
describe('App', () => {
it('renders the router outlet shell', async () => {
const { container } = await render(App, {
providers: [provideRouter([])],
});
// The shell is a thin host for routed pages (the WerkbakPage owns the heading).
expect(container.querySelector('router-outlet')).toBeTruthy();
expect(screen).toBeTruthy();
});
});

View File

@@ -1,12 +0,0 @@
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
@Component({
imports: [RouterModule],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {
protected title = 'behandel';
}

View File

@@ -1,64 +0,0 @@
<main utrecht-document class="utrecht-theme">
<utrecht-article>
<utrecht-heading-1>Werkbak</utrecht-heading-1>
<p utrecht-paragraph>
Registraties die wachten op beoordeling. Keur elke registratie goed of wijs deze af.
</p>
@if (loading()) {
<p utrecht-paragraph role="status">Bezig met laden…</p>
} @else if (failed()) {
<p utrecht-paragraph role="alert">
Kon de werkbak niet laden. Controleer of je als behandelaar bent ingelogd en probeer het
opnieuw.
</p>
} @else if (loaded() && items().length === 0) {
<p utrecht-paragraph role="status">De werkbak is leeg.</p>
} @else if (items().length > 0) {
<table utrecht-table>
<caption>
Registraties in behandeling
</caption>
<thead>
<tr>
<th scope="col">Referentie</th>
<th scope="col">BSN</th>
<th scope="col">Status</th>
<th scope="col">Actie</th>
</tr>
</thead>
<tbody>
@for (item of items(); track item.registrationId) {
<tr>
<td>{{ item.registrationId }}</td>
<td>{{ item.bsn }}</td>
<td>{{ item.status }}</td>
<td>
<button
utrecht-button
appearance="primary-action-button"
type="button"
[attr.aria-label]="'Goedkeuren ' + item.registrationId"
[disabled]="deciding() === item.registrationId"
(click)="decide(item.registrationId, 'goedkeuren')"
>
Goedkeuren
</button>
<button
utrecht-button
appearance="secondary-action-button"
type="button"
[attr.aria-label]="'Afwijzen ' + item.registrationId"
[disabled]="deciding() === item.registrationId"
(click)="decide(item.registrationId, 'afwijzen')"
>
Afwijzen
</button>
</td>
</tr>
}
</tbody>
</table>
}
</utrecht-article>
</main>

View File

@@ -1,110 +0,0 @@
import { signal } from '@angular/core';
import { fireEvent, render, screen } from '@testing-library/angular';
import { of, throwError } from 'rxjs';
import { BffApiV1Service, type WerkbakItem } from 'api-client';
import { AuthService } from 'auth';
import { axe } from 'vitest-axe';
import { WerkbakPage } from './werkbak-page';
const sample: WerkbakItem[] = [
{ registrationId: 'reg-1', bsn: '123456782', status: 'InBehandeling' },
{ registrationId: 'reg-2', bsn: '111222333', status: 'InBehandeling' },
];
class FakeAuth extends AuthService {
readonly isAuthenticated = signal(true);
readonly bsn = signal<string | undefined>(undefined);
override readonly roles = signal<readonly string[]>(['behandelaar']);
login(): void {
/* not exercised here */
}
logout(): void {
/* spied in tests */
}
}
function setup(
overrides: {
getBehandelWerkbak?: ReturnType<typeof vi.fn>;
postBehandelRegistrationsIdDecide?: ReturnType<typeof vi.fn>;
} = {},
) {
const getBehandelWerkbak =
overrides.getBehandelWerkbak ?? vi.fn().mockReturnValue(of(sample));
const postBehandelRegistrationsIdDecide =
overrides.postBehandelRegistrationsIdDecide ?? vi.fn().mockReturnValue(of(undefined));
return {
getBehandelWerkbak,
postBehandelRegistrationsIdDecide,
providers: [
{
provide: BffApiV1Service,
useValue: { getBehandelWerkbak, postBehandelRegistrationsIdDecide },
},
{ provide: AuthService, useClass: FakeAuth },
],
};
}
describe('WerkbakPage', () => {
it('lists the registrations awaiting beoordeling on open', async () => {
const { getBehandelWerkbak, providers } = setup();
await render(WerkbakPage, { providers });
expect(getBehandelWerkbak).toHaveBeenCalled();
expect(await screen.findByText('reg-1')).toBeTruthy();
expect(screen.getByText('123456782')).toBeTruthy();
expect(screen.getByText('reg-2')).toBeTruthy();
});
it('approves a registration (goedkeuren) and refreshes the werkbak', async () => {
const { getBehandelWerkbak, postBehandelRegistrationsIdDecide, providers } = setup();
await render(WerkbakPage, { providers });
fireEvent.click((await screen.findAllByRole('button', { name: /goedkeuren/i }))[0]);
expect(postBehandelRegistrationsIdDecide).toHaveBeenCalledWith('reg-1', {
besluit: 'goedkeuren',
});
// Reloaded after the decision: once on open, once after deciding.
expect(getBehandelWerkbak).toHaveBeenCalledTimes(2);
});
it('rejects a registration (afwijzen) via the decide endpoint', async () => {
const { postBehandelRegistrationsIdDecide, providers } = setup();
await render(WerkbakPage, { providers });
fireEvent.click((await screen.findAllByRole('button', { name: /afwijzen/i }))[0]);
expect(postBehandelRegistrationsIdDecide).toHaveBeenCalledWith('reg-1', {
besluit: 'afwijzen',
});
});
it('shows an empty state when the werkbak has no items', async () => {
const { providers } = setup({ getBehandelWerkbak: vi.fn().mockReturnValue(of([])) });
await render(WerkbakPage, { providers });
expect(await screen.findByText(/werkbak is leeg/i)).toBeTruthy();
});
it('surfaces a load failure instead of swallowing it', async () => {
const { providers } = setup({
getBehandelWerkbak: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
});
await render(WerkbakPage, { providers });
expect(await screen.findByText(/kon de werkbak niet laden/i)).toBeTruthy();
});
it('has no WCAG 2.1 AA violations', async () => {
document.documentElement.lang = 'nl';
const { container } = await render(WerkbakPage, { providers: setup().providers });
const results = await axe(container, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
});
expect(results.violations).toEqual([]);
});
});

View File

@@ -1,65 +0,0 @@
import { Component, inject, signal } from '@angular/core';
import { BffApiV1Service, type WerkbakItem } from 'api-client';
import { UtrechtComponentsModule } from 'ui';
/** The two decisions a behandelaar can make; the BFF validates these exact values (ADR-0013). */
type Besluit = 'goedkeuren' | 'afwijzen';
/**
* The behandel werkbak: a signed-in behandelaar sees the registrations awaiting beoordeling (the open
* Flowable `Beoordelen` tasks, read through the domain) and decides each — goedkeuren or afwijzen. A
* decision posts to the BFF, which applies the domain transition and completes the workflow task
* (ADR-0013; S-12). After a decision the werkbak refreshes so the handled item drops off the list.
*/
@Component({
selector: 'app-werkbak-page',
imports: [UtrechtComponentsModule],
templateUrl: './werkbak-page.html',
})
export class WerkbakPage {
private readonly bff = inject(BffApiV1Service);
protected readonly items = signal<WerkbakItem[]>([]);
protected readonly loading = signal(false);
protected readonly loaded = signal(false);
protected readonly failed = signal(false);
protected readonly deciding = signal<string | undefined>(undefined);
constructor() {
this.load();
}
load(): void {
this.loading.set(true);
this.failed.set(false);
this.bff.getBehandelWerkbak().subscribe({
next: (rows: WerkbakItem[]) => {
this.items.set(rows);
this.loading.set(false);
this.loaded.set(true);
},
// Surface the failure (e.g. 403 for a non-behandelaar) instead of swallowing it.
error: () => {
this.items.set([]);
this.loading.set(false);
this.loaded.set(true);
this.failed.set(true);
},
});
}
decide(registrationId: string, besluit: Besluit): void {
this.deciding.set(registrationId);
this.bff.postBehandelRegistrationsIdDecide(registrationId, { besluit }).subscribe({
// Refresh so the decided registration drops off the werkbak (its task is now completed).
next: () => {
this.deciding.set(undefined);
this.load();
},
error: () => {
this.deciding.set(undefined);
this.failed.set(true);
},
});
}
}

View File

@@ -1,13 +0,0 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<title>Behandelportaal BIG-register</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>

View File

@@ -1,10 +0,0 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig, type RuntimeConfig } from './app/app.config';
// Load environment config before bootstrap so the OIDC authority is set per environment
// (dev: localhost; compose: keycloak:8080) from a single build — 12-factor (S-08d).
fetch('config.json')
.then((response) => response.json() as Promise<RuntimeConfig>)
.then((config) => bootstrapApplication(App, appConfig(config)))
.catch((err) => console.error(err));

View File

@@ -1,2 +0,0 @@
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
@import '@utrecht/design-tokens/dist/index.css';

View File

@@ -1,9 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}

View File

@@ -1,31 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}

View File

@@ -43,7 +43,7 @@
<tbody>
@for (entry of entries(); track entry.id) {
<tr>
<td>{{ entry.reference }}</td>
<td>{{ entry.id }}</td>
<td>{{ entry.status }}</td>
</tr>
}

View File

@@ -5,8 +5,8 @@ import { axe } from 'vitest-axe';
import { RegisterPage } from './register-page';
const sample: OpenbaarEntry[] = [
{ id: 'zaak-abc', status: 'INGEDIEND', reference: 'REG-abc' },
{ id: 'zaak-def', status: 'INGESCHREVEN', reference: 'REG-def' },
{ id: 'zaak-abc', status: 'INGEDIEND' },
{ id: 'zaak-def', status: 'INGESCHREVEN' },
];
function providers(get = vi.fn().mockReturnValue(of(sample))) {
@@ -22,11 +22,9 @@ describe('RegisterPage', () => {
await render(RegisterPage, { providers: providers(get).providers });
expect(get).toHaveBeenCalled();
// 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(await screen.findByText(/zaak-abc/)).toBeTruthy();
expect(screen.getByText(/INGEDIEND/)).toBeTruthy();
expect(screen.getByText(/REG-def/)).toBeTruthy();
expect(screen.getByText(/zaak-def/)).toBeTruthy();
});
it('searches by the entered term', async () => {

View File

@@ -3,29 +3,9 @@
<utrecht-heading-1>Zelfservice — BIG-registratie</utrecht-heading-1>
@if (submitted()) {
@if (withdrawn()) {
<p utrecht-paragraph role="status">
Uw registratie met referentie {{ reference() }} is ingetrokken.
</p>
} @else {
<p utrecht-paragraph role="status">
Uw registratie is ontvangen. Referentie: {{ reference() }}.
</p>
@if (withdrawFailed()) {
<p utrecht-paragraph role="alert">
Het intrekken van uw registratie is niet gelukt. Probeer het opnieuw.
</p>
}
<button
utrecht-button
appearance="secondary-action-button"
type="button"
[disabled]="withdrawing()"
(click)="withdraw()"
>
Trek aanvraag in
</button>
}
<p utrecht-paragraph role="status">
Uw registratie is ontvangen. Referentie: {{ reference() }}.
</p>
} @else {
<p utrecht-paragraph>U bent ingelogd met BSN {{ bsn() }}.</p>
@if (failed()) {

View File

@@ -17,22 +17,12 @@ class FakeAuth extends AuthService {
}
}
function providers(
post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
withdraw = vi.fn().mockReturnValue(of(undefined)),
) {
function providers(post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' }))) {
return {
post,
withdraw,
providers: [
{ provide: AuthService, useClass: FakeAuth },
{
provide: BffApiV1Service,
useValue: {
postSelfServiceRegistrations: post,
postSelfServiceRegistrationsIdWithdraw: withdraw,
},
},
{ provide: BffApiV1Service, useValue: { postSelfServiceRegistrations: post } },
],
};
}
@@ -66,36 +56,6 @@ describe('RegistrationPage', () => {
expect(screen.getByRole('button', { name: /indienen/i })).toBeTruthy();
});
it('offers to withdraw after submitting, and withdrawing confirms', async () => {
const { withdraw, providers: p } = providers();
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
await screen.findByText(/ontvangen/i);
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
// The withdrawal is keyed by the reference the submit returned, and the page confirms it.
expect(withdraw).toHaveBeenCalledWith('reg-9');
expect(await screen.findByText(/ingetrokken/i)).toBeTruthy();
});
it('surfaces a withdraw failure and keeps the action available', async () => {
const { providers: p } = providers(
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
vi.fn().mockReturnValue(throwError(() => new Error('withdraw rejected'))),
);
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
await screen.findByText(/ontvangen/i);
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
expect(await screen.findByRole('alert')).toBeTruthy();
expect(screen.queryByText(/is ingetrokken/i)).toBeNull();
expect(screen.getByRole('button', { name: /trek aanvraag in/i })).toBeTruthy();
});
it('has no WCAG 2.1 AA violations on the submit page', async () => {
// The portal is Dutch; the real index.html sets lang. Set it here so the document-level
// html-has-lang rule reflects the app, not the bare jsdom document.

View File

@@ -6,8 +6,7 @@ import { UtrechtComponentsModule } from 'ui';
/**
* The self-service submit page: a signed-in zorgprofessional confirms and submits their BIG
* registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and-
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c). After
* submitting they can withdraw it — "trek aanvraag in" — keyed by that reference (S-11c).
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c).
*/
@Component({
selector: 'app-registration-page',
@@ -23,9 +22,6 @@ export class RegistrationPage {
protected readonly reference = signal<string | undefined>(undefined);
protected readonly submitted = signal(false);
protected readonly failed = signal(false);
protected readonly withdrawing = signal(false);
protected readonly withdrawn = signal(false);
protected readonly withdrawFailed = signal(false);
submit(): void {
this.submitting.set(true);
@@ -43,24 +39,4 @@ export class RegistrationPage {
},
});
}
withdraw(): void {
const reference = this.reference();
if (!reference) {
return;
}
this.withdrawing.set(true);
this.withdrawFailed.set(false);
this.bff.postSelfServiceRegistrationsIdWithdraw(reference).subscribe({
next: () => {
this.withdrawn.set(true);
this.withdrawing.set(false);
},
// Surface the failure instead of swallowing it: keep the action so the user can retry.
error: () => {
this.withdrawFailed.set(true);
this.withdrawing.set(false);
},
});
}
}

View File

@@ -1,104 +0,0 @@
# 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.

View File

@@ -1,76 +0,0 @@
# 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.

View File

@@ -1,72 +0,0 @@
# ADR-0014: Withdrawal cancels the registratie process via a BPMN message event
- **Status:** Accepted
- **Date:** 2026-07-16
- **Deciders:** Respellion engineering
- **Relates to:** S-11 (#12); builds on ADR-0009 (external-task worker / Workflow Client), ADR-0013
(behandel-portal wiring, the Beoordelen user task)
## Context
S-11 lets a zorgprofessional withdraw a still-open registration ("trek aanvraag in"). S-11a already
advances the aggregate to INGETROKKEN (domain state). But the registratie process is still running in
Flowable — parked at the `Beoordelen` user task — so without a second step the withdrawn registration
would linger as work for a behandelaar. The withdrawal must also **cancel the running process**.
Two questions shape this sub-slice.
1. **How does the case get cancelled — in code, or in the BPMN model?**
2. **How does a withdrawal correlate to the right running process instance?**
## Decision
**The BPMN models the cancellation as an interrupting message boundary event on the `Beoordelen`
task; the Workflow Client correlates a `RegistratieIngetrokken` message to the task's execution.**
- **Modelled in BPMN, not deleted from code.** The `Beoordelen` user task carries an interrupting
message boundary event (`RegistratieIngetrokken`) that routes to a dedicated "Registratie
ingetrokken" end event. The process's own model says *how* a withdrawal ends it — the Workflow
Client only delivers the message; it never reaches into Flowable to delete an instance. This keeps
the workflow's control flow in the workflow (§8.2) and leaves an audit trail in Flowable history
(the process ended via the ingetrokken path, not a raw delete).
- **Correlated by the registration's own process instance.** The aggregate records its Flowable
process instance id at submit, so the `WithdrawRegistration` handler correlates directly by that
id — no task lookup. The Workflow Client asks Flowable for the execution **subscribed to** the
`RegistratieIngetrokken` message in that instance and delivers `messageEventReceived` to it.
Targeting the subscribed execution (not the user task's execution — a message boundary event's
subscription lives on its own execution) is what makes the correlation land.
- **Best-effort, mirroring the beoordeling.** If no open `Beoordelen` task is found (the process has
not yet parked there — the `OpenZaakAanmaken` window — or has already ended), the withdrawal still
stands: the aggregate is INGETROKKEN and the werkbak filters it out regardless (S-11b). We complete
the domain transition first and cancel the workflow best-effort, exactly as `BeoordeelRegistratie`
completes its task best-effort.
## Consequences
**Positive**
- The cancellation path is visible in `registratie.bpmn`; the Workflow Client stays the only code
that talks to Flowable and does not delete instances behind the model's back.
- Reuses the existing task-query correlation — no new plumbing, no correlation store.
- A withdrawn case leaves the werkbak (its `Beoordelen` task is cancelled), and the werkbak also
filters non-open registrations as a belt-and-braces for the brief window before cancellation lands.
**Negative / costs**
- A withdrawal raced ahead of the process reaching `Beoordelen` (during `OpenZaakAanmaken`, seconds)
finds no task to cancel, so that process instance runs on to `Beoordelen` and parks there with no
one to act on it (it is hidden from the werkbak by the status filter). Acceptable for this
reference at these volumes; a process-level interrupting event subprocess would close the gap and
is an additive follow-up if it matters.
- The Flowable message-correlation REST shape is validated live (verify-stack), not in the
Workflow Client's unit tests, which stub the HTTP exchange and assert only the request shape
(consistent with ADR-0009).
## Alternatives considered
- **Delete the process instance from the Workflow Client** (`DELETE /runtime/process-instances/{id}`)
— rejected: it cancels the case but hides the reason from the BPMN model; the "why" lives in code,
not the process. The message event keeps the cancellation a first-class part of the workflow.
- **Interrupting message event subprocess at process level** — more robust (correlates anytime,
closing the `OpenZaakAanmaken`-race gap), but a heavier BPMN construct; deferred as an additive
change if the race proves to matter.

View File

@@ -227,74 +227,3 @@ curl -fsS http://localhost:8140/openbaar/register | jq
> **End of walking skeleton** (S-09 + S-09b): submit → process → projection → public visibility, from
> INGEDIEND through approval to INGESCHREVEN. The subscriber takes any post-creation status-set as the
> approval (ADR-0011) — a walking-skeleton assumption that tightens when more transitions arrive (S-12+).
## #78 — One reference across both portals (ADR-0012)
Before this change the self-service confirmation and the openbaar register showed **different**
identifiers, so a citizen could not look their registration back up. Now both show the same
**reference**: the domain `registrationId` is set as the zaak's `identificatie` by the ACL, and the
Event Subscriber enriches the projection with it by reading the zaak through the ACL (§8.1) — storing
it in the replay log so rebuild stays log-only (ADR-0008).
**The path:** domain passes `registrationId` → ACL sets it as `zaak.identificatie` → NRC →
Event Subscriber asks the ACL for the reference → projection row + replay log → openbaar register.
```bash
# Submit as in S-09 and note the reference on the confirmation, then find it in the public register:
ref="<registration-reference-from-the-confirmation>"
curl -fsS "http://localhost:8140/openbaar/register?q=$ref" | jq
# → [ { "id": "<zaak-uuid>", "status": "INGEDIEND", "reference": "<same-ref-as-confirmation>" } ]
```
> The openbaar register's "Referentie" column and its search now use this reference — the exact value
> the citizen saw on submit. Asserted end-to-end by the Playwright happy path.
## S-12 — Behandel portal: werkbak + beoordeling (#13, ADR-0013)
A behandelaar now works submitted registrations in a real portal instead of the temporary admin
endpoint. After a citizen submits (as above), the workflow parks the registration at the Flowable
`Beoordelen` user task, and it shows up in the **werkbak**. The behandelaar logs in against the
Keycloak `medewerker` realm and decides — **goedkeuren** (→ INGESCHREVEN via the ACL, per ADR-0011)
or **afwijzen** — which also completes the Beoordelen task so the process advances.
```text
# 1. Open the behandel portal and log in as a behandelaar (medewerker realm):
# http://localhost:8142/ → merel-behandelaar / test123
#
# 2. The werkbak lists the registrations awaiting beoordeling (referentie / bsn / status).
# Find the reference from the submit confirmation and click "Goedkeuren" on that row.
#
# 3. The row drops off the werkbak (its Beoordelen task is completed) and the openbaar register
# (http://localhost:8141/) now shows that reference as INGESCHREVEN.
```
**The path:** behandel portal → BFF `POST /behandel/registrations/{id}/decide` (behandelaar policy,
`medewerker` realm) → domain applies the decision + completes the Flowable `Beoordelen` task →
ACL → NRC → event-subscriber → projection → openbaar register shows INGESCHREVEN.
> The full round-trip — DigiD submit → public INGEDIEND → behandelaar goedkeurt in the werkbak →
> public INGESCHREVEN — is the Playwright happy path (`tests/e2e/registration.spec.ts`), which now
> drives the behandel portal in place of the old admin endpoint.
## S-11 — Withdrawal: "trek aanvraag in" (#12, ADR-0014)
A zorgprofessional can withdraw their own still-open registration from the self-service portal. The
withdrawal is owner-scoped (the BFF forwards the DigiD token's bsn; the domain only lets the owner
withdraw) and cancels the running workflow via a BPMN message event, so the case leaves the
behandelaar's werkbak.
```text
# 1. Log in and submit at the self-service portal (http://localhost:8140/, jan-burger / test123),
# note the "Referentie" on the confirmation.
# 2. Click "Trek aanvraag in" → the page confirms the registration is ingetrokken.
# 3. In the behandel werkbak (http://localhost:8142/, merel-behandelaar) the registration no longer
# appears — its Beoordelen task was cancelled.
```
**The path:** self-service → BFF `POST /self-service/registrations/{id}/withdraw` (DigiD, owner-scoped)
→ domain sets INGETROKKEN + correlates the `RegistratieIngetrokken` message to the process → the
interrupting boundary event ends it → the werkbak drops the case.
> DigiD submit → trek aanvraag in → ingetrokken is the Playwright happy path
> (`tests/e2e/withdrawal.spec.ts`); the owner-scoping + workflow cancellation are covered by the
> `Een registratie intrekken` acceptance scenarios and the domain live check.

View File

@@ -117,53 +117,3 @@ with the submit form (S-08c, #67); any deviation from NL DS will be recorded her
(id + status); `bsn`/`naam` never leave the BFF. The e2e asserts the bsn never renders.
- **Loads on open, filters on search.** `RegisterPage` fetches the full register on construction and
re-queries `/openbaar/register?q=` on search — no client-side filtering, the BFF owns the query.
## Behandel portal (S-12, #13)
The staff portal where a behandelaar works the **werkbak** (registrations awaiting beoordeling) and
decides each — goedkeuren or afwijzen. `apps/behandel` mirrors `apps/self-service`; the net-new
frontend work is the medewerker realm auth and the werkbak/decide page. Wiring rationale is in
**ADR-0013**; this entry records the frontend-specific choices.
- **Medewerker realm auth, reusing `libs/auth`.** Staff authenticate against the Keycloak
`medewerker` realm (public client `big-portal`), not `digid`. Rather than fork the auth lib, the
abstract `AuthService` grew a **`roles`/`hasRole` surface** (empty for realms without roles, e.g.
`digid`), and a parallel **`MedewerkerAuthService` + `provideMedewerkerAuth`** were added — same
auth-code + PKCE config, bound to the medewerker realm, reading the nested `realm_access.roles`
claim. The library's own `authInterceptor` attaches the token to the relative `/behandel/` calls
(secure route), exactly as self-service does for `/self-service/`.
- **Roles reach the frontend via a realm mapper.** Keycloak emits realm roles in the access token by
default but not the ID token/userinfo the SPA reads, so the medewerker `big-portal` client gets a
**realm-roles protocol mapper** (`realm_access.roles`, added to id + userinfo tokens). The
**BFF remains the security boundary** (`behandelaar` policy, 401/403 on `/behandel/*`, ADR-0013);
the frontend role signal is for display/UX, and the werkbak page surfaces a load failure (e.g. a
403 for a non-behandelaar) rather than swallowing it.
- **Same-origin via nginx, like the other portals.** The compose `behandel` image serves the built
app and reverse-proxies `/behandel` to the BFF (relative calls, no CORS). Served on `:8142`,
health-checked over IPv4 (`127.0.0.1`), depends on Keycloak for the medewerker realm.
- **Werkbak = decide-and-refresh.** `WerkbakPage` loads `GET /behandel/werkbak` on open and renders a
row per registration (referentie/bsn/status). Goedkeuren/afwijzen `POST /behandel/registrations/
{id}/decide` and then reload the werkbak, so the handled item drops off (its Flowable `Beoordelen`
task is completed). Per-row decide buttons carry an `aria-label` including the reference, so the
e2e (and screen readers) can target a specific registration in a shared werkbak.
- **Testing.** Component tests use `@testing-library/angular` with `BffApiV1Service`/`AuthService`
mocked and the axe WCAG 2.1 AA check; an `app.config.spec` drives the real interceptor + api-client
to assert the medewerker token attaches to `/behandel/*` (and not to the anonymous openbaar call).
The full DigiD-submit → behandel-decide → public INGESCHREVEN round-trip is the Playwright happy
path.
## Self-service withdrawal: "trek aanvraag in" (S-11c, #12)
The submit confirmation grows a **"Trek aanvraag in"** action so a zorgprofessional can withdraw the
registration they just submitted (`apps/self-service`, on the existing `RegistrationPage`).
- **Keyed by the reference, owner-scoped at the BFF.** The button calls the generated
`postSelfServiceRegistrationsIdWithdraw(reference)` with the reference the submit returned. The
DigiD token (attached by the interceptor) carries the bsn the BFF forwards; the domain only lets
the owner withdraw (a mismatch is 404). No extra identity is entered in the UI.
- **Same confirm-and-surface pattern as submit.** A secondary-action button; on success the page
switches to an ingetrokken confirmation; a failure is surfaced (`role="alert"`) and the action
stays available to retry — mirroring how submit handles its failure rather than swallowing it.
- **Testing.** Component tests (`@testing-library/angular`, mocked BFF) cover the button appearing
after submit, the reference being passed, the ingetrokken confirmation, and the failure path; the
browser round-trip is `tests/e2e/withdrawal.spec.ts`.

View File

@@ -17,12 +17,7 @@
#
# Port map (host):
# 8000 OpenZaak · 8001 Open Notificaties · 8080 BFF · 8090 Flowable REST
# 8100 ACL · 8130 Domain · 8180 Keycloak (all admin: admin / admin — dev only)
# 8140 self-service portal · 8141 openbaar register · 8142 behandel portal
#
# Portal OIDC on the HOST: browse the portals at their 8140/8141/8142 ports and log in via
# Keycloak on localhost:8180 (KC_HOSTNAME below pins the issuer there; the BFF still validates
# in-network via keycloak:8080). Test users are in docs/synthetic-data.md.
# 8100 ACL · 8180 Keycloak (all admin: admin / admin — dev only)
services:
@@ -210,12 +205,6 @@ services:
KEYCLOAK_ADMIN_PASSWORD: admin
KC_HEALTH_ENABLED: "true"
KC_HTTP_ENABLED: "true"
# Pin the frontend/issuer URL to the host-published address so a browser on the host and the
# tokens it gets both use localhost:8180. KC_HOSTNAME_BACKCHANNEL_DYNAMIC lets in-network
# callers (the BFF via keycloak:8080) still resolve token/jwks endpoints to their request host,
# so the BFF validates the localhost:8180 issuer while fetching keys over the compose network.
KC_HOSTNAME: http://localhost:8180
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
ports:
- "8180:8080"
volumes:
@@ -306,14 +295,6 @@ services:
context: ../services/bff
dockerfile: Dockerfile
image: register-referentie/bff:dev
environment:
# Reach Keycloak over the compose network for metadata/keys; the discovered issuer is the
# host-pinned localhost:8180 (KC_HOSTNAME above), which is what browser tokens carry — so
# validation matches without the BFF ever needing to resolve localhost:8180 itself.
Keycloak__Authority: http://keycloak:8080/realms/digid
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
Downstream__Domain__BaseUrl: http://domain:8080/
Downstream__Projection__BaseUrl: http://projection-api:8080/
ports:
- "8080:8080"
healthcheck:
@@ -322,39 +303,6 @@ services:
timeout: 3s
retries: 5
start_period: 10s
depends_on:
domain:
condition: service_healthy
projection-api:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg]
# ── BIG Domain Service (S-05) ─────────────────────────────────────────────
domain:
build:
context: ../services/domain
dockerfile: Dockerfile
image: register-referentie/domain:dev
environment:
Flowable__BaseUrl: http://flowable-rest:8080/flowable-rest/
Flowable__Username: rest-admin
Flowable__Password: test
Acl__BaseUrl: http://acl:8080/
ports:
- "8130:8080"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
acl:
condition: service_healthy
flowable-init:
condition: service_completed_successfully
networks: [cg]
# ── Read projection (S-06) ────────────────────────────────────────────────
@@ -380,10 +328,6 @@ services:
image: register-referentie/event-subscriber:dev
environment:
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
# The subscriber enriches the projection with each zaak's reference by asking the ACL — the only
# code allowed to read ZGW (§8.1, #78). Required: startup throws without it (parity with the
# canonical compose).
Acl__BaseUrl: http://acl:8080/
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
ports:
- "8110:8080"
@@ -396,8 +340,6 @@ services:
depends_on:
projection-db:
condition: service_healthy
acl:
condition: service_healthy
networks: [cg]
projection-api:
@@ -420,73 +362,6 @@ services:
condition: service_healthy
networks: [cg]
# ── Portals (S-08/S-09/S-12) ──────────────────────────────────────────────
# nginx serves each Angular app and reverse-proxies its endpoint group to the BFF (same-origin).
# The images bake config.json with the compose authority (keycloak:8080), which a HOST browser
# can't resolve — so here we bind-mount a config.json pointing at the host-published localhost:8180
# (matching KC_HOSTNAME). openbaar is anonymous and needs no config.
self-service:
build:
context: ..
dockerfile: apps/self-service/Dockerfile
image: register-referentie/self-service:dev
ports:
- "8140:80"
volumes:
- ./local-config/self-service.config.json:/usr/share/nginx/html/config.json:ro,z
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
bff:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg]
openbaar:
build:
context: ..
dockerfile: apps/openbaar/Dockerfile
image: register-referentie/openbaar:dev
ports:
- "8141:80"
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
bff:
condition: service_healthy
networks: [cg]
behandel:
build:
context: ..
dockerfile: apps/behandel/Dockerfile
image: register-referentie/behandel:dev
ports:
- "8142:80"
volumes:
- ./local-config/behandel.config.json:/usr/share/nginx/html/config.json:ro,z
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
bff:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg]
volumes:
oz-db:
nrc-db:

View File

@@ -349,8 +349,6 @@ services:
# Keycloak (start-dev) derives the issuer from the request host, so the BFF authority and the
# verify token request both use keycloak:8080 to keep the issuer consistent.
Keycloak__Authority: http://keycloak:8080/realms/digid
# Behandelaars authenticate against the medewerker realm; the BFF validates it for /behandel/* (S-12c).
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
Downstream__Domain__BaseUrl: http://domain:8080/
Downstream__Projection__BaseUrl: http://projection-api:8080/
ports:
@@ -398,9 +396,6 @@ services:
image: register-referentie/event-subscriber:dev
environment:
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
# The subscriber enriches the projection with each zaak's reference (identificatie) by asking
# the ACL — the only code allowed to read ZGW (§8.1, #78).
Acl__BaseUrl: http://acl:8080/
# The bearer Open Notificaties must present on the abonnement callback. NRC's
# registration probe expects a 401 without it (ADR-0007). Dev-only token.
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
@@ -415,8 +410,6 @@ services:
depends_on:
projection-db:
condition: service_healthy
acl:
condition: service_healthy
networks: [cg]
# The read side of the projection. Shares Projection.ReadModel, so build context is root.
@@ -486,29 +479,6 @@ services:
condition: service_healthy
networks: [cg]
# The behandel portal: nginx serves the Angular app and reverse-proxies /behandel to the BFF.
# Behandelaars log in against the Keycloak medewerker realm (ADR-0013; S-12).
behandel:
build:
context: ..
dockerfile: apps/behandel/Dockerfile
image: register-referentie/behandel:dev
ports:
- "8142:80"
healthcheck:
# 127.0.0.1, not localhost: nginx listens on IPv4 only, but localhost resolves to ::1 first.
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
bff:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg]
volumes:
oz-db:
nrc-db:

View File

@@ -16,22 +16,7 @@
"standardFlowEnabled": true,
"directAccessGrantsEnabled": true,
"redirectUris": ["*"],
"webOrigins": ["*"],
"protocolMappers": [
{
"name": "realm roles",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-realm-role-mapper",
"config": {
"multivalued": "true",
"claim.name": "realm_access.roles",
"jsonType.label": "String",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
}
]
"webOrigins": ["*"]
}
],
"users": [

View File

@@ -1,3 +0,0 @@
{
"authority": "http://localhost:8180/realms/medewerker"
}

View File

@@ -1,3 +0,0 @@
{
"authority": "http://localhost:8180/realms/digid"
}

View File

@@ -51,110 +51,18 @@ loc="$(docker run --rm --network "$net" curlimages/curl:latest \
echo ">> registration accepted at $loc"
echo ">> polling the domain until the worker records the opened zaak"
zaak_ok=""
for _ in $(seq 1 30); do
body="$(docker run --rm --network "$net" curlimages/curl:latest \
-fsS "http://$dom_ip:8080$loc" 2>/dev/null || true)"
if echo "$body" | grep -q '/zaken/api/v1/zaken/'; then
echo "OK — the domain opened a zaak and recorded it on the registration:"
echo "$body" | cut -c1-300
zaak_ok=1
break
exit 0
fi
sleep 2
done
if [ -z "$zaak_ok" ]; then
echo "FAIL — the registration never received a zaak URL" >&2
echo "--- domain log ---" >&2; docker logs "$dom" 2>&1 | tail -15 >&2
acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)"
[ -n "$acl" ] && { echo "--- acl log ---" >&2; docker logs "$acl" 2>&1 | tail -15 >&2; }
exit 1
fi
# ── S-12b: the process now parks at the Beoordelen user task. Exercise the exact Flowable REST
# contract the Workflow Client uses (query/claim/complete) against the live engine, reaching
# flowable-rest by container IP (same in-network constraint as above). ──────────────────────────
fl="$(docker ps -q --filter 'name=flowable-rest' | head -1)"
[ -n "$fl" ] || { echo "ERROR: no running flowable-rest container" >&2; exit 1; }
fl_base="http://$(ip "$fl"):8080/flowable-rest/service"
reg_id="${loc##*/}"
# Extracts the Beoordelen task id for a given 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="$1" 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 "$reg_id")"
[ -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 "$reg_id")"
[ -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"
# ── S-11: withdrawal. A second registration parks at Beoordelen; the citizen withdraws it via the
# domain, which delivers the RegistratieIngetrokken message to the task's execution, tripping the
# BPMN boundary event so the process ends and the Beoordelen task disappears (ADR-0014). ────────────
echo ">> submitting a second registration to withdraw"
loc2="$(docker run --rm --network "$net" curlimages/curl:latest \
-fsS -D - -o /dev/null -X POST "http://$dom_ip:8080/registrations" \
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' \
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
[ -n "$loc2" ] || { echo "FAIL — second POST /registrations returned no Location" >&2; exit 1; }
reg_id2="${loc2##*/}"
echo ">> second registration $reg_id2"
echo ">> polling Flowable for its Beoordelen task"
task_id2=""
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_id2="$(printf '%s' "$resp" | task_for_reg "$reg_id2")"
[ -n "$task_id2" ] && break
sleep 2
done
[ -n "$task_id2" ] || { echo "FAIL — no Beoordelen task appeared for registration $reg_id2" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo ">> Beoordelen task $task_id2 is waiting; withdrawing the registration via the domain"
# Owner-scoped: the withdraw carries the same bsn the registration was submitted with (S-11c).
docker run --rm --network "$net" curlimages/curl:latest \
-fsS -X POST "http://$dom_ip:8080/registrations/$reg_id2/withdraw" \
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' >/dev/null
echo ">> asserting the process was cancelled (no Beoordelen task remains for the registration)"
gone=""
for _ in $(seq 1 15); do
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
still2="$(printf '%s' "$resp" | task_for_reg "$reg_id2")"
[ -z "$still2" ] && { gone=1; break; }
sleep 2
done
[ -n "$gone" ] || { echo "FAIL — Beoordelen task for $reg_id2 still active after withdrawal" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo "OK — withdrawal cancelled the Beoordelen task; the registratie process ended (ingetrokken)"
exit 0
echo "FAIL — the registration never received a zaak URL" >&2
echo "--- domain log ---" >&2; docker logs "$dom" 2>&1 | tail -15 >&2
acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)"
[ -n "$acl" ] && { echo "--- acl log ---" >&2; docker logs "$acl" 2>&1 | tail -15 >&2; }
exit 1

View File

@@ -24,15 +24,9 @@ import {
Observable
} from 'rxjs';
export interface DecideRequest {
besluit: string;
}
export interface OpenbaarEntry {
id: string;
status: string;
/** @nullable */
reference: string | null;
}
export interface SubmitAccepted {
@@ -40,12 +34,6 @@ export interface SubmitAccepted {
status: string;
}
export interface WerkbakItem {
registrationId: string;
bsn: string;
status: string;
}
export type GetOpenbaarRegisterParams = {
q?: string;
};
@@ -192,40 +180,6 @@ export class BffApiV1Service {
);
}
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientBodyOptions): Observable<TData>;
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
postSelfServiceRegistrationsIdWithdraw<TData = void>(
id: string, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
if (options?.observe === 'events') {
return this.http.post<TData>(
`/self-service/registrations/${id}/withdraw`,
undefined,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
}
);
}
if (options?.observe === 'response') {
return this.http.post<TData>(
`/self-service/registrations/${id}/withdraw`,
undefined,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
}
);
}
return this.http.post<TData>(
`/self-service/registrations/${id}/withdraw`,
undefined,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'body',
}
);
}
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientBodyOptions): Observable<TData>;
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
@@ -259,73 +213,4 @@ 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',
}
);
}
};

View File

@@ -1,6 +1,4 @@
export * from './lib/auth.service';
export * from './lib/digid-auth.service';
export * from './lib/digid-auth.providers';
export * from './lib/medewerker-auth.service';
export * from './lib/medewerker-auth.providers';
export * from './lib/authenticated.guard';

View File

@@ -1,26 +1,16 @@
import { signal, Signal } from '@angular/core';
import { Signal } from '@angular/core';
/**
* The portal's view of the signed-in user. An abstraction over the OIDC library so components and
* guards depend on a small, mockable surface (the real implementations are DigiadAuthService for
* citizens and MedewerkerAuthService for staff).
* guards depend on a small, mockable surface (the real implementation is DigiadAuthService).
*/
export abstract class AuthService {
/** Whether a session is active. */
/** Whether a DigiD session is active. */
abstract readonly isAuthenticated: Signal<boolean>;
/** The citizen-service number from the DigiD token, once authenticated (staff have none). */
/** The citizen-service number from the DigiD token, once authenticated. */
abstract readonly bsn: Signal<string | undefined>;
/**
* The realm roles carried in the token. Empty for realms that don't grant roles (e.g. `digid`);
* the `medewerker` realm carries `behandelaar`/`teamlead`.
*/
readonly roles: Signal<readonly string[]> = signal<readonly string[]>([]);
/** Start login (redirects to Keycloak). */
/** Start the DigiD login (redirects to Keycloak). */
abstract login(): void;
/** End the session. */
abstract logout(): void;
/** Whether the signed-in user holds the given realm role. */
hasRole(role: string): boolean {
return this.roles().includes(role);
}
}

View File

@@ -1,49 +0,0 @@
import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';
import { LogLevel, provideAuth, withAppInitializerAuthCheck } from 'angular-auth-oidc-client';
import { AuthService } from './auth.service';
import { MedewerkerAuthService } from './medewerker-auth.service';
export interface MedewerkerAuthOptions {
/** The Keycloak `medewerker` realm issuer, as reachable from the browser. */
authority: string;
/** Where Keycloak redirects back to after login (usually the app origin). */
redirectUrl: string;
/**
* Route prefixes whose requests get the bearer token attached. The api-client calls the BFF with
* **relative** URLs (same-origin via the nginx proxy), so these must be relative path prefixes
* (e.g. `/behandel/`) — angular-auth-oidc-client matches `req.url.startsWith(route)`, and a
* relative `req.url` never starts with an absolute origin.
*/
secureRoutes: string[];
}
/**
* Configure medewerker login (Keycloak `medewerker` realm, public client `big-portal`, auth-code +
* PKCE) and bind {@link AuthService} to the medewerker-backed implementation. Register
* {@link authInterceptor} (re-exported from digid-auth.providers) in the app's HttpClient so BFF
* calls carry the token.
*/
export function provideMedewerkerAuth(options: MedewerkerAuthOptions): EnvironmentProviders {
return makeEnvironmentProviders([
provideAuth(
{
config: {
authority: options.authority,
redirectUrl: options.redirectUrl,
postLogoutRedirectUri: options.redirectUrl,
clientId: 'big-portal',
scope: 'openid profile',
responseType: 'code',
silentRenew: true,
useRefreshToken: true,
secureRoutes: options.secureRoutes,
logLevel: LogLevel.Warn,
},
},
// Run checkAuth() at startup so the login callback (?code=…) is processed before the router
// and guard run — without it the guard sees "not authenticated" and re-triggers login (loop).
withAppInitializerAuthCheck(),
),
{ provide: AuthService, useClass: MedewerkerAuthService },
]);
}

View File

@@ -1,43 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { OidcSecurityService } from 'angular-auth-oidc-client';
import { of } from 'rxjs';
import { MedewerkerAuthService } from './medewerker-auth.service';
function makeService(userData: unknown, authenticated = true) {
const oidc = {
isAuthenticated$: of({ isAuthenticated: authenticated }),
userData$: of({ userData }),
authorize: vi.fn(),
logoff: vi.fn(() => of(null)),
};
TestBed.configureTestingModule({
providers: [MedewerkerAuthService, { provide: OidcSecurityService, useValue: oidc }],
});
return { svc: TestBed.inject(MedewerkerAuthService), oidc };
}
describe('MedewerkerAuthService', () => {
it('exposes the realm roles carried in the token', () => {
const { svc } = makeService({ realm_access: { roles: ['behandelaar', 'teamlead'] } });
expect(svc.roles()).toEqual(['behandelaar', 'teamlead']);
expect(svc.hasRole('behandelaar')).toBe(true);
expect(svc.hasRole('beheerder')).toBe(false);
});
it('has no roles when the token omits realm_access', () => {
const { svc } = makeService({ preferred_username: 'merel-behandelaar' });
expect(svc.roles()).toEqual([]);
expect(svc.hasRole('behandelaar')).toBe(false);
});
it('reflects the OIDC authenticated state', () => {
const { svc } = makeService({}, true);
expect(svc.isAuthenticated()).toBe(true);
});
it('starts login by delegating to the OIDC library', () => {
const { svc, oidc } = makeService({});
svc.login();
expect(oidc.authorize).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,41 +0,0 @@
import { inject, Injectable, Signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { OidcSecurityService } from 'angular-auth-oidc-client';
import { map } from 'rxjs';
import { AuthService } from './auth.service';
/** The subset of the medewerker token the portal reads: Keycloak nests realm roles here. */
interface MedewerkerClaims {
realm_access?: { roles?: string[] };
}
/** Medewerker-backed AuthService over angular-auth-oidc-client (Keycloak `medewerker` realm). */
@Injectable()
export class MedewerkerAuthService extends AuthService {
private readonly oidc = inject(OidcSecurityService);
readonly isAuthenticated: Signal<boolean> = toSignal(
this.oidc.isAuthenticated$.pipe(map((result) => result.isAuthenticated)),
{ initialValue: false },
);
// Staff have no BSN; the abstract surface keeps this present for the shared guard/interceptor.
readonly bsn: Signal<string | undefined> = toSignal(this.oidc.userData$.pipe(map(() => undefined)), {
initialValue: undefined,
});
override readonly roles: Signal<readonly string[]> = toSignal(
this.oidc.userData$.pipe(
map((data) => (data.userData as MedewerkerClaims | null)?.realm_access?.roles ?? []),
),
{ initialValue: [] },
);
override login(): void {
this.oidc.authorize();
}
override logout(): void {
this.oidc.logoff().subscribe();
}
}

View File

@@ -20,7 +20,7 @@ app.MapGet("/health", () => "Healthy");
// The ACL's single operation, exposed as a service endpoint.
app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationToken ct) =>
{
var zaakUrl = await acl.OpenZaakAsync(new DomainRegistration(body.Bsn, body.Reference), ct);
var zaakUrl = await acl.OpenZaakAsync(new DomainRegistration(body.Bsn), ct);
return Results.Ok(new { zaakUrl = zaakUrl.ToString() });
});
@@ -32,20 +32,10 @@ app.MapPost("/statussen", async (SetStatusRequest body, AclService acl, Cancella
return Results.NoContent();
});
// Read a zaak's public-safe reference (its identificatie). The Event Subscriber calls this to enrich
// the read projection without reading ZGW itself (§8.1, #78).
app.MapPost("/zaken/reference", async (ZaakReferenceRequest body, AclService acl, CancellationToken ct) =>
{
var reference = await acl.GetZaakReferenceAsync(new Uri(body.ZaakUrl), ct);
return Results.Ok(new { reference });
});
app.Run();
public sealed record OpenZaakRequest(string Bsn, string Reference);
public sealed record OpenZaakRequest(string Bsn);
public sealed record SetStatusRequest(string ZaakUrl);
public sealed record ZaakReferenceRequest(string ZaakUrl);
public partial class Program;

View File

@@ -13,8 +13,7 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
defaults.VerantwoordelijkeOrganisatie,
defaults.Vertrouwelijkheidaanduiding,
defaults.ZaaktypeUrl,
clock.Today,
registration.Reference);
clock.Today);
return gateway.OpenZaakAsync(request, ct);
}
@@ -29,12 +28,4 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
}
/// <summary>The zaak's reference (its ZGW identificatie), for the read projection (#78).</summary>
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
return gateway.GetZaakIdentificatieAsync(zaakUrl, ct);
}
}

View File

@@ -1,5 +1,4 @@
namespace Acl.Application;
/// <summary>Domain-language payload handed to the ACL. No ZGW concepts here. <see cref="Reference"/>
/// is the registration's own id; the ACL records it as the zaak identificatie (adr-proposal #78).</summary>
public sealed record DomainRegistration(string Bsn, string Reference);
/// <summary>Domain-language payload handed to the ACL. No ZGW concepts here.</summary>
public sealed record DomainRegistration(string Bsn);

View File

@@ -12,8 +12,4 @@ public interface IZaakGateway
/// the catalogus and POSTs a status against the zaak, dated <paramref name="datumStatusGezet"/>.
/// </summary>
Task SetZaakToEindstatusAsync(Uri zaakUrl, Uri zaaktypeUrl, DateOnly datumStatusGezet, CancellationToken ct = default);
/// <summary>Read the zaak's <c>identificatie</c> — the public-safe reference the register shows.
/// The Event Subscriber calls this through the ACL rather than reading ZGW itself (§8.1, #78).</summary>
Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default);
}

View File

@@ -6,5 +6,4 @@ public sealed record ZaakRequest(
string VerantwoordelijkeOrganisatie,
string Vertrouwelijkheidaanduiding,
Uri Zaaktype,
DateOnly Startdatum,
string Identificatie);
DateOnly Startdatum);

View File

@@ -20,8 +20,7 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
request.Zaaktype.ToString(),
request.VerantwoordelijkeOrganisatie,
request.Startdatum.ToString("yyyy-MM-dd"),
request.Vertrouwelijkheidaanduiding,
request.Identificatie)),
request.Vertrouwelijkheidaanduiding)),
};
message.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
@@ -62,24 +61,6 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
"Setting the zaak status", ct);
}
public async Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
using var message = new HttpRequestMessage(HttpMethod.Get, zaakUrl);
message.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
// The zaak is a geo resource; the Zaken API requires the CRS header even on GET.
message.Headers.Add("Accept-Crs", "EPSG:4326");
using var response = await http.SendAsync(message, ct);
await EnsureSuccessAsync(response, "Reading the zaak", ct);
var zaak = await response.Content.ReadFromJsonAsync<ZaakReadDto>(ct)
?? throw new InvalidOperationException("OpenZaak returned an empty zaak response");
return zaak.Identificatie;
}
// POSTs a non-geo ZGW resource (resultaat/status — no CRS headers). Buffers the body so uwsgi gets
// a Content-Length instead of a chunked body (as with zaak-create).
private async Task PostAsync(string path, object dto, string action, CancellationToken ct)
@@ -151,15 +132,11 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
[property: JsonPropertyName("zaaktype")] string Zaaktype,
[property: JsonPropertyName("verantwoordelijkeOrganisatie")] string VerantwoordelijkeOrganisatie,
[property: JsonPropertyName("startdatum")] string Startdatum,
[property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding,
[property: JsonPropertyName("identificatie")] string Identificatie);
[property: JsonPropertyName("vertrouwelijkheidaanduiding")] string Vertrouwelijkheidaanduiding);
private sealed record ZaakCreatedDto(
[property: JsonPropertyName("url")] string Url);
private sealed record ZaakReadDto(
[property: JsonPropertyName("identificatie")] string Identificatie);
private sealed record StatusDto(
[property: JsonPropertyName("zaak")] string Zaak,
[property: JsonPropertyName("statustype")] string Statustype,

View File

@@ -22,14 +22,12 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
"seed it with OZ_PUBLISH=1 (`make integration` does this).");
var gateway = new OpenZaakGateway(stack.Http, stack.Options);
var reference = Guid.NewGuid().ToString(); // zaak identificatie must be unique per bronorganisatie
var request = new ZaakRequest(
Bronorganisatie: "517439943",
VerantwoordelijkeOrganisatie: "517439943",
Vertrouwelijkheidaanduiding: "openbaar",
Zaaktype: zaaktype!,
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow),
Identificatie: reference);
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow));
var zaakUrl = await gateway.OpenZaakAsync(request);
@@ -38,12 +36,11 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
new Uri(stack.BaseUrl, "/zaken/api/v1/zaken/").ToString(),
zaakUrl.ToString());
// ...and that zaak is really persisted with the default-filled fields + the reference identificatie.
// ...and that zaak is really persisted with the default-filled fields.
var zaak = await stack.GetZaakAsync(zaakUrl);
Assert.Equal(zaaktype.ToString(), zaak.GetProperty("zaaktype").GetString());
Assert.Equal("517439943", zaak.GetProperty("bronorganisatie").GetString());
Assert.Equal("openbaar", zaak.GetProperty("vertrouwelijkheidaanduiding").GetString());
Assert.Equal(reference, zaak.GetProperty("identificatie").GetString());
}
[Fact]
@@ -60,8 +57,7 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
VerantwoordelijkeOrganisatie: "517439943",
Vertrouwelijkheidaanduiding: "openbaar",
Zaaktype: zaaktype!,
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow),
Identificatie: Guid.NewGuid().ToString()));
Startdatum: DateOnly.FromDateTime(DateTime.UtcNow)));
await gateway.SetZaakToEindstatusAsync(zaakUrl, zaaktype!, DateOnly.FromDateTime(DateTime.UtcNow));

View File

@@ -22,14 +22,6 @@ public class AclServiceTests
Approved = (zaakUrl, zaaktypeUrl, datumStatusGezet);
return Task.CompletedTask;
}
public Uri? ReadReferenceFor;
public Task<string> GetZaakIdentificatieAsync(Uri zaakUrl, CancellationToken ct = default)
{
ReadReferenceFor = zaakUrl;
return Task.FromResult("REG-FROM-ZAAK");
}
}
private static AclDefaults Defaults() => new()
@@ -58,7 +50,7 @@ public class AclServiceTests
};
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
var url = await service.OpenZaakAsync(new DomainRegistration("123456782", "reg-77"));
var url = await service.OpenZaakAsync(new DomainRegistration("123456782"));
Assert.Equal(gateway.Result, url);
var req = Assert.IsType<ZaakRequest>(gateway.Captured);
@@ -67,8 +59,6 @@ public class AclServiceTests
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding);
Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype);
Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum);
// The registration reference becomes the zaak identificatie (#78).
Assert.Equal("reg-77", req.Identificatie);
}
[Fact]
@@ -113,27 +103,4 @@ public class AclServiceTests
await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!));
Assert.Null(gateway.Approved);
}
[Fact]
public async Task Reading_a_zaak_reference_returns_the_zaaks_identificatie()
{
var gateway = new FakeGateway();
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
var reference = await service.GetZaakReferenceAsync(zaak);
Assert.Equal("REG-FROM-ZAAK", reference);
Assert.Equal(zaak, gateway.ReadReferenceFor);
}
[Fact]
public async Task Reading_a_null_zaak_reference_is_rejected()
{
var gateway = new FakeGateway();
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
await Assert.ThrowsAsync<ArgumentNullException>(() => service.GetZaakReferenceAsync(null!));
Assert.Null(gateway.ReadReferenceFor);
}
}

View File

@@ -22,7 +22,7 @@ public class OpenZaakGatewayTests
private static ZaakRequest SampleRequest() => new(
"517439943", "517439943", "openbaar",
new("http://openzaak/catalogi/api/v1/zaaktypen/big"), new DateOnly(2026, 6, 4), "REG-REF-1");
new("http://openzaak/catalogi/api/v1/zaaktypen/big"), new DateOnly(2026, 6, 4));
private static StubHandler Created(out RequestCapture capture)
{
@@ -67,8 +67,6 @@ public class OpenZaakGatewayTests
Assert.Contains("\"vertrouwelijkheidaanduiding\":\"openbaar\"", capture.Body);
Assert.Contains("\"startdatum\":\"2026-06-04\"", capture.Body);
Assert.Contains("\"zaaktype\":\"http://openzaak/catalogi/api/v1/zaaktypen/big\"", capture.Body);
// The registration reference is set as the zaak identificatie (#78).
Assert.Contains("\"identificatie\":\"REG-REF-1\"", capture.Body);
}
[Fact]
@@ -359,61 +357,6 @@ public class OpenZaakGatewayTests
Assert.Equal(4, rec.Requests.Count);
}
[Fact]
public async Task Reading_a_zaak_returns_its_identificatie_with_bearer_and_crs()
{
RequestCapture? capture = null;
var handler = new StubHandler(req =>
{
capture = new RequestCapture { Seen = req };
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(new { identificatie = "REG-XYZ", url = ZaakUrl }),
});
});
var reference = await Gateway(handler).GetZaakIdentificatieAsync(new Uri(ZaakUrl));
Assert.Equal("REG-XYZ", reference);
Assert.Equal(HttpMethod.Get, capture!.Seen!.Method);
Assert.Equal(ZaakUrl, capture.Seen.RequestUri!.ToString());
Assert.Equal("Bearer", capture.Seen.Headers.Authorization!.Scheme);
Assert.Equal("EPSG:4326", Assert.Single(capture.Seen.Headers.GetValues("Accept-Crs")));
}
[Fact]
public async Task Reading_a_zaak_throws_when_openzaak_rejects_it()
{
var handler = new StubHandler(_ =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound) { Content = new StringContent("nope") }));
var ex = await Assert.ThrowsAsync<HttpRequestException>(
() => Gateway(handler).GetZaakIdentificatieAsync(new Uri(ZaakUrl)));
Assert.Contains("Reading the zaak", ex.Message);
}
[Fact]
public async Task Reading_a_zaak_throws_when_openzaak_returns_an_empty_body()
{
var handler = new StubHandler(_ =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("null", System.Text.Encoding.UTF8, "application/json"),
}));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => Gateway(handler).GetZaakIdentificatieAsync(new Uri(ZaakUrl)));
Assert.Contains("empty zaak response", ex.Message);
}
[Fact]
public async Task Reading_a_null_zaak_is_rejected()
{
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
await Assert.ThrowsAsync<ArgumentNullException>(() => Gateway(handler).GetZaakIdentificatieAsync(null!));
}
[Fact]
public async Task Approving_rejects_a_null_zaak_or_zaaktype()
{

View File

@@ -6,32 +6,16 @@ namespace Bff.Api;
public sealed record SubmitAccepted(string RegistrationId, string Status);
/// <summary>A projection row as the projection-api serves it. <c>Bsn</c>/<c>NaamPlaceholder</c> are
/// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09).
/// <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);
/// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09).</summary>
public sealed record ProjectionEntry(string Id, string Status, string? Bsn, string? NaamPlaceholder);
/// <summary>A public-safe openbaar register row — only non-sensitive fields leave the BFF.</summary>
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);
public sealed record OpenbaarEntry(string Id, string Status);
/// <summary>Port to the Domain Service (§8.3: the BFF is the portals' only backend; it fans out).</summary>
public interface IDomainClient
{
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
/// <summary>Withdraw the caller's own registration ("trek aanvraag in"). Owner-scoped by
/// <paramref name="bsn"/>. Returns <c>false</c> when the domain reports the registration is
/// unknown or not the caller's (404), so the BFF can relay a 404 rather than a 500.</summary>
Task<bool> WithdrawRegistrationAsync(string registrationId, 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>
@@ -52,27 +36,6 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
return new SubmitAccepted(dto.RegistrationId, dto.Status);
}
public async Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
{
using var response = await http.PostAsJsonAsync(
$"registrations/{registrationId}/withdraw", new { bsn }, ct);
// The domain 404s an unknown or not-owned registration; relay that rather than fail hard.
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return false;
response.EnsureSuccessStatusCode();
return true;
}
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);
}

View File

@@ -11,10 +11,8 @@ public static class OpenbaarProjection
{
var filtered = string.IsNullOrWhiteSpace(q)
? entries
: entries.Where(e =>
e.Id.Contains(q, StringComparison.OrdinalIgnoreCase) ||
(e.Reference?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false));
: entries.Where(e => e.Id.Contains(q, StringComparison.OrdinalIgnoreCase));
return [.. filtered.Select(e => new OpenbaarEntry(e.Id, e.Status, e.Reference))];
return [.. filtered.Select(e => new OpenbaarEntry(e.Id, e.Status))];
}
}

View File

@@ -1,6 +1,4 @@
using System.Security.Claims;
using System.Text.Json;
using System.Text.Json.Serialization;
using Bff.Api;
using Microsoft.AspNetCore.Authentication.JwtBearer;
@@ -8,10 +6,6 @@ var builder = WebApplication.CreateBuilder(args);
var keycloakAuthority = builder.Configuration["Keycloak:Authority"]
?? throw new InvalidOperationException("Missing configuration 'Keycloak:Authority'");
// Behandelaars authenticate against a *different* Keycloak realm (medewerker) than citizens (digid),
// so the BFF validates a second issuer for the behandel endpoints (ADR-0013).
var medewerkerAuthority = builder.Configuration["Keycloak:MedewerkerAuthority"]
?? throw new InvalidOperationException("Missing configuration 'Keycloak:MedewerkerAuthority'");
var domainBaseUrl = builder.Configuration["Downstream:Domain:BaseUrl"]
?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'");
var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"]
@@ -25,28 +19,8 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
options.Authority = keycloakAuthority;
options.RequireHttpsMetadata = false;
options.TokenValidationParameters.ValidateAudience = false;
})
// The medewerker realm — behandel endpoints only. On validation we lift Keycloak's realm roles
// (the nested realm_access.roles claim) into role claims so authorization policies can require them.
.AddJwtBearer(BehandelAuth.Scheme, options =>
{
options.Authority = medewerkerAuthority;
options.RequireHttpsMetadata = false;
options.TokenValidationParameters.ValidateAudience = false;
options.Events = new JwtBearerEvents
{
OnTokenValidated = context =>
{
BehandelAuth.AddRealmRoles(context.Principal);
return Task.CompletedTask;
},
};
});
builder.Services.AddAuthorization(options =>
options.AddPolicy(BehandelAuth.Policy, policy => policy
.AddAuthenticationSchemes(BehandelAuth.Scheme)
.RequireAuthenticatedUser()
.RequireRole(BehandelAuth.BehandelaarRole)));
builder.Services.AddAuthorization();
// 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));
@@ -86,24 +60,6 @@ app.MapPost("/self-service/registrations", async (ClaimsPrincipal user, IDomainC
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized);
// Self-service withdrawal (S-11): the signed-in zorgprofessional withdraws their own registration.
// The bsn comes from the DigiD token and is forwarded to the domain, which owner-scopes the action;
// a registration that is unknown or not the caller's comes back 404 (ownership is not revealed).
app.MapPost("/self-service/registrations/{id}/withdraw", async (string id, ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) =>
{
var bsn = user.FindFirstValue("bsn");
if (string.IsNullOrWhiteSpace(bsn))
return Results.BadRequest("The token carries no bsn claim.");
var withdrawn = await domain.WithdrawRegistrationAsync(id, bsn, ct);
return withdrawn ? Results.NoContent() : Results.NotFound();
})
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
// Openbaar register: an anonymous public lookup that exposes only public-safe fields (S-09).
app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) =>
{
@@ -112,79 +68,7 @@ app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection,
})
.Produces<IReadOnlyList<OpenbaarEntry>>(StatusCodes.Status200OK);
// Behandelaar's werkbak: registrations awaiting beoordeling. Reached only with a medewerker-realm
// token carrying the behandelaar role; the BFF proxies the domain's werkbak (staff view, ADR-0013).
app.MapGet("/behandel/werkbak", async (IDomainClient domain, CancellationToken ct) =>
Results.Ok(await domain.GetWerkbakAsync(ct)))
.RequireAuthorization(BehandelAuth.Policy)
.Produces<IReadOnlyList<WerkbakItem>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden);
// A behandelaar's beoordeling on a registration (goedkeuren/afwijzen). Forwarded to the domain, which
// applies the decision and completes the workflow task (ADR-0013). Same medewerker/behandelaar gate.
app.MapPost("/behandel/registrations/{id}/decide",
async (string id, DecideRequest body, IDomainClient domain, CancellationToken ct) =>
{
if (!BehandelAuth.IsKnownBesluit(body.Besluit))
return Results.BadRequest(new { error = $"Unknown besluit '{body.Besluit}'. Expected 'goedkeuren' or 'afwijzen'." });
await domain.DecideAsync(id, body.Besluit, ct);
return Results.NoContent();
})
.RequireAuthorization(BehandelAuth.Policy)
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden);
app.Run();
/// <summary>The behandelaar's decision on a registration.</summary>
public sealed record DecideRequest(string Besluit);
// Behandel (medewerker-realm) authentication + authorization wiring (ADR-0013).
internal static class BehandelAuth
{
public const string Scheme = "medewerker";
public const string Policy = "behandelaar";
public const string BehandelaarRole = "behandelaar";
/// <summary>The beoordeling vocabulary the BFF accepts (case-insensitive); an unknown besluit is a
/// 400 without troubling the domain. Mirrors the domain's <c>BeoordelingsBesluit</c>.</summary>
public static bool IsKnownBesluit(string? besluit) =>
string.Equals(besluit, "goedkeuren", StringComparison.OrdinalIgnoreCase) ||
string.Equals(besluit, "afwijzen", StringComparison.OrdinalIgnoreCase);
/// <summary>Lift Keycloak's realm roles (the nested <c>realm_access.roles</c> claim) onto the
/// principal as role claims, so <c>RequireRole</c> can authorize on them.</summary>
public static void AddRealmRoles(ClaimsPrincipal? principal)
{
if (principal?.Identity is not ClaimsIdentity identity)
return;
var realmAccess = principal.FindFirst("realm_access")?.Value;
if (string.IsNullOrWhiteSpace(realmAccess))
return;
// A malformed realm_access claim must not fail authentication (a throw here becomes a 401);
// it simply yields no roles, so the authorization policy answers 403.
string[] roles;
try
{
roles = JsonSerializer.Deserialize<RealmAccess>(realmAccess)?.Roles ?? [];
}
catch (JsonException)
{
return;
}
foreach (var role in roles)
identity.AddClaim(new Claim(identity.RoleClaimType, role));
}
private sealed record RealmAccess([property: JsonPropertyName("roles")] string[] Roles);
}
// Exposed so the test host (WebApplicationFactory<Program>) can boot the app.
public partial class Program;

View File

@@ -7,8 +7,7 @@
},
"AllowedHosts": "*",
"Keycloak": {
"Authority": "http://localhost:8180/realms/digid",
"MedewerkerAuthority": "http://localhost:8180/realms/medewerker"
"Authority": "http://localhost:8180/realms/digid"
},
"Downstream": {
"Domain": { "BaseUrl": "http://localhost:8130/" },

View File

@@ -1,114 +0,0 @@
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);
}
}

View File

@@ -5,7 +5,6 @@ using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
@@ -24,34 +23,9 @@ internal sealed class BffFactory : WebApplicationFactory<Program>
public FakeDomainClient Domain { get; } = new();
public FakeProjectionClient Projection { get; } = new();
private static void ValidateWithTestKey(IServiceCollection services, string scheme) =>
services.Configure<JwtBearerOptions>(scheme, options =>
{
// Validate locally against the test key and NEVER reach out for OIDC metadata. A static
// configuration manager guarantees this regardless of Configure/PostConfigure ordering —
// clearing Authority alone left the medewerker scheme fetching metadata under CI timing
// (2s hang → 401), because JwtBearer's PostConfigure could still build a ConfigurationManager.
options.Authority = null;
options.MetadataAddress = null!;
options.RequireHttpsMetadata = false;
options.Configuration = new OpenIdConnectConfiguration();
options.ConfigurationManager =
new StaticConfigurationManager<OpenIdConnectConfiguration>(new OpenIdConnectConfiguration());
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = TestSigningKey,
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/");
@@ -60,11 +34,23 @@ internal sealed class BffFactory : WebApplicationFactory<Program>
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");
services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
{
// Validate locally against the test key; never reach out for OIDC metadata.
options.Authority = null;
options.MetadataAddress = null!;
options.RequireHttpsMetadata = false;
options.Configuration = new OpenIdConnectConfiguration();
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = TestSigningKey,
ClockSkew = TimeSpan.Zero,
};
});
});
}
}
@@ -74,36 +60,12 @@ internal sealed class FakeDomainClient : IDomainClient
{
public string? SubmittedBsn { get; private set; }
public SubmitAccepted Result { get; set; } = new("reg-123", "Ingediend");
public List<WerkbakItem> Werkbak { get; } = [];
public Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default)
{
SubmittedBsn = bsn;
return Task.FromResult(Result);
}
public (string RegistrationId, string Bsn)? Withdrawn { get; private set; }
/// <summary>Whether the fake domain reports the withdrawal as done (true → 204) or not-found/not-owned
/// (false → 404). Tests set this to exercise the relay.</summary>
public bool WithdrawSucceeds { get; set; } = true;
public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
{
Withdrawn = (registrationId, bsn);
return Task.FromResult(WithdrawSucceeds);
}
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>

View File

@@ -10,7 +10,7 @@ public class OpenbaarEndpointTests
public async Task Serves_public_safe_rows_anonymously()
{
using var factory = new BffFactory();
factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", "REG-abc", "123456782", "Jan"));
factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", "123456782", "Jan"));
// No Authorization header — the openbaar register is a public lookup (ADR-0010/S-09).
var response = await factory.CreateClient().GetAsync("/openbaar/register");
@@ -19,9 +19,7 @@ public class OpenbaarEndpointTests
var body = await response.Content.ReadAsStringAsync();
Assert.Contains("abc-111", body);
Assert.Contains("INGEDIEND", body);
// 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.
// The bsn must never appear in a public response.
Assert.DoesNotContain("123456782", body);
}
@@ -29,8 +27,8 @@ public class OpenbaarEndpointTests
public async Task Filters_by_the_query_parameter()
{
using var factory = new BffFactory();
factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", "REG-abc", null, null));
factory.Projection.Entries.Add(new ProjectionEntry("def-222", "INGEDIEND", "REG-def", null, null));
factory.Projection.Entries.Add(new ProjectionEntry("abc-111", "INGEDIEND", null, null));
factory.Projection.Entries.Add(new ProjectionEntry("def-222", "INGEDIEND", null, null));
var rows = await factory.CreateClient()
.GetFromJsonAsync<List<OpenbaarEntry>>("/openbaar/register?q=abc");

View File

@@ -6,8 +6,8 @@ public class OpenbaarProjectionTests
{
private static readonly ProjectionEntry[] Sample =
[
new("abc-111", "INGEDIEND", Reference: "REG-A", Bsn: "123456782", NaamPlaceholder: "Jan"),
new("def-222", "INGESCHREVEN", Reference: "REG-B", Bsn: "987654321", NaamPlaceholder: "Piet"),
new("abc-111", "INGEDIEND", "123456782", "Jan"),
new("def-222", "INGEDIEND", "987654321", "Piet"),
];
[Fact]
@@ -18,47 +18,31 @@ public class OpenbaarProjectionTests
Assert.Equal(2, view.Count);
Assert.Equal("abc-111", view[0].Id);
Assert.Equal("INGEDIEND", view[0].Status);
// The public reference (zaak identificatie) is surfaced so it matches the submit confirmation (#78).
Assert.Equal("REG-A", view[0].Reference);
}
[Fact]
public void Public_view_exposes_only_id_status_and_reference()
public void Public_view_exposes_only_id_and_status()
{
// OpenbaarEntry structurally carries only public-safe fields — bsn/naam can never leak.
// OpenbaarEntry structurally carries only Id + Status — bsn/naam can never leak.
var props = typeof(OpenbaarEntry).GetProperties().Select(p => p.Name).ToArray();
Assert.Equal(["Id", "Status", "Reference"], props);
Assert.Equal(["Id", "Status"], props);
}
[Theory]
[InlineData("abc", 1)] // by id
[InlineData("ABC", 1)] // by id, case-insensitive
[InlineData("def", 1)] // by id
[InlineData("zzz", 0)] // no match
[InlineData("abc", 1)]
[InlineData("ABC", 1)]
[InlineData("2", 1)]
[InlineData("zzz", 0)]
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);
[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);
Assert.Equal(expected, view.Count);
}
[Fact]
public void Blank_query_is_treated_as_no_filter()
{
Assert.Equal(2, OpenbaarProjection.PublicView(Sample, " ").Count);
}
[Fact]
public void A_row_without_a_reference_never_matches_a_query()
{
// Older rows can have a null reference (the column is additive, #78/ADR-0012). A search must
// simply not match them — it must not throw and must not treat "no reference" as a match.
var entries = new[] { new ProjectionEntry("xyz-999", "INGEDIEND", Reference: null, Bsn: null, NaamPlaceholder: null) };
Assert.Empty(OpenbaarProjection.PublicView(entries, "REG"));
Assert.Equal("xyz-999", Assert.Single(OpenbaarProjection.PublicView(entries, "xyz")).Id);
}
}

View File

@@ -71,46 +71,5 @@ public class SelfServiceEndpointTests
Assert.Equal("reg-123", body!.RegistrationId);
}
private static HttpRequestMessage Withdraw(string? bearer, string id = "reg-123")
{
var request = new HttpRequestMessage(HttpMethod.Post, $"/self-service/registrations/{id}/withdraw");
if (bearer is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
return request;
}
[Fact]
public async Task Rejects_a_withdrawal_without_a_token()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Withdraw(bearer: null));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Null(factory.Domain.Withdrawn);
}
[Fact]
public async Task Withdraws_the_callers_registration_forwarding_the_id_and_bsn()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Withdraw(TestTokens.Valid("123456782"), "reg-9"));
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
Assert.Equal(("reg-9", "123456782"), factory.Domain.Withdrawn);
}
[Fact]
public async Task Relays_not_found_when_the_registration_is_unknown_or_not_the_callers()
{
using var factory = new BffFactory();
factory.Domain.WithdrawSucceeds = false;
var response = await factory.CreateClient().SendAsync(Withdraw(TestTokens.Valid("123456782")));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
private sealed record SubmitAcceptedDto(string RegistrationId, string Status);
}

View File

@@ -17,22 +17,6 @@ internal static class TestTokens
new SymmetricSecurityKey(Encoding.UTF8.GetBytes("a-different-signing-key-256-bits-long-indeed-yes!")),
expired: false);
/// <summary>A medewerker-realm token carrying the given realm roles under <c>realm_access.roles</c>
/// (Keycloak's shape), signed with the valid test key. Used to exercise behandel authorization.</summary>
public static string Medewerker(params string[] roles)
{
var handler = new JsonWebTokenHandler();
return handler.CreateToken(new SecurityTokenDescriptor
{
Claims = new Dictionary<string, object>
{
["realm_access"] = new Dictionary<string, object> { ["roles"] = roles },
},
Expires = DateTime.UtcNow.AddMinutes(30),
SigningCredentials = new SigningCredentials(BffFactory.TestSigningKey, SecurityAlgorithms.HmacSha256),
});
}
private static string Create(string bsn, SymmetricSecurityKey key, bool expired)
{
var handler = new JsonWebTokenHandler();

View File

@@ -30,37 +30,6 @@
}
}
},
"/self-service/registrations/{id}/withdraw": {
"post": {
"tags": [
"Bff.Api"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "No Content"
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Not Found"
}
}
}
},
"/openbaar/register": {
"get": {
"tags": [
@@ -91,95 +60,14 @@
}
}
}
},
"/behandel/werkbak": {
"get": {
"tags": [
"Bff.Api"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/WerkbakItem"
}
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
}
},
"/behandel/registrations/{id}/decide": {
"post": {
"tags": [
"Bff.Api"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DecideRequest"
}
}
},
"required": true
},
"responses": {
"204": {
"description": "No Content"
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
}
}
},
"components": {
"schemas": {
"DecideRequest": {
"required": [
"besluit"
],
"type": "object",
"properties": {
"besluit": {
"type": "string"
}
}
},
"OpenbaarEntry": {
"required": [
"id",
"status",
"reference"
"status"
],
"type": "object",
"properties": {
@@ -188,12 +76,6 @@
},
"status": {
"type": "string"
},
"reference": {
"type": [
"null",
"string"
]
}
}
},
@@ -211,25 +93,6 @@
"type": "string"
}
}
},
"WerkbakItem": {
"required": [
"registrationId",
"bsn",
"status"
],
"type": "object",
"properties": {
"registrationId": {
"type": "string"
},
"bsn": {
"type": "string"
},
"status": {
"type": "string"
}
}
}
}
},

View File

@@ -20,14 +20,10 @@ builder.Services.AddSingleton<IRegistrationStore, InMemoryRegistrationStore>();
builder.Services.AddHttpClient<FlowableWorkflowClient>();
builder.Services.AddTransient<IWorkflowClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddTransient<IExternalWorkerClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddTransient<IUserTaskClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddHttpClient<IAclClient, AclHttpClient>();
builder.Services.AddScoped<SubmitRegistration>();
builder.Services.AddScoped<ApproveRegistration>();
builder.Services.AddScoped<BeoordeelRegistratie>();
builder.Services.AddScoped<WithdrawRegistration>();
builder.Services.AddScoped<Werkbak>();
builder.Services.AddScoped<OpenZaakWorker>();
builder.Services.AddScoped<OpenZaakJobProcessor>();
@@ -59,44 +55,6 @@ app.MapPost("/registrations/{id}/approve", async (string id, ApproveRegistration
return Results.NoContent();
});
// The behandelaar's beoordeling (S-12): decide a registration goedkeuren (→ INGESCHREVEN, sets the
// zaak's final status via the ACL) or afwijzen (→ AFGEWEZEN). Idempotent. This is the domain contract
// the behandel-portal's decision reaches through the BFF; it supersedes the temporary /approve above,
// which is retired once the portal lands.
app.MapPost("/registrations/{id}/decide", async (string id, DecideRequest body, BeoordeelRegistratie beoordeel, CancellationToken ct) =>
{
if (!Guid.TryParse(id, out var guid))
return Results.NotFound();
if (!Enum.TryParse<BeoordelingsBesluit>(body.Besluit, ignoreCase: true, out var besluit))
return Results.BadRequest(new { error = $"Unknown besluit '{body.Besluit}'. Expected 'goedkeuren' or 'afwijzen'." });
await beoordeel.HandleAsync(new BeoordeelRegistratieCommand(new RegistrationId(guid), besluit), ct);
return Results.NoContent();
});
// Withdraw a registration (S-11): the zorgprofessional pulls their own still-open submission back,
// advancing it to INGETROKKEN and cancelling its workflow. Owner-scoped by the caller's bsn (the BFF
// forwards it from the DigiD token, S-11c); a registration that is unknown or not the caller's is
// 404 (indistinguishable, so ownership isn't leaked). Idempotent.
app.MapPost("/registrations/{id}/withdraw", async (string id, WithdrawRequest body, WithdrawRegistration withdraw, CancellationToken ct) =>
{
if (!Guid.TryParse(id, out var guid))
return Results.NotFound();
if (string.IsNullOrWhiteSpace(body?.Bsn))
return Results.BadRequest(new { error = "A bsn is required to withdraw a registration." });
var outcome = await withdraw.HandleAsync(new WithdrawRegistrationCommand(new RegistrationId(guid), body.Bsn), ct);
return outcome == WithdrawOutcome.Withdrawn ? Results.NoContent() : Results.NotFound();
});
// The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open
// Beoordelen user tasks (§8.2) and enriched with bsn + status. The BFF proxies this behind
// medewerker-realm + behandelaar-role authorization; the domain trusts its callers (§8.3).
app.MapGet("/behandel/werkbak", async (Werkbak werkbak, CancellationToken ct) =>
Results.Ok(await werkbak.GetAsync(ct)));
// Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually).
app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) =>
{
@@ -114,10 +72,6 @@ await app.RunAsync();
public sealed record SubmitRegistrationRequest(string Bsn);
public sealed record DecideRequest(string Besluit);
public sealed record WithdrawRequest(string Bsn);
public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl);
public partial class Program;

View File

@@ -1,71 +0,0 @@
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);
}
}

View File

@@ -28,7 +28,7 @@ public sealed class OpenZaakWorker(IRegistrationStore store, IAclClient acl)
if (registration.ZaakUrl is not null)
return registration.ZaakUrl;
var zaakUrl = await acl.OpenZaakAsync(registration.Bsn, registration.Id.ToString(), ct);
var zaakUrl = await acl.OpenZaakAsync(registration.Bsn, ct);
registration.AttachZaak(zaakUrl);
await store.SaveAsync(registration, ct);
return zaakUrl;

View File

@@ -15,14 +15,6 @@ public interface IWorkflowClient
/// aggregate. Returns the process instance id.
/// </summary>
Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default);
/// <summary>
/// Cancel a running <c>registratie</c> process on withdrawal (S-11): correlate the
/// <c>RegistratieIngetrokken</c> message to the instance, tripping the interrupting message event
/// that ends it (ADR-0014). Best-effort — if the instance is not waiting on that message (already
/// ended, or not yet parked) it is a no-op; the aggregate is INGETROKKEN regardless.
/// </summary>
Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default);
}
/// <summary>
@@ -32,10 +24,7 @@ public interface IWorkflowClient
/// </summary>
public interface IAclClient
{
/// <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);
Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default);
/// <summary>
/// Record the approval on the given zaak. The ACL translates this to the ZGW concept — setting
@@ -44,30 +33,6 @@ public interface IAclClient
Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default);
}
/// <summary>
/// The port to the behandelaar's user tasks in the workflow engine (S-12). Implemented by the
/// Workflow Client — the only code that talks to Flowable (§8.2). The application lists the open
/// <c>Beoordelen</c> tasks (the werkbak), claims one for a behandelaar, and completes it with the
/// decision; it never names Flowable's REST shapes.
/// </summary>
public interface IUserTaskClient
{
/// <summary>The werkbak: the <c>Beoordelen</c> tasks awaiting a behandelaar, each with the
/// registration it belongs to.</summary>
Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default);
/// <summary>Claim a beoordeling task for a behandelaar (assigns it to them).</summary>
Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default);
/// <summary>Complete a beoordeling task, carrying the decision into the process as the
/// <c>besluit</c> variable so the workflow can continue on the chosen branch.</summary>
Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default);
}
/// <summary>A <c>Beoordelen</c> user task in the werkbak: the Flowable task id (needed to claim and
/// complete it) and the registration it carries as a process variable.</summary>
public sealed record BeoordelingTask(string TaskId, RegistrationId RegistrationId);
/// <summary>
/// Persistence port for the <see cref="Registration"/> aggregate. In-memory for the minimal slice
/// (ADR-0009); an EF-backed store is a documented follow-up, and this port keeps that change additive.

View File

@@ -1,40 +0,0 @@
using Big.Domain;
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, or whose registration is no longer open for beoordeling
/// (e.g. withdrawn — S-11 — while its task lingers until the workflow cancels it), is skipped.
/// </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 || !IsOpenForBeoordeling(registration.Status))
continue;
items.Add(new WerkbakItem(
registration.Id.ToString(), registration.Bsn, registration.Status.ToString()));
}
return items;
}
// Only registrations still open for a decision belong in the werkbak; a terminal one (decided or
// withdrawn — S-11) whose Beoordelen task has not yet been cleared must not surface to a behandelaar.
private static bool IsOpenForBeoordeling(RegistrationStatus status)
=> status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling;
}

View File

@@ -1,55 +0,0 @@
using Big.Domain;
namespace Big.Application;
/// <summary>A zorgprofessional's request to withdraw their own registration ("trek aanvraag in").
/// <paramref name="Bsn"/> is the authenticated caller (from the DigiD token, forwarded by the BFF):
/// only the registration's own bsn may withdraw it.</summary>
public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId, string Bsn);
/// <summary>The outcome of a withdrawal request.</summary>
public enum WithdrawOutcome
{
/// <summary>The registration is now (or already was) INGETROKKEN.</summary>
Withdrawn,
/// <summary>No registration with that id belongs to the caller — unknown, or owned by someone
/// else (the two are deliberately indistinguishable, so the endpoint reveals neither).</summary>
NotFound,
}
/// <summary>
/// The withdrawal use case (S-11): a zorgprofessional pulls a still-open registration back. It
/// advances the aggregate to INGETROKKEN, persists it, then cancels the running registratie process
/// by correlating the withdrawal message to its instance (ADR-0014), so the case leaves the
/// behandelaar's werkbak. Idempotent — a repeated or redelivered withdrawal of an already-withdrawn
/// registration is a no-op (not persisted or cancelled again). Cancelling is best-effort: if the
/// registration never started a process the withdrawal still stands (the process cancel is skipped),
/// mirroring how <see cref="BeoordeelRegistratie"/> completes its task best-effort.
/// </summary>
public sealed class WithdrawRegistration(IRegistrationStore store, IWorkflowClient workflow)
{
public async Task<WithdrawOutcome> HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(command);
var registration = await store.GetAsync(command.RegistrationId, ct);
// Unknown, or not the caller's registration: report NotFound either way (don't reveal which).
if (registration is null || registration.Bsn != command.Bsn)
return WithdrawOutcome.NotFound;
// A repeated withdrawal is a no-op: don't persist or cancel the already-withdrawn one again.
if (registration.Status == RegistrationStatus.Ingetrokken)
return WithdrawOutcome.Withdrawn;
registration.Withdraw();
await store.SaveAsync(registration, ct);
// Cancel the running process (if one was started) so its Beoordelen task leaves the werkbak.
if (registration.ProcessInstanceId is not null)
await workflow.WithdrawProcessAsync(registration.ProcessInstanceId, ct);
return WithdrawOutcome.Withdrawn;
}
}

View File

@@ -66,28 +66,10 @@ public sealed class Registration
}
/// <summary>
/// A behandelaar picks the registration up for beoordeling. Advances
/// <see cref="RegistrationStatus.Ingediend"/> → <see cref="RegistrationStatus.InBehandeling"/>.
/// Re-taking one already <see cref="RegistrationStatus.InBehandeling"/> is a no-op (the same or
/// another behandelaar re-opens it); a decided registration can no longer be taken into behandeling.
/// </summary>
public void TakeIntoBehandeling()
{
if (Status == RegistrationStatus.InBehandeling)
return;
if (Status != RegistrationStatus.Ingediend)
throw new InvalidOperationException(
$"Registration {Id} is {Status}; only an INGEDIEND registration can be taken into behandeling.");
Status = RegistrationStatus.InBehandeling;
}
/// <summary>
/// Approve the registration — the behandelaar's decision to enter it in the register. Advances a
/// submitted or in-behandeling registration to <see cref="RegistrationStatus.Ingeschreven"/>.
/// Requires an opened zaak (the approval sets that zaak's status via the ACL); a registration that
/// has already been decided cannot be approved again.
/// Approve the registration — the behandelaar's decision to enter it in the register. Advances
/// <see cref="RegistrationStatus.Ingediend"/> → <see cref="RegistrationStatus.Ingeschreven"/>.
/// Requires an opened zaak (the approval sets that zaak's status via the ACL), and only a
/// submitted registration can be approved: re-approving is rejected as an invalid transition.
/// </summary>
public void Approve()
{
@@ -95,43 +77,10 @@ public sealed class Registration
throw new InvalidOperationException(
$"Registration {Id} has no zaak yet; it cannot be approved before its zaak is opened.");
RequireOpenForDecision(nameof(Approve));
if (Status != RegistrationStatus.Ingediend)
throw new InvalidOperationException(
$"Registration {Id} is {Status}; only an INGEDIEND registration can be approved.");
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;
}
/// <summary>
/// Withdraw the registration — the zorgprofessional pulls their own submission back (S-11). Allowed
/// while it is still open (INGEDIEND or IN_BEHANDELING) and needs no zaak; a registration that has
/// already been decided (INGESCHREVEN/AFGEWEZEN) can no longer be withdrawn. Re-withdrawing one
/// already <see cref="RegistrationStatus.Ingetrokken"/> is a no-op.
/// </summary>
public void Withdraw()
{
if (Status == RegistrationStatus.Ingetrokken)
return;
RequireOpenForDecision(nameof(Withdraw));
Status = RegistrationStatus.Ingetrokken;
}
// A decision (or withdrawal) 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}).");
}
}

View File

@@ -1,24 +1,13 @@
namespace Big.Domain;
/// <summary>The lifecycle states a <see cref="Registration"/> moves through. Submission starts in
/// <see cref="Ingediend"/>; a behandelaar takes it <see cref="InBehandeling"/> and decides it into one
/// of the terminal states <see cref="Ingeschreven"/> (approved) or <see cref="Afgewezen"/> (rejected).
/// A zorgprofessional can withdraw a still-open registration into <see cref="Ingetrokken"/> (S-11).
/// The herregistratie state arrives in its own slice.</summary>
/// <summary>The lifecycle states a <see cref="Registration"/> moves through. The walking
/// skeleton knows <see cref="Ingediend"/> and the terminal <see cref="Ingeschreven"/>; withdrawal,
/// beoordeling and herregistratie states arrive in their own slices (Iteration 2+).</summary>
public enum RegistrationStatus
{
/// <summary>Submitted by the zorgprofessional; the registratie process has been started.</summary>
Ingediend,
/// <summary>Picked up by a behandelaar for beoordeling (S-12).</summary>
InBehandeling,
/// <summary>Approved: entered in the register. Terminal.</summary>
/// <summary>Approved: entered in the register. Terminal in the walking skeleton (S-09b).</summary>
Ingeschreven,
/// <summary>Rejected by the behandelaar. Terminal.</summary>
Afgewezen,
/// <summary>Withdrawn by the zorgprofessional before a decision (S-11). Terminal.</summary>
Ingetrokken,
}

View File

@@ -11,10 +11,10 @@ namespace Big.Infrastructure;
/// </summary>
public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclClient
{
public async Task<Uri> OpenZaakAsync(string bsn, string reference, CancellationToken ct = default)
public async Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default)
{
using var response = await http.PostAsJsonAsync(
new Uri(options.BaseUrl, "zaken"), new OpenZaakRequest(bsn, reference), ct);
new Uri(options.BaseUrl, "zaken"), new OpenZaakRequest(bsn), ct);
response.EnsureSuccessStatusCode();
var opened = await response.Content.ReadFromJsonAsync<OpenZaakResponse>(ct)
@@ -31,9 +31,7 @@ public sealed class AclHttpClient(HttpClient http, AclOptions options) : IAclCli
response.EnsureSuccessStatusCode();
}
private sealed record OpenZaakRequest(
[property: JsonPropertyName("bsn")] string Bsn,
[property: JsonPropertyName("reference")] string Reference);
private sealed record OpenZaakRequest([property: JsonPropertyName("bsn")] string Bsn);
private sealed record OpenZaakResponse([property: JsonPropertyName("zaakUrl")] string ZaakUrl);

View File

@@ -15,15 +15,12 @@ namespace Big.Infrastructure;
/// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009).
/// </summary>
public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options)
: IWorkflowClient, IExternalWorkerClient, IUserTaskClient
: IWorkflowClient, IExternalWorkerClient
{
private const string Topic = "OpenZaakAanmaken";
private const string ProcessDefinitionKey = "registratie";
private const string BeoordelenTaskKey = "Beoordelen";
private const string RegistrationIdVariable = "registrationId";
private const string ZaakUrlVariable = "zaakUrl";
private const string BesluitVariable = "besluit";
private const string IngetrokkenMessage = "RegistratieIngetrokken";
public async Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
{
@@ -58,63 +55,6 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
response.EnsureSuccessStatusCode();
}
public async Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default)
{
var request = new TaskQueryRequest(ProcessDefinitionKey, BeoordelenTaskKey, IncludeProcessVariables: true);
var page = await PostAsync<TaskQueryRequest, TaskQueryResult>(
"service/query/tasks", request, ct);
var tasks = page?.Data ?? [];
return [.. tasks.Select(t => new BeoordelingTask(t.Id, RegistrationId.Parse(t.RegistrationId())))];
}
public async Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default)
{
using var response = await SendAsync(
$"service/runtime/tasks/{taskId}", new ClaimTaskRequest("claim", behandelaar), ct);
response.EnsureSuccessStatusCode();
}
public async Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default)
{
var request = new CompleteTaskRequest(
"complete",
[new Variable(BesluitVariable, "string", besluit.ToString().ToLowerInvariant())]);
using var response = await SendAsync($"service/runtime/tasks/{taskId}", request, ct);
response.EnsureSuccessStatusCode();
}
public async Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default)
{
// Correlate the withdrawal message to the instance: find the execution subscribed to it (the
// interrupting message event's own execution — NOT the user task's), then deliver
// messageEventReceived to that execution so the process ends (ADR-0014). If nothing is
// subscribed (the process is not parked at Beoordelen) this is a best-effort no-op.
var subscribed = await GetAsync<ExecutionQueryResult>(
$"service/runtime/executions?messageEventSubscriptionName={IngetrokkenMessage}&processInstanceId={processInstanceId}",
ct);
var execution = subscribed?.Data?.FirstOrDefault();
if (execution is null)
return;
var request = new MessageEventRequest("messageEventReceived", IngetrokkenMessage);
using var response = await SendAsync(
$"service/runtime/executions/{execution.Id}", request, ct, HttpMethod.Put);
response.EnsureSuccessStatusCode();
}
private async Task<TResponse?> GetAsync<TResponse>(string path, CancellationToken ct)
{
var message = new HttpRequestMessage(HttpMethod.Get, new Uri(options.BaseUrl, path));
message.Headers.Authorization = new AuthenticationHeaderValue("Basic", BasicCredentials());
using var response = await http.SendAsync(message, ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<TResponse>(ct);
}
private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct)
{
using var response = await SendAsync(path, body, ct);
@@ -122,9 +62,9 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
return await response.Content.ReadFromJsonAsync<TResponse>(ct);
}
private Task<HttpResponseMessage> SendAsync<TRequest>(string path, TRequest body, CancellationToken ct, HttpMethod? method = null)
private Task<HttpResponseMessage> SendAsync<TRequest>(string path, TRequest body, CancellationToken ct)
{
var message = new HttpRequestMessage(method ?? HttpMethod.Post, new Uri(options.BaseUrl, path))
var message = new HttpRequestMessage(HttpMethod.Post, new Uri(options.BaseUrl, path))
{
Content = JsonContent.Create(body),
};
@@ -149,38 +89,6 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
[property: JsonPropertyName("workerId")] string WorkerId,
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables);
private sealed record TaskQueryRequest(
[property: JsonPropertyName("processDefinitionKey")] string ProcessDefinitionKey,
[property: JsonPropertyName("taskDefinitionKey")] string TaskDefinitionKey,
[property: JsonPropertyName("includeProcessVariables")] bool IncludeProcessVariables);
private sealed record ClaimTaskRequest(
[property: JsonPropertyName("action")] string Action,
[property: JsonPropertyName("assignee")] string Assignee);
private sealed record CompleteTaskRequest(
[property: JsonPropertyName("action")] string Action,
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables);
private sealed record MessageEventRequest(
[property: JsonPropertyName("action")] string Action,
[property: JsonPropertyName("messageName")] string MessageName);
private sealed record TaskQueryResult(
[property: JsonPropertyName("data")] IReadOnlyList<UserTaskDto>? Data);
private sealed record UserTaskDto(
[property: JsonPropertyName("id")] string Id,
// Flowable's task-query returns the (included) process variables under "variables", not
// "processVariables"; the request opts in via includeProcessVariables.
[property: JsonPropertyName("variables")] IReadOnlyList<Variable>? Variables)
{
/// <summary>The registration id this task carries as a process variable.</summary>
public string RegistrationId() =>
Variables?.SingleOrDefault(v => v.Name == "registrationId")?.Value
?? throw new InvalidOperationException($"Beoordelen task {Id} carries no registrationId variable.");
}
private sealed record Variable(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("type")] string Type,
@@ -188,11 +96,6 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
private sealed record ProcessInstance([property: JsonPropertyName("id")] string Id);
private sealed record ExecutionQueryResult(
[property: JsonPropertyName("data")] IReadOnlyList<ExecutionDto>? Data);
private sealed record ExecutionDto([property: JsonPropertyName("id")] string Id);
private sealed record AcquiredJob(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables)

View File

@@ -15,13 +15,12 @@ public class AclHttpClientTests
var client = Client(capture.Responds(HttpStatusCode.OK,
"""{"zaakUrl":"http://openzaak/zaken/api/v1/zaken/abc"}"""));
var url = await client.OpenZaakAsync("123456782", "reg-42");
var url = await client.OpenZaakAsync("123456782");
Assert.Equal("http://openzaak/zaken/api/v1/zaken/abc", url.ToString());
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://acl/zaken", capture.Seen.RequestUri!.ToString());
Assert.Contains("\"bsn\":\"123456782\"", capture.Body);
Assert.Contains("\"reference\":\"reg-42\"", capture.Body);
}
[Fact]
@@ -30,7 +29,7 @@ public class AclHttpClientTests
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.BadGateway));
await Assert.ThrowsAsync<HttpRequestException>(() => client.OpenZaakAsync("123456782", "reg-1"));
await Assert.ThrowsAsync<HttpRequestException>(() => client.OpenZaakAsync("123456782"));
}
[Fact]
@@ -39,7 +38,7 @@ public class AclHttpClientTests
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK, "null"));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => client.OpenZaakAsync("123456782", "reg-1"));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => client.OpenZaakAsync("123456782"));
Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase);
}

View File

@@ -1,168 +0,0 @@
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);
}
}

View File

@@ -32,7 +32,6 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
: IWorkflowClient
{
public RegistrationId? StartedFor { get; private set; }
public string? WithdrawnProcessInstanceId { get; private set; }
public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
{
@@ -40,35 +39,6 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
StartedFor = registrationId;
return Task.FromResult(processInstanceId);
}
public Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default)
{
WithdrawnProcessInstanceId = processInstanceId;
return Task.CompletedTask;
}
}
/// <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
@@ -80,17 +50,15 @@ internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient
private readonly Uri _zaakUrl = zaakUrl ?? DefaultZaakUrl;
public string? OpenedForBsn { get; private set; }
public string? OpenedWithReference { get; private set; }
public int CallCount { get; private set; }
public Uri? ApprovedZaakUrl { get; private set; }
public int ApproveCallCount { get; private set; }
public Task<Uri> OpenZaakAsync(string bsn, string reference, CancellationToken ct = default)
public Task<Uri> OpenZaakAsync(string bsn, CancellationToken ct = default)
{
CallCount++;
OpenedForBsn = bsn;
OpenedWithReference = reference;
return Task.FromResult(_zaakUrl);
}

View File

@@ -1,6 +1,5 @@
using System.Net;
using System.Text;
using Big.Application;
using Big.Domain;
using Big.Infrastructure;
@@ -128,17 +127,6 @@ public class FlowableWorkflowClientTests
() => client.StartRegistrationProcessAsync(RegistrationId.New()));
}
[Fact]
public async Task Start_throws_when_flowable_returns_an_empty_body()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.Created, "null"));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => client.StartRegistrationProcessAsync(RegistrationId.New()));
Assert.Contains("empty process-instance", ex.Message);
}
[Fact]
public async Task Complete_throws_when_flowable_rejects_the_request()
{
@@ -148,162 +136,4 @@ public class FlowableWorkflowClientTests
await Assert.ThrowsAsync<HttpRequestException>(
() => client.CompleteOpenZaakJobAsync("job-1", new Uri("http://openzaak/zaken/api/v1/zaken/abc")));
}
// ── S-12b: behandelaar user tasks (werkbak / claim / complete) ────────────────────────────────
[Fact]
public async Task Open_beoordelingen_queries_beoordelen_tasks_with_their_registration_id()
{
var rid = RegistrationId.New();
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK,
$$"""
{"data":[{"id":"task-1","variables":[{"name":"registrationId","type":"string","value":"{{rid}}"}]}],"total":1}
"""));
var tasks = await client.GetOpenBeoordelingenAsync();
var task = Assert.Single(tasks);
Assert.Equal("task-1", task.TaskId);
Assert.Equal(rid, task.RegistrationId);
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://flowable/flowable-rest/service/query/tasks",
capture.Seen.RequestUri!.ToString());
Assert.Equal("rest-admin:test", DecodeBasic(capture.Seen));
Assert.Contains("\"processDefinitionKey\":\"registratie\"", capture.Body);
Assert.Contains("\"taskDefinitionKey\":\"Beoordelen\"", capture.Body);
Assert.Contains("\"includeProcessVariables\":true", capture.Body);
}
[Theory]
[InlineData("""{"data":[],"total":0}""")]
[InlineData("""{"data":null,"total":0}""")]
public async Task Open_beoordelingen_is_empty_when_no_tasks_are_waiting(string body)
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK, body));
Assert.Empty(await client.GetOpenBeoordelingenAsync());
Assert.NotNull(capture.Seen);
}
[Fact]
public async Task Open_beoordelingen_throws_when_a_task_carries_no_registration_id()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK,
"""{"data":[{"id":"task-1","variables":[]}],"total":1}"""));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => client.GetOpenBeoordelingenAsync());
Assert.Contains("task-1", ex.Message);
Assert.Contains("registrationId", ex.Message);
}
[Fact]
public async Task Claim_assigns_the_task_to_the_behandelaar()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK));
await client.ClaimAsync("task-1", "merel-behandelaar");
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://flowable/flowable-rest/service/runtime/tasks/task-1",
capture.Seen.RequestUri!.ToString());
Assert.Contains("\"action\":\"claim\"", capture.Body);
Assert.Contains("\"assignee\":\"merel-behandelaar\"", capture.Body);
}
[Fact]
public async Task Claim_throws_when_flowable_rejects_the_request()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.Conflict));
await Assert.ThrowsAsync<HttpRequestException>(() => client.ClaimAsync("task-1", "merel-behandelaar"));
}
[Theory]
[InlineData(BeoordelingsBesluit.Goedkeuren, "goedkeuren")]
[InlineData(BeoordelingsBesluit.Afwijzen, "afwijzen")]
public async Task Complete_posts_the_besluit_variable_to_the_task(BeoordelingsBesluit besluit, string expected)
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK));
await client.CompleteBeoordelingAsync("task-1", besluit);
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://flowable/flowable-rest/service/runtime/tasks/task-1",
capture.Seen.RequestUri!.ToString());
Assert.Contains("\"action\":\"complete\"", capture.Body);
Assert.Contains("\"name\":\"besluit\"", capture.Body);
Assert.Contains("\"type\":\"string\"", capture.Body);
Assert.Contains($"\"value\":\"{expected}\"", capture.Body);
}
[Fact]
public async Task Withdraw_process_correlates_the_message_to_the_subscribed_execution()
{
HttpRequestMessage? getReq = null;
HttpRequestMessage? putReq = null;
string? putBody = null;
var client = Client(new StubHandler(async req =>
{
if (req.Method == HttpMethod.Get)
{
getReq = req;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"data":[{"id":"exec-9"}]}""", Encoding.UTF8, "application/json"),
};
}
putReq = req;
putBody = await req.Content!.ReadAsStringAsync();
return new HttpResponseMessage(HttpStatusCode.OK);
}));
await client.WithdrawProcessAsync("pi-1");
// 1. Find the execution subscribed to the withdrawal message for this instance.
Assert.Equal(HttpMethod.Get, getReq!.Method);
Assert.Contains("service/runtime/executions", getReq.RequestUri!.ToString());
Assert.Contains("messageEventSubscriptionName=RegistratieIngetrokken", getReq.RequestUri!.Query);
Assert.Contains("processInstanceId=pi-1", getReq.RequestUri!.Query);
// 2. Deliver messageEventReceived to that execution (PUT), tripping the interrupting event.
Assert.Equal(HttpMethod.Put, putReq!.Method);
Assert.Equal("http://flowable/flowable-rest/service/runtime/executions/exec-9",
putReq.RequestUri!.ToString());
Assert.Contains("\"action\":\"messageEventReceived\"", putBody);
Assert.Contains("\"messageName\":\"RegistratieIngetrokken\"", putBody);
}
[Fact]
public async Task Withdraw_process_is_a_no_op_when_no_execution_is_subscribed()
{
var methods = new List<HttpMethod>();
var client = Client(new StubHandler(req =>
{
methods.Add(req.Method);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"data":[]}""", Encoding.UTF8, "application/json"),
});
}));
await client.WithdrawProcessAsync("pi-1");
// No subscribed execution → no message delivered (best-effort), no throw.
Assert.DoesNotContain(HttpMethod.Put, methods);
}
[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));
}
}

View File

@@ -25,9 +25,6 @@ public class OpenZaakWorkerTests
Assert.Equal(FakeAclClient.DefaultZaakUrl, zaakUrl);
Assert.Equal("123456782", acl.OpenedForBsn);
// The registration's own id is handed to the ACL as the zaak reference (identificatie),
// so the openbaar register can show the reference the zorgprofessional received (#78).
Assert.Equal(registration.Id.ToString(), acl.OpenedWithReference);
var saved = await store.GetAsync(registration.Id);
Assert.Equal(FakeAclClient.DefaultZaakUrl, saved!.ZaakUrl);
Assert.Equal(1, store.SaveCount);

View File

@@ -121,168 +121,4 @@ public class RegistrationTests
Assert.Contains("only an INGEDIEND", ex.Message);
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
}
[Fact]
public void Taking_a_registration_into_behandeling_moves_it_to_in_behandeling()
{
var registration = Registration.Submit("123456782");
registration.TakeIntoBehandeling();
Assert.Equal(RegistrationStatus.InBehandeling, registration.Status);
}
[Fact]
public void Re_taking_an_in_behandeling_registration_is_idempotent()
{
var registration = Registration.Submit("123456782");
registration.TakeIntoBehandeling();
registration.TakeIntoBehandeling();
Assert.Equal(RegistrationStatus.InBehandeling, registration.Status);
}
[Fact]
public void Taking_a_decided_registration_into_behandeling_is_rejected()
{
var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve();
var ex = Assert.Throws<InvalidOperationException>(() => registration.TakeIntoBehandeling());
Assert.Contains("only an INGEDIEND", ex.Message);
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
}
[Fact]
public void Approving_an_in_behandeling_registration_with_a_zaak_sets_it_ingeschreven()
{
var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.TakeIntoBehandeling();
registration.Approve();
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
}
[Fact]
public void Rejecting_a_registration_sets_it_afgewezen()
{
var registration = Registration.Submit("123456782");
registration.Reject();
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
}
[Fact]
public void Rejecting_needs_no_zaak()
{
// A registration can be turned down before its zaak is ever opened, so Reject must not require one.
var registration = Registration.Submit("123456782");
registration.Reject();
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
Assert.Null(registration.ZaakUrl);
}
[Fact]
public void Rejecting_an_in_behandeling_registration_sets_it_afgewezen()
{
var registration = Registration.Submit("123456782");
registration.TakeIntoBehandeling();
registration.Reject();
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
}
[Fact]
public void Deciding_an_already_decided_registration_is_rejected()
{
var registration = Registration.Submit("123456782");
registration.Reject();
var approveEx = Assert.Throws<InvalidOperationException>(() =>
{
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve();
});
Assert.Contains("IN_BEHANDELING", approveEx.Message);
var rejectEx = Assert.Throws<InvalidOperationException>(() => registration.Reject());
Assert.Contains("Afgewezen", rejectEx.Message);
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
}
[Fact]
public void Withdrawing_an_ingediend_registration_sets_it_ingetrokken()
{
var registration = Registration.Submit("123456782");
registration.Withdraw();
Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status);
}
[Fact]
public void Withdrawing_an_in_behandeling_registration_sets_it_ingetrokken()
{
// A citizen can still pull a registration back while a behandelaar has it in behandeling.
var registration = Registration.Submit("123456782");
registration.TakeIntoBehandeling();
registration.Withdraw();
Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status);
}
[Fact]
public void Withdrawing_needs_no_zaak()
{
// Withdrawal is the citizen's own action and does not depend on the zaak having been opened.
var registration = Registration.Submit("123456782");
registration.Withdraw();
Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status);
Assert.Null(registration.ZaakUrl);
}
[Fact]
public void Re_withdrawing_an_already_ingetrokken_registration_is_idempotent()
{
var registration = Registration.Submit("123456782");
registration.Withdraw();
registration.Withdraw();
Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status);
}
[Fact]
public void Withdrawing_an_approved_registration_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.Withdraw());
Assert.Contains("only an INGEDIEND", ex.Message);
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
}
[Fact]
public void Withdrawing_a_rejected_registration_is_rejected()
{
var registration = Registration.Submit("123456782");
registration.Reject();
var ex = Assert.Throws<InvalidOperationException>(() => registration.Withdraw());
Assert.Contains("only an INGEDIEND", ex.Message);
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
}
}

View File

@@ -1,64 +0,0 @@
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());
}
[Fact]
public async Task Skips_a_task_whose_registration_is_no_longer_open()
{
// A withdrawn registration (S-11) may still have a Beoordelen task lingering until the workflow
// cancels it; the werkbak lists only registrations still open for beoordeling, so it drops off.
var store = new FakeRegistrationStore();
var registration = Registration.Submit("123456782");
registration.Withdraw();
store.Seed(registration);
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", registration.Id)]);
var werkbak = new Werkbak(tasks, store);
Assert.Empty(await werkbak.GetAsync());
}
}

View File

@@ -1,123 +0,0 @@
using Big.Application;
using Big.Domain;
namespace Big.Tests;
public class WithdrawRegistrationTests
{
private const string Bsn = "123456782";
private static Registration Submitted(string processInstanceId = "proc-1")
{
var registration = Registration.Submit(Bsn);
registration.RecordProcessStarted(processInstanceId);
return registration;
}
private static WithdrawRegistrationCommand Command(RegistrationId id, string bsn = Bsn) => new(id, bsn);
[Fact]
public async Task Withdrawing_marks_the_registration_ingetrokken_and_persists_it()
{
var store = new FakeRegistrationStore();
var registration = Submitted();
store.Seed(registration);
var handler = new WithdrawRegistration(store, new FakeWorkflowClient());
var outcome = await handler.HandleAsync(Command(registration.Id));
Assert.Equal(WithdrawOutcome.Withdrawn, outcome);
var saved = await store.GetAsync(registration.Id);
Assert.Equal(RegistrationStatus.Ingetrokken, saved!.Status);
Assert.Equal(1, store.SaveCount);
}
[Fact]
public async Task Withdrawing_cancels_the_running_process()
{
var store = new FakeRegistrationStore();
var registration = Submitted("proc-42");
store.Seed(registration);
var workflow = new FakeWorkflowClient();
var handler = new WithdrawRegistration(store, workflow);
await handler.HandleAsync(Command(registration.Id));
// The process the registration recorded at submit is cancelled (ADR-0014).
Assert.Equal("proc-42", workflow.WithdrawnProcessInstanceId);
}
[Fact]
public async Task Withdrawing_before_a_process_was_started_still_marks_ingetrokken()
{
var store = new FakeRegistrationStore();
var registration = Registration.Submit(Bsn); // no RecordProcessStarted
store.Seed(registration);
var workflow = new FakeWorkflowClient();
var handler = new WithdrawRegistration(store, workflow);
await handler.HandleAsync(Command(registration.Id));
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
Assert.Null(workflow.WithdrawnProcessInstanceId);
}
[Fact]
public async Task A_different_bsn_cannot_withdraw_the_registration()
{
// Owner-scoping: only the zorgprofessional who submitted may withdraw. Another bsn is told
// NotFound (we don't reveal the registration exists) and nothing is changed.
var store = new FakeRegistrationStore();
var registration = Submitted();
store.Seed(registration);
var workflow = new FakeWorkflowClient();
var handler = new WithdrawRegistration(store, workflow);
var outcome = await handler.HandleAsync(Command(registration.Id, bsn: "999999990"));
Assert.Equal(WithdrawOutcome.NotFound, outcome);
Assert.Equal(RegistrationStatus.Ingediend, (await store.GetAsync(registration.Id))!.Status);
Assert.Equal(0, store.SaveCount);
Assert.Null(workflow.WithdrawnProcessInstanceId);
}
[Fact]
public async Task Rejects_a_null_command_without_touching_the_store()
{
var store = new FakeRegistrationStore();
var handler = new WithdrawRegistration(store, new FakeWorkflowClient());
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
Assert.Equal(0, store.SaveCount);
}
[Fact]
public async Task Withdrawing_an_unknown_registration_is_not_found()
{
var store = new FakeRegistrationStore();
var handler = new WithdrawRegistration(store, new FakeWorkflowClient());
var outcome = await handler.HandleAsync(Command(RegistrationId.New()));
Assert.Equal(WithdrawOutcome.NotFound, outcome);
Assert.Equal(0, store.SaveCount);
}
[Fact]
public async Task Re_withdrawing_an_already_ingetrokken_registration_is_idempotent()
{
var store = new FakeRegistrationStore();
var registration = Submitted();
store.Seed(registration);
var workflow = new FakeWorkflowClient();
var handler = new WithdrawRegistration(store, workflow);
await handler.HandleAsync(Command(registration.Id));
var second = await handler.HandleAsync(Command(registration.Id));
// The second withdrawal is a no-op: still Withdrawn, but the aggregate is not persisted again.
Assert.Equal(WithdrawOutcome.Withdrawn, second);
Assert.Equal(1, store.SaveCount);
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
}
}

View File

@@ -1,30 +0,0 @@
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);
}

View File

@@ -11,15 +11,9 @@ var connectionString = builder.Configuration.GetConnectionString("Projection")
// this value (ADR-0007), so the webhook enforces it.
var webhookToken = builder.Configuration["EventSubscriber:Webhook:AuthToken"]
?? throw new InvalidOperationException("Missing configuration 'EventSubscriber:Webhook:AuthToken'");
// The ACL is the only code that may read ZGW (§8.1); the subscriber enriches the projection with the
// zaak reference through it (adr-proposal #78).
var aclBaseUrl = builder.Configuration["Acl:BaseUrl"]
?? throw new InvalidOperationException("Missing configuration 'Acl:BaseUrl'");
builder.Services.AddProjectionReadModel(connectionString);
builder.Services.AddProjectionWriteSide();
builder.Services.AddHttpClient<EventSubscriber.Application.IAclClient, EventSubscriber.Api.AclHttpClient>(
c => c.BaseAddress = new Uri(aclBaseUrl));
var app = builder.Build();

View File

@@ -24,12 +24,10 @@ public sealed record Notification(
public bool IsZaakStatusSet =>
Kanaal == "zaken" && Resource == "status" && Actie == "create";
/// <summary>The zaak URL this notification concerns — <c>hoofdObject</c> (the zaak) for a status
/// notification, else the resource URL (which, for a zaak-create, is the zaak).</summary>
public Uri ZaakUrl => HoofdObject ?? ResourceUrl;
/// <summary>The zaak UUID used as the projection key — the trailing segment of <see cref="ZaakUrl"/>.</summary>
public string ZaakId => ZaakUrl.Segments[^1].Trim('/');
/// <summary>The zaak UUID used as the projection key. For a status notification the resource URL is
/// the status, so the zaak comes from <c>hoofdObject</c> (which, for a zaak-create, equals the
/// resource URL) — see the NRC payload note.</summary>
public string ZaakId => (HoofdObject ?? ResourceUrl).Segments[^1].Trim('/');
/// <summary>
/// A deterministic dedup key. Open Notificaties carries no notification id and may

View File

@@ -5,19 +5,17 @@ namespace EventSubscriber.Application;
/// out-of-order deliveries (CLAUDE.md §8.6): the notification log dedups, and the projection
/// upsert is idempotent on the zaak id. Rebuilds the projection by replaying the log.
/// </summary>
public sealed class NotificationProjector(INotificationLog log, IProjectionStore store, IAclClient acl)
public sealed class NotificationProjector(INotificationLog log, IProjectionStore store)
{
/// <summary>Handle one inbound notification. Reacts to a zaak being created (INGEDIEND) and a
/// 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>
/// status being set (INGESCHREVEN); ignores everything else.</summary>
public async Task HandleAsync(Notification notification, CancellationToken ct = default)
{
if (!notification.IsZaakCreated && !notification.IsZaakStatusSet)
return;
var reference = await acl.GetZaakReferenceAsync(notification.ZaakUrl, ct);
var recorded = new RecordedNotification(
notification.IdempotencyKey, notification.Actie, notification.ZaakId, notification.Resource, reference);
notification.IdempotencyKey, notification.Actie, notification.ZaakId, notification.Resource);
// 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.
@@ -38,8 +36,7 @@ public sealed class NotificationProjector(INotificationLog log, IProjectionStore
/// <summary>The projection row for an accepted notification: a status-set maps to INGESCHREVEN,
/// a zaak-create to INGEDIEND. bsn/naam are deferred (ADR-0008).</summary>
private static RegisterEntry ToEntry(RecordedNotification recorded)
=> new(
recorded.ZaakId,
recorded.Resource == "status" ? RegistrationStatus.Ingeschreven : RegistrationStatus.Ingediend,
Reference: recorded.Reference);
=> new(recorded.ZaakId, recorded.Resource == "status"
? RegistrationStatus.Ingeschreven
: RegistrationStatus.Ingediend);
}

View File

@@ -20,20 +20,9 @@ public interface INotificationLog
}
/// <summary>A notification that has been accepted, retaining what a rebuild needs to recompute its
/// projection row — the ZGW <c>resource</c> (zaak-create INGEDIEND vs status-set → INGESCHREVEN) and
/// the zaak <c>reference</c> (identificatie), so a rebuild reproduces the row without re-reading ZGW (#78).</summary>
public sealed record RecordedNotification(string Key, string Actie, string ZaakId, string Resource, string? Reference);
/// <summary>
/// Port to the Anti-Corruption Layer. The subscriber enriches the projection with the zaak's
/// public-safe reference (its identificatie) by asking the ACL — the only code that may read ZGW
/// (§8.1) — rather than reading OpenZaak itself (adr-proposal #78).
/// </summary>
public interface IAclClient
{
/// <summary>The zaak's reference (identificatie) for the read projection.</summary>
Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default);
}
/// projection row — including the ZGW <c>resource</c>, which distinguishes a zaak-create (INGEDIEND)
/// from a status-set (INGESCHREVEN) so a rebuild reproduces the right status.</summary>
public sealed record RecordedNotification(string Key, string Actie, string ZaakId, string Resource);
/// <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>

View File

@@ -9,7 +9,6 @@ namespace EventSubscriber.Application;
public sealed record RegisterEntry(
string Id,
string Status,
string? Reference = null,
string? Bsn = null,
string? NaamPlaceholder = null);

View File

@@ -1,86 +0,0 @@
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;
});
}

View File

@@ -19,7 +19,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EventSubscriber.Api\EventSubscriber.Api.csproj" />
<ProjectReference Include="..\EventSubscriber.Application\EventSubscriber.Application.csproj" />
</ItemGroup>

View File

@@ -5,19 +5,6 @@ namespace EventSubscriber.Tests;
/// <summary>In-memory stand-ins for the projection store and notification log, so the
/// projector's behaviour is exercised without Postgres (hand-written stubs, the repo's
/// convention — no mocking library).</summary>
/// <summary>A fake ACL client that returns a fixed reference derived from the zaak, and records
/// how many times it was called (to prove a rebuild does not re-read via the ACL).</summary>
internal sealed class FakeAclClient : IAclClient
{
public int CallCount { get; private set; }
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
{
CallCount++;
return Task.FromResult("REG-" + zaakUrl.Segments[^1].Trim('/'));
}
}
internal sealed class InMemoryNotificationLog : INotificationLog
{
private readonly Dictionary<string, RecordedNotification> _byKey = [];

View File

@@ -12,9 +12,8 @@ public sealed class NotificationProjectorTests
private readonly InMemoryNotificationLog _log = new();
private readonly InMemoryProjectionStore _store = new();
private readonly FakeAclClient _acl = new();
private NotificationProjector Projector() => new(_log, _store, _acl);
private NotificationProjector Projector() => new(_log, _store);
private static Notification ZaakCreated(string url = ZaakUrl)
=> new("zaken", "zaak", "create", new Uri(url));
@@ -31,23 +30,6 @@ public sealed class NotificationProjectorTests
var entry = Assert.Single(await _store.AllAsync());
Assert.Equal("11111111-1111-1111-1111-111111111111", entry.Id);
Assert.Equal(RegistrationStatus.Ingediend, entry.Status);
// Enriched with the zaak's reference (identificatie), fetched via the ACL (#78).
Assert.Equal("REG-11111111-1111-1111-1111-111111111111", entry.Reference);
}
[Fact]
public async Task rebuild_reproduces_the_reference_without_re_reading_via_the_acl()
{
var projector = Projector();
await projector.HandleAsync(ZaakCreated());
var callsAfterProjection = _acl.CallCount;
await projector.RebuildAsync();
var entry = Assert.Single(await _store.AllAsync());
Assert.Equal("REG-11111111-1111-1111-1111-111111111111", entry.Reference);
// Rebuild replays the log (which stored the reference) — no extra ACL calls (#78, ADR-0008).
Assert.Equal(callsAfterProjection, _acl.CallCount);
}
[Fact]

View File

@@ -16,7 +16,6 @@ public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog
Actie = notification.Actie,
ZaakId = notification.ZaakId,
Resource = notification.Resource,
Reference = notification.Reference,
ReceivedAt = DateTimeOffset.UtcNow,
});
@@ -36,6 +35,6 @@ public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog
public async Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default)
=> await db.ProcessedNotifications
.OrderBy(r => r.ReceivedAt)
.Select(r => new RecordedNotification(r.Key, r.Actie, r.ZaakId, r.Resource, r.Reference))
.Select(r => new RecordedNotification(r.Key, r.Actie, r.ZaakId, r.Resource))
.ToListAsync(ct);
}

View File

@@ -15,7 +15,6 @@ public sealed class EfProjectionStore(ProjectionDbContext db) : IProjectionStore
{
Id = entry.Id,
Status = entry.Status,
Reference = entry.Reference,
Bsn = entry.Bsn,
NaamPlaceholder = entry.NaamPlaceholder,
});
@@ -23,7 +22,6 @@ public sealed class EfProjectionStore(ProjectionDbContext db) : IProjectionStore
else
{
row.Status = entry.Status;
row.Reference = entry.Reference;
row.Bsn = entry.Bsn;
row.NaamPlaceholder = entry.NaamPlaceholder;
}
@@ -37,6 +35,6 @@ public sealed class EfProjectionStore(ProjectionDbContext db) : IProjectionStore
public async Task<IReadOnlyList<RegisterEntry>> AllAsync(CancellationToken ct = default)
=> await db.RegisterEntries
.OrderBy(r => r.Id)
.Select(r => new RegisterEntry(r.Id, r.Status, Reference: r.Reference, Bsn: r.Bsn, NaamPlaceholder: r.NaamPlaceholder))
.Select(r => new RegisterEntry(r.Id, r.Status, r.Bsn, r.NaamPlaceholder))
.ToListAsync(ct);
}

View File

@@ -1,92 +0,0 @@
// <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
}
}
}

View File

@@ -1,38 +0,0 @@
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");
}
}
}

View File

@@ -37,10 +37,6 @@ namespace Projection.ReadModel.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("received_at");
b.Property<string>("Reference")
.HasColumnType("text")
.HasColumnName("reference");
b.Property<string>("Resource")
.IsRequired()
.HasColumnType("text")
@@ -70,10 +66,6 @@ namespace Projection.ReadModel.Migrations
.HasColumnType("text")
.HasColumnName("naam_placeholder");
b.Property<string>("Reference")
.HasColumnType("text")
.HasColumnName("reference");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")

Some files were not shown because too many files have changed in this diff Show More