Compare commits

..
Author SHA1 Message Date
notandClaude Opus 4.8 88af769d45 fix(e2e): run Playwright single-worker to stop OOM page-crash in verify-stack (refs #115)
CI / lint (pull_request) Successful in 1m19s
CI / build (pull_request) Successful in 1m2s
CI / unit (pull_request) Successful in 1m14s
CI / frontend (pull_request) Successful in 2m37s
CI / mutation (pull_request) Successful in 5m34s
CI / verify-stack (pull_request) Successful in 8m49s
verify-stack intermittently failed on the e2e with "Page crashed" mid-action:
Playwright defaulted to 2 workers, so two full chromium browsers ran alongside the
whole compose stack on the 8 GB runner and the renderer was OOM-killed. Pin
workers: 1 (only two long-running happy-path specs) and add --disable-dev-shm-usage,
removing the memory contention rather than masking it with retries (CLAUDE.md §15).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 13:26:34 +02:00
113 changed files with 147 additions and 3798 deletions
+5 -25
View File
@@ -9,12 +9,6 @@ on:
permissions:
contents: read
# Supersede stale runs: a new push to the same branch/PR cancels the previous run, so the runner's
# concurrency slots aren't spent on commits nobody is waiting for (refs #127).
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Self-hosted runner — see docs/runbooks/ci.md for the runner setup.
# `uses:` are absolute, tag-pinned URLs (CLAUDE.md §8.7 / §15).
@@ -135,20 +129,12 @@ jobs:
path: services/bff/StrykerOutput/**/reports/mutation-report.html
if-no-files-found: warn
# One stage for every check that needs the live stack. Booting OpenZaak once (instead
# of once per job) is the cheapest layout (issue #58). No setup-dotnet: the ACL test runs
# in a built image and everything reaches services by container IP. Needs Docker + egress
# One stage for every check that needs the live stack. On the single self-hosted
# runner jobs run sequentially, so booting OpenZaak once (instead of once per job)
# is the cheapest layout (issue #58). No setup-dotnet: the ACL test runs in a built
# image and everything reaches services by container IP. Needs Docker + egress
# (base images, nuget, selectielijst.openzaak.nl).
#
# `needs: [mutation]` is NOT a data dependency — it serialises the two memory-heavy jobs so
# they never co-schedule now the runner has capacity >1. A concurrent Stryker run + full-stack
# bring-up + Playwright browser on one host is what OOMs the e2e (commit d5e5fa2, #126). The
# light .NET/frontend jobs have no `needs`, so they still parallelise up to runner capacity.
# `if: !cancelled()` keeps verify-stack running even when the mutation ratchet fails (so we don't
# lose its signal) while still honouring run cancellation from the concurrency group above.
verify-stack:
needs: [mutation]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
@@ -156,8 +142,6 @@ jobs:
# reaches green health" smoke (it replaces the old compose-smoke job).
- name: Bring up the full stack & wait for health
run: make verify-up
- name: Observability backplane (Grafana + Tempo + Prometheus datasources)
run: OBS_TIMEOUT=180 make verify-observability
- name: ACL ↔ OpenZaak integration tests
run: make verify-acl
- name: OpenZaak → NRC notification delivery
@@ -168,16 +152,12 @@ jobs:
run: make verify-domain
- name: BFF → Keycloak + domain + projection
run: make verify-bff
- name: Distributed traces reach Tempo (one connected trace across services)
run: TRACING_TIMEOUT=120 make verify-tracing
- name: Golden-signal metrics scraped by Prometheus (/metrics on every service)
run: METRICS_TIMEOUT=120 make verify-metrics
- name: Self-service e2e (Playwright, login → submit → success)
run: make verify-e2e
# Log dump must precede teardown (which removes the containers).
- name: Dump container logs on failure
if: failure()
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer tempo prometheus grafana 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
if: always()
run: make down
-1
View File
@@ -57,4 +57,3 @@ vitest.config.*.timestamp*
tests/e2e/node_modules/
tests/e2e/test-results/
tests/e2e/playwright-report/
__pycache__/
+4 -16
View File
@@ -249,29 +249,17 @@ Split (issue #11 closed) into two independently-demoable slices per §13 — the
## Iteration 3 — Maintenance portal and observability *(milestone: `Iteration 3 — Beheer & Observability`)*
### S-15 · Beheer-portal — catalogus & default-fill rules *(split — #16 closed)*
### S-15 · Beheer-portal — catalogus & default-fill rules
**Outcome:** Beheer portal lets an admin view ZTC catalogi (read-only first), and manage the ACL's default-fill configuration via a CRUD UI. MFA on the medewerker realm enforced.
Split into independently deployable sub-slices (CLAUDE.md §13):
- **S-15a** (#130) · Beheer portal skeleton + read-only catalogi viewer — new beheer Angular app (medewerker-realm login) showing ZTC catalogi/zaaktypen read-only, via a BFF `/beheer/*` read endpoint proxying a read-only ACL Catalogi endpoint (§8.1, reuses the ADR-0021 Catalogi client).
- **S-15b** (#131) · ACL default-fill configuration CRUD — the `Acl__Defaults__*` config (ADR-0003) becomes a managed store with CRUD via the BFF + a portal UI. Depends on S-15a.
- **S-15c** (#132) · Enforce MFA (OTP) on the Keycloak medewerker realm.
### S-16 · OpenTelemetry traces + Grafana dashboard *(split — #17 closed)*
### S-16 · OpenTelemetry traces + Grafana dashboard
**Outcome:** Traces span portal → BFF → Domain → ACL → OpenZaak and portal → BFF → Domain → Flowable. Grafana dashboards pre-built for golden signals.
Split into independently deployable sub-slices (CLAUDE.md §13):
### S-17 · Quartz.NET scheduler — herregistratie reminder sweep
- **S-16a** (#122) · Observability backplane — Grafana Tempo + Prometheus + Grafana in compose, datasources auto-provisioned (ADR-0023). No collector; config baked into built images.
- **S-16b** (#123) · Distributed traces across the five .NET services (OTLP → Tempo; traceparent propagates via the typed HttpClients). Depends on S-16a. ✅
- **S-16c** (#124) · Prometheus metrics + golden-signal Grafana dashboards. Depends on S-16a. ✅
### S-17 · Quartz.NET scheduler — herregistratie reminder sweep ✅
**Outcome:** Daily Quartz.NET cron job finds inscriptions within 90 days of their herregistratie deadline and reminds each (flag on the aggregate + log). No outbound notification and no domain event in v1 — the reminder is the persisted flag, surfaced on the read model (ADR-0022, #120). Quartz fires time-triggered sweeps; the existing pumps stay as queue-drainers.
**Outcome:** Nightly job that finds entries within 90 days of expiry and emits a domain event. (No outbound notification in v1 — logged.)
---
+2 -22
View File
@@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml
# Long-running services with a healthcheck — the smoke polls these for readiness
# (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init)
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel beheer
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
# 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
@@ -43,7 +43,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
endif
endif
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-notifications smoke up down local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions)
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
@@ -114,11 +114,6 @@ local:
docker compose -f $(LOCAL_COMPOSE) up -d --build
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
## verify-local: acceptance check for the local stack (S-B04) — a fresh `make local` completes the
## whole flow (zaaktype seeded + DMN deployed + NRC abonnement) with NO manual seeding.
verify-local:
bash infra/run-local-flow-check.sh
## local-down: stop and remove the bind-mount stack
local-down:
docker compose -f $(LOCAL_COMPOSE) down --volumes
@@ -170,21 +165,6 @@ verify-bff:
verify-e2e:
bash infra/run-e2e-check.sh
## verify-observability: assert the observability backplane (Grafana + provisioned Tempo &
## Prometheus datasources) is live, against the already-running stack (S-16a).
verify-observability:
bash infra/run-observability-check.sh
## verify-tracing: assert one connected distributed trace spans the .NET services in Tempo
## (S-16b), against the already-running stack.
verify-tracing:
bash infra/run-tracing-check.sh
## verify-metrics: assert the services expose /metrics and Prometheus scrapes the golden
## signals (S-16c), against the already-running stack.
verify-metrics:
bash infra/run-metrics-check.sh
## verify: local mirror of the CI verify-stack job — full stack up once, all checks,
## tear down (always). For fast single-concern local iteration use `integration`
## (oz-only) or `verify-notifications` (oz+nrc) instead.
-27
View File
@@ -1,27 +0,0 @@
# Multi-stage build for the beheer 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/beheer apps/beheer
COPY libs libs
RUN pnpm nx build beheer
FROM nginx:1.27-alpine AS runtime
COPY apps/beheer/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/apps/beheer/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
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
# the nginx image's /docker-entrypoint.d before nginx starts.
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
EXPOSE 80
-34
View File
@@ -1,34 +0,0 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];
-24
View File
@@ -1,24 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
# even before the BFF is up and picks up restarts — instead of failing to load the config.
resolver 127.0.0.11 ipv6=off valid=30s;
# Same-origin API: proxy the beheer 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 /beheer/ {
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
View File
@@ -1,80 +0,0 @@
{
"name": "beheer",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/beheer/src",
"tags": [],
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"defaultConfiguration": "production",
"options": {
"outputPath": "dist/apps/beheer",
"browser": "apps/beheer/src/main.ts",
"tsConfig": "apps/beheer/tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "apps/beheer/public"
}
],
"styles": ["apps/beheer/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": "beheer:build:production"
},
"development": {
"buildTarget": "beheer: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": "beheer:build",
"staticFilePath": "dist/apps/beheer/browser",
"spa": true
}
}
}
}
-3
View File
@@ -1,3 +0,0 @@
{
"authority": "http://localhost:8180/realms/medewerker"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

-65
View File
@@ -1,65 +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 beheer 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('beheer 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 catalogus call', () => {
bff.getBeheerCatalogiZaaktypen().subscribe();
const req = http.expectOne('/beheer/catalogi/zaaktypen');
expect(req.request.headers.get('Authorization')).toBe(`Bearer ${token}`);
req.flush([]);
});
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
View File
@@ -1,39 +0,0 @@
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { authInterceptor, provideMedewerkerAuth } from 'auth';
import { appRoutes } from './app.routes';
/** Environment-specific settings fetched from /config.json at startup (see main.ts). */
export interface RuntimeConfig {
/** The Keycloak `medewerker` realm issuer as the browser reaches it (dev: localhost; compose: keycloak:8080). */
authority: string;
}
/**
* Route prefixes whose requests carry the medewerker token. These MUST match the **relative** URLs
* the api-client actually calls (same-origin via the nginx proxy) — the interceptor matches on
* `req.url`, which stays relative, so an absolute origin would never match and the token would go
* unattached. Only `/beheer/` is secured; the app calls no other endpoint group.
*/
export const SECURE_API_ROUTES = ['/beheer/'];
/**
* Build the app providers from runtime config. `redirectUrl` is the app's own origin (where Keycloak
* redirects back). `secureRoutes` uses {@link SECURE_API_ROUTES} — relative prefixes, not the origin.
*/
export function appConfig(runtime: RuntimeConfig): ApplicationConfig {
const origin = typeof window !== 'undefined' ? window.location.origin : '/';
return {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(appRoutes),
provideHttpClient(withInterceptors([authInterceptor()])),
provideMedewerkerAuth({
authority: runtime.authority,
redirectUrl: origin,
secureRoutes: SECURE_API_ROUTES,
}),
],
};
}
View File
-1
View File
@@ -1 +0,0 @@
<router-outlet></router-outlet>
-7
View File
@@ -1,7 +0,0 @@
import { Route } from '@angular/router';
import { authenticatedGuard } from 'auth';
import { CatalogusPage } from './catalogus/catalogus-page';
export const appRoutes: Route[] = [
{ path: '', component: CatalogusPage, canActivate: [authenticatedGuard] },
];
-15
View File
@@ -1,15 +0,0 @@
import { provideRouter } from '@angular/router';
import { render, screen } from '@testing-library/angular';
import { App } from './app';
describe('App', () => {
it('renders the router outlet shell', async () => {
const { container } = await render(App, {
providers: [provideRouter([])],
});
// The shell is a thin host for routed pages (the CatalogusPage owns the heading).
expect(container.querySelector('router-outlet')).toBeTruthy();
expect(screen).toBeTruthy();
});
});
-12
View File
@@ -1,12 +0,0 @@
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
@Component({
imports: [RouterModule],
selector: 'app-root',
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {
protected title = 'beheer';
}
@@ -1,40 +0,0 @@
<main utrecht-document class="utrecht-theme">
<utrecht-article>
<utrecht-heading-1>Catalogus</utrecht-heading-1>
<p utrecht-paragraph>
De gepubliceerde zaaktypen uit de ZTC-catalogus. Alleen-lezen — beheer van de default-fill volgt
in een latere slice.
</p>
@if (loading()) {
<p utrecht-paragraph role="status">Bezig met laden…</p>
} @else if (failed()) {
<p utrecht-paragraph role="alert">
Kon de catalogus niet laden. Controleer of je als beheerder bent ingelogd en probeer het
opnieuw.
</p>
} @else if (loaded() && items().length === 0) {
<p utrecht-paragraph role="status">De catalogus bevat geen gepubliceerde zaaktypen.</p>
} @else if (items().length > 0) {
<table utrecht-table>
<caption>
Gepubliceerde zaaktypen
</caption>
<thead>
<tr>
<th scope="col">Identificatie</th>
<th scope="col">Omschrijving</th>
</tr>
</thead>
<tbody>
@for (zaaktype of items(); track zaaktype.identificatie) {
<tr>
<td>{{ zaaktype.identificatie }}</td>
<td>{{ zaaktype.omschrijving }}</td>
</tr>
}
</tbody>
</table>
}
</utrecht-article>
</main>
@@ -1,75 +0,0 @@
import { signal } from '@angular/core';
import { render, screen } from '@testing-library/angular';
import { of, throwError } from 'rxjs';
import { BeheerZaaktype, BffApiV1Service } from 'api-client';
import { AuthService } from 'auth';
import { axe } from 'vitest-axe';
import { CatalogusPage } from './catalogus-page';
const sample: BeheerZaaktype[] = [
{ identificatie: 'BIG-REGISTRATIE', omschrijving: 'BIG-registratie' },
{ identificatie: 'BIG-HERREGISTRATIE', omschrijving: 'BIG-herregistratie' },
];
class FakeAuth extends AuthService {
readonly isAuthenticated = signal(true);
readonly bsn = signal<string | undefined>(undefined);
override readonly roles = signal<readonly string[]>(['beheerder']);
login(): void {
/* not exercised here */
}
logout(): void {
/* not exercised here */
}
}
function setup(overrides: { getBeheerCatalogiZaaktypen?: ReturnType<typeof vi.fn> } = {}) {
const getBeheerCatalogiZaaktypen =
overrides.getBeheerCatalogiZaaktypen ?? vi.fn().mockReturnValue(of(sample));
return {
getBeheerCatalogiZaaktypen,
providers: [
{ provide: BffApiV1Service, useValue: { getBeheerCatalogiZaaktypen } },
{ provide: AuthService, useClass: FakeAuth },
],
};
}
describe('CatalogusPage', () => {
it('lists the published zaaktypen on open', async () => {
const { getBeheerCatalogiZaaktypen, providers } = setup();
await render(CatalogusPage, { providers });
expect(getBeheerCatalogiZaaktypen).toHaveBeenCalled();
expect(await screen.findByText('BIG-REGISTRATIE')).toBeTruthy();
expect(screen.getByText('BIG-registratie')).toBeTruthy();
expect(screen.getByText('BIG-HERREGISTRATIE')).toBeTruthy();
});
it('shows an empty state when the catalogus has no published zaaktypen', async () => {
const { providers } = setup({ getBeheerCatalogiZaaktypen: vi.fn().mockReturnValue(of([])) });
await render(CatalogusPage, { providers });
expect(await screen.findByText(/geen gepubliceerde zaaktypen/i)).toBeTruthy();
});
it('surfaces a load failure instead of swallowing it', async () => {
const { providers } = setup({
getBeheerCatalogiZaaktypen: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
});
await render(CatalogusPage, { providers });
expect(await screen.findByText(/kon de catalogus niet laden/i)).toBeTruthy();
});
it('has no WCAG 2.1 AA violations', async () => {
document.documentElement.lang = 'nl';
const { container } = await render(CatalogusPage, { providers: setup().providers });
const results = await axe(container, {
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
});
expect(results.violations).toEqual([]);
});
});
@@ -1,45 +0,0 @@
import { Component, inject, signal } from '@angular/core';
import { BeheerZaaktype, BffApiV1Service } from 'api-client';
import { UtrechtComponentsModule } from 'ui';
/**
* The beheer catalogus viewer (S-15a): a signed-in beheerder sees the published ZTC zaaktypen,
* read-only. The list is served by the BFF (`GET /beheer/catalogi/zaaktypen`), which proxies the ACL —
* the only code allowed to read the ZGW Catalogi API (§8.1, ADR-0025). Managing default-fill is S-15b.
*/
@Component({
selector: 'app-catalogus-page',
imports: [UtrechtComponentsModule],
templateUrl: './catalogus-page.html',
})
export class CatalogusPage {
private readonly bff = inject(BffApiV1Service);
protected readonly items = signal<BeheerZaaktype[]>([]);
protected readonly loading = signal(false);
protected readonly loaded = signal(false);
protected readonly failed = signal(false);
constructor() {
this.load();
}
load(): void {
this.loading.set(true);
this.failed.set(false);
this.bff.getBeheerCatalogiZaaktypen().subscribe({
next: (rows: BeheerZaaktype[]) => {
this.items.set(rows);
this.loading.set(false);
this.loaded.set(true);
},
// Surface the failure (e.g. 403 for a non-beheerder) instead of swallowing it.
error: () => {
this.items.set([]);
this.loading.set(false);
this.loaded.set(true);
this.failed.set(true);
},
});
}
}
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8" />
<title>Beheerportaal 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
View File
@@ -1,10 +0,0 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { App } from './app/app';
import { appConfig, type RuntimeConfig } from './app/app.config';
// Load environment config before bootstrap so the OIDC authority is set per environment
// (dev: localhost; compose: keycloak:8080) from a single build — 12-factor (S-08d).
fetch('config.json')
.then((response) => response.json() as Promise<RuntimeConfig>)
.then((config) => bootstrapApplication(App, appConfig(config)))
.catch((err) => console.error(err));
-2
View File
@@ -1,2 +0,0 @@
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
@import '@utrecht/design-tokens/dist/index.css';
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
}
-31
View File
@@ -1,31 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
-8
View File
@@ -1,8 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}
@@ -21,20 +21,16 @@ function providers(
post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
withdraw = vi.fn().mockReturnValue(of(undefined)),
provideDocuments = vi.fn().mockReturnValue(of(undefined)),
// Resume lookup (S-26): default to 204/empty — no in-flight registration, so the submit form shows.
getCurrent = vi.fn().mockReturnValue(of(undefined)),
) {
return {
post,
withdraw,
provideDocuments,
getCurrent,
providers: [
{ provide: AuthService, useClass: FakeAuth },
{
provide: BffApiV1Service,
useValue: {
getSelfServiceRegistrations: getCurrent,
postSelfServiceRegistrations: post,
postSelfServiceRegistrationsIdWithdraw: withdraw,
postSelfServiceRegistrationsIdDocuments: provideDocuments,
@@ -60,21 +56,6 @@ describe('RegistrationPage', () => {
expect(await screen.findByText(/ontvangen/i)).toBeTruthy();
});
it('resumes an existing registration on load, without submitting again (S-26)', async () => {
const { post, providers: p } = providers(
undefined,
undefined,
undefined,
vi.fn().mockReturnValue(of({ registrationId: 'reg-77', status: 'Ingediend' })),
);
await render(RegistrationPage, { providers: p });
// The confirmation view is restored from the in-flight registration — no submit click.
expect(await screen.findByText(/ontvangen/i)).toBeTruthy();
expect(screen.getByText(/reg-77/)).toBeTruthy();
expect(post).not.toHaveBeenCalled();
});
it('shows an error and keeps the submit available when the BFF call fails', async () => {
const { post, providers: p } = providers(vi.fn().mockReturnValue(throwError(() => new Error('BFF rejected'))));
await render(RegistrationPage, { providers: p });
@@ -1,5 +1,5 @@
import { Component, inject, type OnInit, signal } from '@angular/core';
import { BffApiV1Service, type CurrentRegistration, type SubmitAccepted } from 'api-client';
import { Component, inject, signal } from '@angular/core';
import { BffApiV1Service, type SubmitAccepted } from 'api-client';
import { AuthService } from 'auth';
import { UtrechtComponentsModule } from 'ui';
@@ -8,16 +8,13 @@ import { UtrechtComponentsModule } from 'ui';
* registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and-
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c). After
* submitting they can withdraw it — "trek aanvraag in" — keyed by that reference (S-11c).
*
* On load it asks the BFF for the caller's current open registration and restores the submitted view
* if there is one, so a page refresh no longer strands an in-flight registration (S-26).
*/
@Component({
selector: 'app-registration-page',
imports: [UtrechtComponentsModule],
templateUrl: './registration-page.html',
})
export class RegistrationPage implements OnInit {
export class RegistrationPage {
private readonly auth = inject(AuthService);
private readonly bff = inject(BffApiV1Service);
@@ -34,23 +31,6 @@ export class RegistrationPage implements OnInit {
protected readonly provideDocumentsFailed = signal(false);
protected readonly selectedFile = signal<File | undefined>(undefined);
/** Resume an existing in-flight registration after a refresh (S-26): the BFF returns the caller's
* current open registration, or 204 (empty body) when there is none — in which case we show the
* submit form as before. Failures are non-fatal for the same reason. */
ngOnInit(): void {
this.bff.getSelfServiceRegistrations().subscribe({
next: (current: CurrentRegistration | void) => {
if (current && current.registrationId) {
this.reference.set(current.registrationId);
this.submitted.set(true);
}
},
error: () => {
// No resumable registration (or the lookup failed) — fall back to the submit form.
},
});
}
submit(): void {
this.submitting.set(true);
this.failed.set(false);
@@ -1,92 +0,0 @@
# ADR-0020: The local stack self-seeds the zaaktype, DMN, and NRC abonnement at bring-up
- **Status:** Accepted
- **Date:** 2026-07-22
- **Deciders:** Respellion engineering
- **Relates to:** S-B04 (#110). Local-stack twin of the seeding the verify-* scripts do for CI
(`infra/run-domain-check.sh`, `infra/verify-notification-driver.py`). Superseded in part by S-27
(#113), which would let the ACL resolve its zaaktype by identificatie and remove the URL injection.
## Context
`infra/docker-compose.local.yml` is the host-browser-friendly stack (`make local`) — the one a
developer clicks through the portals with. It had drifted behind three slices, so a fresh bring-up
could not complete the flow:
1. The ACL pointed at a placeholder zaaktype (`…/00000000-…`), so zaak creation failed with OpenZaak
`400` and the registratie process stuck at `OpenZaakAanmaken` (S-05).
2. `flowable-init` deployed only `registratie.bpmn`, not `diploma-eligibility.dmn`, so completing
`WachtOpDocumenten` 404'd on the missing decision and never reached `Beoordelen` (S-10a/S-13).
3. No NRC abonnement was registered, so notifications reached NRC and went nowhere — the projection
and the openbaar register stayed empty (S-06).
The CI stack (`infra/docker-compose.yml`) does not hit this because its `verify-*` scripts seed the
zaaktype, deploy the DMN, and register the abonnement at *test* time. The local stack has no such
harness — a developer just runs `make local` and browses. The non-obvious wrinkle is (1): the
zaaktype **UUID is assigned by OpenZaak at creation**, so the ACL's zaaktype URL is not knowable when
the compose file is written and cannot be a static value.
## Decision
**Make the local stack self-seed at bring-up via one-shot init containers, and hand the ACL its
server-assigned zaaktype URL through a shared-volume env file it sources on startup.**
- **DMN (gap 2).** `flowable-init` now deploys `diploma-eligibility.dmn` to the DMN engine
(`/flowable-rest/dmn-api/dmn-repository/deployments`) as a separate deployment alongside the BPMN —
identical to the CI `flowable-init`. Idempotent.
- **Zaaktype + ACL wiring (gap 1).** A `local-seed` one-shot runs the existing
`infra/openzaak/seed_catalogus.py` (`OZ_PUBLISH=1`) against OpenZaak and writes the resulting
`Acl__Defaults__ZaaktypeUrl` / `…InformatieobjecttypeUrl` / `Acl__OpenZaak__BaseUrl` into
`seed-env:/out/acl.env`. The ACL mounts that volume read-only and overrides its entrypoint to
`sh -c 'set -a; . /seed/acl.env; set +a; exec dotnet Acl.Api.dll'`, so the real values override the
compose placeholders before the app reads config. The ACL `depends_on: local-seed
(service_completed_successfully)`.
- **Abonnement (gap 3).** A `nrc-subscribe` one-shot registers an abonnement on the `zaken` kanaal
pointing at the event-subscriber's `/notifications` callback (`infra/local/register-abonnement.py`).
It is a leaf — nothing depends on it — so it can wait for the event-subscriber without forming a
cycle with the ACL bootstrap.
- **Reach OpenZaak/NRC by container IP, not service name.** Both the seed's ZTC calls and the
abonnement's `callbackUrl` are validated by Django's URLValidator, which rejects a single-label host
like `openzaak` / `event-subscriber`. The scripts resolve the target's container IP at runtime (as
`infra/run-domain-check.sh` does), keeping the seeded URLs valid **and** host-consistent — the ACL's
base URL is set to the same OpenZaak IP that owns the zaaktype URL.
- **Acceptance.** `make verify-local` (`infra/run-local-flow-check.sh`) submits against a fresh stack
and asserts the zaak opens, the case reaches the werkbak after documents, and the reference appears
in the openbaar register — the red-to-green test for all three gaps.
## Consequences
**Positive**
- A fresh `make local` completes the full demo (submit → werkbak → openbaar) with no manual seeding —
the slice's stated outcome.
- Reuses the proven CI mechanisms (`seed_catalogus.py`, the DMN deploy, the abonnement driver) rather
than inventing new ones; the only genuinely new piece is the entrypoint-sourced env file.
- No service code changes — the fix is entirely in `infra/` (compose + two small scripts), so the ACL
image and the CI stack are untouched.
**Negative / costs**
- The two compose files diverge further: the CI stack seeds at test time, the local stack at bring-up.
Mitigated by reusing the same underlying scripts and cross-referencing them.
- The ACL entrypoint override couples the local ACL to the seed-written file path (`/seed/acl.env`);
if the seed fails, the ACL fails to start (loud, healthcheck-visible — preferred over silently
running with a placeholder).
- Container-IP-based URLs are re-derived on each bring-up; a keep-volumes restart with a changed
OpenZaak IP relies on OpenZaak rebuilding hyperlinked URLs from the request host (it does) so the
idempotent re-seed reports current-IP URLs.
## Alternatives considered
- **ACL resolves its zaaktype by identificatie (`BIG-REGISTRATIE`) at startup.** The cleaner,
less-brittle design — no server-assigned URL to capture — and it would help the CI stack too. But it
changes a service's runtime behaviour and its config contract, needs new ACL tests + mutation
coverage, and still needs a seed step to *create* the zaaktype. Deliberately split out as its own
slice with its own ADR (S-27 / #113) rather than folded into this infra-only fix.
- **A documented `make local-seed` step run after `make local`.** Smallest change, but it fails the
slice's "no manual seeding" outcome — the local stack is exactly the one meant to just work in a
browser. Rejected.
- **Fixed zaaktype UUID via OpenZaak `setup_configuration`/fixtures.** OpenZaak assigns UUIDs on POST;
declaratively creating a fully *published* zaaktype (statustypen + resultaattypen validated against
the Selectielijst + roltypen + iot relations) is not something `setup_configuration` supports
cleanly in 1.28.2. Rejected as more fragile than reusing `seed_catalogus.py`.
@@ -1,67 +0,0 @@
# ADR-0021: The ACL resolves its zaaktype by identificatie, not a pinned URL
- **Status:** Accepted
- **Date:** 2026-07-22
- **Deciders:** Respellion engineering
- **Relates to:** S-27 (#113), proposed in #117. The cleaner design deliberately split out of S-B04
(#110, ADR-0020), which fixed the local stack with an infra-only bootstrap.
## Context
The ACL was handed a **pinned zaaktype URL** (`Acl__Defaults__ZaaktypeUrl`) and diploma
informatieobjecttype URL. OpenZaak assigns those UUIDs at creation, so the URL is not knowable when
the compose file is written — every stack had to seed the catalogus and then capture + inject the
resulting URLs out of band: `run-domain-check.sh` for CI, and the `local-seed``acl.env` bootstrap
(ADR-0020) for `make local`. Brittle, and a stale/placeholder URL failed opaquely (OpenZaak 400).
## Decision
**The ACL resolves its zaaktype (by `identificatie`) and diploma informatieobjecttype (by
`omschrijving`) from OpenZaak's Catalogi API, instead of being handed the URLs.**
- **Config:** `AclDefaults.ZaaktypeUrl`/`InformatieobjecttypeUrl``ZaaktypeIdentificatie`
(`BIG-REGISTRATIE`) / `InformatieobjecttypeOmschrijving` (`Diploma`).
- **Lookup (gateway, §8.1):** `GET /catalogi/api/v1/zaaktypen?status=definitief&identificatie=…`
the published zaaktype URL; `GET /catalogi/api/v1/informatieobjecttypen?status=definitief` matched
on `omschrijving`. Reuses the gateway's existing catalogus-query machinery.
- **Timing = lazy + cached (`CachedZaaktypeCatalog`).** Resolve on first use (first zaak open /
document store) and cache for the process lifetime. Lazy avoids a startup ordering coupling — the
ACL never crash-loops when it boots before the catalogus is published. A **failed** resolution is
not cached, so it is retried on the next call (e.g. once the zaaktype is published); a restart
re-resolves.
- **Failure mode:** no published match → a clear "No published zaaktype with identificatie '…' found
in OpenZaak — is the BIG catalogus seeded and published?" error, replacing the opaque placeholder
400.
## Consequences
**Positive**
- No stack captures or injects a server-assigned URL any more: `run-domain-check.sh` drops the
`ACL_ZAAKTYPE_URL`/`ACL_INFORMATIEOBJECTTYPE_URL` capture+inject, `docker-compose.yml`/`.local.yml`
drop the placeholder URL env, and `local-seed`/`acl.env` shrink to a single line. The ACL
self-configures from the catalogus it already talks to.
- The failure mode is legible (a named error instead of a 400 on a zeros-UUID).
**Negative / costs**
- The ACL still needs its OpenZaak **BaseUrl** pointed at a **URL-valid host (a container IP)**, so
the base-URL injection from ADR-0020 stays (the local `acl.env` now carries only that; CI keeps
`ACL_OPENZAAK_BASEURL`). This is **not** something S-27 can remove: OpenZaak validates the
`zaaktype` field on zaak-create with Django's URLValidator and **rejects a single-label host**
(`http://openzaak:8000/…``zaaktype: bad-url, "Voer een geldige URL in."`, confirmed empirically).
So ADR-0020's `seed-env` volume + ACL entrypoint shim are **simplified, not deleted**.
- New branching in the gateway/resolver → unit + integration test surface; the mutation ratchet
covers it (§5).
- A seed step still **creates + publishes** the zaaktype (this ADR changes only discovery). Reaching
OpenZaak's Catalogi API to *seed* likewise needs the IP host (its query params hit the same
URLValidator) — unchanged from before.
## Alternatives considered
- **Resolve at startup** (eager). Simpler cache, but reintroduces the ordering coupling (crash-loop
if the catalogus isn't published yet). Rejected in favour of lazy.
- **Per-request resolution** (no cache). No stale-cache risk, but a Catalogi lookup on every ACL
operation. Rejected; a process-lifetime cache with restart-to-refresh is enough here.
- **Keep the pinned URL** (status quo / ADR-0020 only). Rejected — the brittleness this ADR removes is
exactly what S-27 was carved out to fix.
@@ -1,79 +0,0 @@
# ADR-0022: Quartz.NET for time-triggered fleet sweeps
- **Status:** Accepted
- **Date:** 2026-07-23
- **Deciders:** Respellion engineering
- **Slice:** S-17 (#18) · **Proposal issue:** #120
## Context
A BIG inscription is valid for a fixed term; before it lapses the zorgprofessional
must herregistreren. S-17 adds a **herregistratie reminder sweep**: once a day,
scan the register for inscriptions whose deadline is within the reminder window and
remind each one.
The Domain Service already runs periodic background work — `OpenZaakJobPump`,
`BeoordelingEscalatiePump`, `RegistratieVerlopenPump`. Those are **continuous job
pollers**: they drain Flowable's external-task/job queues at-least-once, picking up
work as soon as it is parked, on a short poll interval. The reminder sweep is a
different shape of work: **time-triggered**, once a day, over our own store — there
is no queue to drain and no "as soon as possible" requirement.
The PRD already names the scheduler component: "Scheduler (Quartz.NET): fleet-wide
sweeps (expiry, reminders)" (§39, §94). Adding Quartz.NET is nonetheless a new
dependency, so this decision is recorded before the code lands (CLAUDE.md §14).
## Decision
**Use Quartz.NET for time-triggered fleet sweeps, starting with the herregistratie
reminder sweep. Leave the existing pumps as `BackgroundService` job pollers.**
- `HerregistratieReminderJob` (a Quartz `IJob`) is fired by a cron trigger — daily
at 03:00 by default, overridable with `Quartz__Cron`. It is a thin shell: it
resolves the pure `HerregistratieReminderSweep` (application layer) and logs how
many reminders went out.
- The sweep's rule lives in the domain: `Registration.HerregistratieReminderDue(asOf)`,
which the store query and the sweep both build on. The sweep marks each reminded
inscription (`HerregistratieReminderVerstuurd`), so a re-fire reminds no one twice
(§8.6).
Two options were rejected:
1. **A `BackgroundService` with a 24h `Task.Delay`.** No new dependency, but it
drifts to process-start time, has no cron/misfire semantics, and contradicts the
PRD's named component. A daily "run at 03:00" is exactly what cron scheduling is
for.
2. **Migrating the three pumps onto Quartz too, for one mechanism.** Rejected: the
pumps are not schedulers. Forcing a "run at time T" tool onto "drain this queue
continuously" work is churn and a boundary change for negative benefit. The
teachable distinction is worth keeping: **pumps drain queues; Quartz fires
sweeps.**
## Consequences
**Positive**
- Cron scheduling with restart-stable timing and misfire handling, for free.
- The reminder rule is one domain method, reused by the store query and the sweep;
the scheduler owns none of the policy.
- The reference app now demonstrates the intended Scheduler component.
**Negative / costs**
- One new dependency (`Quartz`, `Quartz.Extensions.Hosting`) in the Domain Service.
- Two periodic-work mechanisms coexist (pumps + Quartz). Deliberate — they model
two genuinely different concerns, documented here.
**Follow-up**
- The validity term (5 years) and reminder lead time (16 weeks) are domain
calibration knobs; promote them to beheer config (S-15) if a demo needs them
per-catalogus.
- The Quartz job stores its schedule in RAM (`RAMJobStore`); a persistent/clustered
store is a later concern if the Domain Service is scaled out.
## Coupling rules touched (CLAUDE.md §8)
None. Quartz is internal to the Domain Service and drives an application use case
over the store port. No ZGW or Flowable coupling is added; the sweep talks to no
peer module.
@@ -1,74 +0,0 @@
# ADR-0023: Grafana-native observability stack (Tempo + Prometheus + Grafana)
- **Status:** Accepted
- **Date:** 2026-07-23
- **Deciders:** Respellion engineering
- **Slice:** S-16a (#122), first of the S-16 (#17) split
## Context
The PRD calls for "OpenTelemetry traces, Prometheus metrics; a local Grafana with
pre-built dashboards" (§80). S-16 was split (CLAUDE.md §13) into a backplane slice
(this one), distributed tracing (#123), and metrics + dashboards (#124). The
backplane must stand up first: a local, CI-friendly place for traces and metrics to
land, viewable in one UI, reaching green health within the 3-minute compose budget.
Two shape decisions are non-obvious enough to record.
## Decision
**Run a Grafana-native stack — Grafana Tempo (traces) + Prometheus (metrics) +
Grafana (UI) — with the services exporting OTLP straight to Tempo (no collector),
and ship the config baked into small built images.**
### Trace backend: Tempo (not Jaeger)
Tempo keeps everything under one Grafana pane alongside metrics (and later logs),
which is exactly the "local Grafana with dashboards" the PRD asks for. Jaeger would
add a second UI and a second mental model for no benefit at this scale.
### No OTLP collector
Tempo ingests OTLP directly (gRPC 4317 / HTTP 4318) and Prometheus scrapes each
service's `/metrics`, so a collector would be a hop that processes nothing. Skipped.
If we later need fan-out, tail sampling, or log processing, a collector is an
additive change — the services already speak OTLP.
### Config baked into built images, not config volumes
The upstream Common Ground modules (OpenZaak, NRC, Keycloak, Flowable) run as
**verbatim** images and get their config streamed into external named volumes by
`infra/seed-config.sh`, because bind mounts don't reach sibling containers on the
CI runner (see `docs/runbooks/gitea-actions-gotchas.md`). The observability tools
are **not** peer modules we must run verbatim, so we take the simpler path: a
three-line `Dockerfile` per tool that `COPY`s its config in. This reaches sibling
containers everywhere (docker, podman, CI) with no seed step, no `CFG_VOLS` entry,
and no Makefile sprawl.
### Verified, not assumed
`infra/run-observability-check.sh` (the `verify-observability` step, run early in CI
`verify-stack`) asks Grafana to reach both datasources — Prometheus via its health
method, Tempo via the datasource proxy (Tempo's Grafana plugin implements no health
method) — so the check proves the datasources are actually wired, not merely that
containers started. The containers are not in `WAIT_SVCS`; the check polls Grafana
itself, so no in-image healthcheck tool is required.
## Consequences
**Positive**
- One UI for traces + metrics + (future) logs. Config is versioned in
`infra/observability/` and self-contained in the images.
- Backplane is independent of app instrumentation — #123 and #124 build on it.
**Negative / costs**
- Three more images built each CI run (kept small; not on the health-gate list).
- Storage is ephemeral container fs — a demo backplane, not a retention target.
Object storage for Tempo / remote-write for Prometheus is a later concern.
## Coupling rules touched (CLAUDE.md §8)
None. The stack is passive infrastructure: services *push* OTLP and *expose*
`/metrics`; nothing in the stack calls into a service or a peer module.
@@ -1,53 +0,0 @@
# ADR-0024: Expose OTel metrics with the (prerelease) Prometheus AspNetCore exporter
- **Status:** Accepted
- **Date:** 2026-07-24
- **Deciders:** Respellion engineering
- **Slice:** S-16c (#124), last of the S-16 (#17) split
## Context
ADR-0023 already fixed the shape of metrics collection: **Prometheus scrapes each
service's `/metrics`** (pull, no collector). S-16c implements it. That needs a package
that turns the OpenTelemetry `MeterProvider` into a Prometheus scrape endpoint inside
ASP.NET Core. The canonical one is `OpenTelemetry.Exporter.Prometheus.AspNetCore`
(`AddPrometheusExporter()` + `app.MapPrometheusScrapingEndpoint()`).
The catch: that exporter has **never had a stable release** — the whole OTel .NET
Prometheus exporter line is versioned `-beta` (we pin `1.17.0-beta.1`, matched to the
`1.17.0` core we already use). Adding it is a new dependency (CLAUDE.md §14), and taking
a prerelease package into all five services is the decision worth recording.
## Decision
**Add `OpenTelemetry.Exporter.Prometheus.AspNetCore` `1.17.0-beta.1` to the five .NET
services and expose `/metrics` with it.**
- What it gives us: the OTel-native pull endpoint, so the meters we already register for
tracing-adjacent instrumentation surface as Prometheus text with zero extra plumbing.
- What we'd write to replace it: a hand-rolled `IMetricsListener`/`MeterListener` that
formats Prometheus exposition text — real work, and a reimplementation of a widely-used
library for no gain.
- Risk it adds: a prerelease API that can shift between betas. Contained: it is only
wired in `Program.cs` (two calls per service, excluded from mutation), the version is
pinned, and `verify-metrics` proves the endpoint + scrape actually work each CI run.
The alternative — pushing metrics over OTLP to a collector that re-exposes them — was
already rejected in ADR-0023 (no collector hop). Not revisited here.
## Consequences
**Positive**
- Golden-signal metrics on `/metrics` with the standard OTel names
(`http_server_request_duration_seconds`, `dotnet_*`), scraped straight by Prometheus.
- No collector, no bespoke exposition code.
**Negative / costs**
- A `-beta` package in production services. Mitigated by the pin + the `verify-metrics`
CI gate; upgrading tracks the OTel core version bumps.
## Coupling rules touched (CLAUDE.md §8)
None. Metrics are passive: Prometheus pulls; no service calls into the stack.
@@ -1,58 +0,0 @@
# ADR-0025: The BFF reads the catalogus directly from the ACL
- **Status:** Accepted
- **Date:** 2026-07-24
- **Deciders:** Respellion engineering
- **Slice:** S-15a (#130), first of the S-15 (#16) split
## Context
The beheer portal shows a read-only view of the ZTC catalogus (the published
zaaktypen). Two coupling rules constrain where that data can come from:
- **§8.1** — only the ACL may talk to the ZGW APIs (Catalogi included). So the
catalogus read *must* originate in the ACL.
- **§8.3** — portals talk only to the BFF. So the portal reaches the ACL only
through the BFF.
That leaves the question of *how the BFF gets the data*. Until now the BFF fanned
out to exactly two backends — the Domain Service and the read projection. The
catalogus is neither: it is not a registration (domain) nor a projected read model.
## Decision
**The BFF calls the ACL directly for the beheer catalogus read** — a new typed
`IAclClient` (`GET /catalogi/zaaktypen`), configured by `Downstream:Acl:BaseUrl`,
mirroring the existing `IDomainClient` / `IProjectionClient` pattern.
Rejected alternative — **route it through the Domain Service** (BFF → domain →
ACL): the catalogus is not a domain concern, so the domain would gain a
pass-through endpoint that owns no aggregate and no invariant, blurring the
domain's responsibility purely to avoid a new edge. That is worse coupling, not
better.
This adds one service-to-service edge (BFF → ACL) — an architecturally
significant boundary change (§14), hence this ADR. It does **not** bend §8: the
ACL stays the only code that reads ZGW, and the portal still talks only to the
BFF. The ACL endpoint is a plain read that trusts its callers (§8.3); the
beheerder authorization lives at the BFF (medewerker realm + `beheerder` role).
## Consequences
**Positive**
- The catalogus read follows the shortest honest path; the domain stays about
registrations.
- Symmetric with the other downstream clients — nothing new to learn.
**Negative / costs**
- The BFF now depends on three backends instead of two. The ACL must be reachable
for the beheer portal to load (it already is — the BFF is on the same network).
- A second consumer of the ACL (alongside the domain and event-subscriber), so
ACL read endpoints are now part of more than one caller's contract.
## Coupling rules touched (CLAUDE.md §8)
A new BFF → ACL edge. §8.1 and §8.3 remain intact; §14 (boundary change) is the
reason this ADR exists.
-184
View File
@@ -5,190 +5,6 @@ copy-pasteable walkthrough against a local `make up` stack.
---
## S-15a — Beheer-portal: read-only catalogus viewer (#130, ADR-0025)
**Outcome:** a new **beheer** portal (medewerker realm, like behandel) shows the ZTC catalogus —
the published zaaktypen — **read-only**. A beheerder logs in and sees the seeded BIG-REGISTRATIE
zaaktype. The read path is portal → BFF `GET /beheer/catalogi/zaaktypen` (medewerker realm +
`beheerder` role) → ACL `GET /catalogi/zaaktypen` → ZGW Catalogi API. The BFF reaches the ACL
directly (ADR-0025); managing the default-fill config (S-15b) and MFA (S-15c) come next.
```bash
make up
# 1. Log in as bram-beheerder / test123 → the catalogus lists the published zaaktypen.
open http://localhost:8143
#
# 2. Automated (a CI verify-stack e2e): a beheerder logs in and sees BIG-REGISTRATIE.
make verify-e2e # → catalogus.spec: "a beheerder sees the published zaaktypen in the catalogus"
#
# 3. The BFF endpoint is behind the beheerder role — a plain behandelaar gets 403 (BFF unit tests):
# Bff.Tests → BeheerEndpointTests.
```
**Auth:** the `beheerder` realm role + `bram-beheerder` user live in the medewerker realm
(`infra/keycloak/realms/medewerker-realm.json`); the BFF reuses the medewerker bearer scheme and its
realm-role lifting, requiring `beheerder` rather than `behandelaar`.
---
## S-16c — Prometheus metrics + golden-signal Grafana dashboard (#124, ADR-0023)
**Outcome:** the five .NET services now expose OpenTelemetry metrics in Prometheus format at `/metrics`
— ASP.NET Core + `HttpClient` instrumentation plus the built-in `System.Runtime` meter. Prometheus
scrapes each service (one job per service), and a **pre-built Grafana dashboard** — *Request path —
golden signals* — plots the four golden signals: **traffic** (req/s), **errors** (5xx/s), **latency**
(p95 request duration), and **saturation** (CPU cores in use), split by service. It populates under load.
```bash
# 1. Automated (a CI verify-stack step): generate BFF traffic and assert Prometheus scraped the
# golden-signal metric from every service.
make verify-metrics # → OK — targets up: [...]; request metric scraped from: [...]
# 2. By hand: drive the stack, generate some load, then open the dashboard.
make up
for i in $(seq 1 50); do curl -s localhost:8080/openbaar/register >/dev/null; done # BFF → projection-api
open http://localhost:3000 # Grafana → Dashboards → "Request path — golden signals"
open http://localhost:9090/targets # Prometheus → every service target UP
```
**The path:** each host adds `.WithMetrics(AddAspNetCoreInstrumentation + AddHttpClientInstrumentation +
AddMeter("System.Runtime") + AddPrometheusExporter)` and maps `/metrics`; Prometheus scrapes
`<service>:8080/metrics` (config in `infra/observability/prometheus/prometheus.yml`); Grafana ships the
dashboard via provisioning against the fixed `prometheus` datasource uid. No metrics are pushed over
OTLP — Prometheus pulls, so there is no collector hop (ADR-0023).
---
## S-16b — distributed traces across the .NET services (#123, ADR-0023)
**Outcome:** the five .NET services (BFF, Domain, ACL, projection-api, event-subscriber) now emit
OpenTelemetry traces — ASP.NET Core + `HttpClient` auto-instrumentation, exported over OTLP to Tempo.
Because every cross-service call goes through a typed `HttpClient`, the W3C `traceparent` propagates for
free, so a request is **one connected trace** across the services (bff → domain → acl → openzaak;
bff → projection-api). `/health` is filtered out. No browser-side instrumentation yet, so the trace
begins at the BFF; the async Flowable-poll boundary is a separate trace (ADR-0023).
```bash
# 1. Automated (a CI verify-stack step): generate BFF traffic and assert Tempo holds one trace
# spanning multiple services.
make verify-tracing # → OK — trace <id> spans ['bff', 'projection-api']
# 2. By hand: drive the stack, then explore traces in Grafana.
make up
curl -s localhost:8080/openbaar/register >/dev/null # BFF → projection-api
open http://localhost:3000 # Grafana → Explore → Tempo → Search → service.name = bff → open a trace
```
**The path:** each host wires `AddOpenTelemetry().WithTracing(AddAspNetCoreInstrumentation +
AddHttpClientInstrumentation + AddOtlpExporter)`; `OTEL_SERVICE_NAME` / `OTEL_EXPORTER_OTLP_ENDPOINT`
come from compose; spans export to **tempo:4317** and render in Grafana against the provisioned Tempo
datasource.
---
## S-16a — observability backplane: Tempo + Prometheus + Grafana (#122, ADR-0023)
**Outcome:** the compose stack now includes a Grafana-native observability backplane — **Tempo** (OTLP
trace ingest on 4317/4318), **Prometheus**, and **Grafana** with both datasources auto-provisioned.
Nothing is instrumented yet (traces land in S-16b, metrics + dashboards in S-16c); this slice stands the
backplane up and proves Grafana can reach both datasources. Config is baked into small built images
(`infra/observability/`) — no collector, no config-volume seeding.
```bash
# 1. Bring the stack up, then assert the backplane is live (Grafana healthy + Tempo/Prometheus
# datasources reachable through Grafana). This is a CI verify-stack step.
make up
make verify-observability # → ✓ Grafana healthy ✓ Prometheus reachable ✓ Tempo reachable
# 2. Or just the backplane, no full stack needed (no external egress):
docker compose -f infra/docker-compose.yml up -d --build tempo prometheus grafana
open http://localhost:3000 # Grafana (admin/admin) → Connections → Data sources: Prometheus + Tempo
open http://localhost:9090 # Prometheus
```
**The path:** services will export OTLP → **Tempo:4317** and expose `/metrics`**Prometheus** scrapes;
**Grafana** (:3000) reads both via provisioned datasources with fixed uids `tempo` / `prometheus`.
---
## S-17 — herregistratie reminder sweep on a Quartz cron (#18, ADR-0022)
**Outcome:** an inscription (INGESCHREVEN) now carries the moment it was entered in the register, from
which its herregistratie deadline is derived (inscription + 5-year validity). A **Quartz.NET** cron job
in the Domain Service sweeps once a day (03:00, overridable via `Quartz__Cron`): every inscription
inside the 90-day window before its deadline is flagged `HerregistratieReminderVerstuurd` and logged.
The sweep is idempotent — a re-fire reminds no one twice — and is a deliberately different mechanism
from the queue-draining pumps (Quartz fires time-triggered sweeps; pumps drain Flowable queues,
ADR-0022). There is no outbound notification in v1: the reminder is the flag on the aggregate plus a
log line.
```bash
# 1. The domain unit tests prove the rule and the sweep end to end (rule → store query → sweep):
cd services/domain && dotnet test Big.Tests/Big.Tests.csproj \
--filter "FullyQualifiedName~Herregistratie|FullyQualifiedName~ReminderSweep"
# → the reminder is due once the 90-day window opens, not before; a reminded inscription is skipped
# on the next sweep; the sweep flags + persists every due inscription and returns their ids.
# 2. The read model surfaces the deadline once a registration is approved — the field the sweep acts on:
curl -s localhost:8000/registrations/<id> | jq '{status, herregistratieVoor, herregistratieReminderVerstuurd}'
# → after approval: herregistratieVoor is inscription + 5 years; the flag flips true once swept.
```
**The path:** `Registration.Approve(now)` stamps `IngeschrevenOp` → daily Quartz `HerregistratieReminderJob`
`HerregistratieReminderSweep``IRegistrationStore.FindDueForHerregistratieReminderAsync` (filtered by
the aggregate's own `HerregistratieReminderDue` rule) → `MarkHerregistratieReminderVerstuurd` + log.
---
## S-B04 — `make local` completes the whole flow with no manual seeding (#110, ADR-0020)
**Outcome:** the host-browser stack (`make local`) now self-seeds at bring-up — it publishes the BIG
zaaktype and wires the ACL to it, deploys the `diploma-eligibility` DMN, and registers the NRC
abonnement — so a fresh bring-up runs submit → werkbak → openbaar without the manual seeding the
`verify-*` scripts do for CI. (Previously the process stuck at `OpenZaakAanmaken`, the werkbak stayed
empty, and the openbaar register showed nothing.)
```bash
# 1. Fresh bring-up (self-seeding init containers: local-seed, nrc-subscribe; DMN in flowable-init).
make local
# 2. Assert the whole flow works with no manual seeding — submit opens a zaak, documents route it to
# the werkbak, and the reference appears in the openbaar register:
make verify-local # → "OK — a fresh local stack completed the flow with no manual seeding ..."
# 3. Or by hand in the browser: log in at http://localhost:8140 (jan-burger / test123), submit +
# upload a PDF, then approve it in the werkbak at http://localhost:8142 (merel-behandelaar /
# test123); it shows as INGESCHREVEN in the openbaar register at http://localhost:8141.
```
> The zaaktype is discovered by the ACL itself since S-27 (below); `local-seed`'s `acl.env` now
> carries only OpenZaak's IP base URL, which the ACL still needs because OpenZaak rejects a
> single-label host on zaak-create (ADR-0020 + ADR-0021).
---
## S-27 — ACL resolves its zaaktype by identificatie, not a pinned URL (#113, ADR-0021)
**Outcome:** the ACL discovers its BIG zaaktype (by `identificatie`) and diploma informatieobjecttype
(by `omschrijving`) from OpenZaak's Catalogi API, instead of being handed the server-assigned URLs.
No user-visible behaviour change — the flow runs exactly as before — but no stack captures/injects a
zaaktype URL any more, and a missing catalogus now fails with a clear message instead of an opaque 400.
```bash
# The live ACL↔OpenZaak integration test proves resolution against a real seeded OpenZaak:
make verify-acl # → "resolves the published BIG-REGISTRATIE zaaktype + Diploma informatieobjecttype by business key"
# End-to-end unchanged (the ACL self-discovers the zaaktype during the flow):
make verify-local # local stack — still green, now with no zaaktype-URL injection
make verify-domain # CI stack — recreates the ACL pointed only at OpenZaak's IP (no URL to inject)
```
> The ACL still needs its OpenZaak base URL at a URL-valid host (a container IP): OpenZaak's
> URLValidator rejects a single-label host like `openzaak:8000` on zaak-create. So ADR-0020's base-URL
> injection stays; only the zaaktype/informatieobjecttype **URL** injection is gone (ADR-0021).
---
## S-08d — Walking skeleton complete: browser → submit, end-to-end
**Outcome:** the self-service portal is served in the stack and the full front-of-house happy path
-1
View File
@@ -14,7 +14,6 @@ All test users share the password **`test123`**.
| Realm | Mimics | User | Identifying claim |
|---|---|---|---|
| `digid` | DigiD (burgers) | `jan-burger` | `bsn` = `123456782` |
| `digid` | DigiD (burgers) | `sanne-burger` | `bsn` = `231477813` (S-26 resume e2e — its own user so it can leave an open registration) |
| `eherkenning` | eHerkenning (bedrijven) | `acme-ondernemer` | `kvk` = `12345678` |
| `eidas` | eIDAS (EU) | `pierre-dupont` | `eidas_id` = `FR/NL/AB-1234-5678` |
| `medewerker` | Internal staff | `merel-behandelaar` | role `behandelaar` |
+8 -83
View File
@@ -1,12 +1,7 @@
# LOCAL development stack — runs with a plain `docker compose up`, no make / no
# external seed step / no bash. Use this on a local engine (Docker Desktop on Windows or
# seed step / no bash. Use this on a local engine (Docker Desktop on Windows or
# macOS, or rootless Podman on Linux).
#
# Self-seeding (S-B04, #110, ADR-0020): unlike the CI stack — where the verify-* scripts seed the
# zaaktype and register the NRC abonnement at test time — this stack does that itself, via one-shot
# init containers (local-seed, nrc-subscribe) + a DMN deploy in flowable-init, so a fresh bring-up
# completes the whole flow with no manual steps. `make verify-local` asserts it.
#
# docker compose -f infra/docker-compose.local.yml up -d --build # podman
# docker compose -f infra/docker-compose.local.yml up -d --build --wait # Docker Desktop
# docker compose -f infra/docker-compose.local.yml down --volumes
@@ -262,79 +257,38 @@ services:
restart: "no"
volumes:
- ../workflows/registratie.bpmn:/work/registratie.bpmn:ro,z
- ../workflows/diploma-eligibility.dmn:/work/diploma-eligibility.dmn:ro,z
command:
- sh
- -c
- |
svc=http://flowable-rest:8080/flowable-rest/service/repository/deployments
dmn=http://flowable-rest:8080/flowable-rest/dmn-api/dmn-repository/deployments
until curl -sf -u rest-admin:test "$$svc" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
# Deploy the DMN to the DMN engine and the BPMN to the process engine as SEPARATE deployments:
# flowable-rest does NOT cascade a .dmn bundled in a process .bar into the DMN engine, so the DMN
# must go via dmn-api. The registratie process's DMN service task then resolves the decision across
# deployments by key (S-13, ADR-0016). Without this the WachtOpDocumenten completion 404s on the
# missing decision and the case never reaches Beoordelen (S-B04). Both steps are idempotent.
if curl -s -u rest-admin:test "$$dmn" | grep -q '"name":"diploma-eligibility.dmn"'; then
echo "diploma-eligibility DMN already deployed; skip"
base=http://flowable-rest:8080/flowable-rest/service/repository/deployments
until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then
echo "registratie already deployed; skip"
else
curl -sf -u rest-admin:test -F 'file=@/work/diploma-eligibility.dmn;filename=diploma-eligibility.dmn' "$$dmn" >/dev/null && echo "deployed diploma-eligibility DMN"
fi
if curl -s -u rest-admin:test "$$svc?name=registratie" | grep -q '"name":"registratie"'; then
echo "registratie BPMN already deployed; skip"
else
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$svc" >/dev/null && echo "deployed registratie BPMN"
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie"
fi
depends_on:
flowable-rest:
condition: service_started
networks: [cg]
# ── Local bootstrap: seed the zaaktype + wire the ACL (S-B04, #110, ADR-0020) ─────────────────
# The zaaktype UUID is assigned by OpenZaak at creation, so it can't be a static value in this
# file. This one-shot seeds + publishes the BIG zaaktype (and the Diploma informatieobjecttype)
# and writes their server-assigned URLs into a shared volume as acl.env, which the ACL sources on
# startup (below). It is the local-stack equivalent of what infra/run-domain-check.sh does for CI.
# Reaches OpenZaak by its container IP because a single-label host fails OpenZaak's URLValidator.
local-seed:
image: docker.io/library/python:3-slim
restart: "no"
volumes:
- ./openzaak/seed_catalogus.py:/work/seed_catalogus.py:ro,z
- ./local/seed-zaaktype.sh:/work/seed-zaaktype.sh:ro,z
- seed-env:/out
command: ["sh", "/work/seed-zaaktype.sh"]
depends_on:
openzaak:
condition: service_healthy
networks: [cg]
# ── ACL ──────────────────────────────────────────────────────────────────
acl:
build:
context: ../services/acl
dockerfile: Dockerfile
image: register-referentie/acl:dev
# The ACL discovers its zaaktype + informatieobjecttype URLs from the Catalogi API by the business
# keys below (S-27, ADR-0021), so no URL is injected. It still needs its OpenZaak BaseUrl pointed at
# a URL-valid host (OpenZaak rejects a single-label host like `openzaak` on zaak-create), so the
# local-seed one-shot writes that IP base into seed-env:/seed/acl.env, which the entrypoint sources
# (set -a) before the app starts. A runtime-generated env file is why we override the entrypoint here
# rather than use `env_file:` (which compose reads at parse time, before the seed has run).
entrypoint: ["/bin/sh", "-c", "set -a; . /seed/acl.env; set +a; exec dotnet Acl.Api.dll"]
environment:
Acl__OpenZaak__BaseUrl: http://openzaak:8000/ # placeholder; seed-env/acl.env supplies the IP base
Acl__OpenZaak__BaseUrl: http://openzaak:8000/
Acl__OpenZaak__ClientId: big-reference-seed
Acl__OpenZaak__Secret: insecure-dev-secret-change-me
Acl__Defaults__Bronorganisatie: "517439943"
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000}
ports:
- "8100:8080"
volumes:
- seed-env:/seed:ro
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s
@@ -344,8 +298,6 @@ services:
depends_on:
openzaak:
condition: service_healthy
local-seed:
condition: service_completed_successfully
networks: [cg]
# ── BFF ──────────────────────────────────────────────────────────────────
@@ -448,31 +400,6 @@ services:
condition: service_healthy
networks: [cg]
# ── Local bootstrap: register the NRC abonnement (S-B04, #110, ADR-0020) ──────────────────────
# Without a subscription, OpenZaak's notifications reach NRC and are delivered nowhere, so the
# projection (and the openbaar register) stay empty. This one-shot registers an abonnement on the
# `zaken` kanaal pointing at the event-subscriber's /notifications callback — the CI equivalent is
# infra/verify-notification-driver.py. The callback uses the event-subscriber's container IP (a
# single-label host fails NRC's URLValidator). It is a leaf (nothing depends on it), so it can wait
# for the event-subscriber without creating a cycle with the ACL bootstrap.
nrc-subscribe:
image: docker.io/library/python:3-slim
restart: "no"
volumes:
- ./local/register-abonnement.py:/work/register-abonnement.py:ro,z
environment:
NRC_BASE: http://nrc-web:8000
SINK_HOST: event-subscriber
SINK_PORT: "8080"
SINK_AUTH: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
command: ["python", "/work/register-abonnement.py"]
depends_on:
nrc-web:
condition: service_healthy
event-subscriber:
condition: service_started
networks: [cg]
projection-api:
build:
context: ..
@@ -565,8 +492,6 @@ volumes:
nrc-db:
flowable-db:
projection-db:
# Carries the seed-generated acl.env (server-assigned zaaktype URLs) from local-seed to the ACL.
seed-env:
networks:
cg:
+11 -101
View File
@@ -15,12 +15,12 @@
#
# docker compose -f infra/docker-compose.yml up -d --build --wait
#
# After first boot, seed + publish the BIG catalogus:
# OZ_PUBLISH=1 python infra/openzaak/seed_catalogus.py
# The ACL discovers the zaaktype by identificatie (S-27, ADR-0021), so there is no URL to inject —
# just point its BaseUrl at an OpenZaak host OpenZaak accepts on zaak-create (a container IP; a
# single-label host is rejected):
# ACL_OPENZAAK_BASEURL=http://<openzaak-ip>:8000/ docker compose -f infra/docker-compose.yml up -d acl
# After first boot, seed the BIG catalogus and note the zaaktype URL:
# python infra/openzaak/seed_catalogus.py
# Then set ACL_ZAAKTYPE_URL in a .env file or your shell and re-up the acl
# service:
# export ACL_ZAAKTYPE_URL=http://openzaak:8000/catalogi/api/v1/zaaktypen/<uuid>
# docker compose -f infra/docker-compose.yml up -d acl
services:
@@ -296,10 +296,6 @@ services:
dockerfile: Dockerfile
image: register-referentie/acl:dev
environment:
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_SERVICE_NAME: acl
# Overridable so verify-domain can point the ACL at the same OpenZaak host that
# owns the seeded zaaktype URL (host-consistent zaak creation, ADR-0009).
Acl__OpenZaak__BaseUrl: ${ACL_OPENZAAK_BASEURL:-http://openzaak:8000/}
@@ -308,12 +304,11 @@ services:
Acl__Defaults__Bronorganisatie: "517439943"
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
# The ACL resolves the (server-assigned) zaaktype + diploma informatieobjecttype URLs from the
# Catalogi API by these stable business keys (S-27, ADR-0021) — no URL to capture and inject.
# BaseUrl above stays overridable because OpenZaak rejects a single-label host on zaak creation,
# so verify-domain still points the ACL at OpenZaak's container IP.
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
# Override with the real zaaktype URL after running seed_catalogus.py.
Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000}
# The informatieobjecttype a diploma is filed under (S-10b). Placeholder until seed_catalogus.py
# (OZ_PUBLISH=1) reports the real URL, which verify-domain injects like the zaaktype URL.
Acl__Defaults__InformatieobjecttypeUrl: ${ACL_INFORMATIEOBJECTTYPE_URL:-http://openzaak:8000/catalogi/api/v1/informatieobjecttypen/00000000-0000-0000-0000-000000000000}
ports:
- "8100:8080"
healthcheck:
@@ -338,10 +333,6 @@ services:
dockerfile: Dockerfile
image: register-referentie/domain:dev
environment:
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_SERVICE_NAME: domain
Flowable__BaseUrl: http://flowable-rest:8080/flowable-rest/
Flowable__Username: rest-admin
Flowable__Password: test
@@ -368,10 +359,6 @@ services:
dockerfile: Dockerfile
image: register-referentie/bff:dev
environment:
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_SERVICE_NAME: bff
# The BFF is the portals' only backend; it validates digid tokens and fans out (ADR-0010).
# 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.
@@ -380,8 +367,6 @@ services:
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
Downstream__Domain__BaseUrl: http://domain:8080/
Downstream__Projection__BaseUrl: http://projection-api:8080/
# The beheer catalogus read reaches the ACL directly (S-15a, ADR-0025).
Downstream__Acl__BaseUrl: http://acl:8080/
ports:
- "8080:8080"
healthcheck:
@@ -426,10 +411,6 @@ services:
dockerfile: services/event-subscriber/Dockerfile
image: register-referentie/event-subscriber:dev
environment:
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_SERVICE_NAME: event-subscriber
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
# The subscriber enriches the projection with each zaak's reference (identificatie) by asking
# the ACL — the only code allowed to read ZGW (§8.1, #78).
@@ -459,10 +440,6 @@ services:
dockerfile: services/projection-api/Dockerfile
image: register-referentie/projection-api:dev
environment:
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_SERVICE_NAME: projection-api
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
ports:
- "8120:8080"
@@ -546,73 +523,6 @@ services:
condition: service_started
networks: [cg]
# The beheer portal: nginx serves the Angular app and reverse-proxies /beheer to the BFF.
# Beheerders log in against the Keycloak medewerker realm (same realm as behandel, S-15a).
beheer:
build:
context: ..
dockerfile: apps/beheer/Dockerfile
image: register-referentie/beheer:dev
ports:
- "8143: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]
# ── Observability backplane (S-16a, ADR-0023) ──────────────────────────────
# Grafana-native stack: Tempo ingests OTLP traces (the .NET services export
# straight to it — no collector hop, S-16b), Prometheus scrapes service
# /metrics (S-16c), and Grafana reads both with datasources auto-provisioned.
# Config is baked into small built images (COPY) rather than streamed into
# external config volumes like the upstream CG modules — these aren't verbatim
# peer images, so a built image is the simpler path that still reaches sibling
# containers on the CI runner. Not in WAIT_SVCS: run-observability-check.sh
# polls Grafana itself, so no in-image healthcheck tool is needed.
tempo:
build:
context: ./observability/tempo
image: register-referentie/tempo:dev
command: ["-config.file=/etc/tempo.yaml"]
# Cap the backplane's footprint so it can't starve the app stack + the Playwright browser on the
# memory-tight CI runner (verify-e2e OOM history, commit d5e5fa2). Generous vs idle (~150M).
mem_limit: 400m
networks: [cg]
prometheus:
build:
context: ./observability/prometheus
image: register-referentie/prometheus:dev
mem_limit: 400m
ports:
- "9090:9090"
networks: [cg]
grafana:
build:
context: ./observability/grafana
image: register-referentie/grafana:dev
mem_limit: 512m
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: admin
GF_AUTH_ANONYMOUS_ENABLED: "true"
ports:
- "3000:3000"
depends_on:
- tempo
- prometheus
networks: [cg]
volumes:
oz-db:
nrc-db:
-30
View File
@@ -38,36 +38,6 @@
"emailVerified": true,
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
"attributes": { "bsn": ["123456782"] }
},
{
"username": "sanne-burger",
"enabled": true,
"firstName": "Sanne",
"lastName": "Burger",
"email": "sanne.burger@example.nl",
"emailVerified": true,
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
"attributes": { "bsn": ["231477813"] }
},
{
"username": "emma-burger",
"enabled": true,
"firstName": "Emma",
"lastName": "Burger",
"email": "emma.burger@example.nl",
"emailVerified": true,
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
"attributes": { "bsn": ["231477805"] }
},
{
"username": "lars-burger",
"enabled": true,
"firstName": "Lars",
"lastName": "Burger",
"email": "lars.burger@example.nl",
"emailVerified": true,
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
"attributes": { "bsn": ["231477821"] }
}
]
}
+1 -12
View File
@@ -5,8 +5,7 @@
"roles": {
"realm": [
{ "name": "behandelaar", "description": "Behandelt registratieaanvragen" },
{ "name": "teamlead", "description": "Teamleider behandeling" },
{ "name": "beheerder", "description": "Beheert catalogus en default-fill (beheer-portal, S-15)" }
{ "name": "teamlead", "description": "Teamleider behandeling" }
]
},
"clients": [
@@ -55,16 +54,6 @@
"emailVerified": true,
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
"realmRoles": ["behandelaar", "teamlead"]
},
{
"username": "bram-beheerder",
"enabled": true,
"firstName": "Bram",
"lastName": "Beheerder",
"email": "bram@big.example.nl",
"emailVerified": true,
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
"realmRoles": ["beheerder"]
}
]
}
-78
View File
@@ -1,78 +0,0 @@
#!/usr/bin/env python3
"""Local-stack bootstrap (S-B04, #110, ADR-0020) — register the NRC abonnement.
Runs as the `nrc-subscribe` init container of infra/docker-compose.local.yml. Registers an
abonnement on the `zaken` kanaal pointing at the event-subscriber's /notifications callback, so
OpenZaak's notifications (zaak create + status set) reach the projection — without this the openbaar
(public) register stays empty. This is what infra/verify-notification-driver.py does for CI (minus
the test zaak it also creates).
The callback host is the event-subscriber's resolved **container IP**, not `event-subscriber`, because
NRC validates callbackUrl with Django's URLValidator (a single-label host is rejected — same reason the
zaaktype seed uses OpenZaak's IP). Idempotent + restart-safe: it removes any stale /notifications
abonnement first, then registers one for the current IP. Stdlib only.
Env: NRC_BASE, SINK_HOST, SINK_PORT, SINK_AUTH, OZ_CLIENT_ID, OZ_SECRET.
"""
import base64, hashlib, hmac, json, os, socket, sys, time, urllib.error, urllib.request
NRC = os.environ.get("NRC_BASE", "http://nrc-web:8000").rstrip("/")
SINK_HOST = os.environ.get("SINK_HOST", "event-subscriber")
SINK_PORT = os.environ.get("SINK_PORT", "8080")
SINK_AUTH = os.environ.get("SINK_AUTH", "Bearer big-reference-notifications")
CID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed")
SECRET = os.environ.get("OZ_SECRET", "insecure-dev-secret-change-me")
def token():
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=")
seg = (
b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
+ b"."
+ b64(json.dumps(
{"iss": CID, "iat": int(time.time()), "client_id": CID,
"user_id": "local-seed", "user_representation": "local-seed"},
separators=(",", ":")).encode())
)
return (seg + b"." + b64(hmac.new(SECRET.encode(), seg, hashlib.sha256).digest())).decode()
def call(method, url, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method, headers={
"Authorization": "Bearer " + token(),
"Content-Type": "application/json", "Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as r:
raw = r.read()
return r.status, (json.loads(raw) if raw else None)
except urllib.error.HTTPError as e:
raw = e.read()
return e.code, (json.loads(raw) if raw else None)
def main():
ip = socket.gethostbyname(SINK_HOST)
callback = f"http://{ip}:{SINK_PORT}/notifications"
# Restart-safe: drop any prior /notifications abonnement (its IP may be stale) before creating a
# fresh one for the current event-subscriber IP.
status, body = call("GET", f"{NRC}/api/v1/abonnement")
for ab in (body or []) if status == 200 else []:
if str(ab.get("callbackUrl", "")).endswith("/notifications"):
if ab.get("callbackUrl") == callback:
print(f"abonnement already current: {ab['url']}")
return
call("DELETE", ab["url"])
print(f"removed stale abonnement {ab['url']}")
status, ab = call("POST", f"{NRC}/api/v1/abonnement", {
"callbackUrl": callback, "auth": SINK_AUTH,
"kanalen": [{"naam": "zaken", "filters": {}}]})
if status != 201:
sys.exit(f"create abonnement -> {status}: {json.dumps(ab)}")
print(f"abonnement registered: {ab['url']} -> {callback}")
if __name__ == "__main__":
main()
-33
View File
@@ -1,33 +0,0 @@
#!/bin/sh
# Local-stack bootstrap (S-B04, #110, ADR-0020) — the "seed zaaktype + wire the ACL" step.
#
# Runs as the `local-seed` init container of infra/docker-compose.local.yml. It seeds + publishes
# the BIG zaaktype (and the Diploma informatieobjecttype) into OpenZaak, then writes the resulting
# **server-assigned** URLs into /out/acl.env, which the ACL entrypoint sources before starting. This
# is the local-stack equivalent of what infra/run-domain-check.sh does for CI: the zaaktype UUID is
# assigned by OpenZaak at creation, so it can't be a static value in the compose file.
#
# Why the container IP and not the `openzaak` service name: OpenZaak validates URL query params
# (e.g. ?catalogus=) with Django's URLValidator, which rejects a single-label host like `openzaak`.
# Seeding against the resolved IP keeps the seeded URLs valid AND host-consistent with the ACL, which
# we point at the same IP below. See docs/runbooks/gitea-actions-gotchas.md and ADR-0020.
set -eu
oz_ip="$(python3 -c "import socket;print(socket.gethostbyname('openzaak'))")"
OZ_BASE="http://${oz_ip}:8000"
export OZ_BASE OZ_PUBLISH=1
echo ">> seeding + publishing the BIG zaaktype at ${OZ_BASE} (idempotent)"
out="$(python3 /work/seed_catalogus.py)"
echo "$out"
# Sanity-check that the zaaktype was actually published (the ACL discovers it by identificatie, S-27).
printf '%s\n' "$out" | grep -q '^ZAAKTYPE_URL ' || { echo "ERROR: seed did not publish the zaaktype" >&2; exit 1; }
# The ACL resolves the zaaktype/informatieobjecttype URLs itself (S-27, ADR-0021); the only value it
# still needs injected is the OpenZaak base URL at a URL-valid host (the container IP), because OpenZaak
# rejects a single-label host on zaak-create. The ACL entrypoint sources this.
cat > /out/acl.env <<EOF
Acl__OpenZaak__BaseUrl=${OZ_BASE}/
EOF
echo ">> wrote /out/acl.env (base=${OZ_BASE}/)"
-75
View File
@@ -1,75 +0,0 @@
#!/usr/bin/env python3
"""S-16c (#124): prove the golden-signal metrics pipeline works end to end.
Generate anonymous BFF traffic (GET /openbaar/register no auth, no OpenZaak egress),
then query Prometheus and assert (1) every .NET service's scrape target is UP, and (2)
the http.server.request.duration histogram is actually being scraped i.e. the services
expose /metrics AND Prometheus collects it, which is exactly what the golden-signal
dashboard reads.
Stdlib only (urllib/json) so it runs in a bare python:3-slim container in-network.
"""
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
BFF = os.environ["BFF"] # http://<bff-ip>:8080
PROM = os.environ["PROMETHEUS"] # http://<prometheus-ip>:9090
TIMEOUT = int(os.environ.get("METRICS_TIMEOUT", "90"))
SERVICES = {"acl", "domain", "bff", "event-subscriber", "projection-api"}
def _get(url):
with urllib.request.urlopen(url, timeout=10) as r:
return r.read()
def generate_traffic():
for _ in range(3):
try:
_get(f"{BFF}/openbaar/register")
except urllib.error.HTTPError:
pass # a non-2xx still records an http.server metric
def query(promql):
q = urllib.parse.quote(promql)
try:
data = json.loads(_get(f"{PROM}/api/v1/query?query={q}"))
except Exception:
return []
return data.get("data", {}).get("result", [])
def jobs_up():
return {r["metric"].get("job") for r in query("up == 1")}
def jobs_with_request_metric():
return {r["metric"].get("job")
for r in query("http_server_request_duration_seconds_count")}
def main():
deadline = time.time() + TIMEOUT
while time.time() < deadline:
generate_traffic()
up = jobs_up()
scraped = jobs_with_request_metric()
if SERVICES.issubset(up) and SERVICES.issubset(scraped):
print(f"OK — targets up: {sorted(up & SERVICES)}; "
f"request metric scraped from: {sorted(scraped & SERVICES)}")
return 0
time.sleep(3)
print(f"FAIL — up: {sorted(jobs_up() & SERVICES)}; "
f"request metric from: {sorted(jobs_with_request_metric() & SERVICES)}; "
f"expected all of {sorted(SERVICES)}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
-4
View File
@@ -1,4 +0,0 @@
# Grafana with datasources + the golden-signals dashboard baked in via provisioning
# (S-16a/S-16c, ADR-0023). Everything under provisioning/ is copied in below.
FROM grafana/grafana:11.3.0
COPY provisioning/ /etc/grafana/provisioning/
@@ -1,13 +0,0 @@
# Dashboard provider (S-16c, ADR-0023): Grafana loads every *.json in this folder as a
# read-only, code-owned dashboard. The golden-signals board is versioned here, not
# clicked together in the UI.
apiVersion: 1
providers:
- name: register-referentie
type: file
disableDeletion: true
allowUiUpdates: false
options:
path: /etc/grafana/provisioning/dashboards
foldersFromFilesStructure: false
@@ -1,87 +0,0 @@
{
"uid": "golden-signals",
"title": "Request path — golden signals",
"tags": ["s-16c", "golden-signals"],
"timezone": "browser",
"schemaVersion": 39,
"version": 1,
"editable": true,
"refresh": "10s",
"time": { "from": "now-15m", "to": "now" },
"templating": {
"list": [
{
"name": "job",
"type": "query",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"query": "label_values(http_server_request_duration_seconds_count, job)",
"includeAll": true,
"multi": true,
"current": { "text": "All", "value": "$__all" },
"refresh": 2
}
]
},
"panels": [
{
"id": 1,
"title": "Traffic — requests/sec",
"type": "timeseries",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"fieldConfig": { "defaults": { "unit": "reqps", "custom": { "drawStyle": "line", "fillOpacity": 10 } }, "overrides": [] },
"targets": [
{
"refId": "A",
"expr": "sum by (job) (rate(http_server_request_duration_seconds_count{job=~\"$job\"}[$__rate_interval]))",
"legendFormat": "{{job}}"
}
]
},
{
"id": 2,
"title": "Errors — 5xx responses/sec",
"type": "timeseries",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"fieldConfig": { "defaults": { "unit": "reqps", "custom": { "drawStyle": "line", "fillOpacity": 10 }, "color": { "mode": "fixed", "fixedColor": "red" } }, "overrides": [] },
"targets": [
{
"refId": "A",
"expr": "sum by (job) (rate(http_server_request_duration_seconds_count{job=~\"$job\",http_response_status_code=~\"5..\"}[$__rate_interval]))",
"legendFormat": "{{job}}"
}
]
},
{
"id": 3,
"title": "Latency — p95 request duration",
"type": "timeseries",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
"fieldConfig": { "defaults": { "unit": "s", "custom": { "drawStyle": "line", "fillOpacity": 10 } }, "overrides": [] },
"targets": [
{
"refId": "A",
"expr": "histogram_quantile(0.95, sum by (job, le) (rate(http_server_request_duration_seconds_bucket{job=~\"$job\"}[$__rate_interval])))",
"legendFormat": "{{job}} p95"
}
]
},
{
"id": 4,
"title": "Saturation — CPU cores in use",
"type": "timeseries",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
"fieldConfig": { "defaults": { "unit": "none", "custom": { "drawStyle": "line", "fillOpacity": 10 } }, "overrides": [] },
"targets": [
{
"refId": "A",
"expr": "sum by (job) (rate(dotnet_process_cpu_time_seconds_total{job=~\"$job\"}[$__rate_interval]))",
"legendFormat": "{{job}}"
}
]
}
]
}
@@ -1,17 +0,0 @@
# Auto-provisioned datasources (S-16a, ADR-0023). Fixed uids so dashboards (S-16c)
# and the verify-observability check can reference them by a stable id.
apiVersion: 1
datasources:
- name: Prometheus
uid: prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
- name: Tempo
uid: tempo
type: tempo
access: proxy
url: http://tempo:3200
@@ -1,2 +0,0 @@
FROM prom/prometheus:v2.55.1
COPY prometheus.yml /etc/prometheus/prometheus.yml
@@ -1,27 +0,0 @@
# Prometheus scrape config (S-16c, ADR-0023). Each .NET service exposes OTel metrics
# at /metrics (Prometheus text format); one scrape job per service, so the service is
# identified by the `job` label in the golden-signal dashboard. Targets are reached by
# compose service name on the shared `cg` network (internal port 8080).
global:
scrape_interval: 15s
scrape_configs:
- job_name: prometheus
static_configs:
- targets: ['localhost:9090']
- job_name: acl
static_configs:
- targets: ['acl:8080']
- job_name: domain
static_configs:
- targets: ['domain:8080']
- job_name: bff
static_configs:
- targets: ['bff:8080']
- job_name: event-subscriber
static_configs:
- targets: ['event-subscriber:8080']
- job_name: projection-api
static_configs:
- targets: ['projection-api:8080']
-4
View File
@@ -1,4 +0,0 @@
# Tempo with our config baked in — so it reaches sibling containers on the CI
# runner without the external-config-volume dance the upstream CG images need.
FROM grafana/tempo:2.6.1
COPY tempo.yaml /etc/tempo.yaml
-27
View File
@@ -1,27 +0,0 @@
# Grafana Tempo — single-binary, all-in-one, local storage (S-16a, ADR-0023).
# Ingests OTLP directly (services export straight to Tempo; no collector hop).
# Storage is ephemeral container fs — this is a local/CI demo backplane, not a
# retention target. ponytail: local backend, swap for object storage if traces
# must outlive the stack.
server:
http_listen_port: 3200
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
ingester:
max_block_duration: 5m
storage:
trace:
backend: local
local:
path: /var/tempo/blocks
wal:
path: /var/tempo/wal
+10 -7
View File
@@ -30,18 +30,21 @@ oz_ip="$(ip "$oz")"; dom_ip="$(ip "$dom")"
oz_base="http://$oz_ip:8000"
echo ">> openzaak=$oz_ip domain=$dom_ip network=$net"
echo ">> seeding + publishing a BIG zaaktype (idempotent)"
echo ">> seeding a published BIG zaaktype (idempotent) and capturing its URL"
sid="$(docker create --network "$net" -e "OZ_BASE=$oz_base" -e OZ_PUBLISH=1 python:3-slim python /seed.py)"
docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null
seed_out="$(docker start -a "$sid")"
zt_url="$(printf '%s\n' "$seed_out" | sed -n 's/^ZAAKTYPE_URL //p' | head -1)"
iot_url="$(printf '%s\n' "$seed_out" | sed -n 's/^INFORMATIEOBJECTTYPE_URL //p' | head -1)"
docker rm -f "$sid" >/dev/null
printf '%s\n' "$seed_out" | grep -q '^ZAAKTYPE_URL ' || { echo "ERROR: seed did not publish the zaaktype" >&2; exit 1; }
[ -n "$zt_url" ] || { echo "ERROR: seed did not report a ZAAKTYPE_URL" >&2; exit 1; }
[ -n "$iot_url" ] || { echo "ERROR: seed did not report an INFORMATIEOBJECTTYPE_URL" >&2; exit 1; }
echo ">> zaaktype: $zt_url"
echo ">> informatieobjecttype: $iot_url"
# The ACL resolves the zaaktype + informatieobjecttype by identificatie/omschrijving (S-27, ADR-0021),
# so there is no URL to inject — only the OpenZaak base URL, pointed at the same host's container IP
# (OpenZaak rejects a single-label host on zaak-create).
echo ">> recreating the acl service pointed at OpenZaak's IP (it resolves the zaaktype itself, S-27)"
ACL_OPENZAAK_BASEURL="$oz_base/" docker compose -f "$compose" up -d acl
echo ">> recreating the acl service pointed at the seeded zaaktype + informatieobjecttype (host-consistent)"
ACL_ZAAKTYPE_URL="$zt_url" ACL_INFORMATIEOBJECTTYPE_URL="$iot_url" ACL_OPENZAAK_BASEURL="$oz_base/" \
docker compose -f "$compose" up -d acl
WAIT_TIMEOUT="${WAIT_TIMEOUT:-120}" bash "$here/wait-healthy.sh" acl
echo ">> submitting a registration to the domain"
-67
View File
@@ -1,67 +0,0 @@
#!/usr/bin/env bash
#
# Acceptance check for the local stack (S-B04, #110): a fresh `make local` must complete the whole
# flow with NO manual seeding. Run against an already-up local stack (infra/docker-compose.local.yml)
# via the host-published ports. It exercises, and thereby covers, the three bring-up gaps the slice
# fixes:
#
# 1. zaaktype seeded + ACL wired -> a submitted registration opens a zaak (zaakUrl gets filled).
# 2. diploma-eligibility DMN deployed -> providing documents completes WachtOpDocumenten, routes
# through the DMN, and the case lands on Beoordelen (visible in the behandel werkbak).
# 3. NRC abonnement registered -> the zaak shows up in the openbaar (public) register.
#
# Before the fix this fails at step 1 (ACL points at a placeholder zaaktype -> OpenZaak 400).
set -euo pipefail
DOM=${DOM:-http://localhost:8130} # domain
BFF=${BFF:-http://localhost:8080} # bff (openbaar register)
BSN=${BSN:-123456782}
# A minimal, valid PDF, base64-encoded (the diploma upload).
PDF_B64="$(printf '%%PDF-1.4\n1 0 obj<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>\n%%%%EOF\n' | base64 | tr -d '\n')"
echo ">> 1. submit a registration (no manual seeding expected)"
loc="$(curl -fsS -D - -o /dev/null -X POST "$DOM/registrations" \
-H 'Content-Type: application/json' -d "{\"bsn\":\"$BSN\"}" \
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
[ -n "$loc" ] || { echo "FAIL: POST /registrations returned no Location" >&2; exit 1; }
id="${loc##*/}"
echo " accepted: $id"
echo ">> 2. poll until the ACL opens the zaak (proves the zaaktype is seeded + wired)"
zaak=""
for _ in $(seq 1 30); do
zaak="$(curl -fsS "$DOM$loc" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("zaakUrl") or "")' 2>/dev/null || true)"
[ -n "$zaak" ] && break
sleep 3
done
[ -n "$zaak" ] || { echo "FAIL: zaak never opened — ACL zaaktype not wired (gap 1)" >&2; exit 1; }
echo " zaak opened: $zaak"
echo ">> 3. provide documents (proves the diploma-eligibility DMN is deployed)"
code="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$DOM/registrations/$id/documents" \
-H 'Content-Type: application/json' \
-d "{\"bsn\":\"$BSN\",\"contentBase64\":\"$PDF_B64\",\"fileName\":\"diploma.pdf\",\"contentType\":\"application/pdf\"}")"
[ "$code" = "204" ] || { echo "FAIL: provide documents -> $code (DMN missing routes WachtOpDocumenten to a 404 — gap 2)" >&2; exit 1; }
echo " documents accepted (204)"
echo ">> 4. poll the werkbak until the registration awaits beoordeling (reached Beoordelen)"
in_werkbak=""
for _ in $(seq 1 20); do
in_werkbak="$(curl -fsS "$DOM/behandel/werkbak" | python3 -c "import sys,json;print(any(r.get('registrationId')=='$id' for r in json.load(sys.stdin)))" 2>/dev/null || true)"
[ "$in_werkbak" = "True" ] && break
sleep 3
done
[ "$in_werkbak" = "True" ] || { echo "FAIL: registration never reached the werkbak (gap 2)" >&2; exit 1; }
echo " in the werkbak"
echo ">> 5. poll the openbaar register until the reference is publicly visible (proves NRC abonnement)"
public=""
for _ in $(seq 1 30); do
public="$(curl -fsS "$BFF/openbaar/register" | python3 -c "import sys,json;print(any(r.get('reference')=='$id' for r in json.load(sys.stdin)))" 2>/dev/null || true)"
[ "$public" = "True" ] && break
sleep 3
done
[ "$public" = "True" ] || { echo "FAIL: reference never appeared in the openbaar register — NRC abonnement not registered (gap 3)" >&2; exit 1; }
echo " visible in the openbaar register"
echo "OK — a fresh local stack completed the flow with no manual seeding (zaaktype + DMN + abonnement)"
-28
View File
@@ -1,28 +0,0 @@
#!/usr/bin/env bash
#
# S-16c (#124): assert the golden-signal metrics pipeline works — the .NET services expose
# /metrics and Prometheus scrapes them — against an ALREADY-RUNNING full stack. Runs the
# driver in a python:3-slim container on the stack network (services reached by container IP;
# the runner can't reach published ports — gitea-actions-gotchas.md §5/§6). Does NOT manage
# the stack lifecycle.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
bff="$(docker ps -q --filter 'name=[-_]bff[-_]' | head -1)"
prom="$(docker ps -q --filter 'name=[-_]prometheus[-_]' | head -1)"
[ -n "$bff" ] && [ -n "$prom" ] || { echo "ERROR: bff and/or prometheus not running — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$bff" | head -1)"
bff_ip="$(ip "$bff")"; prom_ip="$(ip "$prom")"
echo ">> network=$net bff=$bff_ip prometheus=$prom_ip"
cid="$(docker create --network "$net" \
-e "BFF=http://$bff_ip:8080" -e "PROMETHEUS=http://$prom_ip:9090" \
-e "METRICS_TIMEOUT=${METRICS_TIMEOUT:-90}" \
python:3-slim python /metrics-check.py)"
docker cp "$here/metrics-check.py" "$cid:/metrics-check.py" >/dev/null
rc=0; docker start -a "$cid" || rc=$?
docker rm -f "$cid" >/dev/null
exit $rc
-45
View File
@@ -1,45 +0,0 @@
#!/usr/bin/env bash
#
# S-16a (#122): assert the observability backplane is live against an ALREADY-RUNNING
# stack. Runs curl INSIDE the compose network (like the other verify checks) because
# the stack's published ports aren't on the CI runner's localhost — the stack is a set
# of sibling containers on the host daemon. It asks Grafana to reach its provisioned
# datasources — Prometheus via its health method, Tempo via the datasource proxy (Tempo's
# Grafana plugin implements no health method) — so it proves the datasources are wired,
# not merely that the containers started. Polls, so it tolerates a cold Grafana.
#
# Does NOT manage the stack lifecycle (the caller owns bring-up + teardown).
set -euo pipefail
TIMEOUT="${OBS_TIMEOUT:-60}"
AUTH="${GRAFANA_AUTH:-admin:admin}"
gf="$(docker ps -q --filter 'name=[-_]grafana[-_]' | head -1)"
[ -n "$gf" ] || { echo "ERROR: no running grafana container — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$gf" | head -1)"
gf_ip="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$gf")"
base="http://$gf_ip:3000"
echo ">> grafana=$gf_ip network=$net"
# Run curl inside a throwaway container on the stack network (reaches services by IP).
net_curl() { docker run --rm --network "$net" curlimages/curl:latest "$@"; }
# poll <description> <grep -E pattern> <curl args...>
poll() {
local desc="$1" pat="$2"; shift 2
local deadline=$(( $(date +%s) + TIMEOUT ))
while :; do
if net_curl -fsS "$@" 2>/dev/null | grep -Eq "$pat"; then echo "$desc"; return 0; fi
if [ "$(date +%s)" -ge "$deadline" ]; then echo "$desc ($*)" >&2; return 1; fi
sleep 3
done
}
echo "Checking observability backplane at $base ..."
poll "Grafana is healthy" \
'"database":[[:space:]]*"ok"' "$base/api/health"
poll "Prometheus datasource reachable" \
'"status":[[:space:]]*"OK"' -u "$AUTH" "$base/api/datasources/uid/prometheus/health"
poll "Tempo datasource reachable (via Grafana proxy)" \
'"version"' -u "$AUTH" "$base/api/datasources/proxy/uid/tempo/api/status/buildinfo"
echo "Observability backplane OK."
-27
View File
@@ -1,27 +0,0 @@
#!/usr/bin/env bash
#
# S-16b (#123): assert one connected distributed trace spans the .NET services in Tempo,
# against an ALREADY-RUNNING full stack. Runs the driver in a python:3-slim container on the
# stack network (services reached by container IP; the runner can't reach published ports —
# gitea-actions-gotchas.md §5/§6). Does NOT manage the stack lifecycle.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
bff="$(docker ps -q --filter 'name=[-_]bff[-_]' | head -1)"
tempo="$(docker ps -q --filter 'name=[-_]tempo[-_]' | head -1)"
[ -n "$bff" ] && [ -n "$tempo" ] || { echo "ERROR: bff and/or tempo not running — bring the stack up first" >&2; exit 1; }
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$bff" | head -1)"
bff_ip="$(ip "$bff")"; tempo_ip="$(ip "$tempo")"
echo ">> network=$net bff=$bff_ip tempo=$tempo_ip"
cid="$(docker create --network "$net" \
-e "BFF=http://$bff_ip:8080" -e "TEMPO=http://$tempo_ip:3200" \
-e "TRACING_TIMEOUT=${TRACING_TIMEOUT:-90}" \
python:3-slim python /tracing-check.py)"
docker cp "$here/tracing-check.py" "$cid:/tracing-check.py" >/dev/null
rc=0; docker start -a "$cid" || rc=$?
docker rm -f "$cid" >/dev/null
exit $rc
-81
View File
@@ -1,81 +0,0 @@
#!/usr/bin/env python3
"""S-16b (#123): prove distributed tracing works end to end.
Generate anonymous BFF traffic (GET /openbaar/register, which the BFF serves by
calling projection-api no auth, no OpenZaak egress), then query Tempo and assert
that ONE trace contains spans from both `bff` and `projection-api`. That proves the
services export OTLP to Tempo AND that the W3C traceparent propagates across the
HttpClient hop, stitching the request into a single connected trace.
Stdlib only (urllib/json) so it runs in a bare python:3-slim container in-network.
"""
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
BFF = os.environ["BFF"] # http://<bff-ip>:8080
TEMPO = os.environ["TEMPO"] # http://<tempo-ip>:3200
TIMEOUT = int(os.environ.get("TRACING_TIMEOUT", "90"))
WANT = {"bff", "projection-api"} # the two services that must share one trace
def _get(url):
with urllib.request.urlopen(url, timeout=10) as r:
return r.read()
def generate_traffic():
# A non-2xx still produces spans; only total unreachability of the BFF is fatal.
for _ in range(3):
try:
_get(f"{BFF}/openbaar/register")
except urllib.error.HTTPError:
pass
def search_trace_ids():
q = urllib.parse.quote('{ resource.service.name = "bff" }')
try:
data = json.loads(_get(f"{TEMPO}/api/search?q={q}&limit=50"))
except Exception:
return []
return [t["traceID"] for t in data.get("traces", [])]
def services_in_trace(trace_id):
try:
data = json.loads(_get(f"{TEMPO}/api/traces/{trace_id}"))
except Exception:
return set()
names = set()
for batch in data.get("batches", []):
for attr in batch.get("resource", {}).get("attributes", []):
if attr.get("key") == "service.name":
names.add(attr.get("value", {}).get("stringValue"))
return names
def main():
deadline = time.time() + TIMEOUT
generate_traffic()
seen = set()
while time.time() < deadline:
for tid in search_trace_ids():
names = services_in_trace(tid)
seen |= names
if WANT.issubset(names):
print(f"OK — trace {tid} spans {sorted(names)}")
return 0
time.sleep(3)
generate_traffic()
print(f"FAIL — no single trace spanned {sorted(WANT)}; services seen: {sorted(seen)}",
file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
@@ -24,16 +24,6 @@ import {
Observable
} from 'rxjs';
export interface BeheerZaaktype {
identificatie: string;
omschrijving: string;
}
export interface CurrentRegistration {
registrationId: string;
status: string;
}
export interface DecideRequest {
besluit: string;
}
@@ -210,37 +200,6 @@ export class BffApiV1Service {
);
}
getSelfServiceRegistrations<TData = CurrentRegistration | void>( options?: HttpClientBodyOptions): Observable<TData>;
getSelfServiceRegistrations<TData = CurrentRegistration | void>( options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
getSelfServiceRegistrations<TData = CurrentRegistration | void>( options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
getSelfServiceRegistrations<TData = CurrentRegistration | void>(
options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
if (options?.observe === 'events') {
return this.http.get<TData>(
`/self-service/registrations`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
}
);
}
if (options?.observe === 'response') {
return this.http.get<TData>(
`/self-service/registrations`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
}
);
}
return this.http.get<TData>(
`/self-service/registrations`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'body',
}
);
}
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientBodyOptions): Observable<TData>;
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
@@ -415,35 +374,4 @@ export class BffApiV1Service {
);
}
getBeheerCatalogiZaaktypen<TData = BeheerZaaktype[]>( options?: HttpClientBodyOptions): Observable<TData>;
getBeheerCatalogiZaaktypen<TData = BeheerZaaktype[]>( options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
getBeheerCatalogiZaaktypen<TData = BeheerZaaktype[]>( options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
getBeheerCatalogiZaaktypen<TData = BeheerZaaktype[]>(
options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
if (options?.observe === 'events') {
return this.http.get<TData>(
`/beheer/catalogi/zaaktypen`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
}
);
}
if (options?.observe === 'response') {
return this.http.get<TData>(
`/beheer/catalogi/zaaktypen`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
}
);
}
return this.http.get<TData>(
`/beheer/catalogi/zaaktypen`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'body',
}
);
}
};
-8
View File
@@ -5,14 +5,6 @@
<ProjectReference Include="..\Acl.Infrastructure\Acl.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.17.0-beta.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
-34
View File
@@ -1,31 +1,8 @@
using Acl.Application;
using Acl.Infrastructure;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
var builder = WebApplication.CreateBuilder(args);
// OpenTelemetry tracing (S-16b, ADR-0023): auto-instrument incoming ASP.NET Core requests and
// outgoing HttpClient calls (the ACL → OpenZaak hop), exported over OTLP to Tempo. Service name +
// OTLP endpoint come from OTEL_* env (compose); the exporter no-ops when Tempo is unreachable.
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService(
builder.Configuration["OTEL_SERVICE_NAME"] ?? builder.Environment.ApplicationName))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation(o => o.Filter = ctx => ctx.Request.Path != "/health")
.AddHttpClientInstrumentation()
.AddOtlpExporter())
// OpenTelemetry metrics (S-16c, ADR-0023): golden signals for the request path —
// http.server.request.duration (traffic/errors/latency) + http.client.* for downstream hops, plus
// the built-in System.Runtime meter for saturation (GC, CPU, thread pool). Prometheus scrapes these
// from /metrics (mapped below); metrics aren't pushed over OTLP, so no collector hop (ADR-0023).
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddMeter("System.Runtime")
.AddPrometheusExporter());
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
.GetSection("Acl:Defaults").Get<AclDefaults>()
@@ -34,17 +11,12 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
.GetSection("Acl:OpenZaak").Get<OpenZaakOptions>()
?? throw new InvalidOperationException("Missing configuration section 'Acl:OpenZaak'"));
builder.Services.AddHttpClient<IZaakGateway, OpenZaakGateway>();
// Singleton so the resolved zaaktype/informatieobjecttype URLs are cached across requests (S-27).
builder.Services.AddSingleton<IZaaktypeCatalog, CachedZaaktypeCatalog>();
builder.Services.AddScoped<AclService>();
var app = builder.Build();
app.MapGet("/health", () => "Healthy");
// Prometheus scrape endpoint (S-16c): exposes the OTel metrics above in Prometheus text format.
app.MapPrometheusScrapingEndpoint();
// The ACL's single operation, exposed as a service endpoint.
app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationToken ct) =>
{
@@ -85,12 +57,6 @@ app.MapPost("/documenten", async (StoreDocumentRequest body, AclService acl, Can
return Results.Ok(new { informatieobjectUrl = url.ToString() });
});
// List the published zaaktypen — the read-only catalogus the beheer portal shows (S-15a). The BFF
// proxies this behind medewerker-realm + beheerder authorization; the ACL trusts its callers (§8.3)
// and is the only code allowed to read the ZGW Catalogi API (§8.1).
app.MapGet("/catalogi/zaaktypen", async (AclService acl, CancellationToken ct) =>
Results.Ok(await acl.ListZaaktypenAsync(ct)));
app.Run();
public sealed record OpenZaakRequest(string Bsn, string Reference);
+4 -7
View File
@@ -6,12 +6,9 @@ public sealed class AclDefaults
public required string Bronorganisatie { get; init; }
public required string VerantwoordelijkeOrganisatie { get; init; }
public required string Vertrouwelijkheidaanduiding { get; init; }
public required Uri ZaaktypeUrl { get; init; }
/// <summary>The BIG zaaktype's stable business key. The ACL resolves the (server-assigned) zaaktype
/// URL from this via the Catalogi API instead of being handed a pinned URL (S-27, ADR-0021).</summary>
public required string ZaaktypeIdentificatie { get; init; }
/// <summary>The omschrijving of the informatieobjecttype an uploaded diploma is filed under (S-10b);
/// resolved to a URL by the Catalogi API, like <see cref="ZaaktypeIdentificatie"/>.</summary>
public required string InformatieobjecttypeOmschrijving { get; init; }
/// <summary>The informatieobjecttype an uploaded diploma is filed under (S-10b). Seeded in the
/// catalogus and injected like <see cref="ZaaktypeUrl"/>.</summary>
public required Uri InformatieobjecttypeUrl { get; init; }
}
+15 -20
View File
@@ -2,9 +2,9 @@ namespace Acl.Application;
/// <summary>The ACL's single operation: open a zaak from a domain payload,
/// default-filling the ZGW-mandatory fields (ADR-0003).</summary>
public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaaktypeCatalog catalog, IClock clock)
public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IClock clock)
{
public async Task<Uri> OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default)
public Task<Uri> OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(registration);
@@ -12,41 +12,36 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaak
defaults.Bronorganisatie,
defaults.VerantwoordelijkeOrganisatie,
defaults.Vertrouwelijkheidaanduiding,
await catalog.GetZaaktypeUrlAsync(ct),
defaults.ZaaktypeUrl,
clock.Today,
registration.Reference);
return await gateway.OpenZaakAsync(request, ct);
return gateway.OpenZaakAsync(request, ct);
}
/// <summary>
/// Approve a zaak: set it to the eindstatus of the BIG zaaktype (resolved by identificatie, S-27).
/// The domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1).
/// Approve a zaak: set it to the eindstatus of the configured BIG zaaktype (ADR-0003 default). The
/// domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1).
/// </summary>
public async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
await gateway.SetZaakToEindstatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct);
return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
}
/// <summary>
/// Cancel a zaak on document-timeout expiry (S-10c): set it to the BIG zaaktype's cancellation
/// statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which
/// Cancel a zaak on document-timeout expiry (S-10c): set it to the configured BIG zaaktype's
/// cancellation statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which
/// statustype/resultaat means "cancelled" (§8.1).
/// </summary>
public async Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
public Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
await gateway.SetZaakToCancellationStatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct);
return gateway.SetZaakToCancellationStatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
}
/// <summary>The published zaaktypen, for the beheer catalogus viewer (S-15a). Read-only passthrough:
/// no default-fill, the ACL is simply the only code allowed to read ZGW (§8.1).</summary>
public Task<IReadOnlyList<ZaaktypeSummary>> ListZaaktypenAsync(CancellationToken ct = default) =>
gateway.ListZaaktypenAsync(ct);
/// <summary>The zaak's reference (its ZGW identificatie), for the read projection (#78).</summary>
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
{
@@ -61,7 +56,7 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaak
/// and hand the file to the gateway, which creates the informatieobject and relates it to the zaak.
/// The domain supplies only the zaak, the bytes, and the file's name/type (§8.1).
/// </summary>
public async Task<Uri> StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default)
public Task<Uri> StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
ArgumentNullException.ThrowIfNull(content);
@@ -70,7 +65,7 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaak
var request = new DocumentRequest(
defaults.Bronorganisatie,
await catalog.GetInformatieobjecttypeUrlAsync(ct),
defaults.InformatieobjecttypeUrl,
defaults.Vertrouwelijkheidaanduiding,
zaakUrl,
clock.Today,
@@ -81,6 +76,6 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaak
Formaat: contentType,
Inhoud: content);
return await gateway.StoreDocumentAsync(request, ct);
return gateway.StoreDocumentAsync(request, ct);
}
}
@@ -1,46 +0,0 @@
namespace Acl.Application;
/// <summary>Resolves the zaaktype + diploma-informatieobjecttype URLs from the Catalogi API on first
/// use and caches them for the process lifetime (S-27, ADR-0021). Lazy (not at startup) so the ACL
/// never crash-loops when it boots before the catalogus is seeded/published; a <em>failed</em>
/// resolution is not cached, so it is retried on the next call (e.g. once the zaaktype is published).
/// A process restart re-resolves.</summary>
public sealed class CachedZaaktypeCatalog(IZaakGateway gateway, AclDefaults defaults) : IZaaktypeCatalog
{
private readonly SemaphoreSlim gate = new(1, 1);
private Uri? zaaktype;
private Uri? informatieobjecttype;
public Task<Uri> GetZaaktypeUrlAsync(CancellationToken ct = default) =>
ResolveOnceAsync(
() => zaaktype, value => zaaktype = value,
() => gateway.ResolveZaaktypeUrlAsync(defaults.ZaaktypeIdentificatie, ct), ct);
public Task<Uri> GetInformatieobjecttypeUrlAsync(CancellationToken ct = default) =>
ResolveOnceAsync(
() => informatieobjecttype, value => informatieobjecttype = value,
() => gateway.ResolveInformatieobjecttypeUrlAsync(defaults.InformatieobjecttypeOmschrijving, ct), ct);
// Double-checked, single-flight resolution: return the cache if set; otherwise resolve under the
// gate and cache only on success (a throw leaves the cache empty so the next call retries).
private async Task<Uri> ResolveOnceAsync(Func<Uri?> read, Action<Uri> store, Func<Task<Uri>> resolve, CancellationToken ct)
{
if (read() is { } cached)
return cached;
await gate.WaitAsync(ct);
try
{
if (read() is { } existing)
return existing;
var resolved = await resolve();
store(resolved);
return resolved;
}
finally
{
gate.Release();
}
}
}
@@ -32,16 +32,4 @@ public interface IZaakGateway
/// the created informatieobject.
/// </summary>
Task<Uri> StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default);
/// <summary>Resolve the URL of the published zaaktype with the given <paramref name="identificatie"/>
/// from the Catalogi API (S-27). Throws if no published zaaktype matches.</summary>
Task<Uri> ResolveZaaktypeUrlAsync(string identificatie, CancellationToken ct = default);
/// <summary>Resolve the URL of the published informatieobjecttype with the given
/// <paramref name="omschrijving"/> from the Catalogi API (S-27). Throws if none matches.</summary>
Task<Uri> ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default);
/// <summary>List the published zaaktypen from the Catalogi API — the read-only catalogus the beheer
/// portal shows (S-15a). The ACL is the only code allowed to read ZGW (§8.1).</summary>
Task<IReadOnlyList<ZaaktypeSummary>> ListZaaktypenAsync(CancellationToken ct = default);
}
@@ -1,12 +0,0 @@
namespace Acl.Application;
/// <summary>Supplies the ACL's zaaktype + diploma-informatieobjecttype URLs, resolved from OpenZaak's
/// Catalogi API by their stable business keys (<see cref="AclDefaults.ZaaktypeIdentificatie"/> /
/// <see cref="AclDefaults.InformatieobjecttypeOmschrijving"/>) rather than pinned in config (S-27,
/// ADR-0021). Implementations resolve lazily on first use and cache the result.</summary>
public interface IZaaktypeCatalog
{
Task<Uri> GetZaaktypeUrlAsync(CancellationToken ct = default);
Task<Uri> GetInformatieobjecttypeUrlAsync(CancellationToken ct = default);
}
@@ -1,6 +0,0 @@
namespace Acl.Application;
/// <summary>A published zaaktype as the beheer catalogus viewer shows it (S-15a). Public-safe: the
/// business <see cref="Identificatie"/> + human <see cref="Omschrijving"/> and the ZGW <see cref="Url"/>
/// (the URL is the ACL's own reference, not shown to end users).</summary>
public sealed record ZaaktypeSummary(string Identificatie, string Omschrijving, Uri Url);
@@ -142,58 +142,6 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
return created;
}
public async Task<Uri> ResolveZaaktypeUrlAsync(string identificatie, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(identificatie);
// The published zaaktype with this identificatie; status=definitief excludes concepts.
var page = await GetAsync<ZaaktypePage>(
"/catalogi/api/v1/zaaktypen?status=definitief&identificatie=" + Uri.EscapeDataString(identificatie),
"zaaktypen", ct);
var match = (page.Results ?? []).FirstOrDefault()
?? throw new InvalidOperationException(
$"No published zaaktype with identificatie '{identificatie}' found in OpenZaak — is the BIG catalogus seeded and published?");
return new Uri(match.Url);
}
public async Task<Uri> ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(omschrijving);
// The informatieobjecttypen collection has no omschrijving filter, so match client-side over the
// published ones.
var page = await GetAsync<InformatieobjecttypePage>(
"/catalogi/api/v1/informatieobjecttypen?status=definitief", "informatieobjecttypen", ct);
var match = (page.Results ?? []).FirstOrDefault(i => i.Omschrijving == omschrijving)
?? throw new InvalidOperationException(
$"No published informatieobjecttype '{omschrijving}' found in OpenZaak — is the BIG catalogus seeded and published?");
return new Uri(match.Url);
}
public async Task<IReadOnlyList<ZaaktypeSummary>> ListZaaktypenAsync(CancellationToken ct = default)
{
// Only published zaaktypen (status=definitief excludes concepts) — the read-only catalogus the
// beheer portal shows. Public-safe fields only.
var page = await GetAsync<ZaaktypePage>("/catalogi/api/v1/zaaktypen?status=definitief", "zaaktypen", ct);
return (page.Results ?? [])
.Select(z => new ZaaktypeSummary(z.Identificatie ?? "", z.Omschrijving ?? "", new Uri(z.Url)))
.ToList();
}
// GETs an absolute-by-path ZGW resource with auth (no CRS — catalogi is not a geo API).
private async Task<T> GetAsync<T>(string pathAndQuery, string label, CancellationToken ct)
{
using var message = new HttpRequestMessage(HttpMethod.Get, new Uri(options.BaseUrl, pathAndQuery));
message.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
using var response = await http.SendAsync(message, ct);
await EnsureSuccessAsync(response, $"Querying {label}", ct);
return await response.Content.ReadFromJsonAsync<T>(ct)
?? throw new InvalidOperationException($"OpenZaak returned an empty {label} response");
}
// POSTs a non-geo ZGW resource (resultaat/status — no CRS headers). Buffers the body so uwsgi gets
// a Content-Length instead of a chunked body (as with zaak-create).
private async Task PostAsync(string path, object dto, string action, CancellationToken ct)
@@ -350,19 +298,4 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
private sealed record ZaakInformatieobjectDto(
[property: JsonPropertyName("zaak")] string Zaak,
[property: JsonPropertyName("informatieobject")] string Informatieobject);
private sealed record ZaaktypePage(
[property: JsonPropertyName("results")] IReadOnlyList<ZaaktypeDto>? Results);
private sealed record ZaaktypeDto(
[property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("identificatie")] string? Identificatie,
[property: JsonPropertyName("omschrijving")] string? Omschrijving = null);
private sealed record InformatieobjecttypePage(
[property: JsonPropertyName("results")] IReadOnlyList<InformatieobjecttypeDto>? Results);
private sealed record InformatieobjecttypeDto(
[property: JsonPropertyName("url")] string Url,
[property: JsonPropertyName("omschrijving")] string? Omschrijving);
}
@@ -161,32 +161,4 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
Assert.Contains(relations.EnumerateArray(),
r => r.GetProperty("zaak").GetString() == zaakUrl.ToString());
}
[Fact]
public async Task Resolves_the_published_zaaktype_and_diploma_informatieobjecttype_by_business_key()
{
var expectedZaaktype = await stack.FindPublishedBigZaaktypeAsync();
Assert.True(expectedZaaktype is not null,
"No published BIG-REGISTRATIE zaaktype found — seed the stack with OZ_PUBLISH=1.");
var expectedInformatieobjecttype = await stack.FindPublishedDiplomaInformatieobjecttypeAsync();
Assert.True(expectedInformatieobjecttype is not null,
"No published Diploma informatieobjecttype found — seed the stack with OZ_PUBLISH=1.");
var gateway = new OpenZaakGateway(stack.Http, stack.Options);
// The ACL discovers both URLs from the live Catalogi API by their stable business keys (S-27),
// matching what the fixture found independently — no pinned URL needed.
Assert.Equal(expectedZaaktype, await gateway.ResolveZaaktypeUrlAsync("BIG-REGISTRATIE"));
Assert.Equal(expectedInformatieobjecttype, await gateway.ResolveInformatieobjecttypeUrlAsync("Diploma"));
}
[Fact]
public async Task Resolving_an_unknown_zaaktype_identificatie_throws_a_clear_error()
{
var gateway = new OpenZaakGateway(stack.Http, stack.Options);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => gateway.ResolveZaaktypeUrlAsync("NO-SUCH-ZAAKTYPE"));
Assert.Contains("NO-SUCH-ZAAKTYPE", ex.Message);
}
}
+38 -66
View File
@@ -6,12 +6,6 @@ public class AclServiceTests
{
private sealed class FakeGateway : IZaakGateway
{
// The URLs the catalogus resolves the configured identificatie/omschrijving to (S-27).
public Uri ResolvedZaaktype { get; } = new("http://openzaak/catalogi/api/v1/zaaktypen/big");
public Uri ResolvedInformatieobjecttype { get; } = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip");
public string? ResolvedByIdentificatie;
public string? ResolvedByOmschrijving;
public ZaakRequest? Captured;
public Uri Result { get; } = new("http://openzaak/zaken/api/v1/zaken/abc");
@@ -53,26 +47,6 @@ public class AclServiceTests
StoredDocument = request;
return Task.FromResult(DocumentResult);
}
public Task<Uri> ResolveZaaktypeUrlAsync(string identificatie, CancellationToken ct = default)
{
ResolvedByIdentificatie = identificatie;
return Task.FromResult(ResolvedZaaktype);
}
public Task<Uri> ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default)
{
ResolvedByOmschrijving = omschrijving;
return Task.FromResult(ResolvedInformatieobjecttype);
}
public IReadOnlyList<ZaaktypeSummary> Zaaktypen { get; } =
[
new("BIG-REGISTRATIE", "BIG-registratie", new Uri("http://openzaak/catalogi/api/v1/zaaktypen/big")),
];
public Task<IReadOnlyList<ZaaktypeSummary>> ListZaaktypenAsync(CancellationToken ct = default) =>
Task.FromResult(Zaaktypen);
}
private static AclDefaults Defaults() => new()
@@ -80,23 +54,28 @@ public class AclServiceTests
Bronorganisatie = "517439943",
VerantwoordelijkeOrganisatie = "517439943",
Vertrouwelijkheidaanduiding = "openbaar",
ZaaktypeIdentificatie = "BIG-REGISTRATIE",
InformatieobjecttypeOmschrijving = "Diploma",
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"),
};
private static AclService ServiceWith(FakeGateway gateway, AclDefaults defaults, DateOnly today) =>
new(gateway, defaults, new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today));
private sealed class FixedClock(DateOnly today) : IClock
{
public DateOnly Today { get; } = today;
}
[Fact]
public async Task Opening_a_zaak_default_fills_zgw_fields_and_uses_the_resolved_zaaktype()
public async Task Opening_a_zaak_default_fills_zgw_fields_and_returns_the_zaak_url()
{
var gateway = new FakeGateway();
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
var defaults = new AclDefaults
{
Bronorganisatie = "517439943",
VerantwoordelijkeOrganisatie = "517439943",
Vertrouwelijkheidaanduiding = "openbaar",
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"),
};
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
var url = await service.OpenZaakAsync(new DomainRegistration("123456782", "reg-77"));
@@ -105,9 +84,7 @@ public class AclServiceTests
Assert.Equal("517439943", req.Bronorganisatie);
Assert.Equal("517439943", req.VerantwoordelijkeOrganisatie);
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding);
// The zaaktype is resolved from the configured identificatie, not a pinned URL (S-27).
Assert.Equal("BIG-REGISTRATIE", gateway.ResolvedByIdentificatie);
Assert.Equal(gateway.ResolvedZaaktype, req.Zaaktype);
Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype);
Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum);
// The registration reference becomes the zaak identificatie (#78).
Assert.Equal("reg-77", req.Identificatie);
@@ -117,24 +94,33 @@ public class AclServiceTests
public async Task Rejects_a_null_registration_without_calling_the_gateway()
{
var gateway = new FakeGateway();
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
var defaults = new AclDefaults
{
Bronorganisatie = "517439943",
VerantwoordelijkeOrganisatie = "517439943",
Vertrouwelijkheidaanduiding = "openbaar",
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"),
};
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
await Assert.ThrowsAsync<ArgumentNullException>(() => service.OpenZaakAsync(null!));
Assert.Null(gateway.Captured);
}
[Fact]
public async Task Approving_a_zaak_sets_it_to_its_resolved_zaaktypes_eindstatus_dated_today()
public async Task Approving_a_zaak_sets_it_to_its_zaaktypes_eindstatus_dated_today()
{
var gateway = new FakeGateway();
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
var defaults = Defaults();
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
await service.ApproveZaakAsync(zaak);
Assert.NotNull(gateway.Approved);
Assert.Equal(zaak, gateway.Approved!.Value.Zaak);
Assert.Equal(gateway.ResolvedZaaktype, gateway.Approved.Value.Zaaktype);
Assert.Equal(defaults.ZaaktypeUrl, gateway.Approved.Value.Zaaktype);
Assert.Equal(new DateOnly(2026, 6, 4), gateway.Approved.Value.Datum);
}
@@ -142,7 +128,7 @@ public class AclServiceTests
public async Task Approving_a_null_zaak_is_rejected_without_touching_the_gateway()
{
var gateway = new FakeGateway();
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!));
Assert.Null(gateway.Approved);
@@ -152,14 +138,15 @@ public class AclServiceTests
public async Task Cancelling_a_zaak_sets_it_to_the_cancellation_status_dated_today()
{
var gateway = new FakeGateway();
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
var defaults = Defaults();
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
await service.CancelZaakAsync(zaak);
Assert.NotNull(gateway.Cancelled);
Assert.Equal(zaak, gateway.Cancelled!.Value.Zaak);
Assert.Equal(gateway.ResolvedZaaktype, gateway.Cancelled.Value.Zaaktype);
Assert.Equal(defaults.ZaaktypeUrl, gateway.Cancelled.Value.Zaaktype);
Assert.Equal(new DateOnly(2026, 6, 4), gateway.Cancelled.Value.Datum);
// Cancellation must not touch the approval path.
Assert.Null(gateway.Approved);
@@ -169,17 +156,18 @@ public class AclServiceTests
public async Task Cancelling_a_null_zaak_is_rejected_without_touching_the_gateway()
{
var gateway = new FakeGateway();
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
await Assert.ThrowsAsync<ArgumentNullException>(() => service.CancelZaakAsync(null!));
Assert.Null(gateway.Cancelled);
}
[Fact]
public async Task Storing_a_diploma_default_fills_the_document_fields_and_uses_the_resolved_informatieobjecttype()
public async Task Storing_a_diploma_default_fills_the_document_fields_and_returns_its_url()
{
var gateway = new FakeGateway();
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
var defaults = Defaults();
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
var url = await service.StoreDiplomaAsync(zaak, [1, 2, 3], "diploma.pdf", "application/pdf");
@@ -187,9 +175,7 @@ public class AclServiceTests
Assert.Equal(gateway.DocumentResult, url);
var req = gateway.StoredDocument!;
Assert.Equal(zaak, req.Zaak);
// The informatieobjecttype is resolved from the configured omschrijving (S-27).
Assert.Equal("Diploma", gateway.ResolvedByOmschrijving);
Assert.Equal(gateway.ResolvedInformatieobjecttype, req.Informatieobjecttype);
Assert.Equal(defaults.InformatieobjecttypeUrl, req.Informatieobjecttype);
Assert.Equal("517439943", req.Bronorganisatie);
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding);
Assert.Equal(new DateOnly(2026, 6, 4), req.Creatiedatum);
@@ -202,7 +188,7 @@ public class AclServiceTests
[Fact]
public async Task Storing_a_diploma_rejects_null_or_blank_arguments()
{
var service = ServiceWith(new FakeGateway(), Defaults(), new DateOnly(2026, 6, 4));
var service = new AclService(new FakeGateway(), Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
await Assert.ThrowsAsync<ArgumentNullException>(() => service.StoreDiplomaAsync(null!, [1], "d.pdf", "application/pdf"));
@@ -215,7 +201,7 @@ public class AclServiceTests
public async Task Reading_a_zaak_reference_returns_the_zaaks_identificatie()
{
var gateway = new FakeGateway();
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
var reference = await service.GetZaakReferenceAsync(zaak);
@@ -228,23 +214,9 @@ public class AclServiceTests
public async Task Reading_a_null_zaak_reference_is_rejected()
{
var gateway = new FakeGateway();
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
await Assert.ThrowsAsync<ArgumentNullException>(() => service.GetZaakReferenceAsync(null!));
Assert.Null(gateway.ReadReferenceFor);
}
[Fact]
public async Task Listing_zaaktypen_returns_the_gateways_published_zaaktypen(/* S-15a */)
{
var gateway = new FakeGateway();
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
var zaaktypen = await service.ListZaaktypenAsync();
var only = Assert.Single(zaaktypen);
Assert.Equal("BIG-REGISTRATIE", only.Identificatie);
Assert.Equal("BIG-registratie", only.Omschrijving);
Assert.Equal(new Uri("http://openzaak/catalogi/api/v1/zaaktypen/big"), only.Url);
}
}
@@ -675,200 +675,4 @@ public class OpenZaakGatewayTests
await Assert.ThrowsAsync<ArgumentNullException>(() => Gateway(handler).StoreDocumentAsync(null!));
}
// ── Catalogi resolution by business key (S-27) ────────────────────────────────────────────────
[Fact]
public async Task Resolves_the_published_zaaktype_url_by_identificatie()
{
HttpRequestMessage? seen = null;
var handler = new StubHandler(req =>
{
seen = req;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(new
{
results = new[] { new { url = "http://openzaak/catalogi/api/v1/zaaktypen/big", identificatie = "BIG-REGISTRATIE" } },
}),
});
});
var url = await Gateway(handler).ResolveZaaktypeUrlAsync("BIG-REGISTRATIE");
Assert.Equal("http://openzaak/catalogi/api/v1/zaaktypen/big", url.ToString());
Assert.Equal(HttpMethod.Get, seen!.Method);
// Filters to the published zaaktype with that identificatie, and authenticates.
Assert.Contains("/catalogi/api/v1/zaaktypen", seen.RequestUri!.ToString());
Assert.Contains("status=definitief", seen.RequestUri!.Query);
Assert.Contains("identificatie=BIG-REGISTRATIE", seen.RequestUri!.Query);
Assert.Equal("Bearer", seen.Headers.Authorization!.Scheme);
}
[Fact]
public async Task Resolving_a_zaaktype_throws_a_clear_error_when_none_is_published()
{
var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(new { results = Array.Empty<object>() }),
}));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => Gateway(handler).ResolveZaaktypeUrlAsync("BIG-REGISTRATIE"));
Assert.Contains("BIG-REGISTRATIE", ex.Message);
}
[Fact]
public async Task Resolves_the_informatieobjecttype_url_by_omschrijving()
{
HttpRequestMessage? seen = null;
var handler = new StubHandler(req =>
{
seen = req;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(new
{
results = new[]
{
new { url = "http://openzaak/catalogi/api/v1/informatieobjecttypen/other", omschrijving = "Overig" },
new { url = "http://openzaak/catalogi/api/v1/informatieobjecttypen/dip", omschrijving = "Diploma" },
},
}),
});
});
var url = await Gateway(handler).ResolveInformatieobjecttypeUrlAsync("Diploma");
// Queries the published informatieobjecttypen collection, and matches on omschrijving (not position).
Assert.Contains("/catalogi/api/v1/informatieobjecttypen", seen!.RequestUri!.ToString());
Assert.Contains("status=definitief", seen.RequestUri!.Query);
Assert.Equal("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip", url.ToString());
}
[Fact]
public async Task Resolving_a_zaaktype_throws_when_the_response_carries_no_results()
{
// No "results" property → the page's Results is null; the gateway must treat that as "none
// found" (not dereference null).
var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(new { count = 0 }),
}));
await Assert.ThrowsAsync<InvalidOperationException>(
() => Gateway(handler).ResolveZaaktypeUrlAsync("BIG-REGISTRATIE"));
}
[Fact]
public async Task Resolving_an_informatieobjecttype_throws_when_the_response_carries_no_results()
{
var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(new { count = 0 }),
}));
await Assert.ThrowsAsync<InvalidOperationException>(
() => Gateway(handler).ResolveInformatieobjecttypeUrlAsync("Diploma"));
}
[Fact]
public async Task Resolving_a_zaaktype_surfaces_a_non_success_catalogi_response()
{
var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("boom"),
}));
var ex = await Assert.ThrowsAsync<HttpRequestException>(
() => Gateway(handler).ResolveZaaktypeUrlAsync("BIG-REGISTRATIE"));
// The error names the resource being queried and includes OpenZaak's body.
Assert.Contains("zaaktypen", ex.Message);
Assert.Contains("boom", ex.Message);
}
[Fact]
public async Task Resolving_an_informatieobjecttype_surfaces_a_non_success_catalogi_response()
{
var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("boom"),
}));
var ex = await Assert.ThrowsAsync<HttpRequestException>(
() => Gateway(handler).ResolveInformatieobjecttypeUrlAsync("Diploma"));
Assert.Contains("informatieobjecttypen", ex.Message);
}
[Fact]
public async Task Resolving_an_informatieobjecttype_throws_when_no_omschrijving_matches()
{
var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(new
{
results = new[] { new { url = "http://openzaak/catalogi/api/v1/informatieobjecttypen/other", omschrijving = "Overig" } },
}),
}));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => Gateway(handler).ResolveInformatieobjecttypeUrlAsync("Diploma"));
Assert.Contains("Diploma", ex.Message);
}
[Fact]
public async Task Resolving_rejects_a_blank_business_key_without_calling_openzaak()
{
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
await Assert.ThrowsAnyAsync<ArgumentException>(() => Gateway(handler).ResolveZaaktypeUrlAsync(" "));
await Assert.ThrowsAnyAsync<ArgumentException>(() => Gateway(handler).ResolveInformatieobjecttypeUrlAsync(" "));
}
[Fact]
public async Task Listing_zaaktypen_queries_published_zaaktypen_and_maps_them(/* S-15a */)
{
HttpRequestMessage? seen = null;
var handler = new StubHandler(req =>
{
seen = req;
const string json = """
{"results":[
{"url":"http://openzaak/catalogi/api/v1/zaaktypen/big","identificatie":"BIG-REGISTRATIE","omschrijving":"BIG-registratie"},
{"url":"http://openzaak/catalogi/api/v1/zaaktypen/her","identificatie":"BIG-HERREGISTRATIE","omschrijving":"BIG-herregistratie"}
]}
""";
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json"),
});
});
var zaaktypen = await Gateway(handler).ListZaaktypenAsync();
// Only the published zaaktypen collection is queried (status=definitief excludes concepts).
Assert.Contains("/catalogi/api/v1/zaaktypen", seen!.RequestUri!.ToString());
Assert.Contains("status=definitief", seen.RequestUri!.ToString());
// Authenticated like the other catalogi reads.
Assert.Equal("Bearer", seen.Headers.Authorization!.Scheme);
// Each result maps to a public-safe summary (identificatie + omschrijving + url).
Assert.Equal(2, zaaktypen.Count);
Assert.Equal("BIG-REGISTRATIE", zaaktypen[0].Identificatie);
Assert.Equal("BIG-registratie", zaaktypen[0].Omschrijving);
Assert.Equal(new Uri("http://openzaak/catalogi/api/v1/zaaktypen/big"), zaaktypen[0].Url);
Assert.Equal("BIG-HERREGISTRATIE", zaaktypen[1].Identificatie);
}
[Fact]
public async Task Listing_zaaktypen_returns_empty_when_the_catalogus_has_none()
{
var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"results":[]}""", Encoding.UTF8, "application/json"),
}));
var zaaktypen = await Gateway(handler).ListZaaktypenAsync();
Assert.Empty(zaaktypen);
}
}
@@ -1,93 +0,0 @@
using Acl.Application;
namespace Acl.Tests;
public class ZaaktypeCatalogTests
{
// A gateway that only supports resolution; the other members are unused here.
private sealed class ResolvingGateway : IZaakGateway
{
public int ZaaktypeCalls;
public int InformatieobjecttypeCalls;
public string? LastIdentificatie;
public string? LastOmschrijving;
public int ThrowZaaktypeTimes;
public Uri ZaaktypeUrl { get; } = new("http://openzaak/catalogi/api/v1/zaaktypen/big");
public Uri InformatieobjecttypeUrl { get; } = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip");
public Task<Uri> ResolveZaaktypeUrlAsync(string identificatie, CancellationToken ct = default)
{
ZaaktypeCalls++;
LastIdentificatie = identificatie;
if (ZaaktypeCalls <= ThrowZaaktypeTimes)
throw new InvalidOperationException("no published zaaktype yet");
return Task.FromResult(ZaaktypeUrl);
}
public Task<Uri> ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default)
{
InformatieobjecttypeCalls++;
LastOmschrijving = omschrijving;
return Task.FromResult(InformatieobjecttypeUrl);
}
public Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default) => throw new NotSupportedException();
public Task SetZaakToEindstatusAsync(Uri z, Uri zt, DateOnly d, CancellationToken ct = default) => throw new NotSupportedException();
public Task SetZaakToCancellationStatusAsync(Uri z, Uri zt, DateOnly d, CancellationToken ct = default) => throw new NotSupportedException();
public Task<string> GetZaakIdentificatieAsync(Uri z, CancellationToken ct = default) => throw new NotSupportedException();
public Task<Uri> StoreDocumentAsync(DocumentRequest r, CancellationToken ct = default) => throw new NotSupportedException();
public Task<IReadOnlyList<ZaaktypeSummary>> ListZaaktypenAsync(CancellationToken ct = default) => throw new NotSupportedException();
}
private static AclDefaults Defaults() => new()
{
Bronorganisatie = "517439943",
VerantwoordelijkeOrganisatie = "517439943",
Vertrouwelijkheidaanduiding = "openbaar",
ZaaktypeIdentificatie = "BIG-REGISTRATIE",
InformatieobjecttypeOmschrijving = "Diploma",
};
[Fact]
public async Task Resolves_the_zaaktype_and_informatieobjecttype_by_their_configured_business_keys()
{
var gateway = new ResolvingGateway();
var catalog = new CachedZaaktypeCatalog(gateway, Defaults());
Assert.Equal(gateway.ZaaktypeUrl, await catalog.GetZaaktypeUrlAsync());
Assert.Equal(gateway.InformatieobjecttypeUrl, await catalog.GetInformatieobjecttypeUrlAsync());
Assert.Equal("BIG-REGISTRATIE", gateway.LastIdentificatie);
Assert.Equal("Diploma", gateway.LastOmschrijving);
}
[Fact]
public async Task Caches_the_resolved_urls_so_the_gateway_is_hit_once()
{
var gateway = new ResolvingGateway();
var catalog = new CachedZaaktypeCatalog(gateway, Defaults());
for (var i = 0; i < 3; i++)
{
await catalog.GetZaaktypeUrlAsync();
await catalog.GetInformatieobjecttypeUrlAsync();
}
Assert.Equal(1, gateway.ZaaktypeCalls);
Assert.Equal(1, gateway.InformatieobjecttypeCalls);
}
[Fact]
public async Task Does_not_cache_a_failed_resolution_so_it_is_retried()
{
// The zaaktype is not published yet on the first call; the catalog must retry (not cache the
// failure) so a later call succeeds once it is published.
var gateway = new ResolvingGateway { ThrowZaaktypeTimes = 1 };
var catalog = new CachedZaaktypeCatalog(gateway, Defaults());
await Assert.ThrowsAsync<InvalidOperationException>(() => catalog.GetZaaktypeUrlAsync());
var url = await catalog.GetZaaktypeUrlAsync();
Assert.Equal(gateway.ZaaktypeUrl, url);
Assert.Equal(2, gateway.ZaaktypeCalls);
}
}
-5
View File
@@ -10,11 +10,6 @@
<!-- OIDC/JWT validation of Keycloak-issued tokens (ADR-0010) and OpenAPI generation. -->
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.17.0-beta.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
</ItemGroup>
</Project>
-41
View File
@@ -5,10 +5,6 @@ namespace Bff.Api;
/// <summary>What the self-service submit returns to the portal (the domain's registration id + status).</summary>
public sealed record SubmitAccepted(string RegistrationId, string Status);
/// <summary>The caller's current open registration, for resuming the self-service portal after a
/// refresh (S-26): the reference (registration id) + its status.</summary>
public sealed record CurrentRegistration(string RegistrationId, string Status);
/// <summary>A projection row as the projection-api serves it. <c>Bsn</c>/<c>NaamPlaceholder</c> are
/// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09).
/// <c>Reference</c> is the public-safe citizen reference (the zaak identificatie, #78).</summary>
@@ -26,10 +22,6 @@ public interface IDomainClient
{
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
/// <summary>The caller's current open registration (resume after refresh, S-26), or <c>null</c>
/// when they have none in flight. Owner-scoped by <paramref name="bsn"/>.</summary>
Task<CurrentRegistration?> GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default);
/// <summary>Withdraw the caller's own registration ("trek aanvraag in"). Owner-scoped by
/// <paramref name="bsn"/>. Returns <c>false</c> when the domain reports the registration is
/// unknown or not the caller's (404), so the BFF can relay a 404 rather than a 500.</summary>
@@ -54,19 +46,6 @@ public interface IProjectionClient
Task<IReadOnlyList<ProjectionEntry>> GetRegisterAsync(CancellationToken ct = default);
}
/// <summary>A published zaaktype as the beheer catalogus viewer shows it (S-15a): the business
/// <c>Identificatie</c> + human <c>Omschrijving</c>. The ZGW URL the ACL also returns is dropped — an
/// internal reference, not shown in the portal.</summary>
public sealed record BeheerZaaktype(string Identificatie, string Omschrijving);
/// <summary>Port to the ACL for read-only catalogus queries (beheer portal, S-15a). The BFF reaches the
/// ACL directly for this read: the catalogus isn't a domain concern, and the ACL is the only code
/// allowed to read the ZGW Catalogi API (§8.1, ADR-0025).</summary>
public interface IAclClient
{
Task<IReadOnlyList<BeheerZaaktype>> GetZaaktypenAsync(CancellationToken ct = default);
}
/// <summary>Calls the Domain Service's <c>POST /registrations</c>.</summary>
public sealed class DomainClient(HttpClient http) : IDomainClient
{
@@ -79,18 +58,6 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
return new SubmitAccepted(dto.RegistrationId, dto.Status);
}
public async Task<CurrentRegistration?> GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default)
{
using var response = await http.GetAsync($"registrations/current?bsn={Uri.EscapeDataString(bsn)}", ct);
// The domain 404s when the citizen has no open registration — that's "none", not an error.
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
var dto = await response.Content.ReadFromJsonAsync<DomainResponse>(ct)
?? throw new InvalidOperationException("The Domain Service returned an empty registration response.");
return new CurrentRegistration(dto.RegistrationId, dto.Status);
}
public async Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
{
using var response = await http.PostAsJsonAsync(
@@ -134,11 +101,3 @@ public sealed class ProjectionClient(HttpClient http) : IProjectionClient
public async Task<IReadOnlyList<ProjectionEntry>> GetRegisterAsync(CancellationToken ct = default)
=> await http.GetFromJsonAsync<List<ProjectionEntry>>("register", ct) ?? [];
}
/// <summary>Calls the ACL's <c>GET /catalogi/zaaktypen</c> (S-15a). The ACL also returns each zaaktype's
/// ZGW URL; deserializing into <see cref="BeheerZaaktype"/> keeps only the public-safe fields.</summary>
public sealed class AclClient(HttpClient http) : IAclClient
{
public async Task<IReadOnlyList<BeheerZaaktype>> GetZaaktypenAsync(CancellationToken ct = default)
=> await http.GetFromJsonAsync<List<BeheerZaaktype>>("catalogi/zaaktypen", ct) ?? [];
}
+2 -78
View File
@@ -3,33 +3,9 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using Bff.Api;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
var builder = WebApplication.CreateBuilder(args);
// OpenTelemetry tracing (S-16b, ADR-0023): auto-instrument incoming ASP.NET Core requests and
// outgoing HttpClient calls (BFF → Domain, BFF → projection-api), exported over OTLP to Tempo, so a
// portal request is one connected trace across the services. Service name + OTLP endpoint come from
// OTEL_* env (compose); the exporter no-ops when Tempo is unreachable. /health is filtered out.
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService(
builder.Configuration["OTEL_SERVICE_NAME"] ?? builder.Environment.ApplicationName))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation(o => o.Filter = ctx => ctx.Request.Path != "/health")
.AddHttpClientInstrumentation()
.AddOtlpExporter())
// OpenTelemetry metrics (S-16c, ADR-0023): the golden signals for the request path —
// http.server.request.duration (traffic/errors/latency) + http.client.* for the downstream hops,
// plus the built-in System.Runtime meter for saturation (GC, CPU, thread pool). Prometheus scrapes
// these from /metrics (mapped below); no OTLP push for metrics, so no collector hop (ADR-0023).
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddMeter("System.Runtime")
.AddPrometheusExporter());
var keycloakAuthority = builder.Configuration["Keycloak:Authority"]
?? throw new InvalidOperationException("Missing configuration 'Keycloak:Authority'");
// Behandelaars authenticate against a *different* Keycloak realm (medewerker) than citizens (digid),
@@ -40,10 +16,6 @@ var domainBaseUrl = builder.Configuration["Downstream:Domain:BaseUrl"]
?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'");
var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"]
?? throw new InvalidOperationException("Missing configuration 'Downstream:Projection:BaseUrl'");
// The beheer portal's read-only catalogus view reaches the ACL directly (ADR-0025): the catalogus is
// not a domain concern, and only the ACL may read the ZGW Catalogi API (§8.1).
var aclBaseUrl = builder.Configuration["Downstream:Acl:BaseUrl"]
?? throw new InvalidOperationException("Missing configuration 'Downstream:Acl:BaseUrl'");
// Validate Keycloak-issued tokens (ADR-0010). Audience validation is off for the walking skeleton —
// Keycloak's audience mapping is a later hardening; signature/issuer/expiry are validated.
@@ -71,24 +43,14 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy(BehandelAuth.Policy, policy => policy
.AddAuthenticationSchemes(BehandelAuth.Scheme)
.RequireAuthenticatedUser()
.RequireRole(BehandelAuth.BehandelaarRole));
// Beheer endpoints reuse the medewerker scheme (same realm, same realm-role lifting) but require the
// beheerder role rather than behandelaar (S-15a).
options.AddPolicy(BeheerAuth.Policy, policy => policy
.AddAuthenticationSchemes(BehandelAuth.Scheme)
.RequireAuthenticatedUser()
.RequireRole(BeheerAuth.BeheerderRole));
});
.RequireRole(BehandelAuth.BehandelaarRole)));
// The BFF is the portals' only backend; it fans out to the domain and projection (§8.3), and reaches
// the ACL for the beheer catalogus read (ADR-0025).
// 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<IProjectionClient, ProjectionClient>(c => c.BaseAddress = new Uri(projectionBaseUrl));
builder.Services.AddHttpClient<IAclClient, AclClient>(c => c.BaseAddress = new Uri(aclBaseUrl));
builder.Services.AddHealthChecks();
// Clear the auto-populated `servers` block so the committed spec is stable regardless of the host
@@ -106,9 +68,6 @@ app.UseAuthentication();
app.UseAuthorization();
app.MapHealthChecks("/health");
// Prometheus scrape endpoint (S-16c): exposes the OTel metrics above in Prometheus text format.
app.MapPrometheusScrapingEndpoint();
app.MapOpenApi();
// Self-service submit: requires a valid digid token; the bsn comes from the token, not the body,
@@ -127,24 +86,6 @@ app.MapPost("/self-service/registrations", async (ClaimsPrincipal user, IDomainC
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized);
// Self-service resume (S-26): the signed-in zorgprofessional's current open registration, so the
// portal can restore its reference + actions after a page refresh. The bsn comes from the DigiD token;
// 204 when the citizen has none in flight (so the portal shows the submit form).
app.MapGet("/self-service/registrations", async (ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) =>
{
var bsn = user.FindFirstValue("bsn");
if (string.IsNullOrWhiteSpace(bsn))
return Results.BadRequest("The token carries no bsn claim.");
var current = await domain.GetCurrentRegistrationAsync(bsn, ct);
return current is null ? Results.NoContent() : Results.Ok(current);
})
.RequireAuthorization()
.Produces<CurrentRegistration>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized);
// Self-service withdrawal (S-11): the signed-in zorgprofessional withdraws their own registration.
// The bsn comes from the DigiD token and is forwarded to the domain, which owner-scopes the action;
// a registration that is unknown or not the caller's comes back 404 (ownership is not revealed).
@@ -219,15 +160,6 @@ app.MapPost("/behandel/registrations/{id}/decide",
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden);
// Beheer catalogus viewer (S-15a): the published zaaktypen, read-only. Reached only with a medewerker-
// realm token carrying the beheerder role; the BFF proxies the ACL's read (ADR-0025). Public-safe.
app.MapGet("/beheer/catalogi/zaaktypen", async (IAclClient acl, CancellationToken ct) =>
Results.Ok(await acl.GetZaaktypenAsync(ct)))
.RequireAuthorization(BeheerAuth.Policy)
.Produces<IReadOnlyList<BeheerZaaktype>>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden);
app.Run();
/// <summary>The behandelaar's decision on a registration.</summary>
@@ -280,13 +212,5 @@ internal static class BehandelAuth
private sealed record RealmAccess([property: JsonPropertyName("roles")] string[] Roles);
}
// Beheer (medewerker-realm) authorization wiring (S-15a). Reuses the "medewerker" bearer scheme
// (BehandelAuth.Scheme) and its realm-role lifting; only the required role differs.
internal static class BeheerAuth
{
public const string Policy = "beheerder";
public const string BeheerderRole = "beheerder";
}
// Exposed so the test host (WebApplicationFactory<Program>) can boot the app.
public partial class Program;
+1 -2
View File
@@ -12,7 +12,6 @@
},
"Downstream": {
"Domain": { "BaseUrl": "http://localhost:8130/" },
"Projection": { "BaseUrl": "http://localhost:8120/" },
"Acl": { "BaseUrl": "http://localhost:8100/" }
"Projection": { "BaseUrl": "http://localhost:8120/" }
}
}
@@ -1,57 +0,0 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Bff.Api;
namespace Bff.Tests;
/// <summary>
/// The beheer catalogus viewer (S-15a): reached only with a medewerker-realm token carrying the
/// <c>beheerder</c> role. A missing token is 401; an authenticated medewerker without the role (e.g.
/// a plain behandelaar) is 403; a beheerder gets the read-only list of published zaaktypen.
/// </summary>
public class BeheerEndpointTests
{
private static HttpRequestMessage Zaaktypen(string? bearer)
{
var request = new HttpRequestMessage(HttpMethod.Get, "/beheer/catalogi/zaaktypen");
if (bearer is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
return request;
}
[Fact]
public async Task Rejects_the_catalogus_without_a_token()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Zaaktypen(bearer: null));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Rejects_a_medewerker_without_the_beheerder_role()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Zaaktypen(TestTokens.Medewerker("behandelaar")));
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task Serves_the_published_zaaktypen_to_a_beheerder()
{
using var factory = new BffFactory();
factory.Acl.Zaaktypen.Add(new BeheerZaaktype("BIG-REGISTRATIE", "BIG-registratie"));
var response = await factory.CreateClient().SendAsync(Zaaktypen(TestTokens.Medewerker("beheerder")));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var items = await response.Content.ReadFromJsonAsync<List<BeheerZaaktype>>();
var item = Assert.Single(items!);
Assert.Equal("BIG-REGISTRATIE", item.Identificatie);
Assert.Equal("BIG-registratie", item.Omschrijving);
}
}
-24
View File
@@ -23,7 +23,6 @@ internal sealed class BffFactory : WebApplicationFactory<Program>
public FakeDomainClient Domain { get; } = new();
public FakeProjectionClient Projection { get; } = new();
public FakeAclClient Acl { get; } = new();
private static void ValidateWithTestKey(IServiceCollection services, string scheme) =>
services.Configure<JwtBearerOptions>(scheme, options =>
@@ -55,13 +54,11 @@ internal sealed class BffFactory : WebApplicationFactory<Program>
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.UseSetting("Downstream:Acl:BaseUrl", "http://acl.invalid/");
builder.ConfigureTestServices(services =>
{
services.AddSingleton<IDomainClient>(Domain);
services.AddSingleton<IProjectionClient>(Projection);
services.AddSingleton<IAclClient>(Acl);
// 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
@@ -85,18 +82,6 @@ internal sealed class FakeDomainClient : IDomainClient
return Task.FromResult(Result);
}
public string? CurrentQueriedBsn { get; private set; }
/// <summary>The current open registration the fake domain returns (null → the citizen has none in
/// flight, so the BFF replies 204). Tests set this to exercise resume.</summary>
public CurrentRegistration? Current { get; set; }
public Task<CurrentRegistration?> GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default)
{
CurrentQueriedBsn = bsn;
return Task.FromResult(Current);
}
public (string RegistrationId, string Bsn)? Withdrawn { get; private set; }
/// <summary>Whether the fake domain reports the withdrawal as done (true → 204) or not-found/not-owned
@@ -141,12 +126,3 @@ internal sealed class FakeProjectionClient : IProjectionClient
public Task<IReadOnlyList<ProjectionEntry>> GetRegisterAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ProjectionEntry>>(Entries);
}
/// <summary>Serves a configurable set of catalogus zaaktypen (beheer viewer, S-15a).</summary>
internal sealed class FakeAclClient : IAclClient
{
public List<BeheerZaaktype> Zaaktypen { get; } = [];
public Task<IReadOnlyList<BeheerZaaktype>> GetZaaktypenAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<BeheerZaaktype>>(Zaaktypen);
}
@@ -1,28 +0,0 @@
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
namespace Bff.Tests;
/// <summary>
/// S-16c (#124): the service exposes OTel HTTP-server metrics in Prometheus text format at /metrics,
/// so Prometheus can scrape the golden signals (traffic, errors, latency) for the request path.
/// </summary>
public class MetricsEndpointTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task Metrics_endpoint_exposes_http_server_request_duration_after_traffic()
{
var client = factory.CreateClient();
// One request produces an http.server.request.duration measurement...
await client.GetAsync("/health");
// ...which the /metrics scrape endpoint then exposes in Prometheus text format.
var response = await client.GetAsync("/metrics");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Contains("http_server_request_duration", body);
}
}
@@ -1,7 +1,6 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Bff.Api;
namespace Bff.Tests;
@@ -169,52 +168,5 @@ public class SelfServiceEndpointTests
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
private static HttpRequestMessage Current(string? bearer)
{
var request = new HttpRequestMessage(HttpMethod.Get, "/self-service/registrations");
if (bearer is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
return request;
}
[Fact]
public async Task Rejects_the_current_registration_lookup_without_a_token()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Current(bearer: null));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Returns_no_content_when_the_caller_has_no_open_registration()
{
using var factory = new BffFactory();
factory.Domain.Current = null;
var response = await factory.CreateClient().SendAsync(Current(TestTokens.Valid("123456782")));
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
Assert.Equal("123456782", factory.Domain.CurrentQueriedBsn);
}
[Fact]
public async Task Returns_the_callers_current_registration_when_one_is_open()
{
using var factory = new BffFactory();
factory.Domain.Current = new CurrentRegistration("reg-77", "Ingediend");
var response = await factory.CreateClient().SendAsync(Current(TestTokens.Valid("123456782")));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("123456782", factory.Domain.CurrentQueriedBsn);
var body = await response.Content.ReadFromJsonAsync<CurrentRegistrationDto>();
Assert.Equal("reg-77", body!.RegistrationId);
Assert.Equal("Ingediend", body.Status);
}
private sealed record SubmitAcceptedDto(string RegistrationId, string Status);
private sealed record CurrentRegistrationDto(string RegistrationId, string Status);
}
+1 -85
View File
@@ -28,32 +28,6 @@
"description": "Unauthorized"
}
}
},
"get": {
"tags": [
"Bff.Api"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CurrentRegistration"
}
}
}
},
"204": {
"description": "No Content"
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
}
}
}
},
"/self-service/registrations/{id}/withdraw": {
@@ -227,68 +201,10 @@
}
}
}
},
"/beheer/catalogi/zaaktypen": {
"get": {
"tags": [
"Bff.Api"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/BeheerZaaktype"
}
}
}
}
},
"401": {
"description": "Unauthorized"
},
"403": {
"description": "Forbidden"
}
}
}
}
},
"components": {
"schemas": {
"BeheerZaaktype": {
"required": [
"identificatie",
"omschrijving"
],
"type": "object",
"properties": {
"identificatie": {
"type": "string"
},
"omschrijving": {
"type": "string"
}
}
},
"CurrentRegistration": {
"required": [
"registrationId",
"status"
],
"type": "object",
"properties": {
"registrationId": {
"type": "string"
},
"status": {
"type": "string"
}
}
},
"DecideRequest": {
"required": [
"besluit"
@@ -386,4 +302,4 @@
"name": "Bff.Api"
}
]
}
}
-9
View File
@@ -5,15 +5,6 @@
<ProjectReference Include="..\Big.Infrastructure\Big.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.17.0-beta.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.18.2" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
+2 -70
View File
@@ -1,35 +1,9 @@
using Big.Application;
using Big.Domain;
using Big.Infrastructure;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using Quartz;
var builder = WebApplication.CreateBuilder(args);
// OpenTelemetry tracing (S-16b, ADR-0023): auto-instrument incoming ASP.NET Core requests and
// outgoing HttpClient calls, exported over OTLP to Tempo, so a request is one connected trace across
// the services. Service name + OTLP endpoint come from OTEL_* env (compose); the exporter no-ops
// harmlessly when Tempo is unreachable (e.g. a service run standalone). /health is filtered out so
// liveness polls don't flood the traces.
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService(
builder.Configuration["OTEL_SERVICE_NAME"] ?? builder.Environment.ApplicationName))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation(o => o.Filter = ctx => ctx.Request.Path != "/health")
.AddHttpClientInstrumentation()
.AddOtlpExporter())
// OpenTelemetry metrics (S-16c, ADR-0023): golden signals for the request path —
// http.server.request.duration (traffic/errors/latency) + http.client.* for downstream hops, plus
// the built-in System.Runtime meter for saturation (GC, CPU, thread pool). Prometheus scrapes these
// from /metrics (mapped below); metrics aren't pushed over OTLP, so no collector hop (ADR-0023).
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddMeter("System.Runtime")
.AddPrometheusExporter());
// Options bound from configuration (compose sets Flowable__* and Acl__* env vars).
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
.GetSection("Flowable").Get<FlowableOptions>()
@@ -41,10 +15,6 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
// The in-memory registration store is shared between the submit endpoint and the worker (ADR-0009).
builder.Services.AddSingleton<IRegistrationStore, InMemoryRegistrationStore>();
// The system clock, injected wherever a use case needs "now" (e.g. stamping the inscription moment
// on approval, S-17). Injected as TimeProvider so tests can substitute a fixed clock.
builder.Services.AddSingleton(TimeProvider.System);
// The Workflow Client is one type behind two ports (start side + worker side); both resolve to the
// same HttpClient-backed implementation — the only code that talks to Flowable (§8.2).
builder.Services.AddHttpClient<FlowableWorkflowClient>();
@@ -66,7 +36,6 @@ builder.Services.AddScoped<OpenZaakJobProcessor>();
builder.Services.AddScoped<BeoordelingEscalatieProcessor>();
builder.Services.AddScoped<ExpireRegistrationWorker>();
builder.Services.AddScoped<RegistratieVerlopenProcessor>();
builder.Services.AddScoped<HerregistratieReminderSweep>();
// The hosted external-task job worker polls Flowable and drives OpenZaakAanmaken to completion.
builder.Services.AddHostedService<OpenZaakJobPump>();
@@ -77,26 +46,10 @@ builder.Services.AddHostedService<BeoordelingEscalatiePump>();
// parks and expires each lapsed registration to VERLOPEN (S-10a, ADR-0017).
builder.Services.AddHostedService<RegistratieVerlopenPump>();
// The herregistratie reminder sweep runs on a daily cron via Quartz.NET (S-17, ADR-0022) — a
// time-triggered fleet sweep, deliberately a different mechanism from the queue-draining pumps above.
// The cron is overridable with Quartz__Cron; it defaults to 03:00 daily.
builder.Services.AddQuartz(q =>
{
var jobKey = new JobKey("herregistratie-reminder");
q.AddJob<HerregistratieReminderJob>(jobKey);
q.AddTrigger(t => t
.ForJob(jobKey)
.WithCronSchedule(builder.Configuration["Quartz:Cron"] ?? "0 0 3 * * ?"));
});
builder.Services.AddQuartzHostedService(o => o.WaitForJobsToComplete = true);
var app = builder.Build();
app.MapGet("/health", () => "Healthy");
// Prometheus scrape endpoint (S-16c): exposes the OTel metrics above in Prometheus text format.
app.MapPrometheusScrapingEndpoint();
// Submit a registration. The aggregate is created (INGEDIEND) and the registratie process started;
// the zaak is opened later, off the request path, by the worker — so this returns 202 Accepted with
// a location to read the registration's progress (ADR-0009, eventual consistency).
@@ -188,21 +141,6 @@ 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).
// The citizen's current open registration, looked up by bsn — lets the self-service portal resume
// after a refresh (S-26). The BFF forwards the bsn from the DigiD token; the domain trusts its
// callers (§8.3). 404 when the citizen has none in flight.
app.MapGet("/registrations/current", async (string bsn, IRegistrationStore store, CancellationToken ct) =>
{
if (string.IsNullOrWhiteSpace(bsn))
return Results.BadRequest("A bsn is required.");
var registration = await store.FindOpenByBsnAsync(bsn, ct);
return registration is null
? Results.NotFound()
: Results.Ok(new RegistrationResponse(
registration.Id.ToString(), registration.Status.ToString(), registration.ZaakUrl?.ToString()));
});
app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) =>
{
if (!Guid.TryParse(id, out var guid))
@@ -212,8 +150,7 @@ app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, Ca
return registration is null
? Results.NotFound()
: Results.Ok(new RegistrationResponse(
registration.Id.ToString(), registration.Status.ToString(), registration.ZaakUrl?.ToString(),
registration.HerregistratieVoor?.ToString("O"), registration.HerregistratieReminderVerstuurd));
registration.Id.ToString(), registration.Status.ToString(), registration.ZaakUrl?.ToString()));
});
await app.RunAsync();
@@ -226,11 +163,6 @@ public sealed record WithdrawRequest(string Bsn);
public sealed record ProvideDocumentsRequest(string Bsn, string ContentBase64, string? FileName = null, string? ContentType = null);
public sealed record RegistrationResponse(
string RegistrationId,
string Status,
string? ZaakUrl,
string? HerregistratieVoor = null,
bool HerregistratieReminderVerstuurd = false);
public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl);
public partial class Program;
@@ -12,7 +12,7 @@ public sealed record ApproveRegistrationCommand(RegistrationId RegistrationId);
/// zaak status is the projection's source of truth (it flows back over NRC); the aggregate transition
/// keeps the domain's own view consistent.
/// </summary>
public sealed class ApproveRegistration(IRegistrationStore store, IAclClient acl, TimeProvider clock)
public sealed class ApproveRegistration(IRegistrationStore store, IAclClient acl)
{
public async Task HandleAsync(ApproveRegistrationCommand command, CancellationToken ct = default)
{
@@ -30,7 +30,7 @@ public sealed class ApproveRegistration(IRegistrationStore store, IAclClient acl
$"Registration {command.RegistrationId} has no zaak yet; it cannot be approved.");
await acl.ApproveZaakAsync(registration.ZaakUrl, ct);
registration.Approve(clock.GetUtcNow());
registration.Approve();
await store.SaveAsync(registration, ct);
}
}
@@ -25,7 +25,7 @@ public sealed record BeoordeelRegistratieCommand(RegistrationId RegistrationId,
/// decisions are idempotent — a repeated or redelivered decision that matches the current terminal
/// state is a no-op, so the ACL is not called and the task not completed twice.
/// </summary>
public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient acl, IUserTaskClient tasks, TimeProvider clock)
public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient acl, IUserTaskClient tasks)
{
public async Task HandleAsync(BeoordeelRegistratieCommand command, CancellationToken ct = default)
{
@@ -44,7 +44,7 @@ public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient ac
throw new InvalidOperationException(
$"Registration {command.RegistrationId} has no zaak yet; it cannot be approved.");
await acl.ApproveZaakAsync(registration.ZaakUrl, ct);
registration.Approve(clock.GetUtcNow());
registration.Approve();
break;
case BeoordelingsBesluit.Afwijzen:
@@ -1,30 +0,0 @@
using Big.Domain;
namespace Big.Application;
/// <summary>
/// The herregistratie reminder sweep (S-17): find the inscriptions whose herregistratie deadline is
/// within the reminder window and have not yet been reminded, mark each reminded, and persist it. Pure
/// application logic over ports — it knows nothing of Quartz; the scheduled job that fires it on a cron
/// lives in Infrastructure (mirroring how the pumps' processors are pure and the pump is the shell).
/// Idempotent: <see cref="Registration.MarkHerregistratieReminderVerstuurd"/> drops an inscription from
/// the next sweep's candidate set, so a re-fire reminds no one twice. Returns the reminded ids so the
/// caller can observe the sweep's effect — the reminder itself is the flag persisted on the aggregate.
/// </summary>
public sealed class HerregistratieReminderSweep(IRegistrationStore store, TimeProvider clock)
{
public async Task<IReadOnlyList<RegistrationId>> SweepAsync(CancellationToken ct = default)
{
var due = await store.FindDueForHerregistratieReminderAsync(clock.GetUtcNow(), ct);
var reminded = new List<RegistrationId>(due.Count);
foreach (var registration in due)
{
registration.MarkHerregistratieReminderVerstuurd();
await store.SaveAsync(registration, ct);
reminded.Add(registration.Id);
}
return reminded;
}
}
-12
View File
@@ -102,18 +102,6 @@ public interface IRegistrationStore
/// <summary>Load a registration by id, or <c>null</c> if none exists.</summary>
Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default);
/// <summary>The citizen's current <em>open</em> (non-terminal: INGEDIEND/IN_BEHANDELING)
/// registration, or <c>null</c> if they have none in flight. Lets the self-service portal resume
/// an existing registration after a refresh (S-26); terminal registrations are not resumed.</summary>
Task<Registration?> FindOpenByBsnAsync(string bsn, CancellationToken ct = default);
/// <summary>The inscriptions whose herregistratie reminder is due as of <paramref name="asOf"/> and
/// not yet sent — the herregistratie reminder sweep's candidate set (S-17). The predicate is the
/// aggregate's own <see cref="Registration.HerregistratieReminderDue"/> rule, so the store never
/// duplicates the herregistratie policy.</summary>
Task<IReadOnlyList<Registration>> FindDueForHerregistratieReminderAsync(
DateTimeOffset asOf, CancellationToken ct = default);
}
/// <summary>
+4 -53
View File
@@ -92,12 +92,11 @@ public sealed class Registration
/// <summary>
/// Approve the registration — the behandelaar's decision to enter it in the register. Advances a
/// submitted or in-behandeling registration to <see cref="RegistrationStatus.Ingeschreven"/> and
/// records <paramref name="ingeschrevenOp"/> as the moment of inscription, which starts the
/// herregistratie clock (S-17). Requires an opened zaak (the approval sets that zaak's status via
/// the ACL); a registration that has already been decided cannot be approved again.
/// submitted or in-behandeling registration to <see cref="RegistrationStatus.Ingeschreven"/>.
/// Requires an opened zaak (the approval sets that zaak's status via the ACL); a registration that
/// has already been decided cannot be approved again.
/// </summary>
public void Approve(DateTimeOffset ingeschrevenOp)
public void Approve()
{
if (ZaakUrl is null)
throw new InvalidOperationException(
@@ -105,54 +104,6 @@ public sealed class Registration
RequireOpenForDecision(nameof(Approve));
Status = RegistrationStatus.Ingeschreven;
IngeschrevenOp = ingeschrevenOp;
}
// --- Herregistratie (S-17) — RED stubs, implemented in the green commit ---------------------
/// <summary>How long a BIG inscription stays valid before herregistratie is required.</summary>
// ponytail: fixed 5-year term — a calibration knob, not a config surface. If a demo needs it
// per-catalogus, promote it to policy passed in from the beheer config (S-15).
public static readonly TimeSpan HerregistratieGeldigheid = TimeSpan.FromDays(365 * 5);
/// <summary>How long before the deadline the herregistratie reminder is sent (S-17: 90 days).</summary>
// ponytail: fixed 90-day lead time — calibration knob; same promotion path as HerregistratieGeldigheid.
public static readonly TimeSpan Herinneringstermijn = TimeSpan.FromDays(90);
/// <summary>When the registration was entered in the register, once approved; the start of its
/// herregistratie clock. Null until it is <see cref="RegistrationStatus.Ingeschreven"/>.</summary>
public DateTimeOffset? IngeschrevenOp { get; private set; }
/// <summary>The date by which herregistratie must happen: inscription + validity. Null until
/// inscribed.</summary>
public DateTimeOffset? HerregistratieVoor =>
IngeschrevenOp is DateTimeOffset ingeschrevenOp ? ingeschrevenOp + HerregistratieGeldigheid : null;
/// <summary>Whether the herregistratie reminder has been sent for this inscription (S-17).</summary>
public bool HerregistratieReminderVerstuurd { get; private set; }
/// <summary>Whether, as of <paramref name="asOf"/>, this registration is due a herregistratie
/// reminder: it is inscribed, the reminder window before its deadline has opened, and it has not
/// already been reminded. Once inside the window it stays due until reminded (an overdue inscription
/// is still due). This is the single rule the store query and the sweep both build on.</summary>
public bool HerregistratieReminderDue(DateTimeOffset asOf) =>
Status == RegistrationStatus.Ingeschreven
&& !HerregistratieReminderVerstuurd
&& IngeschrevenOp is DateTimeOffset ingeschrevenOp
&& asOf >= ingeschrevenOp + HerregistratieGeldigheid - Herinneringstermijn;
/// <summary>Record that the herregistratie reminder has been sent. Idempotent — a re-sweep is a
/// no-op (§8.6); only an inscribed registration can be reminded.</summary>
public void MarkHerregistratieReminderVerstuurd()
{
if (HerregistratieReminderVerstuurd)
return;
if (Status != RegistrationStatus.Ingeschreven)
throw new InvalidOperationException(
$"Registration {Id} is {Status}; only an INGESCHREVEN registration can be sent a herregistratie reminder.");
HerregistratieReminderVerstuurd = true;
}
/// <summary>
@@ -19,7 +19,6 @@
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Quartz" Version="3.18.2" />
</ItemGroup>
</Project>
@@ -1,26 +0,0 @@
using Big.Application;
using Microsoft.Extensions.Logging;
using Quartz;
namespace Big.Infrastructure;
/// <summary>
/// The Quartz job that fires the herregistratie reminder sweep on a cron schedule (S-17, ADR-0022).
/// A deliberately thin shell — it resolves the pure <see cref="HerregistratieReminderSweep"/> (Quartz's
/// MS-DI job factory gives each fire its own scope) and logs how many reminders went out; all the
/// sweep logic is unit-tested in the application layer. Quartz drives this — rather than a
/// BackgroundService poll loop like the pumps — because it is a time-triggered fleet sweep, not a
/// queue to drain (the distinction recorded in ADR-0022). <see cref="DisallowConcurrentExecutionAttribute"/>
/// stops a slow sweep overlapping the next fire against the shared store.
/// </summary>
[DisallowConcurrentExecution]
public sealed class HerregistratieReminderJob(
HerregistratieReminderSweep sweep, ILogger<HerregistratieReminderJob> logger) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
var reminded = await sweep.SweepAsync(context.CancellationToken);
logger.LogInformation(
"Herregistratie-sweep voltooid: {Count} herinnering(en) verstuurd.", reminded.Count);
}
}
@@ -22,13 +22,4 @@ public sealed class InMemoryRegistrationStore : IRegistrationStore
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
=> Task.FromResult(_byId.GetValueOrDefault(id));
public Task<Registration?> FindOpenByBsnAsync(string bsn, CancellationToken ct = default)
=> Task.FromResult(_byId.Values.FirstOrDefault(r =>
r.Bsn == bsn && r.Status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling));
public Task<IReadOnlyList<Registration>> FindDueForHerregistratieReminderAsync(
DateTimeOffset asOf, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Registration>>(
_byId.Values.Where(r => r.HerregistratieReminderDue(asOf)).ToList());
}
@@ -19,7 +19,7 @@ public class ApproveRegistrationTests
var acl = new FakeAclClient();
var registration = WithZaak();
store.Seed(registration);
var handler = new ApproveRegistration(store, acl, TimeProvider.System);
var handler = new ApproveRegistration(store, acl);
await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id));
@@ -36,7 +36,7 @@ public class ApproveRegistrationTests
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var handler = new ApproveRegistration(store, acl, TimeProvider.System);
var handler = new ApproveRegistration(store, acl);
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
Assert.Equal(0, acl.ApproveCallCount);
@@ -47,7 +47,7 @@ public class ApproveRegistrationTests
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var handler = new ApproveRegistration(store, acl, TimeProvider.System);
var handler = new ApproveRegistration(store, acl);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => handler.HandleAsync(new ApproveRegistrationCommand(RegistrationId.New())));
@@ -62,7 +62,7 @@ public class ApproveRegistrationTests
var acl = new FakeAclClient();
var registration = Registration.Submit("123456782"); // no zaak yet
store.Seed(registration);
var handler = new ApproveRegistration(store, acl, TimeProvider.System);
var handler = new ApproveRegistration(store, acl);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => handler.HandleAsync(new ApproveRegistrationCommand(registration.Id)));
@@ -77,7 +77,7 @@ public class ApproveRegistrationTests
var acl = new FakeAclClient();
var registration = WithZaak();
store.Seed(registration);
var handler = new ApproveRegistration(store, acl, TimeProvider.System);
var handler = new ApproveRegistration(store, acl);
await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id));
await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id));
@@ -29,7 +29,7 @@ public class BeoordeelRegistratieTests
var registration = WithZaak();
store.Seed(registration);
var tasks = TaskFor(registration);
var handler = new BeoordeelRegistratie(store, acl, tasks, TimeProvider.System);
var handler = new BeoordeelRegistratie(store, acl, tasks);
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
@@ -50,7 +50,7 @@ public class BeoordeelRegistratieTests
var registration = WithZaak();
store.Seed(registration);
var tasks = TaskFor(registration);
var handler = new BeoordeelRegistratie(store, acl, tasks, TimeProvider.System);
var handler = new BeoordeelRegistratie(store, acl, tasks);
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
@@ -69,7 +69,7 @@ public class BeoordeelRegistratieTests
var registration = WithZaak();
registration.TakeIntoBehandeling();
store.Seed(registration);
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration), TimeProvider.System);
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
@@ -81,7 +81,7 @@ public class BeoordeelRegistratieTests
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]), TimeProvider.System);
var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]));
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
Assert.Equal(0, acl.ApproveCallCount);
@@ -93,7 +93,7 @@ public class BeoordeelRegistratieTests
{
var store = new FakeRegistrationStore();
var acl = new FakeAclClient();
var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]), TimeProvider.System);
var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
handler.HandleAsync(new BeoordeelRegistratieCommand(RegistrationId.New(), BeoordelingsBesluit.Goedkeuren)));
@@ -108,7 +108,7 @@ public class BeoordeelRegistratieTests
var acl = new FakeAclClient();
var registration = Registration.Submit("123456782"); // no zaak yet
store.Seed(registration);
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration), TimeProvider.System);
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren)));
@@ -123,7 +123,7 @@ public class BeoordeelRegistratieTests
var acl = new FakeAclClient();
var registration = WithZaak();
store.Seed(registration);
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration), TimeProvider.System);
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));
@@ -139,7 +139,7 @@ public class BeoordeelRegistratieTests
var acl = new FakeAclClient();
var registration = WithZaak();
store.Seed(registration);
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration), TimeProvider.System);
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));
@@ -158,7 +158,7 @@ public class BeoordeelRegistratieTests
var registration = WithZaak();
store.Seed(registration);
var tasks = new FakeUserTaskClient([]); // no open task for this registration
var handler = new BeoordeelRegistratie(store, acl, tasks, TimeProvider.System);
var handler = new BeoordeelRegistratie(store, acl, tasks);
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
-16
View File
@@ -22,15 +22,6 @@ internal sealed class FakeRegistrationStore : IRegistrationStore
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
=> Task.FromResult(_byId.GetValueOrDefault(id));
public Task<Registration?> FindOpenByBsnAsync(string bsn, CancellationToken ct = default)
=> Task.FromResult(_byId.Values.FirstOrDefault(r =>
r.Bsn == bsn && r.Status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling));
public Task<IReadOnlyList<Registration>> FindDueForHerregistratieReminderAsync(
DateTimeOffset asOf, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Registration>>(
_byId.Values.Where(r => r.HerregistratieReminderDue(asOf)).ToList());
public void Seed(Registration registration) => _byId[registration.Id] = registration;
}
@@ -90,13 +81,6 @@ internal sealed class FakeUserTaskClient(IReadOnlyList<BeoordelingTask> open) :
}
}
/// <summary>A <see cref="TimeProvider"/> pinned to a fixed instant, so time-based use cases (the
/// herregistratie sweep, S-17) are deterministic without the TimeProvider.Testing package.</summary>
internal sealed class FixedClock(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
}
/// <summary>A fake ACL client that records the bsn it was asked to open a zaak for and returns a
/// fixed zaak URL.</summary>
internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient
@@ -1,67 +0,0 @@
using Big.Application;
using Big.Domain;
namespace Big.Tests;
// S-17 (#18): the sweep behind the Quartz job. It reminds every inscription whose herregistratie
// reminder is due, marks each so a re-fire is a no-op (§8.6), and returns the reminded ids. Pure over
// the store + an injected clock — no Quartz here.
public class HerregistratieReminderSweepTests
{
private static readonly DateTimeOffset Now = new(2026, 7, 23, 0, 0, 0, TimeSpan.Zero);
private static Registration Inscribed(string bsn, DateTimeOffset ingeschrevenOp)
{
var registration = Registration.Submit(bsn);
registration.AttachZaak(FakeAclClient.DefaultZaakUrl);
registration.Approve(ingeschrevenOp);
return registration;
}
// Inscribed exactly (geldigheid - herinneringstermijn) before Now: the reminder window is open.
private static Registration Due(string bsn)
=> Inscribed(bsn, Now - Registration.HerregistratieGeldigheid + Registration.Herinneringstermijn);
[Fact]
public async Task Reminds_and_persists_every_due_inscription_and_returns_their_ids()
{
var store = new FakeRegistrationStore();
var a = Due("123456782");
var b = Due("111111110");
var freshlyInscribed = Inscribed("222222222", Now); // not yet in the window
store.Seed(a);
store.Seed(b);
store.Seed(freshlyInscribed);
var reminded = await new HerregistratieReminderSweep(store, new FixedClock(Now)).SweepAsync();
Assert.Equal(new HashSet<RegistrationId> { a.Id, b.Id }, reminded.ToHashSet());
Assert.True((await store.GetAsync(a.Id))!.HerregistratieReminderVerstuurd);
Assert.True((await store.GetAsync(b.Id))!.HerregistratieReminderVerstuurd);
Assert.False((await store.GetAsync(freshlyInscribed.Id))!.HerregistratieReminderVerstuurd);
Assert.Equal(2, store.SaveCount);
}
[Fact]
public async Task A_second_sweep_reminds_no_one_again()
{
var store = new FakeRegistrationStore();
store.Seed(Due("123456782"));
var sweep = new HerregistratieReminderSweep(store, new FixedClock(Now));
await sweep.SweepAsync();
var second = await sweep.SweepAsync();
Assert.Empty(second);
Assert.Equal(1, store.SaveCount); // only the first sweep persisted anything
}
[Fact]
public async Task Reminds_no_one_when_nothing_is_due()
{
var store = new FakeRegistrationStore();
store.Seed(Inscribed("123456782", Now)); // freshly inscribed — deadline is 5 years off
Assert.Empty(await new HerregistratieReminderSweep(store, new FixedClock(Now)).SweepAsync());
}
}
@@ -41,82 +41,4 @@ public class InMemoryRegistrationStoreTests
await Assert.ThrowsAsync<ArgumentNullException>(() => store.SaveAsync(null!));
}
[Fact]
public async Task Finds_the_open_registration_for_a_bsn()
{
var store = new InMemoryRegistrationStore();
var open = Registration.Submit("123456782");
await store.SaveAsync(open);
var found = await store.FindOpenByBsnAsync("123456782");
Assert.NotNull(found);
Assert.Equal(open.Id, found.Id);
}
[Fact]
public async Task An_in_behandeling_registration_is_still_open()
{
var store = new InMemoryRegistrationStore();
var registration = Registration.Submit("123456782");
registration.TakeIntoBehandeling();
await store.SaveAsync(registration);
Assert.NotNull(await store.FindOpenByBsnAsync("123456782"));
}
[Theory]
[InlineData(nameof(Registration.Withdraw))]
[InlineData(nameof(Registration.Approve))]
[InlineData(nameof(Registration.Reject))]
[InlineData(nameof(Registration.Expire))]
public async Task A_terminal_registration_is_not_returned_as_open(string transition)
{
var store = new InMemoryRegistrationStore();
var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); // Approve requires an opened zaak
switch (transition)
{
case nameof(Registration.Withdraw): registration.Withdraw(); break;
case nameof(Registration.Approve): registration.Approve(DateTimeOffset.UtcNow); break;
case nameof(Registration.Reject): registration.Reject(); break;
case nameof(Registration.Expire): registration.Expire(); break;
}
await store.SaveAsync(registration);
Assert.Null(await store.FindOpenByBsnAsync("123456782"));
}
[Fact]
public async Task Does_not_return_another_bsns_registration_or_an_unknown_bsn()
{
var store = new InMemoryRegistrationStore();
await store.SaveAsync(Registration.Submit("111111110"));
Assert.Null(await store.FindOpenByBsnAsync("123456782"));
}
[Fact]
public async Task Finds_only_the_inscriptions_due_for_a_herregistratie_reminder()
{
var now = new DateTimeOffset(2026, 7, 23, 0, 0, 0, TimeSpan.Zero);
var store = new InMemoryRegistrationStore();
var due = Registration.Submit("123456782");
due.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
due.Approve(now - Registration.HerregistratieGeldigheid + Registration.Herinneringstermijn);
await store.SaveAsync(due);
var freshlyInscribed = Registration.Submit("111111110");
freshlyInscribed.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/def"));
freshlyInscribed.Approve(now);
await store.SaveAsync(freshlyInscribed);
await store.SaveAsync(Registration.Submit("222222222")); // still INGEDIEND — never inscribed
var result = await store.FindDueForHerregistratieReminderAsync(now);
Assert.Equal([due.Id], result.Select(r => r.Id).ToArray());
}
}
@@ -1,90 +0,0 @@
using Big.Domain;
namespace Big.Tests;
// S-17 (#18): a BIG inscription is valid for a fixed term; before it lapses the zorgprofessional must
// herregistreren. The aggregate records when it was inscribed, derives the herregistratie deadline, and
// answers whether a reminder is due as of a given moment — the single rule the Quartz sweep and the
// store query both build on. All arithmetic is against an explicit "now" so it is wall-clock-free.
public class RegistrationHerregistratieTests
{
private static readonly DateTimeOffset Now = new(2026, 7, 23, 0, 0, 0, TimeSpan.Zero);
// The moment the reminder window opens: inscribed exactly (geldigheid - herinneringstermijn) ago.
private static DateTimeOffset InscribedSoDueAt(DateTimeOffset asOf)
=> asOf - Registration.HerregistratieGeldigheid + Registration.Herinneringstermijn;
private static Registration Inscribed(DateTimeOffset ingeschrevenOp)
{
var registration = Registration.Submit("123456782");
registration.AttachZaak(FakeAclClient.DefaultZaakUrl);
registration.Approve(ingeschrevenOp);
return registration;
}
[Fact]
public void Approving_records_the_inscription_moment_and_the_herregistratie_deadline()
{
var registration = Inscribed(Now);
Assert.Equal(Now, registration.IngeschrevenOp);
Assert.Equal(Now + Registration.HerregistratieGeldigheid, registration.HerregistratieVoor);
}
[Fact]
public void A_reminder_is_due_the_moment_the_window_before_the_deadline_opens()
{
var registration = Inscribed(InscribedSoDueAt(Now));
Assert.True(registration.HerregistratieReminderDue(Now));
}
[Fact]
public void A_reminder_is_not_yet_due_one_day_before_the_window_opens()
{
var registration = Inscribed(InscribedSoDueAt(Now) + TimeSpan.FromDays(1));
Assert.False(registration.HerregistratieReminderDue(Now));
}
[Fact]
public void A_registration_that_is_not_ingeschreven_is_never_due_and_has_no_deadline()
{
var registration = Registration.Submit("123456782"); // INGEDIEND, never inscribed
Assert.Null(registration.IngeschrevenOp);
Assert.Null(registration.HerregistratieVoor);
Assert.False(registration.HerregistratieReminderDue(Now));
}
[Fact]
public void A_reminded_registration_is_no_longer_due()
{
var registration = Inscribed(InscribedSoDueAt(Now));
registration.MarkHerregistratieReminderVerstuurd();
Assert.True(registration.HerregistratieReminderVerstuurd);
Assert.False(registration.HerregistratieReminderDue(Now));
}
[Fact]
public void Marking_the_reminder_sent_twice_is_idempotent()
{
var registration = Inscribed(InscribedSoDueAt(Now));
registration.MarkHerregistratieReminderVerstuurd();
registration.MarkHerregistratieReminderVerstuurd();
Assert.True(registration.HerregistratieReminderVerstuurd);
}
[Fact]
public void Marking_a_reminder_on_a_registration_that_is_not_ingeschreven_is_rejected()
{
var registration = Registration.Submit("123456782");
var ex = Assert.Throws<InvalidOperationException>(() => registration.MarkHerregistratieReminderVerstuurd());
Assert.Contains("INGESCHREVEN", ex.Message);
}
}
+9 -12
View File
@@ -4,9 +4,6 @@ namespace Big.Tests;
public class RegistrationTests
{
// A fixed inscription moment for the approval tests; its exact value is irrelevant to them.
private static readonly DateTimeOffset Ingeschreven = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
[Fact]
public void Submitting_a_registration_starts_in_ingediend()
{
@@ -106,7 +103,7 @@ public class RegistrationTests
var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve(Ingeschreven);
registration.Approve();
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
}
@@ -116,7 +113,7 @@ public class RegistrationTests
{
var registration = Registration.Submit("123456782");
var ex = Assert.Throws<InvalidOperationException>(() => registration.Approve(Ingeschreven));
var ex = Assert.Throws<InvalidOperationException>(() => registration.Approve());
Assert.Contains("no zaak", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal(RegistrationStatus.Ingediend, registration.Status);
@@ -127,9 +124,9 @@ public class RegistrationTests
{
var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve(Ingeschreven);
registration.Approve();
var ex = Assert.Throws<InvalidOperationException>(() => registration.Approve(Ingeschreven));
var ex = Assert.Throws<InvalidOperationException>(() => registration.Approve());
Assert.Contains("only an INGEDIEND", ex.Message);
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
}
@@ -160,7 +157,7 @@ public class RegistrationTests
{
var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve(Ingeschreven);
registration.Approve();
var ex = Assert.Throws<InvalidOperationException>(() => registration.TakeIntoBehandeling());
Assert.Contains("only an INGEDIEND", ex.Message);
@@ -174,7 +171,7 @@ public class RegistrationTests
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.TakeIntoBehandeling();
registration.Approve(Ingeschreven);
registration.Approve();
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
}
@@ -221,7 +218,7 @@ public class RegistrationTests
var approveEx = Assert.Throws<InvalidOperationException>(() =>
{
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve(Ingeschreven);
registration.Approve();
});
Assert.Contains("IN_BEHANDELING", approveEx.Message);
@@ -280,7 +277,7 @@ public class RegistrationTests
{
var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve(Ingeschreven);
registration.Approve();
var ex = Assert.Throws<InvalidOperationException>(() => registration.Withdraw());
Assert.Contains("only an INGEDIEND", ex.Message);
@@ -339,7 +336,7 @@ public class RegistrationTests
{
var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve(Ingeschreven);
registration.Approve();
var ex = Assert.Throws<InvalidOperationException>(() => registration.Expire());
Assert.Contains("only an INGEDIEND", ex.Message);
+1 -2
View File
@@ -6,8 +6,7 @@
"mutate": [
"!**/OpenZaakJobPump.cs",
"!**/BeoordelingEscalatiePump.cs",
"!**/RegistratieVerlopenPump.cs",
"!**/HerregistratieReminderJob.cs"
"!**/RegistratieVerlopenPump.cs"
],
"thresholds": {
"high": 95,
@@ -5,14 +5,6 @@
<ProjectReference Include="..\..\projection-api\Projection.ReadModel\Projection.ReadModel.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.17.0-beta.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>

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