Compare commits
10 Commits
feat/13-be
...
feat/13-be
| Author | SHA1 | Date | |
|---|---|---|---|
| 52ba2fc242 | |||
| 2c88cb0db0 | |||
| 4e792c2d16 | |||
| 7b327a5601 | |||
| b4c4ffcd19 | |||
| 76fe414802 | |||
| a88584514c | |||
| 1d12d693ce | |||
| d226b6402d | |||
| 9c3da48d8e |
@@ -157,7 +157,7 @@ jobs:
|
|||||||
# Log dump must precede teardown (which removes the containers).
|
# Log dump must precede teardown (which removes the containers).
|
||||||
- name: Dump container logs on failure
|
- name: Dump container logs on failure
|
||||||
if: 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 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 openbaar behandel 2>&1 || true
|
||||||
- name: Tear down
|
- name: Tear down
|
||||||
if: always()
|
if: always()
|
||||||
run: make down
|
run: make down
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml
|
|||||||
# Long-running services with a healthcheck — the smoke polls these for readiness
|
# 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)
|
# (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.
|
# 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
|
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel
|
||||||
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
|
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
|
||||||
# into external named volumes via `docker cp` (infra/seed-config.sh) instead of
|
# 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
|
# bind-mounted, because bind mounts don't reach sibling containers on the
|
||||||
|
|||||||
23
apps/behandel/Dockerfile
Normal file
23
apps/behandel/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# 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
|
||||||
34
apps/behandel/eslint.config.mjs
Normal file
34
apps/behandel/eslint.config.mjs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
24
apps/behandel/nginx.conf
Normal file
24
apps/behandel/nginx.conf
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
80
apps/behandel/project.json
Normal file
80
apps/behandel/project.json
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
apps/behandel/public/config.json
Normal file
3
apps/behandel/public/config.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"authority": "http://localhost:8180/realms/medewerker"
|
||||||
|
}
|
||||||
BIN
apps/behandel/public/favicon.ico
Normal file
BIN
apps/behandel/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
73
apps/behandel/src/app/app.config.spec.ts
Normal file
73
apps/behandel/src/app/app.config.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
39
apps/behandel/src/app/app.config.ts
Normal file
39
apps/behandel/src/app/app.config.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
0
apps/behandel/src/app/app.css
Normal file
0
apps/behandel/src/app/app.css
Normal file
1
apps/behandel/src/app/app.html
Normal file
1
apps/behandel/src/app/app.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<router-outlet></router-outlet>
|
||||||
7
apps/behandel/src/app/app.routes.ts
Normal file
7
apps/behandel/src/app/app.routes.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Route } from '@angular/router';
|
||||||
|
import { authenticatedGuard } from 'auth';
|
||||||
|
import { WerkbakPage } from './werkbak/werkbak-page';
|
||||||
|
|
||||||
|
export const appRoutes: Route[] = [
|
||||||
|
{ path: '', component: WerkbakPage, canActivate: [authenticatedGuard] },
|
||||||
|
];
|
||||||
15
apps/behandel/src/app/app.spec.ts
Normal file
15
apps/behandel/src/app/app.spec.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
12
apps/behandel/src/app/app.ts
Normal file
12
apps/behandel/src/app/app.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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';
|
||||||
|
}
|
||||||
64
apps/behandel/src/app/werkbak/werkbak-page.html
Normal file
64
apps/behandel/src/app/werkbak/werkbak-page.html
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<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>
|
||||||
110
apps/behandel/src/app/werkbak/werkbak-page.spec.ts
Normal file
110
apps/behandel/src/app/werkbak/werkbak-page.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
65
apps/behandel/src/app/werkbak/werkbak-page.ts
Normal file
65
apps/behandel/src/app/werkbak/werkbak-page.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/behandel/src/index.html
Normal file
13
apps/behandel/src/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!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>
|
||||||
10
apps/behandel/src/main.ts
Normal file
10
apps/behandel/src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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));
|
||||||
2
apps/behandel/src/styles.css
Normal file
2
apps/behandel/src/styles.css
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
|
||||||
|
@import '@utrecht/design-tokens/dist/index.css';
|
||||||
9
apps/behandel/tsconfig.app.json
Normal file
9
apps/behandel/tsconfig.app.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||||
|
}
|
||||||
31
apps/behandel/tsconfig.json
Normal file
31
apps/behandel/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
8
apps/behandel/tsconfig.spec.json
Normal file
8
apps/behandel/tsconfig.spec.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"types": ["vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts"]
|
||||||
|
}
|
||||||
76
docs/architecture/adr-0013-behandel-portal-wiring.md
Normal file
76
docs/architecture/adr-0013-behandel-portal-wiring.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# ADR-0013: Behandel-portal wiring — multi-realm BFF auth, werkbak from Flowable tasks, decision completes the task
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-07-15
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** #84 (adr-proposal), S-12 (#13); builds on ADR-0010 (BFF OIDC), ADR-0011 (approval status flow), ADR-0009 (external-task worker), ADR-0008 (read projection)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
S-12 adds the behandel-portal: a behandelaar logs in, sees a **werkbak** of registrations awaiting
|
||||||
|
beoordeling, and decides each (goedkeuren/afwijzen). Three questions had no obvious answer and shape
|
||||||
|
the whole slice.
|
||||||
|
|
||||||
|
1. **Which realm authenticates behandelaars, and how does the BFF accept it?** Citizens use the
|
||||||
|
`digid` realm (ADR-0010); staff use a separate `medewerker` realm with roles (`behandelaar`,
|
||||||
|
`teamlead`). Keycloak realms are distinct issuers with distinct signing keys, so the BFF's single
|
||||||
|
`digid`-realm JWT validation rejects a medewerker token outright.
|
||||||
|
2. **Where does the werkbak get its data?** The registrations awaiting beoordeling could come from
|
||||||
|
the read projection (status-filtered rows) or from the Flowable `Beoordelen` user tasks (S-12b).
|
||||||
|
3. **How does a decision correlate to the workflow?** The process parks at the `Beoordelen` user
|
||||||
|
task; the decision must advance it, and also apply the domain transition (ADR-0011).
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**The BFF validates a second realm for behandel endpoints; the werkbak is the set of open Flowable
|
||||||
|
`Beoordelen` tasks (read through the domain); and a decision both applies the domain transition and
|
||||||
|
completes the Flowable task.**
|
||||||
|
|
||||||
|
- **Multi-realm BFF auth.** The BFF registers a second JWT bearer scheme (`medewerker`, authority =
|
||||||
|
the medewerker realm) alongside the default `digid` scheme. `/behandel/*` endpoints require an
|
||||||
|
authorization policy bound to the `medewerker` scheme **and** the `behandelaar` role. Keycloak puts
|
||||||
|
realm roles in the nested `realm_access.roles` claim, which ASP.NET does not map automatically, so
|
||||||
|
the scheme's `OnTokenValidated` lifts those roles onto the principal as role claims. Self-service
|
||||||
|
keeps the `digid` scheme. Audience validation stays off (ADR-0010's deferred hardening).
|
||||||
|
- **Werkbak = Flowable user tasks (via the domain).** The domain's `Werkbak` query reads the open
|
||||||
|
`Beoordelen` tasks from the Workflow Client (§8.2, `IUserTaskClient`) and enriches each with its
|
||||||
|
aggregate's bsn + status; `GET /behandel/werkbak` exposes it and the BFF proxies it behind the
|
||||||
|
behandelaar policy. The list **is** the authoritative set of claimable/decidable work items, so a
|
||||||
|
decision acts on a real task with no separate correlation store. The read projection stays the
|
||||||
|
anonymous openbaar model — we do **not** project `IN_BEHANDELING` or populate staff-only personal
|
||||||
|
data (both deferred in ADR-0008) just to render a staff view.
|
||||||
|
- **Decision completes the task (S-12c-2).** A behandelaar decision applies the domain transition
|
||||||
|
(aggregate + ACL for approval, per ADR-0011) **and** completes the Flowable `Beoordelen` task
|
||||||
|
(looked up by registrationId), so the process advances. Implemented in the next sub-slice; recorded
|
||||||
|
here so the boundary is decided up front.
|
||||||
|
|
||||||
|
Delivery is split: **S-12c-1** (this PR) = multi-realm auth + werkbak read; **S-12c-2** = the decide
|
||||||
|
endpoint + task completion.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- Staff and citizens are cleanly separated by realm; the `behandelaar` role gates the behandel API.
|
||||||
|
- The werkbak reflects exactly what a behandelaar can act on; claim/decide need no extra correlation.
|
||||||
|
- No premature projection changes — the openbaar read model stays focused and personal-data-free.
|
||||||
|
- Only the ACL/Workflow Client talk to their peers; the BFF still fans out only to domain/projection
|
||||||
|
(§8.3).
|
||||||
|
|
||||||
|
**Negative / costs**
|
||||||
|
|
||||||
|
- The BFF now depends on two Keycloak realms being reachable (`Keycloak:MedewerkerAuthority`).
|
||||||
|
- Rendering the werkbak fans out to Flowable (one task query) plus a store read per task — acceptable
|
||||||
|
for the caseload sizes here; a denormalized staff read model is an additive follow-up if needed.
|
||||||
|
- Realm separation (distinct issuers/keys) is validated live, not in the BFF unit tests, where issuer
|
||||||
|
validation is off and one test key signs both realms; the tests exercise the role-based authorization.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Werkbak from the read projection** — rejected for now: needs new plumbing to project
|
||||||
|
`IN_BEHANDELING` and to populate staff-only bsn/naam (deferred, ADR-0008), plus a separate way to
|
||||||
|
find the Flowable task at decide-time. Revisit if a high-volume denormalized staff view is needed.
|
||||||
|
- **One JWT scheme accepting both realms (issuer validation off)** — rejected: trusting multiple
|
||||||
|
issuers without validation is a security regression; two schemes keep each realm's issuer/key checked.
|
||||||
|
- **A dedicated behandel BFF/service** — rejected as premature; one BFF with per-endpoint policies is
|
||||||
|
enough at this size and keeps §8.3 simple.
|
||||||
@@ -248,3 +248,30 @@ curl -fsS "http://localhost:8140/openbaar/register?q=$ref" | jq
|
|||||||
|
|
||||||
> The openbaar register's "Referentie" column and its search now use this reference — the exact value
|
> 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.
|
> 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.
|
||||||
|
|||||||
@@ -117,3 +117,37 @@ 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.
|
(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
|
- **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.
|
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.
|
||||||
|
|||||||
@@ -349,6 +349,8 @@ services:
|
|||||||
# Keycloak (start-dev) derives the issuer from the request host, so the BFF authority and the
|
# Keycloak (start-dev) derives the issuer from the request host, so the BFF authority and the
|
||||||
# verify token request both use keycloak:8080 to keep the issuer consistent.
|
# verify token request both use keycloak:8080 to keep the issuer consistent.
|
||||||
Keycloak__Authority: http://keycloak:8080/realms/digid
|
Keycloak__Authority: http://keycloak:8080/realms/digid
|
||||||
|
# Behandelaars authenticate against the medewerker realm; the BFF validates it for /behandel/* (S-12c).
|
||||||
|
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
|
||||||
Downstream__Domain__BaseUrl: http://domain:8080/
|
Downstream__Domain__BaseUrl: http://domain:8080/
|
||||||
Downstream__Projection__BaseUrl: http://projection-api:8080/
|
Downstream__Projection__BaseUrl: http://projection-api:8080/
|
||||||
ports:
|
ports:
|
||||||
@@ -484,6 +486,29 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks: [cg]
|
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:
|
volumes:
|
||||||
oz-db:
|
oz-db:
|
||||||
nrc-db:
|
nrc-db:
|
||||||
|
|||||||
@@ -16,7 +16,22 @@
|
|||||||
"standardFlowEnabled": true,
|
"standardFlowEnabled": true,
|
||||||
"directAccessGrantsEnabled": true,
|
"directAccessGrantsEnabled": true,
|
||||||
"redirectUris": ["*"],
|
"redirectUris": ["*"],
|
||||||
"webOrigins": ["*"]
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"users": [
|
"users": [
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import {
|
|||||||
Observable
|
Observable
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
|
|
||||||
|
export interface DecideRequest {
|
||||||
|
besluit: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OpenbaarEntry {
|
export interface OpenbaarEntry {
|
||||||
id: string;
|
id: string;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -36,6 +40,12 @@ export interface SubmitAccepted {
|
|||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WerkbakItem {
|
||||||
|
registrationId: string;
|
||||||
|
bsn: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type GetOpenbaarRegisterParams = {
|
export type GetOpenbaarRegisterParams = {
|
||||||
q?: string;
|
q?: string;
|
||||||
};
|
};
|
||||||
@@ -215,4 +225,73 @@ export class BffApiV1Service {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getBehandelWerkbak<TData = WerkbakItem[]>( options?: HttpClientBodyOptions): Observable<TData>;
|
||||||
|
getBehandelWerkbak<TData = WerkbakItem[]>( options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||||
|
getBehandelWerkbak<TData = WerkbakItem[]>( options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||||
|
getBehandelWerkbak<TData = WerkbakItem[]>(
|
||||||
|
options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
||||||
|
if (options?.observe === 'events') {
|
||||||
|
return this.http.get<TData>(
|
||||||
|
`/behandel/werkbak`,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'events',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.observe === 'response') {
|
||||||
|
return this.http.get<TData>(
|
||||||
|
`/behandel/werkbak`,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'response',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.get<TData>(
|
||||||
|
`/behandel/werkbak`,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'body',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
postBehandelRegistrationsIdDecide<TData = void>(id: string,
|
||||||
|
decideRequest: DecideRequest, options?: HttpClientBodyOptions): Observable<TData>;
|
||||||
|
postBehandelRegistrationsIdDecide<TData = void>(id: string,
|
||||||
|
decideRequest: DecideRequest, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||||
|
postBehandelRegistrationsIdDecide<TData = void>(id: string,
|
||||||
|
decideRequest: DecideRequest, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||||
|
postBehandelRegistrationsIdDecide<TData = void>(
|
||||||
|
id: string,
|
||||||
|
decideRequest: DecideRequest, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
||||||
|
if (options?.observe === 'events') {
|
||||||
|
return this.http.post<TData>(
|
||||||
|
`/behandel/registrations/${id}/decide`,
|
||||||
|
decideRequest,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'events',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.observe === 'response') {
|
||||||
|
return this.http.post<TData>(
|
||||||
|
`/behandel/registrations/${id}/decide`,
|
||||||
|
decideRequest,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'response',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.post<TData>(
|
||||||
|
`/behandel/registrations/${id}/decide`,
|
||||||
|
decideRequest,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'body',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export * from './lib/auth.service';
|
export * from './lib/auth.service';
|
||||||
export * from './lib/digid-auth.service';
|
export * from './lib/digid-auth.service';
|
||||||
export * from './lib/digid-auth.providers';
|
export * from './lib/digid-auth.providers';
|
||||||
|
export * from './lib/medewerker-auth.service';
|
||||||
|
export * from './lib/medewerker-auth.providers';
|
||||||
export * from './lib/authenticated.guard';
|
export * from './lib/authenticated.guard';
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
import { Signal } from '@angular/core';
|
import { signal, Signal } from '@angular/core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The portal's view of the signed-in user. An abstraction over the OIDC library so components and
|
* 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 implementation is DigiadAuthService).
|
* guards depend on a small, mockable surface (the real implementations are DigiadAuthService for
|
||||||
|
* citizens and MedewerkerAuthService for staff).
|
||||||
*/
|
*/
|
||||||
export abstract class AuthService {
|
export abstract class AuthService {
|
||||||
/** Whether a DigiD session is active. */
|
/** Whether a session is active. */
|
||||||
abstract readonly isAuthenticated: Signal<boolean>;
|
abstract readonly isAuthenticated: Signal<boolean>;
|
||||||
/** The citizen-service number from the DigiD token, once authenticated. */
|
/** The citizen-service number from the DigiD token, once authenticated (staff have none). */
|
||||||
abstract readonly bsn: Signal<string | undefined>;
|
abstract readonly bsn: Signal<string | undefined>;
|
||||||
/** Start the DigiD login (redirects to Keycloak). */
|
/**
|
||||||
|
* 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). */
|
||||||
abstract login(): void;
|
abstract login(): void;
|
||||||
/** End the session. */
|
/** End the session. */
|
||||||
abstract logout(): void;
|
abstract logout(): void;
|
||||||
|
/** Whether the signed-in user holds the given realm role. */
|
||||||
|
hasRole(role: string): boolean {
|
||||||
|
return this.roles().includes(role);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
libs/auth/src/lib/medewerker-auth.providers.ts
Normal file
49
libs/auth/src/lib/medewerker-auth.providers.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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 },
|
||||||
|
]);
|
||||||
|
}
|
||||||
43
libs/auth/src/lib/medewerker-auth.service.spec.ts
Normal file
43
libs/auth/src/lib/medewerker-auth.service.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
41
libs/auth/src/lib/medewerker-auth.service.ts
Normal file
41
libs/auth/src/lib/medewerker-auth.service.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,10 +13,20 @@ public sealed record ProjectionEntry(string Id, string Status, string? Reference
|
|||||||
/// <summary>A public-safe openbaar register row — only non-sensitive fields leave the BFF.</summary>
|
/// <summary>A public-safe openbaar register row — only non-sensitive fields leave the BFF.</summary>
|
||||||
public sealed record OpenbaarEntry(string Id, string Status, string? Reference);
|
public sealed record OpenbaarEntry(string Id, string Status, string? Reference);
|
||||||
|
|
||||||
|
/// <summary>A behandelaar's werkbak row: a registration awaiting beoordeling, with the bsn + status a
|
||||||
|
/// behandelaar sees (staff view — reached only behind medewerker/behandelaar authorization, S-12c).</summary>
|
||||||
|
public sealed record WerkbakItem(string RegistrationId, string Bsn, string Status);
|
||||||
|
|
||||||
/// <summary>Port to the Domain Service (§8.3: the BFF is the portals' only backend; it fans out).</summary>
|
/// <summary>Port to the Domain Service (§8.3: the BFF is the portals' only backend; it fans out).</summary>
|
||||||
public interface IDomainClient
|
public interface IDomainClient
|
||||||
{
|
{
|
||||||
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
|
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary>
|
||||||
|
Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Apply a behandelaar's decision (<c>goedkeuren</c>/<c>afwijzen</c>) to a registration.</summary>
|
||||||
|
Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Port to the read projection.</summary>
|
/// <summary>Port to the read projection.</summary>
|
||||||
@@ -37,6 +47,16 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
|
|||||||
return new SubmitAccepted(dto.RegistrationId, dto.Status);
|
return new SubmitAccepted(dto.RegistrationId, dto.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||||
|
=> await http.GetFromJsonAsync<List<WerkbakItem>>("behandel/werkbak", ct) ?? [];
|
||||||
|
|
||||||
|
public async Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var response = await http.PostAsJsonAsync(
|
||||||
|
$"registrations/{registrationId}/decide", new { besluit }, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record DomainResponse(string RegistrationId, string Status, string? ZaakUrl);
|
private sealed record DomainResponse(string RegistrationId, string Status, string? ZaakUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
using Bff.Api;
|
using Bff.Api;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
|
||||||
@@ -6,6 +8,10 @@ var builder = WebApplication.CreateBuilder(args);
|
|||||||
|
|
||||||
var keycloakAuthority = builder.Configuration["Keycloak:Authority"]
|
var keycloakAuthority = builder.Configuration["Keycloak:Authority"]
|
||||||
?? throw new InvalidOperationException("Missing configuration 'Keycloak:Authority'");
|
?? throw new InvalidOperationException("Missing configuration 'Keycloak:Authority'");
|
||||||
|
// Behandelaars authenticate against a *different* Keycloak realm (medewerker) than citizens (digid),
|
||||||
|
// so the BFF validates a second issuer for the behandel endpoints (ADR-0013).
|
||||||
|
var medewerkerAuthority = builder.Configuration["Keycloak:MedewerkerAuthority"]
|
||||||
|
?? throw new InvalidOperationException("Missing configuration 'Keycloak:MedewerkerAuthority'");
|
||||||
var domainBaseUrl = builder.Configuration["Downstream:Domain:BaseUrl"]
|
var domainBaseUrl = builder.Configuration["Downstream:Domain:BaseUrl"]
|
||||||
?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'");
|
?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'");
|
||||||
var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"]
|
var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"]
|
||||||
@@ -19,8 +25,28 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
|||||||
options.Authority = keycloakAuthority;
|
options.Authority = keycloakAuthority;
|
||||||
options.RequireHttpsMetadata = false;
|
options.RequireHttpsMetadata = false;
|
||||||
options.TokenValidationParameters.ValidateAudience = false;
|
options.TokenValidationParameters.ValidateAudience = false;
|
||||||
|
})
|
||||||
|
// The medewerker realm — behandel endpoints only. On validation we lift Keycloak's realm roles
|
||||||
|
// (the nested realm_access.roles claim) into role claims so authorization policies can require them.
|
||||||
|
.AddJwtBearer(BehandelAuth.Scheme, options =>
|
||||||
|
{
|
||||||
|
options.Authority = medewerkerAuthority;
|
||||||
|
options.RequireHttpsMetadata = false;
|
||||||
|
options.TokenValidationParameters.ValidateAudience = false;
|
||||||
|
options.Events = new JwtBearerEvents
|
||||||
|
{
|
||||||
|
OnTokenValidated = context =>
|
||||||
|
{
|
||||||
|
BehandelAuth.AddRealmRoles(context.Principal);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
builder.Services.AddAuthorization();
|
builder.Services.AddAuthorization(options =>
|
||||||
|
options.AddPolicy(BehandelAuth.Policy, policy => policy
|
||||||
|
.AddAuthenticationSchemes(BehandelAuth.Scheme)
|
||||||
|
.RequireAuthenticatedUser()
|
||||||
|
.RequireRole(BehandelAuth.BehandelaarRole)));
|
||||||
|
|
||||||
// The BFF is the portals' only backend; it fans out to the domain and projection (§8.3).
|
// The BFF is the portals' only backend; it fans out to the domain and projection (§8.3).
|
||||||
builder.Services.AddHttpClient<IDomainClient, DomainClient>(c => c.BaseAddress = new Uri(domainBaseUrl));
|
builder.Services.AddHttpClient<IDomainClient, DomainClient>(c => c.BaseAddress = new Uri(domainBaseUrl));
|
||||||
@@ -68,7 +94,79 @@ app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection,
|
|||||||
})
|
})
|
||||||
.Produces<IReadOnlyList<OpenbaarEntry>>(StatusCodes.Status200OK);
|
.Produces<IReadOnlyList<OpenbaarEntry>>(StatusCodes.Status200OK);
|
||||||
|
|
||||||
|
// Behandelaar's werkbak: registrations awaiting beoordeling. Reached only with a medewerker-realm
|
||||||
|
// token carrying the behandelaar role; the BFF proxies the domain's werkbak (staff view, ADR-0013).
|
||||||
|
app.MapGet("/behandel/werkbak", async (IDomainClient domain, CancellationToken ct) =>
|
||||||
|
Results.Ok(await domain.GetWerkbakAsync(ct)))
|
||||||
|
.RequireAuthorization(BehandelAuth.Policy)
|
||||||
|
.Produces<IReadOnlyList<WerkbakItem>>(StatusCodes.Status200OK)
|
||||||
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
|
// A behandelaar's beoordeling on a registration (goedkeuren/afwijzen). Forwarded to the domain, which
|
||||||
|
// applies the decision and completes the workflow task (ADR-0013). Same medewerker/behandelaar gate.
|
||||||
|
app.MapPost("/behandel/registrations/{id}/decide",
|
||||||
|
async (string id, DecideRequest body, IDomainClient domain, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (!BehandelAuth.IsKnownBesluit(body.Besluit))
|
||||||
|
return Results.BadRequest(new { error = $"Unknown besluit '{body.Besluit}'. Expected 'goedkeuren' or 'afwijzen'." });
|
||||||
|
|
||||||
|
await domain.DecideAsync(id, body.Besluit, ct);
|
||||||
|
return Results.NoContent();
|
||||||
|
})
|
||||||
|
.RequireAuthorization(BehandelAuth.Policy)
|
||||||
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
|
.Produces(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces(StatusCodes.Status403Forbidden);
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
/// <summary>The behandelaar's decision on a registration.</summary>
|
||||||
|
public sealed record DecideRequest(string Besluit);
|
||||||
|
|
||||||
|
// Behandel (medewerker-realm) authentication + authorization wiring (ADR-0013).
|
||||||
|
internal static class BehandelAuth
|
||||||
|
{
|
||||||
|
public const string Scheme = "medewerker";
|
||||||
|
public const string Policy = "behandelaar";
|
||||||
|
public const string BehandelaarRole = "behandelaar";
|
||||||
|
|
||||||
|
/// <summary>The beoordeling vocabulary the BFF accepts (case-insensitive); an unknown besluit is a
|
||||||
|
/// 400 without troubling the domain. Mirrors the domain's <c>BeoordelingsBesluit</c>.</summary>
|
||||||
|
public static bool IsKnownBesluit(string? besluit) =>
|
||||||
|
string.Equals(besluit, "goedkeuren", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(besluit, "afwijzen", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>Lift Keycloak's realm roles (the nested <c>realm_access.roles</c> claim) onto the
|
||||||
|
/// principal as role claims, so <c>RequireRole</c> can authorize on them.</summary>
|
||||||
|
public static void AddRealmRoles(ClaimsPrincipal? principal)
|
||||||
|
{
|
||||||
|
if (principal?.Identity is not ClaimsIdentity identity)
|
||||||
|
return;
|
||||||
|
|
||||||
|
var realmAccess = principal.FindFirst("realm_access")?.Value;
|
||||||
|
if (string.IsNullOrWhiteSpace(realmAccess))
|
||||||
|
return;
|
||||||
|
|
||||||
|
// A malformed realm_access claim must not fail authentication (a throw here becomes a 401);
|
||||||
|
// it simply yields no roles, so the authorization policy answers 403.
|
||||||
|
string[] roles;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
roles = JsonSerializer.Deserialize<RealmAccess>(realmAccess)?.Roles ?? [];
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var role in roles)
|
||||||
|
identity.AddClaim(new Claim(identity.RoleClaimType, role));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record RealmAccess([property: JsonPropertyName("roles")] string[] Roles);
|
||||||
|
}
|
||||||
|
|
||||||
// Exposed so the test host (WebApplicationFactory<Program>) can boot the app.
|
// Exposed so the test host (WebApplicationFactory<Program>) can boot the app.
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"Keycloak": {
|
"Keycloak": {
|
||||||
"Authority": "http://localhost:8180/realms/digid"
|
"Authority": "http://localhost:8180/realms/digid",
|
||||||
|
"MedewerkerAuthority": "http://localhost:8180/realms/medewerker"
|
||||||
},
|
},
|
||||||
"Downstream": {
|
"Downstream": {
|
||||||
"Domain": { "BaseUrl": "http://localhost:8130/" },
|
"Domain": { "BaseUrl": "http://localhost:8130/" },
|
||||||
|
|||||||
114
services/bff/Bff.Tests/BehandelEndpointTests.cs
Normal file
114
services/bff/Bff.Tests/BehandelEndpointTests.cs
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Bff.Api;
|
||||||
|
|
||||||
|
namespace Bff.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The behandel werkbak endpoint (S-12c): reached only with a medewerker-realm token that carries the
|
||||||
|
/// <c>behandelaar</c> role. A missing token is 401; an authenticated medewerker without the role is
|
||||||
|
/// 403; a behandelaar gets the werkbak (staff view, incl. bsn).
|
||||||
|
/// </summary>
|
||||||
|
public class BehandelEndpointTests
|
||||||
|
{
|
||||||
|
private static HttpRequestMessage Werkbak(string? bearer)
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, "/behandel/werkbak");
|
||||||
|
if (bearer is not null)
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Rejects_the_werkbak_without_a_token()
|
||||||
|
{
|
||||||
|
using var factory = new BffFactory();
|
||||||
|
|
||||||
|
var response = await factory.CreateClient().SendAsync(Werkbak(bearer: null));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Rejects_a_medewerker_without_the_behandelaar_role()
|
||||||
|
{
|
||||||
|
using var factory = new BffFactory();
|
||||||
|
|
||||||
|
var response = await factory.CreateClient().SendAsync(Werkbak(TestTokens.Medewerker("teamlead")));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Serves_the_werkbak_to_a_behandelaar()
|
||||||
|
{
|
||||||
|
using var factory = new BffFactory();
|
||||||
|
factory.Domain.Werkbak.Add(new WerkbakItem("reg-1", "123456782", "InBehandeling"));
|
||||||
|
|
||||||
|
var response = await factory.CreateClient().SendAsync(Werkbak(TestTokens.Medewerker("behandelaar")));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
var items = await response.Content.ReadFromJsonAsync<List<WerkbakItem>>();
|
||||||
|
var item = Assert.Single(items!);
|
||||||
|
Assert.Equal("reg-1", item.RegistrationId);
|
||||||
|
Assert.Equal("123456782", item.Bsn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpRequestMessage Decide(string? bearer, string id = "reg-1", string besluit = "goedkeuren")
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, $"/behandel/registrations/{id}/decide")
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new { besluit }),
|
||||||
|
};
|
||||||
|
if (bearer is not null)
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Rejects_a_decision_without_a_token()
|
||||||
|
{
|
||||||
|
using var factory = new BffFactory();
|
||||||
|
|
||||||
|
var response = await factory.CreateClient().SendAsync(Decide(bearer: null));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
|
Assert.Null(factory.Domain.Decided);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Rejects_a_decision_from_a_medewerker_without_the_behandelaar_role()
|
||||||
|
{
|
||||||
|
using var factory = new BffFactory();
|
||||||
|
|
||||||
|
var response = await factory.CreateClient().SendAsync(Decide(TestTokens.Medewerker("teamlead")));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
Assert.Null(factory.Domain.Decided);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Forwards_a_behandelaar_decision_to_the_domain()
|
||||||
|
{
|
||||||
|
using var factory = new BffFactory();
|
||||||
|
|
||||||
|
var response = await factory.CreateClient()
|
||||||
|
.SendAsync(Decide(TestTokens.Medewerker("behandelaar"), id: "reg-42", besluit: "afwijzen"));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||||
|
Assert.Equal(("reg-42", "afwijzen"), factory.Domain.Decided);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Rejects_an_unknown_besluit_without_calling_the_domain()
|
||||||
|
{
|
||||||
|
using var factory = new BffFactory();
|
||||||
|
|
||||||
|
var response = await factory.CreateClient()
|
||||||
|
.SendAsync(Decide(TestTokens.Medewerker("behandelaar"), besluit: "misschien"));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
Assert.Null(factory.Domain.Decided);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Hosting;
|
|||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
using Microsoft.AspNetCore.TestHost;
|
using Microsoft.AspNetCore.TestHost;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.IdentityModel.Protocols;
|
||||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
@@ -23,24 +24,19 @@ internal sealed class BffFactory : WebApplicationFactory<Program>
|
|||||||
public FakeDomainClient Domain { get; } = new();
|
public FakeDomainClient Domain { get; } = new();
|
||||||
public FakeProjectionClient Projection { get; } = new();
|
public FakeProjectionClient Projection { get; } = new();
|
||||||
|
|
||||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
private static void ValidateWithTestKey(IServiceCollection services, string scheme) =>
|
||||||
|
services.Configure<JwtBearerOptions>(scheme, options =>
|
||||||
{
|
{
|
||||||
builder.UseSetting("Keycloak:Authority", "https://keycloak.invalid/realms/digid");
|
// Validate locally against the test key and NEVER reach out for OIDC metadata. A static
|
||||||
builder.UseSetting("Downstream:Domain:BaseUrl", "http://domain.invalid/");
|
// configuration manager guarantees this regardless of Configure/PostConfigure ordering —
|
||||||
builder.UseSetting("Downstream:Projection:BaseUrl", "http://projection.invalid/");
|
// clearing Authority alone left the medewerker scheme fetching metadata under CI timing
|
||||||
|
// (2s hang → 401), because JwtBearer's PostConfigure could still build a ConfigurationManager.
|
||||||
builder.ConfigureTestServices(services =>
|
|
||||||
{
|
|
||||||
services.AddSingleton<IDomainClient>(Domain);
|
|
||||||
services.AddSingleton<IProjectionClient>(Projection);
|
|
||||||
|
|
||||||
services.Configure<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme, options =>
|
|
||||||
{
|
|
||||||
// Validate locally against the test key; never reach out for OIDC metadata.
|
|
||||||
options.Authority = null;
|
options.Authority = null;
|
||||||
options.MetadataAddress = null!;
|
options.MetadataAddress = null!;
|
||||||
options.RequireHttpsMetadata = false;
|
options.RequireHttpsMetadata = false;
|
||||||
options.Configuration = new OpenIdConnectConfiguration();
|
options.Configuration = new OpenIdConnectConfiguration();
|
||||||
|
options.ConfigurationManager =
|
||||||
|
new StaticConfigurationManager<OpenIdConnectConfiguration>(new OpenIdConnectConfiguration());
|
||||||
options.TokenValidationParameters = new TokenValidationParameters
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
{
|
{
|
||||||
ValidateIssuer = false,
|
ValidateIssuer = false,
|
||||||
@@ -51,6 +47,24 @@ internal sealed class BffFactory : WebApplicationFactory<Program>
|
|||||||
ClockSkew = TimeSpan.Zero,
|
ClockSkew = TimeSpan.Zero,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
|
{
|
||||||
|
builder.UseSetting("Keycloak:Authority", "https://keycloak.invalid/realms/digid");
|
||||||
|
builder.UseSetting("Keycloak:MedewerkerAuthority", "https://keycloak.invalid/realms/medewerker");
|
||||||
|
builder.UseSetting("Downstream:Domain:BaseUrl", "http://domain.invalid/");
|
||||||
|
builder.UseSetting("Downstream:Projection:BaseUrl", "http://projection.invalid/");
|
||||||
|
|
||||||
|
builder.ConfigureTestServices(services =>
|
||||||
|
{
|
||||||
|
services.AddSingleton<IDomainClient>(Domain);
|
||||||
|
services.AddSingleton<IProjectionClient>(Projection);
|
||||||
|
|
||||||
|
// Both realms validate locally against the test key (no live Keycloak). The medewerker
|
||||||
|
// scheme keeps its OnTokenValidated role-lifting from Program.cs — only the validation
|
||||||
|
// parameters are swapped here.
|
||||||
|
ValidateWithTestKey(services, JwtBearerDefaults.AuthenticationScheme);
|
||||||
|
ValidateWithTestKey(services, "medewerker");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,12 +74,24 @@ internal sealed class FakeDomainClient : IDomainClient
|
|||||||
{
|
{
|
||||||
public string? SubmittedBsn { get; private set; }
|
public string? SubmittedBsn { get; private set; }
|
||||||
public SubmitAccepted Result { get; set; } = new("reg-123", "Ingediend");
|
public SubmitAccepted Result { get; set; } = new("reg-123", "Ingediend");
|
||||||
|
public List<WerkbakItem> Werkbak { get; } = [];
|
||||||
|
|
||||||
public Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default)
|
public Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
SubmittedBsn = bsn;
|
SubmittedBsn = bsn;
|
||||||
return Task.FromResult(Result);
|
return Task.FromResult(Result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public (string RegistrationId, string Besluit)? Decided { get; private set; }
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||||
|
=> Task.FromResult<IReadOnlyList<WerkbakItem>>(Werkbak);
|
||||||
|
|
||||||
|
public Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Decided = (registrationId, besluit);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Serves a configurable set of projection rows.</summary>
|
/// <summary>Serves a configurable set of projection rows.</summary>
|
||||||
|
|||||||
@@ -17,6 +17,22 @@ internal static class TestTokens
|
|||||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes("a-different-signing-key-256-bits-long-indeed-yes!")),
|
new SymmetricSecurityKey(Encoding.UTF8.GetBytes("a-different-signing-key-256-bits-long-indeed-yes!")),
|
||||||
expired: false);
|
expired: false);
|
||||||
|
|
||||||
|
/// <summary>A medewerker-realm token carrying the given realm roles under <c>realm_access.roles</c>
|
||||||
|
/// (Keycloak's shape), signed with the valid test key. Used to exercise behandel authorization.</summary>
|
||||||
|
public static string Medewerker(params string[] roles)
|
||||||
|
{
|
||||||
|
var handler = new JsonWebTokenHandler();
|
||||||
|
return handler.CreateToken(new SecurityTokenDescriptor
|
||||||
|
{
|
||||||
|
Claims = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["realm_access"] = new Dictionary<string, object> { ["roles"] = roles },
|
||||||
|
},
|
||||||
|
Expires = DateTime.UtcNow.AddMinutes(30),
|
||||||
|
SigningCredentials = new SigningCredentials(BffFactory.TestSigningKey, SecurityAlgorithms.HmacSha256),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private static string Create(string bsn, SymmetricSecurityKey key, bool expired)
|
private static string Create(string bsn, SymmetricSecurityKey key, bool expired)
|
||||||
{
|
{
|
||||||
var handler = new JsonWebTokenHandler();
|
var handler = new JsonWebTokenHandler();
|
||||||
|
|||||||
@@ -60,10 +60,90 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/behandel/werkbak": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Bff.Api"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/WerkbakItem"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/behandel/registrations/{id}/decide": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Bff.Api"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/DecideRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": "No Content"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
"schemas": {
|
"schemas": {
|
||||||
|
"DecideRequest": {
|
||||||
|
"required": [
|
||||||
|
"besluit"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"besluit": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"OpenbaarEntry": {
|
"OpenbaarEntry": {
|
||||||
"required": [
|
"required": [
|
||||||
"id",
|
"id",
|
||||||
@@ -100,6 +180,25 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"WerkbakItem": {
|
||||||
|
"required": [
|
||||||
|
"registrationId",
|
||||||
|
"bsn",
|
||||||
|
"status"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"registrationId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"bsn": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ builder.Services.AddHttpClient<IAclClient, AclHttpClient>();
|
|||||||
builder.Services.AddScoped<SubmitRegistration>();
|
builder.Services.AddScoped<SubmitRegistration>();
|
||||||
builder.Services.AddScoped<ApproveRegistration>();
|
builder.Services.AddScoped<ApproveRegistration>();
|
||||||
builder.Services.AddScoped<BeoordeelRegistratie>();
|
builder.Services.AddScoped<BeoordeelRegistratie>();
|
||||||
|
builder.Services.AddScoped<Werkbak>();
|
||||||
builder.Services.AddScoped<OpenZaakWorker>();
|
builder.Services.AddScoped<OpenZaakWorker>();
|
||||||
builder.Services.AddScoped<OpenZaakJobProcessor>();
|
builder.Services.AddScoped<OpenZaakJobProcessor>();
|
||||||
|
|
||||||
@@ -73,6 +74,12 @@ app.MapPost("/registrations/{id}/decide", async (string id, DecideRequest body,
|
|||||||
return Results.NoContent();
|
return Results.NoContent();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open
|
||||||
|
// Beoordelen user tasks (§8.2) and enriched with bsn + status. The BFF proxies this behind
|
||||||
|
// medewerker-realm + behandelaar-role authorization; the domain trusts its callers (§8.3).
|
||||||
|
app.MapGet("/behandel/werkbak", async (Werkbak werkbak, CancellationToken ct) =>
|
||||||
|
Results.Ok(await werkbak.GetAsync(ct)));
|
||||||
|
|
||||||
// Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually).
|
// Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually).
|
||||||
app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) =>
|
app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ public sealed record BeoordeelRegistratieCommand(RegistrationId RegistrationId,
|
|||||||
/// <see cref="BeoordelingsBesluit.Goedkeuren"/> sets the zaak's final status via the ACL (§8.1) and
|
/// <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
|
/// 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
|
/// AFGEWEZEN in the domain (propagating a rejection to the zaak, so the openbaar projection reflects
|
||||||
/// it, is a later sub-slice of S-12). Both decisions are idempotent — a repeated or redelivered
|
/// it, is a later sub-slice of S-12). After applying the decision it completes the Flowable
|
||||||
/// decision that matches the current terminal state is a no-op, so the ACL is not called twice.
|
/// <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>
|
/// </summary>
|
||||||
public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient acl)
|
public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient acl, IUserTaskClient tasks)
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(BeoordeelRegistratieCommand command, CancellationToken ct = default)
|
public async Task HandleAsync(BeoordeelRegistratieCommand command, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -53,5 +55,17 @@ public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient ac
|
|||||||
}
|
}
|
||||||
|
|
||||||
await store.SaveAsync(registration, ct);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
services/domain/Big.Application/Werkbak.cs
Normal file
32
services/domain/Big.Application/Werkbak.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
namespace Big.Application;
|
||||||
|
|
||||||
|
/// <summary>One row of the behandelaar's werkbak: a registration awaiting beoordeling, with the
|
||||||
|
/// public-facing reference (its id) plus the bsn and status a behandelaar needs to triage it.</summary>
|
||||||
|
public sealed record WerkbakItem(string RegistrationId, string Bsn, string Status);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The werkbak query (S-12c): the registrations awaiting a behandelaar's beoordeling. It reads the
|
||||||
|
/// open <c>Beoordelen</c> tasks from the workflow engine (§8.2, via <see cref="IUserTaskClient"/>) —
|
||||||
|
/// the authoritative set of work items — and enriches each with its aggregate (bsn + status). A task
|
||||||
|
/// whose registration the domain doesn't know is skipped rather than invented.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store)
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyList<WerkbakItem>> GetAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var open = await tasks.GetOpenBeoordelingenAsync(ct);
|
||||||
|
|
||||||
|
var items = new List<WerkbakItem>(open.Count);
|
||||||
|
foreach (var task in open)
|
||||||
|
{
|
||||||
|
var registration = await store.GetAsync(task.RegistrationId, ct);
|
||||||
|
if (registration is null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
items.Add(new WerkbakItem(
|
||||||
|
registration.Id.ToString(), registration.Bsn, registration.Status.ToString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,8 @@ namespace Big.Tests;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// The beoordeling use case (S-12): a behandelaar's decision on a registration. Goedkeuren sets the
|
/// 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
|
/// zaak's final status via the ACL (§8.1) and marks the aggregate INGESCHREVEN; Afwijzen marks it
|
||||||
/// AFGEWEZEN in the domain (propagating a rejection to the zaak is a later sub-slice). Both are
|
/// AFGEWEZEN. Either way the decision also completes the Flowable Beoordelen task (found by
|
||||||
/// idempotent so a repeated or redelivered decision is a no-op.
|
/// registrationId) so the process advances. All idempotent — a repeated decision is a no-op.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class BeoordeelRegistratieTests
|
public class BeoordeelRegistratieTests
|
||||||
{
|
{
|
||||||
@@ -18,6 +18,9 @@ public class BeoordeelRegistratieTests
|
|||||||
return registration;
|
return registration;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static FakeUserTaskClient TaskFor(Registration registration) =>
|
||||||
|
new([new BeoordelingTask("task-1", registration.Id)]);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Goedkeuren_sets_the_zaak_status_via_the_acl_and_marks_the_registration_ingeschreven()
|
public async Task Goedkeuren_sets_the_zaak_status_via_the_acl_and_marks_the_registration_ingeschreven()
|
||||||
{
|
{
|
||||||
@@ -25,7 +28,8 @@ public class BeoordeelRegistratieTests
|
|||||||
var acl = new FakeAclClient();
|
var acl = new FakeAclClient();
|
||||||
var registration = WithZaak();
|
var registration = WithZaak();
|
||||||
store.Seed(registration);
|
store.Seed(registration);
|
||||||
var handler = new BeoordeelRegistratie(store, acl);
|
var tasks = TaskFor(registration);
|
||||||
|
var handler = new BeoordeelRegistratie(store, acl, tasks);
|
||||||
|
|
||||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
||||||
|
|
||||||
@@ -34,6 +38,8 @@ public class BeoordeelRegistratieTests
|
|||||||
Assert.Equal(FakeAclClient.DefaultZaakUrl, acl.ApprovedZaakUrl);
|
Assert.Equal(FakeAclClient.DefaultZaakUrl, acl.ApprovedZaakUrl);
|
||||||
Assert.Equal(1, acl.ApproveCallCount);
|
Assert.Equal(1, acl.ApproveCallCount);
|
||||||
Assert.Equal(1, store.SaveCount);
|
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]
|
[Fact]
|
||||||
@@ -43,7 +49,8 @@ public class BeoordeelRegistratieTests
|
|||||||
var acl = new FakeAclClient();
|
var acl = new FakeAclClient();
|
||||||
var registration = WithZaak();
|
var registration = WithZaak();
|
||||||
store.Seed(registration);
|
store.Seed(registration);
|
||||||
var handler = new BeoordeelRegistratie(store, acl);
|
var tasks = TaskFor(registration);
|
||||||
|
var handler = new BeoordeelRegistratie(store, acl, tasks);
|
||||||
|
|
||||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
|
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
|
||||||
|
|
||||||
@@ -51,6 +58,7 @@ public class BeoordeelRegistratieTests
|
|||||||
Assert.Equal(RegistrationStatus.Afgewezen, saved!.Status);
|
Assert.Equal(RegistrationStatus.Afgewezen, saved!.Status);
|
||||||
Assert.Equal(0, acl.ApproveCallCount);
|
Assert.Equal(0, acl.ApproveCallCount);
|
||||||
Assert.Equal(1, store.SaveCount);
|
Assert.Equal(1, store.SaveCount);
|
||||||
|
Assert.Equal(("task-1", BeoordelingsBesluit.Afwijzen), tasks.Completed);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -61,7 +69,7 @@ public class BeoordeelRegistratieTests
|
|||||||
var registration = WithZaak();
|
var registration = WithZaak();
|
||||||
registration.TakeIntoBehandeling();
|
registration.TakeIntoBehandeling();
|
||||||
store.Seed(registration);
|
store.Seed(registration);
|
||||||
var handler = new BeoordeelRegistratie(store, acl);
|
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));
|
||||||
|
|
||||||
@@ -73,7 +81,7 @@ public class BeoordeelRegistratieTests
|
|||||||
{
|
{
|
||||||
var store = new FakeRegistrationStore();
|
var store = new FakeRegistrationStore();
|
||||||
var acl = new FakeAclClient();
|
var acl = new FakeAclClient();
|
||||||
var handler = new BeoordeelRegistratie(store, acl);
|
var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]));
|
||||||
|
|
||||||
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
|
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
|
||||||
Assert.Equal(0, acl.ApproveCallCount);
|
Assert.Equal(0, acl.ApproveCallCount);
|
||||||
@@ -85,7 +93,7 @@ public class BeoordeelRegistratieTests
|
|||||||
{
|
{
|
||||||
var store = new FakeRegistrationStore();
|
var store = new FakeRegistrationStore();
|
||||||
var acl = new FakeAclClient();
|
var acl = new FakeAclClient();
|
||||||
var handler = new BeoordeelRegistratie(store, acl);
|
var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]));
|
||||||
|
|
||||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
handler.HandleAsync(new BeoordeelRegistratieCommand(RegistrationId.New(), BeoordelingsBesluit.Goedkeuren)));
|
handler.HandleAsync(new BeoordeelRegistratieCommand(RegistrationId.New(), BeoordelingsBesluit.Goedkeuren)));
|
||||||
@@ -100,7 +108,7 @@ public class BeoordeelRegistratieTests
|
|||||||
var acl = new FakeAclClient();
|
var acl = new FakeAclClient();
|
||||||
var registration = Registration.Submit("123456782"); // no zaak yet
|
var registration = Registration.Submit("123456782"); // no zaak yet
|
||||||
store.Seed(registration);
|
store.Seed(registration);
|
||||||
var handler = new BeoordeelRegistratie(store, acl);
|
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
|
||||||
|
|
||||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||||
handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren)));
|
handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren)));
|
||||||
@@ -115,7 +123,7 @@ public class BeoordeelRegistratieTests
|
|||||||
var acl = new FakeAclClient();
|
var acl = new FakeAclClient();
|
||||||
var registration = WithZaak();
|
var registration = WithZaak();
|
||||||
store.Seed(registration);
|
store.Seed(registration);
|
||||||
var handler = new BeoordeelRegistratie(store, acl);
|
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));
|
||||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
||||||
@@ -131,7 +139,7 @@ public class BeoordeelRegistratieTests
|
|||||||
var acl = new FakeAclClient();
|
var acl = new FakeAclClient();
|
||||||
var registration = WithZaak();
|
var registration = WithZaak();
|
||||||
store.Seed(registration);
|
store.Seed(registration);
|
||||||
var handler = new BeoordeelRegistratie(store, acl);
|
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));
|
||||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
|
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
|
||||||
@@ -139,4 +147,22 @@ public class BeoordeelRegistratieTests
|
|||||||
Assert.Equal(1, store.SaveCount);
|
Assert.Equal(1, store.SaveCount);
|
||||||
Assert.Equal(RegistrationStatus.Afgewezen, (await store.GetAsync(registration.Id))!.Status);
|
Assert.Equal(RegistrationStatus.Afgewezen, (await store.GetAsync(registration.Id))!.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deciding_completes_no_task_when_none_is_open_for_the_registration()
|
||||||
|
{
|
||||||
|
// The task may already be gone (redelivery / manual completion). The decision still applies
|
||||||
|
// and simply completes nothing rather than failing.
|
||||||
|
var store = new FakeRegistrationStore();
|
||||||
|
var acl = new FakeAclClient();
|
||||||
|
var registration = WithZaak();
|
||||||
|
store.Seed(registration);
|
||||||
|
var tasks = new FakeUserTaskClient([]); // no open task for this registration
|
||||||
|
var handler = new BeoordeelRegistratie(store, acl, tasks);
|
||||||
|
|
||||||
|
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
||||||
|
|
||||||
|
Assert.Equal(RegistrationStatus.Ingeschreven, (await store.GetAsync(registration.Id))!.Status);
|
||||||
|
Assert.Null(tasks.Completed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,29 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A fake user-task client for the werkbak/decision use cases: returns a scripted set of
|
||||||
|
/// open beoordeling tasks and records claim/complete calls.</summary>
|
||||||
|
internal sealed class FakeUserTaskClient(IReadOnlyList<BeoordelingTask> open) : IUserTaskClient
|
||||||
|
{
|
||||||
|
public (string TaskId, string Behandelaar)? Claimed { get; private set; }
|
||||||
|
public (string TaskId, BeoordelingsBesluit Besluit)? Completed { get; private set; }
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(open);
|
||||||
|
|
||||||
|
public Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Claimed = (taskId, behandelaar);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Completed = (taskId, besluit);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>A fake ACL client that records the bsn it was asked to open a zaak for and returns a
|
/// <summary>A fake ACL client that records the bsn it was asked to open a zaak for and returns a
|
||||||
/// fixed zaak URL.</summary>
|
/// fixed zaak URL.</summary>
|
||||||
internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient
|
internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient
|
||||||
|
|||||||
49
services/domain/Big.Tests/WerkbakTests.cs
Normal file
49
services/domain/Big.Tests/WerkbakTests.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using Big.Application;
|
||||||
|
using Big.Domain;
|
||||||
|
|
||||||
|
namespace Big.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The werkbak query (S-12c): the behandelaar's list of registrations awaiting beoordeling. It reads
|
||||||
|
/// the open Beoordelen tasks from the workflow engine (§8.2) and enriches each with its registration
|
||||||
|
/// (bsn + status) from the store. A task whose registration is unknown is skipped defensively.
|
||||||
|
/// </summary>
|
||||||
|
public class WerkbakTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Lists_an_item_per_open_beoordeling_enriched_from_the_registration()
|
||||||
|
{
|
||||||
|
var store = new FakeRegistrationStore();
|
||||||
|
var registration = Registration.Submit("123456782");
|
||||||
|
registration.AttachZaak(FakeAclClient.DefaultZaakUrl);
|
||||||
|
registration.TakeIntoBehandeling();
|
||||||
|
store.Seed(registration);
|
||||||
|
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", registration.Id)]);
|
||||||
|
var werkbak = new Werkbak(tasks, store);
|
||||||
|
|
||||||
|
var items = await werkbak.GetAsync();
|
||||||
|
|
||||||
|
var item = Assert.Single(items);
|
||||||
|
Assert.Equal(registration.Id.ToString(), item.RegistrationId);
|
||||||
|
Assert.Equal("123456782", item.Bsn);
|
||||||
|
Assert.Equal("InBehandeling", item.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Is_empty_when_no_beoordelingen_are_open()
|
||||||
|
{
|
||||||
|
var werkbak = new Werkbak(new FakeUserTaskClient([]), new FakeRegistrationStore());
|
||||||
|
|
||||||
|
Assert.Empty(await werkbak.GetAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Skips_a_task_whose_registration_is_unknown()
|
||||||
|
{
|
||||||
|
// Defensive: the werkbak never invents an item for a task the domain has no aggregate for.
|
||||||
|
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", RegistrationId.New())]);
|
||||||
|
var werkbak = new Werkbak(tasks, new FakeRegistrationStore());
|
||||||
|
|
||||||
|
Assert.Empty(await werkbak.GetAsync());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ Feature: Een registratie beoordelen
|
|||||||
When the behandelaar decides "goedkeuren"
|
When the behandelaar decides "goedkeuren"
|
||||||
Then the registration has status "INGESCHREVEN"
|
Then the registration has status "INGESCHREVEN"
|
||||||
And the zaak's final status is set via the ACL
|
And the zaak's final status is set via the ACL
|
||||||
|
And the beoordeling task is completed with "goedkeuren"
|
||||||
|
|
||||||
Scenario: Afwijzen wijst de registratie af zonder de ACL
|
Scenario: Afwijzen wijst de registratie af zonder de ACL
|
||||||
Given a submitted registration with an opened zaak
|
Given a submitted registration with an opened zaak
|
||||||
@@ -22,3 +23,4 @@ Feature: Een registratie beoordelen
|
|||||||
And the behandelaar decides "afwijzen"
|
And the behandelaar decides "afwijzen"
|
||||||
Then the registration has status "AFGEWEZEN"
|
Then the registration has status "AFGEWEZEN"
|
||||||
And the ACL is not asked to set the zaak status
|
And the ACL is not asked to set the zaak status
|
||||||
|
And the beoordeling task is completed with "afwijzen"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public sealed class EenRegistratieBeoordelenSteps
|
|||||||
{
|
{
|
||||||
private readonly InMemoryAclClient _acl = new();
|
private readonly InMemoryAclClient _acl = new();
|
||||||
private readonly InMemoryRegistrationStore _store = new();
|
private readonly InMemoryRegistrationStore _store = new();
|
||||||
|
private readonly InMemoryUserTaskClient _tasks = new();
|
||||||
private RegistrationId _id;
|
private RegistrationId _id;
|
||||||
|
|
||||||
[Given("a submitted registration with an opened zaak")]
|
[Given("a submitted registration with an opened zaak")]
|
||||||
@@ -25,6 +26,8 @@ public sealed class EenRegistratieBeoordelenSteps
|
|||||||
registration.AttachZaak(InMemoryAclClient.OpenedZaakUrl);
|
registration.AttachZaak(InMemoryAclClient.OpenedZaakUrl);
|
||||||
await _store.SaveAsync(registration);
|
await _store.SaveAsync(registration);
|
||||||
_id = registration.Id;
|
_id = registration.Id;
|
||||||
|
// The process has parked at the Beoordelen user task awaiting the behandelaar.
|
||||||
|
_tasks.Open(_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
[When("the behandelaar takes it into behandeling")]
|
[When("the behandelaar takes it into behandeling")]
|
||||||
@@ -37,7 +40,7 @@ public sealed class EenRegistratieBeoordelenSteps
|
|||||||
|
|
||||||
[When("the behandelaar decides \"(.*)\"")]
|
[When("the behandelaar decides \"(.*)\"")]
|
||||||
public async Task WhenTheBehandelaarDecides(string besluit)
|
public async Task WhenTheBehandelaarDecides(string besluit)
|
||||||
=> await new BeoordeelRegistratie(_store, _acl).HandleAsync(
|
=> await new BeoordeelRegistratie(_store, _acl, _tasks).HandleAsync(
|
||||||
new BeoordeelRegistratieCommand(_id, Enum.Parse<BeoordelingsBesluit>(besluit, ignoreCase: true)));
|
new BeoordeelRegistratieCommand(_id, Enum.Parse<BeoordelingsBesluit>(besluit, ignoreCase: true)));
|
||||||
|
|
||||||
[Then("the registration has status \"(.*)\"")]
|
[Then("the registration has status \"(.*)\"")]
|
||||||
@@ -55,4 +58,8 @@ public sealed class EenRegistratieBeoordelenSteps
|
|||||||
[Then("the ACL is not asked to set the zaak status")]
|
[Then("the ACL is not asked to set the zaak status")]
|
||||||
public void ThenTheAclIsNotAskedToSetTheZaakStatus()
|
public void ThenTheAclIsNotAskedToSetTheZaakStatus()
|
||||||
=> Assert.Null(_acl.ApprovedZaakUrl);
|
=> Assert.Null(_acl.ApprovedZaakUrl);
|
||||||
|
|
||||||
|
[Then("the beoordeling task is completed with \"(.*)\"")]
|
||||||
|
public void ThenTheBeoordelingTaskIsCompletedWith(string besluit)
|
||||||
|
=> Assert.Equal(Enum.Parse<BeoordelingsBesluit>(besluit, ignoreCase: true), _tasks.Completed?.Besluit);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ public sealed class CapturingDomainClient : IDomainClient
|
|||||||
SubmittedBsn = bsn;
|
SubmittedBsn = bsn;
|
||||||
return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend"));
|
return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||||
|
=> Task.FromResult<IReadOnlyList<WerkbakItem>>([]);
|
||||||
|
|
||||||
|
public Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default)
|
||||||
|
=> Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Serves configurable projection rows.</summary>
|
/// <summary>Serves configurable projection rows.</summary>
|
||||||
|
|||||||
@@ -43,6 +43,29 @@ public sealed class InMemoryAclClient : IAclClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>An in-memory user-task client for the beoordeling acceptance scenario: it holds one open
|
||||||
|
/// Beoordelen task per registration and records the besluit each is completed with.</summary>
|
||||||
|
public sealed class InMemoryUserTaskClient : IUserTaskClient
|
||||||
|
{
|
||||||
|
private readonly List<BeoordelingTask> _open = [];
|
||||||
|
|
||||||
|
public (string TaskId, BeoordelingsBesluit Besluit)? Completed { get; private set; }
|
||||||
|
|
||||||
|
public void Open(RegistrationId registrationId) => _open.Add(new BeoordelingTask($"task-{registrationId}", registrationId));
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default)
|
||||||
|
=> Task.FromResult<IReadOnlyList<BeoordelingTask>>(_open);
|
||||||
|
|
||||||
|
public Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
Completed = (taskId, besluit);
|
||||||
|
_open.RemoveAll(t => t.TaskId == taskId);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>An in-memory registration store for the domain acceptance scenario.</summary>
|
/// <summary>An in-memory registration store for the domain acceptance scenario.</summary>
|
||||||
public sealed class InMemoryRegistrationStore : IRegistrationStore
|
public sealed class InMemoryRegistrationStore : IRegistrationStore
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { defineConfig, devices } from '@playwright/test';
|
|||||||
// The e2e runs inside the compose network (infra/run-e2e-check.sh); baseURL defaults to the
|
// The e2e runs inside the compose network (infra/run-e2e-check.sh); baseURL defaults to the
|
||||||
// self-service service. Keep timeouts generous — the first navigation triggers the DigiD flow.
|
// self-service service. Keep timeouts generous — the first navigation triggers the DigiD flow.
|
||||||
const baseURL = process.env.SELF_SERVICE_URL ?? 'http://self-service';
|
const baseURL = process.env.SELF_SERVICE_URL ?? 'http://self-service';
|
||||||
|
// The behandel portal is a second origin the happy path visits (staff approve from the werkbak);
|
||||||
|
// it needs the same insecure-origin-as-secure treatment as self-service for the PKCE login (below).
|
||||||
|
const behandelURL = process.env.BEHANDEL_URL ?? 'http://behandel';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: '.',
|
testDir: '.',
|
||||||
@@ -22,7 +25,9 @@ export default defineConfig({
|
|||||||
// the production HTTPS context. This flag is only honoured by the full Chromium build (new
|
// the production HTTPS context. This flag is only honoured by the full Chromium build (new
|
||||||
// headless), not Playwright's default headless-shell, so pin `channel: 'chromium'`.
|
// headless), not Playwright's default headless-shell, so pin `channel: 'chromium'`.
|
||||||
channel: 'chromium',
|
channel: 'chromium',
|
||||||
launchOptions: { args: [`--unsafely-treat-insecure-origin-as-secure=${baseURL}`] },
|
launchOptions: {
|
||||||
|
args: [`--unsafely-treat-insecure-origin-as-secure=${baseURL},${behandelURL}`],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
// Walking-skeleton happy path (S-08d + S-09 + S-09b): a zorgprofessional logs in via mock DigiD and
|
// Walking-skeleton happy path (S-08d + S-09 + S-09b + S-12): a zorgprofessional logs in via mock
|
||||||
// submits through the self-service portal → BFF → domain; the entry appears in the openbaar register
|
// DigiD and submits through the self-service portal → BFF → domain; the entry appears in the openbaar
|
||||||
// as INGEDIEND; a behandelaar approves it via the temporary admin endpoint; the approval flows via the
|
// register as INGEDIEND; a behandelaar then logs in to the behandel portal, finds the registration in
|
||||||
// ACL → NRC → event-subscriber → projection, and the openbaar register then shows it as INGESCHREVEN.
|
// the werkbak, and approves it (goedkeuren); the decision completes the Flowable Beoordelen task and
|
||||||
test('DigiD login → submit → public INGEDIEND → approve → public INGESCHREVEN', async ({ page, request }) => {
|
// flows via the ACL → NRC → event-subscriber → projection, and the openbaar register shows INGESCHREVEN.
|
||||||
|
test('DigiD submit → public INGEDIEND → behandelaar goedkeurt → public INGESCHREVEN', async ({ page }) => {
|
||||||
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
|
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
@@ -43,21 +44,42 @@ test('DigiD login → submit → public INGEDIEND → approve → public INGESCH
|
|||||||
await expect(page.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' }))
|
await expect(page.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' }))
|
||||||
.toBeVisible();
|
.toBeVisible();
|
||||||
|
|
||||||
// Approve via the temporary admin endpoint (reached directly on the compose network, as a
|
// A behandelaar picks the registration up in the behandel-portal werkbak and approves it
|
||||||
// behandelaar would until the behandel-portal exists — S-12). The zaak is opened off the request
|
// (goedkeuren) — the S-12 flow that replaces the temporary admin endpoint. Navigating here switches
|
||||||
// path by the worker, so wait for it before approving.
|
// to the medewerker realm (a different Keycloak realm than the citizen's digid session).
|
||||||
|
await page.goto('http://behandel/');
|
||||||
|
await page.locator('#username').fill('merel-behandelaar');
|
||||||
|
await page.locator('#password').fill('test123');
|
||||||
|
await page.locator('#kc-login').click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: /Werkbak/i })).toBeVisible();
|
||||||
|
|
||||||
|
// The registration parks at the Beoordelen user task only after the worker has opened its zaak, so
|
||||||
|
// it appears in the werkbak asynchronously — reload until this reference's row shows up. Target the
|
||||||
|
// decide button by reference (not a generic "Goedkeuren"): the shared verify stack holds other open
|
||||||
|
// tasks, so a positional match could act on someone else's registration.
|
||||||
|
const goedkeuren = page.getByRole('button', { name: `Goedkeuren ${reference}` });
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
const res = await request.get(`http://domain:8080/registrations/${reference}`);
|
await page.reload();
|
||||||
return res.ok() ? (await res.json()).zaakUrl : null;
|
return goedkeuren.count();
|
||||||
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
||||||
.toBeTruthy();
|
.toBeGreaterThan(0);
|
||||||
|
|
||||||
const approve = await request.post(`http://domain:8080/registrations/${reference}/approve`);
|
// Click and wait for the decide POST to finish (204) BEFORE leaving the page. `click()` only
|
||||||
expect(approve.status()).toBe(204);
|
// dispatches the request; navigating away immediately cancels it in flight (nginx logs a 499) and
|
||||||
|
// the decision never reaches the domain — so the registration would stay INGEDIEND.
|
||||||
|
const decided = page.waitForResponse(
|
||||||
|
(r) =>
|
||||||
|
r.url().includes(`/behandel/registrations/${reference}/decide`) &&
|
||||||
|
r.request().method() === 'POST',
|
||||||
|
);
|
||||||
|
await goedkeuren.click();
|
||||||
|
expect((await decided).status()).toBe(204);
|
||||||
|
|
||||||
// The approval flows back to the projection; the openbaar register now shows *our* row (matched by
|
// The approval flows back to the projection; back on the openbaar register *our* row (matched by
|
||||||
// its reference) as INGESCHREVEN.
|
// its reference) now shows INGESCHREVEN.
|
||||||
|
await page.goto('http://openbaar/');
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
await page.reload();
|
await page.reload();
|
||||||
|
|||||||
Reference in New Issue
Block a user