Compare commits
5 Commits
feat/12-wi
...
feat/13-be
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cb3ce6956 | |||
| 4ebc263bdf | |||
| d50a60c98b | |||
| 94d5feb6e0 | |||
| c005b0d627 |
@@ -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 openbaar behandel 2>&1 || true
|
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service 2>&1 || true
|
||||||
- name: Tear down
|
- 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 behandel
|
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar
|
||||||
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
|
# 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
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
# Multi-stage build for the behandel portal (Angular → nginx).
|
|
||||||
# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
|
|
||||||
FROM node:24-slim AS build
|
|
||||||
WORKDIR /src
|
|
||||||
RUN corepack enable && corepack prepare pnpm@11.5.2 --activate
|
|
||||||
|
|
||||||
# Restore first (cached unless the manifests change).
|
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml nx.json tsconfig.base.json eslint.config.mjs ./
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
# Sources (only what the app + its libs need).
|
|
||||||
COPY apps/behandel apps/behandel
|
|
||||||
COPY libs libs
|
|
||||||
RUN pnpm nx build behandel
|
|
||||||
|
|
||||||
FROM nginx:1.27-alpine AS runtime
|
|
||||||
COPY apps/behandel/nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
COPY --from=build /src/dist/apps/behandel/browser /usr/share/nginx/html
|
|
||||||
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
|
|
||||||
# service name, so the token issuer matches the BFF's medewerker authority (host-consistent, ADR-0013).
|
|
||||||
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/nginx/html/config.json
|
|
||||||
|
|
||||||
EXPOSE 80
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import nx from '@nx/eslint-plugin';
|
|
||||||
import baseConfig from '../../eslint.config.mjs';
|
|
||||||
|
|
||||||
export default [
|
|
||||||
...nx.configs['flat/angular'],
|
|
||||||
...nx.configs['flat/angular-template'],
|
|
||||||
...baseConfig,
|
|
||||||
{
|
|
||||||
files: ['**/*.ts'],
|
|
||||||
rules: {
|
|
||||||
'@angular-eslint/directive-selector': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
type: 'attribute',
|
|
||||||
prefix: 'app',
|
|
||||||
style: 'camelCase',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'@angular-eslint/component-selector': [
|
|
||||||
'error',
|
|
||||||
{
|
|
||||||
type: 'element',
|
|
||||||
prefix: 'app',
|
|
||||||
style: 'kebab-case',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ['**/*.html'],
|
|
||||||
// Override or add rules here
|
|
||||||
rules: {},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
|
|
||||||
# even before the BFF is up and picks up restarts — instead of failing to load the config.
|
|
||||||
resolver 127.0.0.11 ipv6=off valid=30s;
|
|
||||||
|
|
||||||
# Same-origin API: proxy the behandel endpoint group to the bff service. The api-client uses
|
|
||||||
# relative URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS, and the
|
|
||||||
# medewerker token (same-origin) is attached by the app's interceptor (ADR-0013).
|
|
||||||
location /behandel/ {
|
|
||||||
set $bff http://bff:8080;
|
|
||||||
proxy_pass $bff;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
}
|
|
||||||
|
|
||||||
# SPA fallback — Angular client-side routing.
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "behandel",
|
|
||||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
|
||||||
"projectType": "application",
|
|
||||||
"prefix": "app",
|
|
||||||
"sourceRoot": "apps/behandel/src",
|
|
||||||
"tags": [],
|
|
||||||
"targets": {
|
|
||||||
"build": {
|
|
||||||
"executor": "@angular/build:application",
|
|
||||||
"outputs": ["{options.outputPath}"],
|
|
||||||
"defaultConfiguration": "production",
|
|
||||||
"options": {
|
|
||||||
"outputPath": "dist/apps/behandel",
|
|
||||||
"browser": "apps/behandel/src/main.ts",
|
|
||||||
"tsConfig": "apps/behandel/tsconfig.app.json",
|
|
||||||
"assets": [
|
|
||||||
{
|
|
||||||
"glob": "**/*",
|
|
||||||
"input": "apps/behandel/public"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"styles": ["apps/behandel/src/styles.css"]
|
|
||||||
},
|
|
||||||
"configurations": {
|
|
||||||
"production": {
|
|
||||||
"budgets": [
|
|
||||||
{
|
|
||||||
"type": "initial",
|
|
||||||
"maximumWarning": "1mb",
|
|
||||||
"maximumError": "2mb"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "anyComponentStyle",
|
|
||||||
"maximumWarning": "4kb",
|
|
||||||
"maximumError": "8kb"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"outputHashing": "all"
|
|
||||||
},
|
|
||||||
"development": {
|
|
||||||
"optimization": false,
|
|
||||||
"extractLicenses": false,
|
|
||||||
"sourceMap": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"serve": {
|
|
||||||
"continuous": true,
|
|
||||||
"executor": "@angular/build:dev-server",
|
|
||||||
"defaultConfiguration": "development",
|
|
||||||
"configurations": {
|
|
||||||
"production": {
|
|
||||||
"buildTarget": "behandel:build:production"
|
|
||||||
},
|
|
||||||
"development": {
|
|
||||||
"buildTarget": "behandel:build:development"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"lint": {
|
|
||||||
"executor": "@nx/eslint:lint"
|
|
||||||
},
|
|
||||||
"test": {
|
|
||||||
"executor": "@angular/build:unit-test",
|
|
||||||
"options": {
|
|
||||||
"watch": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"serve-static": {
|
|
||||||
"continuous": true,
|
|
||||||
"executor": "@nx/web:file-server",
|
|
||||||
"options": {
|
|
||||||
"buildTarget": "behandel:build",
|
|
||||||
"staticFilePath": "dist/apps/behandel/browser",
|
|
||||||
"spa": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"authority": "http://localhost:8180/realms/medewerker"
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,73 +0,0 @@
|
|||||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
|
||||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
|
||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { BffApiV1Service } from 'api-client';
|
|
||||||
import { authInterceptor } from 'auth';
|
|
||||||
import { AbstractSecurityStorage, ConfigurationService } from 'angular-auth-oidc-client';
|
|
||||||
import { SECURE_API_ROUTES } from './app.config';
|
|
||||||
|
|
||||||
// Guards the medewerker token wiring end-to-end. The api-client calls the BFF with RELATIVE URLs, and
|
|
||||||
// the angular-auth-oidc-client interceptor attaches the token only when `req.url` starts with a
|
|
||||||
// configured secureRoute. A regression to an absolute origin makes the relative URL never match, so
|
|
||||||
// the behandel calls go out unauthenticated and the BFF answers 401. This drives the REAL interceptor
|
|
||||||
// and the REAL api-client against the REAL production route value (SECURE_API_ROUTES); only the config
|
|
||||||
// source and token storage are faked, so the assertion turns on the actual route-matching.
|
|
||||||
describe('behandel medewerker token wiring', () => {
|
|
||||||
let http: HttpTestingController;
|
|
||||||
let bff: BffApiV1Service;
|
|
||||||
const token = 'medewerker-access-token';
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
providers: [
|
|
||||||
provideHttpClient(withInterceptors([authInterceptor()])),
|
|
||||||
provideHttpClientTesting(),
|
|
||||||
{
|
|
||||||
provide: ConfigurationService,
|
|
||||||
useValue: {
|
|
||||||
hasAtLeastOneConfig: () => true,
|
|
||||||
getAllConfigurations: () => [{ configId: 'medewerker', secureRoutes: SECURE_API_ROUTES }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// A signed-in session: the storage the interceptor's token lookup reads from.
|
|
||||||
provide: AbstractSecurityStorage,
|
|
||||||
useValue: {
|
|
||||||
read: () => JSON.stringify({ authzData: token, authnResult: { id_token: 'id-token' } }),
|
|
||||||
write: () => undefined,
|
|
||||||
remove: () => undefined,
|
|
||||||
clear: () => undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
http = TestBed.inject(HttpTestingController);
|
|
||||||
bff = TestBed.inject(BffApiV1Service);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => http.verify());
|
|
||||||
|
|
||||||
it('attaches the bearer token to the relative werkbak call', () => {
|
|
||||||
bff.getBehandelWerkbak().subscribe();
|
|
||||||
|
|
||||||
const req = http.expectOne('/behandel/werkbak');
|
|
||||||
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
|
|
||||||
req.flush([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('attaches the bearer token to the relative decide call', () => {
|
|
||||||
bff.postBehandelRegistrationsIdDecide('reg-1', { besluit: 'goedkeuren' }).subscribe();
|
|
||||||
|
|
||||||
const req = http.expectOne('/behandel/registrations/reg-1/decide');
|
|
||||||
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
|
|
||||||
req.flush(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves the anonymous openbaar register call unauthenticated', () => {
|
|
||||||
bff.getOpenbaarRegister().subscribe();
|
|
||||||
|
|
||||||
const req = http.expectOne((r) => r.url === '/openbaar/register');
|
|
||||||
expect(req.request.headers.has('Authorization')).toBe(false);
|
|
||||||
req.flush([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
|
||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
|
||||||
import { provideRouter } from '@angular/router';
|
|
||||||
import { authInterceptor, provideMedewerkerAuth } from 'auth';
|
|
||||||
import { appRoutes } from './app.routes';
|
|
||||||
|
|
||||||
/** Environment-specific settings fetched from /config.json at startup (see main.ts). */
|
|
||||||
export interface RuntimeConfig {
|
|
||||||
/** The Keycloak `medewerker` realm issuer as the browser reaches it (dev: localhost; compose: keycloak:8080). */
|
|
||||||
authority: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Route prefixes whose requests carry the medewerker token. These MUST match the **relative** URLs
|
|
||||||
* the api-client actually calls (same-origin via the nginx proxy) — the interceptor matches on
|
|
||||||
* `req.url`, which stays relative, so an absolute origin would never match and the token would go
|
|
||||||
* unattached. Only `/behandel/` is secured; the app calls no other endpoint group.
|
|
||||||
*/
|
|
||||||
export const SECURE_API_ROUTES = ['/behandel/'];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the app providers from runtime config. `redirectUrl` is the app's own origin (where Keycloak
|
|
||||||
* redirects back). `secureRoutes` uses {@link SECURE_API_ROUTES} — relative prefixes, not the origin.
|
|
||||||
*/
|
|
||||||
export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
|
|
||||||
const origin = typeof window !== 'undefined' ? window.location.origin : '/';
|
|
||||||
return {
|
|
||||||
providers: [
|
|
||||||
provideBrowserGlobalErrorListeners(),
|
|
||||||
provideRouter(appRoutes),
|
|
||||||
provideHttpClient(withInterceptors([authInterceptor()])),
|
|
||||||
provideMedewerkerAuth({
|
|
||||||
authority: runtime.authority,
|
|
||||||
redirectUrl: origin,
|
|
||||||
secureRoutes: SECURE_API_ROUTES,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<router-outlet></router-outlet>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { Route } from '@angular/router';
|
|
||||||
import { authenticatedGuard } from 'auth';
|
|
||||||
import { WerkbakPage } from './werkbak/werkbak-page';
|
|
||||||
|
|
||||||
export const appRoutes: Route[] = [
|
|
||||||
{ path: '', component: WerkbakPage, canActivate: [authenticatedGuard] },
|
|
||||||
];
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { provideRouter } from '@angular/router';
|
|
||||||
import { render, screen } from '@testing-library/angular';
|
|
||||||
import { App } from './app';
|
|
||||||
|
|
||||||
describe('App', () => {
|
|
||||||
it('renders the router outlet shell', async () => {
|
|
||||||
const { container } = await render(App, {
|
|
||||||
providers: [provideRouter([])],
|
|
||||||
});
|
|
||||||
|
|
||||||
// The shell is a thin host for routed pages (the WerkbakPage owns the heading).
|
|
||||||
expect(container.querySelector('router-outlet')).toBeTruthy();
|
|
||||||
expect(screen).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { Component } from '@angular/core';
|
|
||||||
import { RouterModule } from '@angular/router';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
imports: [RouterModule],
|
|
||||||
selector: 'app-root',
|
|
||||||
templateUrl: './app.html',
|
|
||||||
styleUrl: './app.css',
|
|
||||||
})
|
|
||||||
export class App {
|
|
||||||
protected title = 'behandel';
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
<main utrecht-document class="utrecht-theme">
|
|
||||||
<utrecht-article>
|
|
||||||
<utrecht-heading-1>Werkbak</utrecht-heading-1>
|
|
||||||
<p utrecht-paragraph>
|
|
||||||
Registraties die wachten op beoordeling. Keur elke registratie goed of wijs deze af.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
@if (loading()) {
|
|
||||||
<p utrecht-paragraph role="status">Bezig met laden…</p>
|
|
||||||
} @else if (failed()) {
|
|
||||||
<p utrecht-paragraph role="alert">
|
|
||||||
Kon de werkbak niet laden. Controleer of je als behandelaar bent ingelogd en probeer het
|
|
||||||
opnieuw.
|
|
||||||
</p>
|
|
||||||
} @else if (loaded() && items().length === 0) {
|
|
||||||
<p utrecht-paragraph role="status">De werkbak is leeg.</p>
|
|
||||||
} @else if (items().length > 0) {
|
|
||||||
<table utrecht-table>
|
|
||||||
<caption>
|
|
||||||
Registraties in behandeling
|
|
||||||
</caption>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Referentie</th>
|
|
||||||
<th scope="col">BSN</th>
|
|
||||||
<th scope="col">Status</th>
|
|
||||||
<th scope="col">Actie</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (item of items(); track item.registrationId) {
|
|
||||||
<tr>
|
|
||||||
<td>{{ item.registrationId }}</td>
|
|
||||||
<td>{{ item.bsn }}</td>
|
|
||||||
<td>{{ item.status }}</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
utrecht-button
|
|
||||||
appearance="primary-action-button"
|
|
||||||
type="button"
|
|
||||||
[attr.aria-label]="'Goedkeuren ' + item.registrationId"
|
|
||||||
[disabled]="deciding() === item.registrationId"
|
|
||||||
(click)="decide(item.registrationId, 'goedkeuren')"
|
|
||||||
>
|
|
||||||
Goedkeuren
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
utrecht-button
|
|
||||||
appearance="secondary-action-button"
|
|
||||||
type="button"
|
|
||||||
[attr.aria-label]="'Afwijzen ' + item.registrationId"
|
|
||||||
[disabled]="deciding() === item.registrationId"
|
|
||||||
(click)="decide(item.registrationId, 'afwijzen')"
|
|
||||||
>
|
|
||||||
Afwijzen
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
}
|
|
||||||
</utrecht-article>
|
|
||||||
</main>
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import { signal } from '@angular/core';
|
|
||||||
import { fireEvent, render, screen } from '@testing-library/angular';
|
|
||||||
import { of, throwError } from 'rxjs';
|
|
||||||
import { BffApiV1Service, type WerkbakItem } from 'api-client';
|
|
||||||
import { AuthService } from 'auth';
|
|
||||||
import { axe } from 'vitest-axe';
|
|
||||||
import { WerkbakPage } from './werkbak-page';
|
|
||||||
|
|
||||||
const sample: WerkbakItem[] = [
|
|
||||||
{ registrationId: 'reg-1', bsn: '123456782', status: 'InBehandeling' },
|
|
||||||
{ registrationId: 'reg-2', bsn: '111222333', status: 'InBehandeling' },
|
|
||||||
];
|
|
||||||
|
|
||||||
class FakeAuth extends AuthService {
|
|
||||||
readonly isAuthenticated = signal(true);
|
|
||||||
readonly bsn = signal<string | undefined>(undefined);
|
|
||||||
override readonly roles = signal<readonly string[]>(['behandelaar']);
|
|
||||||
login(): void {
|
|
||||||
/* not exercised here */
|
|
||||||
}
|
|
||||||
logout(): void {
|
|
||||||
/* spied in tests */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setup(
|
|
||||||
overrides: {
|
|
||||||
getBehandelWerkbak?: ReturnType<typeof vi.fn>;
|
|
||||||
postBehandelRegistrationsIdDecide?: ReturnType<typeof vi.fn>;
|
|
||||||
} = {},
|
|
||||||
) {
|
|
||||||
const getBehandelWerkbak =
|
|
||||||
overrides.getBehandelWerkbak ?? vi.fn().mockReturnValue(of(sample));
|
|
||||||
const postBehandelRegistrationsIdDecide =
|
|
||||||
overrides.postBehandelRegistrationsIdDecide ?? vi.fn().mockReturnValue(of(undefined));
|
|
||||||
return {
|
|
||||||
getBehandelWerkbak,
|
|
||||||
postBehandelRegistrationsIdDecide,
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: BffApiV1Service,
|
|
||||||
useValue: { getBehandelWerkbak, postBehandelRegistrationsIdDecide },
|
|
||||||
},
|
|
||||||
{ provide: AuthService, useClass: FakeAuth },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('WerkbakPage', () => {
|
|
||||||
it('lists the registrations awaiting beoordeling on open', async () => {
|
|
||||||
const { getBehandelWerkbak, providers } = setup();
|
|
||||||
await render(WerkbakPage, { providers });
|
|
||||||
|
|
||||||
expect(getBehandelWerkbak).toHaveBeenCalled();
|
|
||||||
expect(await screen.findByText('reg-1')).toBeTruthy();
|
|
||||||
expect(screen.getByText('123456782')).toBeTruthy();
|
|
||||||
expect(screen.getByText('reg-2')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('approves a registration (goedkeuren) and refreshes the werkbak', async () => {
|
|
||||||
const { getBehandelWerkbak, postBehandelRegistrationsIdDecide, providers } = setup();
|
|
||||||
await render(WerkbakPage, { providers });
|
|
||||||
|
|
||||||
fireEvent.click((await screen.findAllByRole('button', { name: /goedkeuren/i }))[0]);
|
|
||||||
|
|
||||||
expect(postBehandelRegistrationsIdDecide).toHaveBeenCalledWith('reg-1', {
|
|
||||||
besluit: 'goedkeuren',
|
|
||||||
});
|
|
||||||
// Reloaded after the decision: once on open, once after deciding.
|
|
||||||
expect(getBehandelWerkbak).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a registration (afwijzen) via the decide endpoint', async () => {
|
|
||||||
const { postBehandelRegistrationsIdDecide, providers } = setup();
|
|
||||||
await render(WerkbakPage, { providers });
|
|
||||||
|
|
||||||
fireEvent.click((await screen.findAllByRole('button', { name: /afwijzen/i }))[0]);
|
|
||||||
|
|
||||||
expect(postBehandelRegistrationsIdDecide).toHaveBeenCalledWith('reg-1', {
|
|
||||||
besluit: 'afwijzen',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows an empty state when the werkbak has no items', async () => {
|
|
||||||
const { providers } = setup({ getBehandelWerkbak: vi.fn().mockReturnValue(of([])) });
|
|
||||||
await render(WerkbakPage, { providers });
|
|
||||||
|
|
||||||
expect(await screen.findByText(/werkbak is leeg/i)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('surfaces a load failure instead of swallowing it', async () => {
|
|
||||||
const { providers } = setup({
|
|
||||||
getBehandelWerkbak: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
|
|
||||||
});
|
|
||||||
await render(WerkbakPage, { providers });
|
|
||||||
|
|
||||||
expect(await screen.findByText(/kon de werkbak niet laden/i)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has no WCAG 2.1 AA violations', async () => {
|
|
||||||
document.documentElement.lang = 'nl';
|
|
||||||
const { container } = await render(WerkbakPage, { providers: setup().providers });
|
|
||||||
|
|
||||||
const results = await axe(container, {
|
|
||||||
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(results.violations).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
|
||||||
import { BffApiV1Service, type WerkbakItem } from 'api-client';
|
|
||||||
import { UtrechtComponentsModule } from 'ui';
|
|
||||||
|
|
||||||
/** The two decisions a behandelaar can make; the BFF validates these exact values (ADR-0013). */
|
|
||||||
type Besluit = 'goedkeuren' | 'afwijzen';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The behandel werkbak: a signed-in behandelaar sees the registrations awaiting beoordeling (the open
|
|
||||||
* Flowable `Beoordelen` tasks, read through the domain) and decides each — goedkeuren or afwijzen. A
|
|
||||||
* decision posts to the BFF, which applies the domain transition and completes the workflow task
|
|
||||||
* (ADR-0013; S-12). After a decision the werkbak refreshes so the handled item drops off the list.
|
|
||||||
*/
|
|
||||||
@Component({
|
|
||||||
selector: 'app-werkbak-page',
|
|
||||||
imports: [UtrechtComponentsModule],
|
|
||||||
templateUrl: './werkbak-page.html',
|
|
||||||
})
|
|
||||||
export class WerkbakPage {
|
|
||||||
private readonly bff = inject(BffApiV1Service);
|
|
||||||
|
|
||||||
protected readonly items = signal<WerkbakItem[]>([]);
|
|
||||||
protected readonly loading = signal(false);
|
|
||||||
protected readonly loaded = signal(false);
|
|
||||||
protected readonly failed = signal(false);
|
|
||||||
protected readonly deciding = signal<string | undefined>(undefined);
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.load();
|
|
||||||
}
|
|
||||||
|
|
||||||
load(): void {
|
|
||||||
this.loading.set(true);
|
|
||||||
this.failed.set(false);
|
|
||||||
this.bff.getBehandelWerkbak().subscribe({
|
|
||||||
next: (rows: WerkbakItem[]) => {
|
|
||||||
this.items.set(rows);
|
|
||||||
this.loading.set(false);
|
|
||||||
this.loaded.set(true);
|
|
||||||
},
|
|
||||||
// Surface the failure (e.g. 403 for a non-behandelaar) instead of swallowing it.
|
|
||||||
error: () => {
|
|
||||||
this.items.set([]);
|
|
||||||
this.loading.set(false);
|
|
||||||
this.loaded.set(true);
|
|
||||||
this.failed.set(true);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
decide(registrationId: string, besluit: Besluit): void {
|
|
||||||
this.deciding.set(registrationId);
|
|
||||||
this.bff.postBehandelRegistrationsIdDecide(registrationId, { besluit }).subscribe({
|
|
||||||
// Refresh so the decided registration drops off the werkbak (its task is now completed).
|
|
||||||
next: () => {
|
|
||||||
this.deciding.set(undefined);
|
|
||||||
this.load();
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
this.deciding.set(undefined);
|
|
||||||
this.failed.set(true);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="nl">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<title>Behandelportaal BIG-register</title>
|
|
||||||
<base href="/" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<app-root></app-root>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { bootstrapApplication } from '@angular/platform-browser';
|
|
||||||
import { App } from './app/app';
|
|
||||||
import { appConfig, type RuntimeConfig } from './app/app.config';
|
|
||||||
|
|
||||||
// Load environment config before bootstrap so the OIDC authority is set per environment
|
|
||||||
// (dev: localhost; compose: keycloak:8080) from a single build — 12-factor (S-08d).
|
|
||||||
fetch('config.json')
|
|
||||||
.then((response) => response.json() as Promise<RuntimeConfig>)
|
|
||||||
.then((config) => bootstrapApplication(App, appConfig(config)))
|
|
||||||
.catch((err) => console.error(err));
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
|
|
||||||
@import '@utrecht/design-tokens/dist/index.css';
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "../../dist/out-tsc",
|
|
||||||
"types": []
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts"],
|
|
||||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"strict": true,
|
|
||||||
"noImplicitOverride": true,
|
|
||||||
"noPropertyAccessFromIndexSignature": true,
|
|
||||||
"noImplicitReturns": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"target": "es2022",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"emitDecoratorMetadata": false,
|
|
||||||
"module": "preserve"
|
|
||||||
},
|
|
||||||
"angularCompilerOptions": {
|
|
||||||
"enableI18nLegacyMessageIdFormat": false,
|
|
||||||
"strictInjectionParameters": true,
|
|
||||||
"strictInputAccessModifiers": true,
|
|
||||||
"strictTemplates": true
|
|
||||||
},
|
|
||||||
"files": [],
|
|
||||||
"include": [],
|
|
||||||
"references": [
|
|
||||||
{
|
|
||||||
"path": "./tsconfig.app.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "./tsconfig.spec.json"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "../../dist/out-tsc",
|
|
||||||
"types": ["vitest/globals"]
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts", "src/**/*.d.ts"]
|
|
||||||
}
|
|
||||||
@@ -248,30 +248,3 @@ 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,37 +117,3 @@ with the submit form (S-08c, #67); any deviation from NL DS will be recorded her
|
|||||||
(id + status); `bsn`/`naam` never leave the BFF. The e2e asserts the bsn never renders.
|
(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.
|
|
||||||
|
|||||||
@@ -486,29 +486,6 @@ 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,22 +16,7 @@
|
|||||||
"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": [
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
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,26 +1,16 @@
|
|||||||
import { signal, Signal } from '@angular/core';
|
import { Signal } from '@angular/core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The portal's view of the signed-in user. An abstraction over the OIDC library so components and
|
* The portal's view of the signed-in user. An abstraction over the OIDC library so components and
|
||||||
* guards depend on a small, mockable surface (the real implementations are DigiadAuthService for
|
* guards depend on a small, mockable surface (the real implementation is DigiadAuthService).
|
||||||
* citizens and MedewerkerAuthService for staff).
|
|
||||||
*/
|
*/
|
||||||
export abstract class AuthService {
|
export abstract class AuthService {
|
||||||
/** Whether a session is active. */
|
/** Whether a DigiD session is active. */
|
||||||
abstract readonly isAuthenticated: Signal<boolean>;
|
abstract readonly isAuthenticated: Signal<boolean>;
|
||||||
/** The citizen-service number from the DigiD token, once authenticated (staff have none). */
|
/** The citizen-service number from the DigiD token, once authenticated. */
|
||||||
abstract readonly bsn: Signal<string | undefined>;
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';
|
|
||||||
import { LogLevel, provideAuth, withAppInitializerAuthCheck } from 'angular-auth-oidc-client';
|
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
import { MedewerkerAuthService } from './medewerker-auth.service';
|
|
||||||
|
|
||||||
export interface MedewerkerAuthOptions {
|
|
||||||
/** The Keycloak `medewerker` realm issuer, as reachable from the browser. */
|
|
||||||
authority: string;
|
|
||||||
/** Where Keycloak redirects back to after login (usually the app origin). */
|
|
||||||
redirectUrl: string;
|
|
||||||
/**
|
|
||||||
* Route prefixes whose requests get the bearer token attached. The api-client calls the BFF with
|
|
||||||
* **relative** URLs (same-origin via the nginx proxy), so these must be relative path prefixes
|
|
||||||
* (e.g. `/behandel/`) — angular-auth-oidc-client matches `req.url.startsWith(route)`, and a
|
|
||||||
* relative `req.url` never starts with an absolute origin.
|
|
||||||
*/
|
|
||||||
secureRoutes: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure medewerker login (Keycloak `medewerker` realm, public client `big-portal`, auth-code +
|
|
||||||
* PKCE) and bind {@link AuthService} to the medewerker-backed implementation. Register
|
|
||||||
* {@link authInterceptor} (re-exported from digid-auth.providers) in the app's HttpClient so BFF
|
|
||||||
* calls carry the token.
|
|
||||||
*/
|
|
||||||
export function provideMedewerkerAuth(options: MedewerkerAuthOptions): EnvironmentProviders {
|
|
||||||
return makeEnvironmentProviders([
|
|
||||||
provideAuth(
|
|
||||||
{
|
|
||||||
config: {
|
|
||||||
authority: options.authority,
|
|
||||||
redirectUrl: options.redirectUrl,
|
|
||||||
postLogoutRedirectUri: options.redirectUrl,
|
|
||||||
clientId: 'big-portal',
|
|
||||||
scope: 'openid profile',
|
|
||||||
responseType: 'code',
|
|
||||||
silentRenew: true,
|
|
||||||
useRefreshToken: true,
|
|
||||||
secureRoutes: options.secureRoutes,
|
|
||||||
logLevel: LogLevel.Warn,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Run checkAuth() at startup so the login callback (?code=…) is processed before the router
|
|
||||||
// and guard run — without it the guard sees "not authenticated" and re-triggers login (loop).
|
|
||||||
withAppInitializerAuthCheck(),
|
|
||||||
),
|
|
||||||
{ provide: AuthService, useClass: MedewerkerAuthService },
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
|
||||||
import { OidcSecurityService } from 'angular-auth-oidc-client';
|
|
||||||
import { of } from 'rxjs';
|
|
||||||
import { MedewerkerAuthService } from './medewerker-auth.service';
|
|
||||||
|
|
||||||
function makeService(userData: unknown, authenticated = true) {
|
|
||||||
const oidc = {
|
|
||||||
isAuthenticated$: of({ isAuthenticated: authenticated }),
|
|
||||||
userData$: of({ userData }),
|
|
||||||
authorize: vi.fn(),
|
|
||||||
logoff: vi.fn(() => of(null)),
|
|
||||||
};
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
providers: [MedewerkerAuthService, { provide: OidcSecurityService, useValue: oidc }],
|
|
||||||
});
|
|
||||||
return { svc: TestBed.inject(MedewerkerAuthService), oidc };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('MedewerkerAuthService', () => {
|
|
||||||
it('exposes the realm roles carried in the token', () => {
|
|
||||||
const { svc } = makeService({ realm_access: { roles: ['behandelaar', 'teamlead'] } });
|
|
||||||
expect(svc.roles()).toEqual(['behandelaar', 'teamlead']);
|
|
||||||
expect(svc.hasRole('behandelaar')).toBe(true);
|
|
||||||
expect(svc.hasRole('beheerder')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('has no roles when the token omits realm_access', () => {
|
|
||||||
const { svc } = makeService({ preferred_username: 'merel-behandelaar' });
|
|
||||||
expect(svc.roles()).toEqual([]);
|
|
||||||
expect(svc.hasRole('behandelaar')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reflects the OIDC authenticated state', () => {
|
|
||||||
const { svc } = makeService({}, true);
|
|
||||||
expect(svc.isAuthenticated()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('starts login by delegating to the OIDC library', () => {
|
|
||||||
const { svc, oidc } = makeService({});
|
|
||||||
svc.login();
|
|
||||||
expect(oidc.authorize).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { inject, Injectable, Signal } from '@angular/core';
|
|
||||||
import { toSignal } from '@angular/core/rxjs-interop';
|
|
||||||
import { OidcSecurityService } from 'angular-auth-oidc-client';
|
|
||||||
import { map } from 'rxjs';
|
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
|
|
||||||
/** The subset of the medewerker token the portal reads: Keycloak nests realm roles here. */
|
|
||||||
interface MedewerkerClaims {
|
|
||||||
realm_access?: { roles?: string[] };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Medewerker-backed AuthService over angular-auth-oidc-client (Keycloak `medewerker` realm). */
|
|
||||||
@Injectable()
|
|
||||||
export class MedewerkerAuthService extends AuthService {
|
|
||||||
private readonly oidc = inject(OidcSecurityService);
|
|
||||||
|
|
||||||
readonly isAuthenticated: Signal<boolean> = toSignal(
|
|
||||||
this.oidc.isAuthenticated$.pipe(map((result) => result.isAuthenticated)),
|
|
||||||
{ initialValue: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Staff have no BSN; the abstract surface keeps this present for the shared guard/interceptor.
|
|
||||||
readonly bsn: Signal<string | undefined> = toSignal(this.oidc.userData$.pipe(map(() => undefined)), {
|
|
||||||
initialValue: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
override readonly roles: Signal<readonly string[]> = toSignal(
|
|
||||||
this.oidc.userData$.pipe(
|
|
||||||
map((data) => (data.userData as MedewerkerClaims | null)?.realm_access?.roles ?? []),
|
|
||||||
),
|
|
||||||
{ initialValue: [] },
|
|
||||||
);
|
|
||||||
|
|
||||||
override login(): void {
|
|
||||||
this.oidc.authorize();
|
|
||||||
}
|
|
||||||
|
|
||||||
override logout(): void {
|
|
||||||
this.oidc.logoff().subscribe();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -26,7 +26,6 @@ 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<WithdrawRegistration>();
|
|
||||||
builder.Services.AddScoped<Werkbak>();
|
builder.Services.AddScoped<Werkbak>();
|
||||||
builder.Services.AddScoped<OpenZaakWorker>();
|
builder.Services.AddScoped<OpenZaakWorker>();
|
||||||
builder.Services.AddScoped<OpenZaakJobProcessor>();
|
builder.Services.AddScoped<OpenZaakJobProcessor>();
|
||||||
@@ -75,19 +74,6 @@ app.MapPost("/registrations/{id}/decide", async (string id, DecideRequest body,
|
|||||||
return Results.NoContent();
|
return Results.NoContent();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Withdraw a registration (S-11): the zorgprofessional pulls their own still-open submission back,
|
|
||||||
// advancing it to INGETROKKEN. Idempotent. The BFF reaches this behind a digid token, owner-scoped
|
|
||||||
// to the caller's bsn (S-11c); the domain trusts its callers (§8.3). Cancelling the running Flowable
|
|
||||||
// process is a later sub-slice (S-11b).
|
|
||||||
app.MapPost("/registrations/{id}/withdraw", async (string id, WithdrawRegistration withdraw, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
if (!Guid.TryParse(id, out var guid))
|
|
||||||
return Results.NotFound();
|
|
||||||
|
|
||||||
await withdraw.HandleAsync(new WithdrawRegistrationCommand(new RegistrationId(guid)), ct);
|
|
||||||
return Results.NoContent();
|
|
||||||
});
|
|
||||||
|
|
||||||
// The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open
|
// 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
|
// 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).
|
// medewerker-realm + behandelaar-role authorization; the domain trusts its callers (§8.3).
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
using Big.Domain;
|
|
||||||
|
|
||||||
namespace Big.Application;
|
|
||||||
|
|
||||||
/// <summary>A zorgprofessional's request to withdraw their own registration ("trek aanvraag in").</summary>
|
|
||||||
public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The withdrawal use case (S-11): a zorgprofessional pulls a still-open registration back. It
|
|
||||||
/// advances the aggregate to INGETROKKEN and persists it. Idempotent — a repeated or redelivered
|
|
||||||
/// withdrawal of an already-withdrawn registration is a no-op (the aggregate is not persisted again).
|
|
||||||
/// Cancelling the running Flowable process (so the case leaves the behandelaar's werkbak) is a later
|
|
||||||
/// sub-slice (S-11b, via a BPMN message event); this sub-slice owns the domain transition only —
|
|
||||||
/// mirroring how the beoordeling's rejection deferred its zaak propagation.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class WithdrawRegistration(IRegistrationStore store)
|
|
||||||
{
|
|
||||||
public async Task HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(command);
|
|
||||||
|
|
||||||
var registration = await store.GetAsync(command.RegistrationId, ct)
|
|
||||||
?? throw new InvalidOperationException($"No registration {command.RegistrationId} to withdraw.");
|
|
||||||
|
|
||||||
// A repeated withdrawal is a no-op: don't persist the already-withdrawn aggregate again.
|
|
||||||
if (registration.Status == RegistrationStatus.Ingetrokken)
|
|
||||||
return;
|
|
||||||
|
|
||||||
registration.Withdraw();
|
|
||||||
await store.SaveAsync(registration, ct);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -111,23 +111,7 @@ public sealed class Registration
|
|||||||
Status = RegistrationStatus.Afgewezen;
|
Status = RegistrationStatus.Afgewezen;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
// A decision is only valid while the registration is still open (INGEDIEND or IN_BEHANDELING).
|
||||||
/// Withdraw the registration — the zorgprofessional pulls their own submission back (S-11). Allowed
|
|
||||||
/// while it is still open (INGEDIEND or IN_BEHANDELING) and needs no zaak; a registration that has
|
|
||||||
/// already been decided (INGESCHREVEN/AFGEWEZEN) can no longer be withdrawn. Re-withdrawing one
|
|
||||||
/// already <see cref="RegistrationStatus.Ingetrokken"/> is a no-op.
|
|
||||||
/// </summary>
|
|
||||||
public void Withdraw()
|
|
||||||
{
|
|
||||||
if (Status == RegistrationStatus.Ingetrokken)
|
|
||||||
return;
|
|
||||||
|
|
||||||
RequireOpenForDecision(nameof(Withdraw));
|
|
||||||
Status = RegistrationStatus.Ingetrokken;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A decision (or withdrawal) is only valid while the registration is still open (INGEDIEND or
|
|
||||||
// IN_BEHANDELING).
|
|
||||||
private void RequireOpenForDecision(string decision)
|
private void RequireOpenForDecision(string decision)
|
||||||
{
|
{
|
||||||
if (Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling))
|
if (Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling))
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ namespace Big.Domain;
|
|||||||
/// <summary>The lifecycle states a <see cref="Registration"/> moves through. Submission starts in
|
/// <summary>The lifecycle states a <see cref="Registration"/> moves through. Submission starts in
|
||||||
/// <see cref="Ingediend"/>; a behandelaar takes it <see cref="InBehandeling"/> and decides it into one
|
/// <see cref="Ingediend"/>; a behandelaar takes it <see cref="InBehandeling"/> and decides it into one
|
||||||
/// of the terminal states <see cref="Ingeschreven"/> (approved) or <see cref="Afgewezen"/> (rejected).
|
/// of the terminal states <see cref="Ingeschreven"/> (approved) or <see cref="Afgewezen"/> (rejected).
|
||||||
/// A zorgprofessional can withdraw a still-open registration into <see cref="Ingetrokken"/> (S-11).
|
/// Withdrawal and herregistratie states arrive in their own slices (S-11+).</summary>
|
||||||
/// The herregistratie state arrives in its own slice.</summary>
|
|
||||||
public enum RegistrationStatus
|
public enum RegistrationStatus
|
||||||
{
|
{
|
||||||
/// <summary>Submitted by the zorgprofessional; the registratie process has been started.</summary>
|
/// <summary>Submitted by the zorgprofessional; the registratie process has been started.</summary>
|
||||||
@@ -18,7 +17,4 @@ public enum RegistrationStatus
|
|||||||
|
|
||||||
/// <summary>Rejected by the behandelaar. Terminal.</summary>
|
/// <summary>Rejected by the behandelaar. Terminal.</summary>
|
||||||
Afgewezen,
|
Afgewezen,
|
||||||
|
|
||||||
/// <summary>Withdrawn by the zorgprofessional before a decision (S-11). Terminal.</summary>
|
|
||||||
Ingetrokken,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -217,72 +217,4 @@ public class RegistrationTests
|
|||||||
Assert.Contains("Afgewezen", rejectEx.Message);
|
Assert.Contains("Afgewezen", rejectEx.Message);
|
||||||
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
|
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Withdrawing_an_ingediend_registration_sets_it_ingetrokken()
|
|
||||||
{
|
|
||||||
var registration = Registration.Submit("123456782");
|
|
||||||
|
|
||||||
registration.Withdraw();
|
|
||||||
|
|
||||||
Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Withdrawing_an_in_behandeling_registration_sets_it_ingetrokken()
|
|
||||||
{
|
|
||||||
// A citizen can still pull a registration back while a behandelaar has it in behandeling.
|
|
||||||
var registration = Registration.Submit("123456782");
|
|
||||||
registration.TakeIntoBehandeling();
|
|
||||||
|
|
||||||
registration.Withdraw();
|
|
||||||
|
|
||||||
Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Withdrawing_needs_no_zaak()
|
|
||||||
{
|
|
||||||
// Withdrawal is the citizen's own action and does not depend on the zaak having been opened.
|
|
||||||
var registration = Registration.Submit("123456782");
|
|
||||||
|
|
||||||
registration.Withdraw();
|
|
||||||
|
|
||||||
Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status);
|
|
||||||
Assert.Null(registration.ZaakUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Re_withdrawing_an_already_ingetrokken_registration_is_idempotent()
|
|
||||||
{
|
|
||||||
var registration = Registration.Submit("123456782");
|
|
||||||
registration.Withdraw();
|
|
||||||
|
|
||||||
registration.Withdraw();
|
|
||||||
|
|
||||||
Assert.Equal(RegistrationStatus.Ingetrokken, registration.Status);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Withdrawing_an_approved_registration_is_rejected()
|
|
||||||
{
|
|
||||||
var registration = Registration.Submit("123456782");
|
|
||||||
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
|
||||||
registration.Approve();
|
|
||||||
|
|
||||||
var ex = Assert.Throws<InvalidOperationException>(() => registration.Withdraw());
|
|
||||||
Assert.Contains("only an INGEDIEND", ex.Message);
|
|
||||||
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Withdrawing_a_rejected_registration_is_rejected()
|
|
||||||
{
|
|
||||||
var registration = Registration.Submit("123456782");
|
|
||||||
registration.Reject();
|
|
||||||
|
|
||||||
var ex = Assert.Throws<InvalidOperationException>(() => registration.Withdraw());
|
|
||||||
Assert.Contains("only an INGEDIEND", ex.Message);
|
|
||||||
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
using Big.Application;
|
|
||||||
using Big.Domain;
|
|
||||||
|
|
||||||
namespace Big.Tests;
|
|
||||||
|
|
||||||
public class WithdrawRegistrationTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task Withdrawing_marks_the_registration_ingetrokken_and_persists_it()
|
|
||||||
{
|
|
||||||
var store = new FakeRegistrationStore();
|
|
||||||
var registration = Registration.Submit("123456782");
|
|
||||||
store.Seed(registration);
|
|
||||||
var handler = new WithdrawRegistration(store);
|
|
||||||
|
|
||||||
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
|
|
||||||
|
|
||||||
var saved = await store.GetAsync(registration.Id);
|
|
||||||
Assert.Equal(RegistrationStatus.Ingetrokken, saved!.Status);
|
|
||||||
Assert.Equal(1, store.SaveCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Rejects_a_null_command_without_touching_the_store()
|
|
||||||
{
|
|
||||||
var store = new FakeRegistrationStore();
|
|
||||||
var handler = new WithdrawRegistration(store);
|
|
||||||
|
|
||||||
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
|
|
||||||
Assert.Equal(0, store.SaveCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Withdrawing_an_unknown_registration_throws()
|
|
||||||
{
|
|
||||||
var store = new FakeRegistrationStore();
|
|
||||||
var handler = new WithdrawRegistration(store);
|
|
||||||
|
|
||||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
||||||
() => handler.HandleAsync(new WithdrawRegistrationCommand(RegistrationId.New())));
|
|
||||||
Assert.Contains("No registration", ex.Message);
|
|
||||||
Assert.Equal(0, store.SaveCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Re_withdrawing_an_already_ingetrokken_registration_is_idempotent()
|
|
||||||
{
|
|
||||||
var store = new FakeRegistrationStore();
|
|
||||||
var registration = Registration.Submit("123456782");
|
|
||||||
store.Seed(registration);
|
|
||||||
var handler = new WithdrawRegistration(store);
|
|
||||||
|
|
||||||
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
|
|
||||||
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
|
|
||||||
|
|
||||||
// The second withdrawal is a no-op: the aggregate is not persisted again.
|
|
||||||
Assert.Equal(1, store.SaveCount);
|
|
||||||
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,9 +3,6 @@ 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: '.',
|
||||||
@@ -25,9 +22,7 @@ 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: {
|
launchOptions: { args: [`--unsafely-treat-insecure-origin-as-secure=${baseURL}`] },
|
||||||
args: [`--unsafely-treat-insecure-origin-as-secure=${baseURL},${behandelURL}`],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
// Walking-skeleton happy path (S-08d + S-09 + S-09b + S-12): a zorgprofessional logs in via mock
|
// Walking-skeleton happy path (S-08d + S-09 + S-09b): a zorgprofessional logs in via mock DigiD and
|
||||||
// DigiD and submits through the self-service portal → BFF → domain; the entry appears in the openbaar
|
// submits through the self-service portal → BFF → domain; the entry appears in the openbaar register
|
||||||
// register as INGEDIEND; a behandelaar then logs in to the behandel portal, finds the registration in
|
// as INGEDIEND; a behandelaar approves it via the temporary admin endpoint; the approval flows via the
|
||||||
// the werkbak, and approves it (goedkeuren); the decision completes the Flowable Beoordelen task and
|
// ACL → NRC → event-subscriber → projection, and the openbaar register then shows it as INGESCHREVEN.
|
||||||
// flows via the ACL → NRC → event-subscriber → projection, and the openbaar register shows INGESCHREVEN.
|
test('DigiD login → submit → public INGEDIEND → approve → public INGESCHREVEN', async ({ page, request }) => {
|
||||||
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('/');
|
||||||
|
|
||||||
@@ -44,42 +43,21 @@ test('DigiD submit → public INGEDIEND → behandelaar goedkeurt → public ING
|
|||||||
await expect(page.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' }))
|
await expect(page.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGEDIEND' }))
|
||||||
.toBeVisible();
|
.toBeVisible();
|
||||||
|
|
||||||
// A behandelaar picks the registration up in the behandel-portal werkbak and approves it
|
// Approve via the temporary admin endpoint (reached directly on the compose network, as a
|
||||||
// (goedkeuren) — the S-12 flow that replaces the temporary admin endpoint. Navigating here switches
|
// behandelaar would until the behandel-portal exists — S-12). The zaak is opened off the request
|
||||||
// to the medewerker realm (a different Keycloak realm than the citizen's digid session).
|
// path by the worker, so wait for it before approving.
|
||||||
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 () => {
|
||||||
await page.reload();
|
const res = await request.get(`http://domain:8080/registrations/${reference}`);
|
||||||
return goedkeuren.count();
|
return res.ok() ? (await res.json()).zaakUrl : null;
|
||||||
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
||||||
.toBeGreaterThan(0);
|
.toBeTruthy();
|
||||||
|
|
||||||
// Click and wait for the decide POST to finish (204) BEFORE leaving the page. `click()` only
|
const approve = await request.post(`http://domain:8080/registrations/${reference}/approve`);
|
||||||
// dispatches the request; navigating away immediately cancels it in flight (nginx logs a 499) and
|
expect(approve.status()).toBe(204);
|
||||||
// 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; back on the openbaar register *our* row (matched by
|
// The approval flows back to the projection; the openbaar register now shows *our* row (matched by
|
||||||
// its reference) now shows INGESCHREVEN.
|
// its reference) as 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