Compare commits

..
Author SHA1 Message Date
not dcd24d17a3 Merge branch 'main' into fix/110-compose-local-flow
CI / unit (pull_request) Successful in 1m12s
CI / lint (pull_request) Successful in 1m17s
CI / build (pull_request) Successful in 59s
CI / frontend (pull_request) Successful in 2m39s
CI / mutation (pull_request) Successful in 5m43s
CI / verify-stack (pull_request) Successful in 7m57s
2026-07-22 14:04:26 +02:00
notandClaude Opus 4.8 a693137c7c docs(infra): ADR-0020 + demo note for the local-stack self-seed (refs #110)
CI / lint (pull_request) Successful in 1m19s
CI / build (pull_request) Successful in 56s
CI / unit (pull_request) Successful in 1m6s
CI / frontend (pull_request) Successful in 2m31s
CI / mutation (pull_request) Successful in 6m0s
CI / verify-stack (pull_request) Failing after 10m33s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 11:34:36 +02:00
notandClaude Opus 4.8 a940a6c9ce fix(infra): docker-compose.local self-seeds zaaktype, DMN + NRC abonnement (refs #110)
A fresh `make local` now completes the whole flow with no manual seeding, closing
the three S-B04 gaps in the host-browser stack:

- flowable-init also deploys diploma-eligibility.dmn (was BPMN-only), so completing
  WachtOpDocumenten routes through the DMN to Beoordelen instead of 404ing.
- a local-seed one-shot seeds + publishes the BIG zaaktype (server-assigned URL) and
  writes it to seed-env:/acl.env; the ACL sources it on startup (entrypoint override),
  since the UUID isn't knowable at compose-write time.
- an nrc-subscribe one-shot registers the `zaken` abonnement at the event-subscriber
  callback, so notifications reach the projection and the openbaar register.

Both one-shots reach OpenZaak/NRC by container IP (a single-label host fails their
Django URLValidator), mirroring the CI verify scripts. Asserted by `make verify-local`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 11:34:08 +02:00
notandClaude Opus 4.8 97ef3b9535 test(infra): acceptance check that make local completes the flow unseeded (refs #110)
Adds infra/run-local-flow-check.sh (+ `make verify-local`): submits a
registration against a fresh local stack and asserts it opens a zaak, reaches
the werkbak after documents, and appears in the openbaar register — all with no
manual seeding. Fails today (ACL points at a placeholder zaaktype; the DMN is
undeployed; no NRC abonnement is registered), covering the three S-B04 gaps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 11:28:15 +02:00
110 changed files with 158 additions and 3431 deletions
+5 -25
View File
@@ -9,12 +9,6 @@ on:
permissions: permissions:
contents: read 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. # Self-hosted runner — see docs/runbooks/ci.md for the runner setup.
# `uses:` are absolute, tag-pinned URLs (CLAUDE.md §8.7 / §15). # `uses:` are absolute, tag-pinned URLs (CLAUDE.md §8.7 / §15).
@@ -135,20 +129,12 @@ jobs:
path: services/bff/StrykerOutput/**/reports/mutation-report.html path: services/bff/StrykerOutput/**/reports/mutation-report.html
if-no-files-found: warn if-no-files-found: warn
# One stage for every check that needs the live stack. Booting OpenZaak once (instead # One stage for every check that needs the live stack. On the single self-hosted
# of once per job) is the cheapest layout (issue #58). No setup-dotnet: the ACL test runs # runner jobs run sequentially, so booting OpenZaak once (instead of once per job)
# in a built image and everything reaches services by container IP. Needs Docker + egress # 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). # (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: verify-stack:
needs: [mutation]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: https://github.com/actions/checkout@v4 - uses: https://github.com/actions/checkout@v4
@@ -156,8 +142,6 @@ jobs:
# reaches green health" smoke (it replaces the old compose-smoke job). # reaches green health" smoke (it replaces the old compose-smoke job).
- name: Bring up the full stack & wait for health - name: Bring up the full stack & wait for health
run: make verify-up run: make verify-up
- name: Observability backplane (Grafana + Tempo + Prometheus datasources)
run: OBS_TIMEOUT=180 make verify-observability
- name: ACL ↔ OpenZaak integration tests - name: ACL ↔ OpenZaak integration tests
run: make verify-acl run: make verify-acl
- name: OpenZaak → NRC notification delivery - name: OpenZaak → NRC notification delivery
@@ -168,16 +152,12 @@ jobs:
run: make verify-domain run: make verify-domain
- name: BFF → Keycloak + domain + projection - name: BFF → Keycloak + domain + projection
run: make verify-bff 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) - name: Self-service e2e (Playwright, login → submit → success)
run: make verify-e2e run: make verify-e2e
# Log dump must precede teardown (which removes the containers). # Log dump must precede teardown (which removes the containers).
- name: Dump container logs on failure - name: Dump container logs on failure
if: failure() if: failure()
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel 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 - name: Tear down
if: always() if: always()
run: make down run: make down
-1
View File
@@ -57,4 +57,3 @@ vitest.config.*.timestamp*
tests/e2e/node_modules/ tests/e2e/node_modules/
tests/e2e/test-results/ tests/e2e/test-results/
tests/e2e/playwright-report/ 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`)* ## 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. **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-16 · OpenTelemetry traces + Grafana dashboard
- **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)*
**Outcome:** Traces span portal → BFF → Domain → ACL → OpenZaak and portal → BFF → Domain → Flowable. Grafana dashboards pre-built for golden signals. **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. **Outcome:** Nightly job that finds entries within 90 days of expiry and emits a domain event. (No outbound notification in v1 — logged.)
- **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.
--- ---
+2 -17
View File
@@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml
# Long-running services with a healthcheck — the smoke polls these for readiness # Long-running services with a healthcheck — the smoke polls these for readiness
# (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init) # (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init)
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md. # are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel 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 # Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
# into external named volumes via `docker cp` (infra/seed-config.sh) instead of # into external named volumes via `docker cp` (infra/seed-config.sh) instead of
# bind-mounted, because bind mounts don't reach sibling containers on the # bind-mounted, because bind mounts don't reach sibling containers on the
@@ -43,7 +43,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
endif endif
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 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
## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions) ## 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). ## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
@@ -170,21 +170,6 @@ verify-bff:
verify-e2e: verify-e2e:
bash infra/run-e2e-check.sh 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, ## 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` ## tear down (always). For fast single-concern local iteration use `integration`
## (oz-only) or `verify-notifications` (oz+nrc) instead. ## (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' })), post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
withdraw = vi.fn().mockReturnValue(of(undefined)), withdraw = vi.fn().mockReturnValue(of(undefined)),
provideDocuments = 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 { return {
post, post,
withdraw, withdraw,
provideDocuments, provideDocuments,
getCurrent,
providers: [ providers: [
{ provide: AuthService, useClass: FakeAuth }, { provide: AuthService, useClass: FakeAuth },
{ {
provide: BffApiV1Service, provide: BffApiV1Service,
useValue: { useValue: {
getSelfServiceRegistrations: getCurrent,
postSelfServiceRegistrations: post, postSelfServiceRegistrations: post,
postSelfServiceRegistrationsIdWithdraw: withdraw, postSelfServiceRegistrationsIdWithdraw: withdraw,
postSelfServiceRegistrationsIdDocuments: provideDocuments, postSelfServiceRegistrationsIdDocuments: provideDocuments,
@@ -60,21 +56,6 @@ describe('RegistrationPage', () => {
expect(await screen.findByText(/ontvangen/i)).toBeTruthy(); 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 () => { 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')))); const { post, providers: p } = providers(vi.fn().mockReturnValue(throwError(() => new Error('BFF rejected'))));
await render(RegistrationPage, { providers: p }); await render(RegistrationPage, { providers: p });
@@ -1,5 +1,5 @@
import { Component, inject, type OnInit, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { BffApiV1Service, type CurrentRegistration, type SubmitAccepted } from 'api-client'; import { BffApiV1Service, type SubmitAccepted } from 'api-client';
import { AuthService } from 'auth'; import { AuthService } from 'auth';
import { UtrechtComponentsModule } from 'ui'; 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- * 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 * 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). * 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({ @Component({
selector: 'app-registration-page', selector: 'app-registration-page',
imports: [UtrechtComponentsModule], imports: [UtrechtComponentsModule],
templateUrl: './registration-page.html', templateUrl: './registration-page.html',
}) })
export class RegistrationPage implements OnInit { export class RegistrationPage {
private readonly auth = inject(AuthService); private readonly auth = inject(AuthService);
private readonly bff = inject(BffApiV1Service); private readonly bff = inject(BffApiV1Service);
@@ -34,23 +31,6 @@ export class RegistrationPage implements OnInit {
protected readonly provideDocumentsFailed = signal(false); protected readonly provideDocumentsFailed = signal(false);
protected readonly selectedFile = signal<File | undefined>(undefined); 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 { submit(): void {
this.submitting.set(true); this.submitting.set(true);
this.failed.set(false); this.failed.set(false);
@@ -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.
+3 -160
View File
@@ -5,141 +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) ## 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 **Outcome:** the host-browser stack (`make local`) now self-seeds at bring-up — it publishes the BIG
@@ -161,31 +26,9 @@ make verify-local # → "OK — a fresh local stack completed the flow with
# test123); it shows as INGESCHREVEN in the openbaar register at http://localhost:8141. # 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 > The zaaktype UUID is server-assigned, so `local-seed` writes the real URL into a shared volume as
> carries only OpenZaak's IP base URL, which the ACL still needs because OpenZaak rejects a > `acl.env` and the ACL sources it on startup (ADR-0020). The cleaner long-term fix — the ACL
> single-label host on zaak-create (ADR-0020 + ADR-0021). > resolving its zaaktype by `identificatie` — is tracked separately as S-27 (#113).
---
## 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).
--- ---
-1
View File
@@ -14,7 +14,6 @@ All test users share the password **`test123`**.
| Realm | Mimics | User | Identifying claim | | Realm | Mimics | User | Identifying claim |
|---|---|---|---| |---|---|---|---|
| `digid` | DigiD (burgers) | `jan-burger` | `bsn` = `123456782` | | `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` | | `eherkenning` | eHerkenning (bedrijven) | `acme-ondernemer` | `kvk` = `12345678` |
| `eidas` | eIDAS (EU) | `pierre-dupont` | `eidas_id` = `FR/NL/AB-1234-5678` | | `eidas` | eIDAS (EU) | `pierre-dupont` | `eidas_id` = `FR/NL/AB-1234-5678` |
| `medewerker` | Internal staff | `merel-behandelaar` | role `behandelaar` | | `medewerker` | Internal staff | `merel-behandelaar` | role `behandelaar` |
+8 -9
View File
@@ -315,22 +315,21 @@ services:
context: ../services/acl context: ../services/acl
dockerfile: Dockerfile dockerfile: Dockerfile
image: register-referentie/acl:dev image: register-referentie/acl:dev
# The ACL discovers its zaaktype + informatieobjecttype URLs from the Catalogi API by the business # The base/zaaktype/informatieobjecttype below are PLACEHOLDERS. The real, server-assigned
# keys below (S-27, ADR-0021), so no URL is injected. It still needs its OpenZaak BaseUrl pointed at # values are written by the local-seed one-shot into seed-env:/seed/acl.env, which the entrypoint
# a URL-valid host (OpenZaak rejects a single-label host like `openzaak` on zaak-create), so the # sources (set -a) so they override these before the app starts (S-B04, #110, ADR-0020). Sourcing
# local-seed one-shot writes that IP base into seed-env:/seed/acl.env, which the entrypoint sources # a runtime-generated env file is why we override the entrypoint here rather than use `env_file:`
# (set -a) before the app starts. A runtime-generated env file is why we override the entrypoint here # (which compose reads at parse time, before the seed has run).
# 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"] entrypoint: ["/bin/sh", "-c", "set -a; . /seed/acl.env; set +a; exec dotnet Acl.Api.dll"]
environment: 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__ClientId: big-reference-seed
Acl__OpenZaak__Secret: insecure-dev-secret-change-me Acl__OpenZaak__Secret: insecure-dev-secret-change-me
Acl__Defaults__Bronorganisatie: "517439943" Acl__Defaults__Bronorganisatie: "517439943"
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943" Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE Acl__Defaults__ZaaktypeUrl: http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma Acl__Defaults__InformatieobjecttypeUrl: http://openzaak:8000/catalogi/api/v1/informatieobjecttypen/00000000-0000-0000-0000-000000000000
ports: ports:
- "8100:8080" - "8100:8080"
volumes: volumes:
+11 -101
View File
@@ -15,12 +15,12 @@
# #
# docker compose -f infra/docker-compose.yml up -d --build --wait # docker compose -f infra/docker-compose.yml up -d --build --wait
# #
# After first boot, seed + publish the BIG catalogus: # After first boot, seed the BIG catalogus and note the zaaktype URL:
# OZ_PUBLISH=1 python infra/openzaak/seed_catalogus.py # python infra/openzaak/seed_catalogus.py
# The ACL discovers the zaaktype by identificatie (S-27, ADR-0021), so there is no URL to inject — # Then set ACL_ZAAKTYPE_URL in a .env file or your shell and re-up the acl
# just point its BaseUrl at an OpenZaak host OpenZaak accepts on zaak-create (a container IP; a # service:
# single-label host is rejected): # export ACL_ZAAKTYPE_URL=http://openzaak:8000/catalogi/api/v1/zaaktypen/<uuid>
# ACL_OPENZAAK_BASEURL=http://<openzaak-ip>:8000/ docker compose -f infra/docker-compose.yml up -d acl # docker compose -f infra/docker-compose.yml up -d acl
services: services:
@@ -296,10 +296,6 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
image: register-referentie/acl:dev image: register-referentie/acl:dev
environment: 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 # 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). # owns the seeded zaaktype URL (host-consistent zaak creation, ADR-0009).
Acl__OpenZaak__BaseUrl: ${ACL_OPENZAAK_BASEURL:-http://openzaak:8000/} Acl__OpenZaak__BaseUrl: ${ACL_OPENZAAK_BASEURL:-http://openzaak:8000/}
@@ -308,12 +304,11 @@ services:
Acl__Defaults__Bronorganisatie: "517439943" Acl__Defaults__Bronorganisatie: "517439943"
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943" Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
# The ACL resolves the (server-assigned) zaaktype + diploma informatieobjecttype URLs from the # Override with the real zaaktype URL after running seed_catalogus.py.
# Catalogi API by these stable business keys (S-27, ADR-0021) — no URL to capture and inject. Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000}
# BaseUrl above stays overridable because OpenZaak rejects a single-label host on zaak creation, # The informatieobjecttype a diploma is filed under (S-10b). Placeholder until seed_catalogus.py
# so verify-domain still points the ACL at OpenZaak's container IP. # (OZ_PUBLISH=1) reports the real URL, which verify-domain injects like the zaaktype URL.
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE Acl__Defaults__InformatieobjecttypeUrl: ${ACL_INFORMATIEOBJECTTYPE_URL:-http://openzaak:8000/catalogi/api/v1/informatieobjecttypen/00000000-0000-0000-0000-000000000000}
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
ports: ports:
- "8100:8080" - "8100:8080"
healthcheck: healthcheck:
@@ -338,10 +333,6 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
image: register-referentie/domain:dev image: register-referentie/domain:dev
environment: 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__BaseUrl: http://flowable-rest:8080/flowable-rest/
Flowable__Username: rest-admin Flowable__Username: rest-admin
Flowable__Password: test Flowable__Password: test
@@ -368,10 +359,6 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
image: register-referentie/bff:dev image: register-referentie/bff:dev
environment: 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). # 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 # Keycloak (start-dev) derives the issuer from the request host, so the BFF authority and the
# verify token request both use keycloak:8080 to keep the issuer consistent. # verify token request both use keycloak:8080 to keep the issuer consistent.
@@ -380,8 +367,6 @@ services:
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
Downstream__Domain__BaseUrl: http://domain:8080/ Downstream__Domain__BaseUrl: http://domain:8080/
Downstream__Projection__BaseUrl: http://projection-api:8080/ Downstream__Projection__BaseUrl: http://projection-api:8080/
# The beheer catalogus read reaches the ACL directly (S-15a, ADR-0025).
Downstream__Acl__BaseUrl: http://acl:8080/
ports: ports:
- "8080:8080" - "8080:8080"
healthcheck: healthcheck:
@@ -426,10 +411,6 @@ services:
dockerfile: services/event-subscriber/Dockerfile dockerfile: services/event-subscriber/Dockerfile
image: register-referentie/event-subscriber:dev image: register-referentie/event-subscriber:dev
environment: 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 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 subscriber enriches the projection with each zaak's reference (identificatie) by asking
# the ACL — the only code allowed to read ZGW (§8.1, #78). # the ACL — the only code allowed to read ZGW (§8.1, #78).
@@ -459,10 +440,6 @@ services:
dockerfile: services/projection-api/Dockerfile dockerfile: services/projection-api/Dockerfile
image: register-referentie/projection-api:dev image: register-referentie/projection-api:dev
environment: 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 ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
ports: ports:
- "8120:8080" - "8120:8080"
@@ -546,73 +523,6 @@ services:
condition: service_started condition: service_started
networks: [cg] 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: volumes:
oz-db: oz-db:
nrc-db: nrc-db:
-30
View File
@@ -38,36 +38,6 @@
"emailVerified": true, "emailVerified": true,
"credentials": [{ "type": "password", "value": "test123", "temporary": false }], "credentials": [{ "type": "password", "value": "test123", "temporary": false }],
"attributes": { "bsn": ["123456782"] } "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": { "roles": {
"realm": [ "realm": [
{ "name": "behandelaar", "description": "Behandelt registratieaanvragen" }, { "name": "behandelaar", "description": "Behandelt registratieaanvragen" },
{ "name": "teamlead", "description": "Teamleider behandeling" }, { "name": "teamlead", "description": "Teamleider behandeling" }
{ "name": "beheerder", "description": "Beheert catalogus en default-fill (beheer-portal, S-15)" }
] ]
}, },
"clients": [ "clients": [
@@ -55,16 +54,6 @@
"emailVerified": true, "emailVerified": true,
"credentials": [{ "type": "password", "value": "test123", "temporary": false }], "credentials": [{ "type": "password", "value": "test123", "temporary": false }],
"realmRoles": ["behandelaar", "teamlead"] "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"]
} }
] ]
} }
+8 -6
View File
@@ -21,13 +21,15 @@ echo ">> seeding + publishing the BIG zaaktype at ${OZ_BASE} (idempotent)"
out="$(python3 /work/seed_catalogus.py)" out="$(python3 /work/seed_catalogus.py)"
echo "$out" echo "$out"
# Sanity-check that the zaaktype was actually published (the ACL discovers it by identificatie, S-27). zt="$(printf '%s\n' "$out" | sed -n 's/^ZAAKTYPE_URL //p' | head -1)"
printf '%s\n' "$out" | grep -q '^ZAAKTYPE_URL ' || { echo "ERROR: seed did not publish the zaaktype" >&2; exit 1; } iot="$(printf '%s\n' "$out" | sed -n 's/^INFORMATIEOBJECTTYPE_URL //p' | head -1)"
[ -n "$zt" ] || { echo "ERROR: seed did not report a ZAAKTYPE_URL" >&2; exit 1; }
[ -n "$iot" ] || { echo "ERROR: seed did not report an INFORMATIEOBJECTTYPE_URL" >&2; exit 1; }
# The ACL resolves the zaaktype/informatieobjecttype URLs itself (S-27, ADR-0021); the only value it # The ACL entrypoint sources this; these keys override the placeholder defaults in the compose file.
# 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 cat > /out/acl.env <<EOF
Acl__OpenZaak__BaseUrl=${OZ_BASE}/ Acl__OpenZaak__BaseUrl=${OZ_BASE}/
Acl__Defaults__ZaaktypeUrl=${zt}
Acl__Defaults__InformatieobjecttypeUrl=${iot}
EOF EOF
echo ">> wrote /out/acl.env (base=${OZ_BASE}/)" echo ">> wrote /out/acl.env (base=${OZ_BASE}/ zaaktype=${zt})"
-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" oz_base="http://$oz_ip:8000"
echo ">> openzaak=$oz_ip domain=$dom_ip network=$net" 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)" 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 docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null
seed_out="$(docker start -a "$sid")" 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 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), echo ">> recreating the acl service pointed at the seeded zaaktype + informatieobjecttype (host-consistent)"
# so there is no URL to inject — only the OpenZaak base URL, pointed at the same host's container IP ACL_ZAAKTYPE_URL="$zt_url" ACL_INFORMATIEOBJECTTYPE_URL="$iot_url" ACL_OPENZAAK_BASEURL="$oz_base/" \
# (OpenZaak rejects a single-label host on zaak-create). docker compose -f "$compose" up -d acl
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
WAIT_TIMEOUT="${WAIT_TIMEOUT:-120}" bash "$here/wait-healthy.sh" acl WAIT_TIMEOUT="${WAIT_TIMEOUT:-120}" bash "$here/wait-healthy.sh" acl
echo ">> submitting a registration to the domain" echo ">> submitting a registration to the domain"
-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 Observable
} from 'rxjs'; } from 'rxjs';
export interface BeheerZaaktype {
identificatie: string;
omschrijving: string;
}
export interface CurrentRegistration {
registrationId: string;
status: string;
}
export interface DecideRequest { export interface DecideRequest {
besluit: string; 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?: HttpClientBodyOptions): Observable<TData>;
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>; postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>; postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: 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" /> <ProjectReference Include="..\Acl.Infrastructure\Acl.Infrastructure.csproj" />
</ItemGroup> </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> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
-34
View File
@@ -1,31 +1,8 @@
using Acl.Application; using Acl.Application;
using Acl.Infrastructure; using Acl.Infrastructure;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
var builder = WebApplication.CreateBuilder(args); 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<IClock, SystemClock>();
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>() builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
.GetSection("Acl:Defaults").Get<AclDefaults>() .GetSection("Acl:Defaults").Get<AclDefaults>()
@@ -34,17 +11,12 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
.GetSection("Acl:OpenZaak").Get<OpenZaakOptions>() .GetSection("Acl:OpenZaak").Get<OpenZaakOptions>()
?? throw new InvalidOperationException("Missing configuration section 'Acl:OpenZaak'")); ?? throw new InvalidOperationException("Missing configuration section 'Acl:OpenZaak'"));
builder.Services.AddHttpClient<IZaakGateway, OpenZaakGateway>(); 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>(); builder.Services.AddScoped<AclService>();
var app = builder.Build(); var app = builder.Build();
app.MapGet("/health", () => "Healthy"); 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. // The ACL's single operation, exposed as a service endpoint.
app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationToken ct) => app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationToken ct) =>
{ {
@@ -85,12 +57,6 @@ app.MapPost("/documenten", async (StoreDocumentRequest body, AclService acl, Can
return Results.Ok(new { informatieobjectUrl = url.ToString() }); 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(); app.Run();
public sealed record OpenZaakRequest(string Bsn, string Reference); 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 Bronorganisatie { get; init; }
public required string VerantwoordelijkeOrganisatie { get; init; } public required string VerantwoordelijkeOrganisatie { get; init; }
public required string Vertrouwelijkheidaanduiding { 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 /// <summary>The informatieobjecttype an uploaded diploma is filed under (S-10b). Seeded in the
/// URL from this via the Catalogi API instead of being handed a pinned URL (S-27, ADR-0021).</summary> /// catalogus and injected like <see cref="ZaaktypeUrl"/>.</summary>
public required string ZaaktypeIdentificatie { get; init; } public required Uri InformatieobjecttypeUrl { 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; }
} }
+15 -20
View File
@@ -2,9 +2,9 @@ namespace Acl.Application;
/// <summary>The ACL's single operation: open a zaak from a domain payload, /// <summary>The ACL's single operation: open a zaak from a domain payload,
/// default-filling the ZGW-mandatory fields (ADR-0003).</summary> /// 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); ArgumentNullException.ThrowIfNull(registration);
@@ -12,41 +12,36 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaak
defaults.Bronorganisatie, defaults.Bronorganisatie,
defaults.VerantwoordelijkeOrganisatie, defaults.VerantwoordelijkeOrganisatie,
defaults.Vertrouwelijkheidaanduiding, defaults.Vertrouwelijkheidaanduiding,
await catalog.GetZaaktypeUrlAsync(ct), defaults.ZaaktypeUrl,
clock.Today, clock.Today,
registration.Reference); registration.Reference);
return await gateway.OpenZaakAsync(request, ct); return gateway.OpenZaakAsync(request, ct);
} }
/// <summary> /// <summary>
/// Approve a zaak: set it to the eindstatus of the BIG zaaktype (resolved by identificatie, S-27). /// Approve a zaak: set it to the eindstatus of the configured BIG zaaktype (ADR-0003 default). The
/// The domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1). /// domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1).
/// </summary> /// </summary>
public async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default) public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
{ {
ArgumentNullException.ThrowIfNull(zaakUrl); ArgumentNullException.ThrowIfNull(zaakUrl);
await gateway.SetZaakToEindstatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct); return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
} }
/// <summary> /// <summary>
/// Cancel a zaak on document-timeout expiry (S-10c): set it to the BIG zaaktype's cancellation /// Cancel a zaak on document-timeout expiry (S-10c): set it to the configured BIG zaaktype's
/// statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which /// cancellation statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which
/// statustype/resultaat means "cancelled" (§8.1). /// statustype/resultaat means "cancelled" (§8.1).
/// </summary> /// </summary>
public async Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default) public Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
{ {
ArgumentNullException.ThrowIfNull(zaakUrl); 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> /// <summary>The zaak's reference (its ZGW identificatie), for the read projection (#78).</summary>
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default) 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. /// 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). /// The domain supplies only the zaak, the bytes, and the file's name/type (§8.1).
/// </summary> /// </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(zaakUrl);
ArgumentNullException.ThrowIfNull(content); ArgumentNullException.ThrowIfNull(content);
@@ -70,7 +65,7 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaak
var request = new DocumentRequest( var request = new DocumentRequest(
defaults.Bronorganisatie, defaults.Bronorganisatie,
await catalog.GetInformatieobjecttypeUrlAsync(ct), defaults.InformatieobjecttypeUrl,
defaults.Vertrouwelijkheidaanduiding, defaults.Vertrouwelijkheidaanduiding,
zaakUrl, zaakUrl,
clock.Today, clock.Today,
@@ -81,6 +76,6 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaak
Formaat: contentType, Formaat: contentType,
Inhoud: content); 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. /// the created informatieobject.
/// </summary> /// </summary>
Task<Uri> StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default); 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; 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 // 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). // a Content-Length instead of a chunked body (as with zaak-create).
private async Task PostAsync(string path, object dto, string action, CancellationToken ct) 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( private sealed record ZaakInformatieobjectDto(
[property: JsonPropertyName("zaak")] string Zaak, [property: JsonPropertyName("zaak")] string Zaak,
[property: JsonPropertyName("informatieobject")] string Informatieobject); [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(), Assert.Contains(relations.EnumerateArray(),
r => r.GetProperty("zaak").GetString() == zaakUrl.ToString()); 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 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 ZaakRequest? Captured;
public Uri Result { get; } = new("http://openzaak/zaken/api/v1/zaken/abc"); public Uri Result { get; } = new("http://openzaak/zaken/api/v1/zaken/abc");
@@ -53,26 +47,6 @@ public class AclServiceTests
StoredDocument = request; StoredDocument = request;
return Task.FromResult(DocumentResult); 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() private static AclDefaults Defaults() => new()
@@ -80,23 +54,28 @@ public class AclServiceTests
Bronorganisatie = "517439943", Bronorganisatie = "517439943",
VerantwoordelijkeOrganisatie = "517439943", VerantwoordelijkeOrganisatie = "517439943",
Vertrouwelijkheidaanduiding = "openbaar", Vertrouwelijkheidaanduiding = "openbaar",
ZaaktypeIdentificatie = "BIG-REGISTRATIE", ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
InformatieobjecttypeOmschrijving = "Diploma", 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 private sealed class FixedClock(DateOnly today) : IClock
{ {
public DateOnly Today { get; } = today; public DateOnly Today { get; } = today;
} }
[Fact] [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 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")); 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.Bronorganisatie);
Assert.Equal("517439943", req.VerantwoordelijkeOrganisatie); Assert.Equal("517439943", req.VerantwoordelijkeOrganisatie);
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding); Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding);
// The zaaktype is resolved from the configured identificatie, not a pinned URL (S-27). Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype);
Assert.Equal("BIG-REGISTRATIE", gateway.ResolvedByIdentificatie);
Assert.Equal(gateway.ResolvedZaaktype, req.Zaaktype);
Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum); Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum);
// The registration reference becomes the zaak identificatie (#78). // The registration reference becomes the zaak identificatie (#78).
Assert.Equal("reg-77", req.Identificatie); Assert.Equal("reg-77", req.Identificatie);
@@ -117,24 +94,33 @@ public class AclServiceTests
public async Task Rejects_a_null_registration_without_calling_the_gateway() public async Task Rejects_a_null_registration_without_calling_the_gateway()
{ {
var gateway = new FakeGateway(); 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!)); await Assert.ThrowsAsync<ArgumentNullException>(() => service.OpenZaakAsync(null!));
Assert.Null(gateway.Captured); Assert.Null(gateway.Captured);
} }
[Fact] [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 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 zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
await service.ApproveZaakAsync(zaak); await service.ApproveZaakAsync(zaak);
Assert.NotNull(gateway.Approved); Assert.NotNull(gateway.Approved);
Assert.Equal(zaak, gateway.Approved!.Value.Zaak); 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); 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() public async Task Approving_a_null_zaak_is_rejected_without_touching_the_gateway()
{ {
var gateway = new FakeGateway(); 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!)); await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!));
Assert.Null(gateway.Approved); 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() public async Task Cancelling_a_zaak_sets_it_to_the_cancellation_status_dated_today()
{ {
var gateway = new FakeGateway(); 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 zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
await service.CancelZaakAsync(zaak); await service.CancelZaakAsync(zaak);
Assert.NotNull(gateway.Cancelled); Assert.NotNull(gateway.Cancelled);
Assert.Equal(zaak, gateway.Cancelled!.Value.Zaak); 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); Assert.Equal(new DateOnly(2026, 6, 4), gateway.Cancelled.Value.Datum);
// Cancellation must not touch the approval path. // Cancellation must not touch the approval path.
Assert.Null(gateway.Approved); Assert.Null(gateway.Approved);
@@ -169,17 +156,18 @@ public class AclServiceTests
public async Task Cancelling_a_null_zaak_is_rejected_without_touching_the_gateway() public async Task Cancelling_a_null_zaak_is_rejected_without_touching_the_gateway()
{ {
var gateway = new FakeGateway(); 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!)); await Assert.ThrowsAsync<ArgumentNullException>(() => service.CancelZaakAsync(null!));
Assert.Null(gateway.Cancelled); Assert.Null(gateway.Cancelled);
} }
[Fact] [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 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 zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
var url = await service.StoreDiplomaAsync(zaak, [1, 2, 3], "diploma.pdf", "application/pdf"); 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); Assert.Equal(gateway.DocumentResult, url);
var req = gateway.StoredDocument!; var req = gateway.StoredDocument!;
Assert.Equal(zaak, req.Zaak); Assert.Equal(zaak, req.Zaak);
// The informatieobjecttype is resolved from the configured omschrijving (S-27). Assert.Equal(defaults.InformatieobjecttypeUrl, req.Informatieobjecttype);
Assert.Equal("Diploma", gateway.ResolvedByOmschrijving);
Assert.Equal(gateway.ResolvedInformatieobjecttype, req.Informatieobjecttype);
Assert.Equal("517439943", req.Bronorganisatie); Assert.Equal("517439943", req.Bronorganisatie);
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding); Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding);
Assert.Equal(new DateOnly(2026, 6, 4), req.Creatiedatum); Assert.Equal(new DateOnly(2026, 6, 4), req.Creatiedatum);
@@ -202,7 +188,7 @@ public class AclServiceTests
[Fact] [Fact]
public async Task Storing_a_diploma_rejects_null_or_blank_arguments() 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"); var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
await Assert.ThrowsAsync<ArgumentNullException>(() => service.StoreDiplomaAsync(null!, [1], "d.pdf", "application/pdf")); 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() public async Task Reading_a_zaak_reference_returns_the_zaaks_identificatie()
{ {
var gateway = new FakeGateway(); 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 zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
var reference = await service.GetZaakReferenceAsync(zaak); var reference = await service.GetZaakReferenceAsync(zaak);
@@ -228,23 +214,9 @@ public class AclServiceTests
public async Task Reading_a_null_zaak_reference_is_rejected() public async Task Reading_a_null_zaak_reference_is_rejected()
{ {
var gateway = new FakeGateway(); 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!)); await Assert.ThrowsAsync<ArgumentNullException>(() => service.GetZaakReferenceAsync(null!));
Assert.Null(gateway.ReadReferenceFor); 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!)); 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. --> <!-- 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.Authentication.JwtBearer" Version="10.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" 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> </ItemGroup>
</Project> </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> /// <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); 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 /// <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). /// 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> /// <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); 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 /// <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 /// <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> /// 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); 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> /// <summary>Calls the Domain Service's <c>POST /registrations</c>.</summary>
public sealed class DomainClient(HttpClient http) : IDomainClient 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); 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) public async Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
{ {
using var response = await http.PostAsJsonAsync( 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) public async Task<IReadOnlyList<ProjectionEntry>> GetRegisterAsync(CancellationToken ct = default)
=> await http.GetFromJsonAsync<List<ProjectionEntry>>("register", ct) ?? []; => 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 System.Text.Json.Serialization;
using Bff.Api; using Bff.Api;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
var builder = WebApplication.CreateBuilder(args); 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"] var keycloakAuthority = builder.Configuration["Keycloak:Authority"]
?? throw new InvalidOperationException("Missing configuration 'Keycloak:Authority'"); ?? throw new InvalidOperationException("Missing configuration 'Keycloak:Authority'");
// Behandelaars authenticate against a *different* Keycloak realm (medewerker) than citizens (digid), // 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'"); ?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'");
var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"] var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"]
?? throw new InvalidOperationException("Missing 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 — // 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. // 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 => builder.Services.AddAuthorization(options =>
{
options.AddPolicy(BehandelAuth.Policy, policy => policy options.AddPolicy(BehandelAuth.Policy, policy => policy
.AddAuthenticationSchemes(BehandelAuth.Scheme) .AddAuthenticationSchemes(BehandelAuth.Scheme)
.RequireAuthenticatedUser() .RequireAuthenticatedUser()
.RequireRole(BehandelAuth.BehandelaarRole)); .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));
});
// The BFF is the portals' only backend; it fans out to the domain and projection (§8.3), and reaches // The BFF is the portals' only backend; it fans out to the domain and projection (§8.3).
// the ACL for the beheer catalogus read (ADR-0025).
builder.Services.AddHttpClient<IDomainClient, DomainClient>(c => c.BaseAddress = new Uri(domainBaseUrl)); 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<IProjectionClient, ProjectionClient>(c => c.BaseAddress = new Uri(projectionBaseUrl));
builder.Services.AddHttpClient<IAclClient, AclClient>(c => c.BaseAddress = new Uri(aclBaseUrl));
builder.Services.AddHealthChecks(); builder.Services.AddHealthChecks();
// Clear the auto-populated `servers` block so the committed spec is stable regardless of the host // 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.UseAuthorization();
app.MapHealthChecks("/health"); app.MapHealthChecks("/health");
// Prometheus scrape endpoint (S-16c): exposes the OTel metrics above in Prometheus text format.
app.MapPrometheusScrapingEndpoint();
app.MapOpenApi(); app.MapOpenApi();
// Self-service submit: requires a valid digid token; the bsn comes from the token, not the body, // 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.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized); .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. // 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; // 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). // 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.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden); .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(); app.Run();
/// <summary>The behandelaar's decision on a registration.</summary> /// <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); 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. // Exposed so the test host (WebApplicationFactory<Program>) can boot the app.
public partial class Program; public partial class Program;
+1 -2
View File
@@ -12,7 +12,6 @@
}, },
"Downstream": { "Downstream": {
"Domain": { "BaseUrl": "http://localhost:8130/" }, "Domain": { "BaseUrl": "http://localhost:8130/" },
"Projection": { "BaseUrl": "http://localhost:8120/" }, "Projection": { "BaseUrl": "http://localhost:8120/" }
"Acl": { "BaseUrl": "http://localhost:8100/" }
} }
} }
@@ -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 FakeDomainClient Domain { get; } = new();
public FakeProjectionClient Projection { get; } = new(); public FakeProjectionClient Projection { get; } = new();
public FakeAclClient Acl { get; } = new();
private static void ValidateWithTestKey(IServiceCollection services, string scheme) => private static void ValidateWithTestKey(IServiceCollection services, string scheme) =>
services.Configure<JwtBearerOptions>(scheme, options => 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("Keycloak:MedewerkerAuthority", "https://keycloak.invalid/realms/medewerker");
builder.UseSetting("Downstream:Domain:BaseUrl", "http://domain.invalid/"); builder.UseSetting("Downstream:Domain:BaseUrl", "http://domain.invalid/");
builder.UseSetting("Downstream:Projection:BaseUrl", "http://projection.invalid/"); builder.UseSetting("Downstream:Projection:BaseUrl", "http://projection.invalid/");
builder.UseSetting("Downstream:Acl:BaseUrl", "http://acl.invalid/");
builder.ConfigureTestServices(services => builder.ConfigureTestServices(services =>
{ {
services.AddSingleton<IDomainClient>(Domain); services.AddSingleton<IDomainClient>(Domain);
services.AddSingleton<IProjectionClient>(Projection); services.AddSingleton<IProjectionClient>(Projection);
services.AddSingleton<IAclClient>(Acl);
// Both realms validate locally against the test key (no live Keycloak). The medewerker // 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 // 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); 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; } 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 /// <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) public Task<IReadOnlyList<ProjectionEntry>> GetRegisterAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<ProjectionEntry>>(Entries); => 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;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using Bff.Api;
namespace Bff.Tests; namespace Bff.Tests;
@@ -169,52 +168,5 @@ public class SelfServiceEndpointTests
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); 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 SubmitAcceptedDto(string RegistrationId, string Status);
private sealed record CurrentRegistrationDto(string RegistrationId, string Status);
} }
+1 -85
View File
@@ -28,32 +28,6 @@
"description": "Unauthorized" "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": { "/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": { "components": {
"schemas": { "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": { "DecideRequest": {
"required": [ "required": [
"besluit" "besluit"
@@ -386,4 +302,4 @@
"name": "Bff.Api" "name": "Bff.Api"
} }
] ]
} }
-9
View File
@@ -5,15 +5,6 @@
<ProjectReference Include="..\Big.Infrastructure\Big.Infrastructure.csproj" /> <ProjectReference Include="..\Big.Infrastructure\Big.Infrastructure.csproj" />
</ItemGroup> </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> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
+2 -70
View File
@@ -1,35 +1,9 @@
using Big.Application; using Big.Application;
using Big.Domain; using Big.Domain;
using Big.Infrastructure; using Big.Infrastructure;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using Quartz;
var builder = WebApplication.CreateBuilder(args); 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). // Options bound from configuration (compose sets Flowable__* and Acl__* env vars).
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>() builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
.GetSection("Flowable").Get<FlowableOptions>() .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). // The in-memory registration store is shared between the submit endpoint and the worker (ADR-0009).
builder.Services.AddSingleton<IRegistrationStore, InMemoryRegistrationStore>(); 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 // 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). // same HttpClient-backed implementation — the only code that talks to Flowable (§8.2).
builder.Services.AddHttpClient<FlowableWorkflowClient>(); builder.Services.AddHttpClient<FlowableWorkflowClient>();
@@ -66,7 +36,6 @@ builder.Services.AddScoped<OpenZaakJobProcessor>();
builder.Services.AddScoped<BeoordelingEscalatieProcessor>(); builder.Services.AddScoped<BeoordelingEscalatieProcessor>();
builder.Services.AddScoped<ExpireRegistrationWorker>(); builder.Services.AddScoped<ExpireRegistrationWorker>();
builder.Services.AddScoped<RegistratieVerlopenProcessor>(); builder.Services.AddScoped<RegistratieVerlopenProcessor>();
builder.Services.AddScoped<HerregistratieReminderSweep>();
// The hosted external-task job worker polls Flowable and drives OpenZaakAanmaken to completion. // The hosted external-task job worker polls Flowable and drives OpenZaakAanmaken to completion.
builder.Services.AddHostedService<OpenZaakJobPump>(); builder.Services.AddHostedService<OpenZaakJobPump>();
@@ -77,26 +46,10 @@ builder.Services.AddHostedService<BeoordelingEscalatiePump>();
// parks and expires each lapsed registration to VERLOPEN (S-10a, ADR-0017). // parks and expires each lapsed registration to VERLOPEN (S-10a, ADR-0017).
builder.Services.AddHostedService<RegistratieVerlopenPump>(); 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(); var app = builder.Build();
app.MapGet("/health", () => "Healthy"); 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; // 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 // 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). // 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))); Results.Ok(await werkbak.GetAsync(ct)));
// Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually). // Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually).
// 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) => app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) =>
{ {
if (!Guid.TryParse(id, out var guid)) 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 return registration is null
? Results.NotFound() ? Results.NotFound()
: Results.Ok(new RegistrationResponse( : Results.Ok(new RegistrationResponse(
registration.Id.ToString(), registration.Status.ToString(), registration.ZaakUrl?.ToString(), registration.Id.ToString(), registration.Status.ToString(), registration.ZaakUrl?.ToString()));
registration.HerregistratieVoor?.ToString("O"), registration.HerregistratieReminderVerstuurd));
}); });
await app.RunAsync(); 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 ProvideDocumentsRequest(string Bsn, string ContentBase64, string? FileName = null, string? ContentType = null);
public sealed record RegistrationResponse( public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl);
string RegistrationId,
string Status,
string? ZaakUrl,
string? HerregistratieVoor = null,
bool HerregistratieReminderVerstuurd = false);
public partial class Program; 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 /// zaak status is the projection's source of truth (it flows back over NRC); the aggregate transition
/// keeps the domain's own view consistent. /// keeps the domain's own view consistent.
/// </summary> /// </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) 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."); $"Registration {command.RegistrationId} has no zaak yet; it cannot be approved.");
await acl.ApproveZaakAsync(registration.ZaakUrl, ct); await acl.ApproveZaakAsync(registration.ZaakUrl, ct);
registration.Approve(clock.GetUtcNow()); registration.Approve();
await store.SaveAsync(registration, ct); 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 /// 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. /// state is a no-op, so the ACL is not called and the task not completed twice.
/// </summary> /// </summary>
public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient acl, IUserTaskClient tasks, TimeProvider clock) public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient acl, IUserTaskClient tasks)
{ {
public async Task HandleAsync(BeoordeelRegistratieCommand command, CancellationToken ct = default) public async Task HandleAsync(BeoordeelRegistratieCommand command, CancellationToken ct = default)
{ {
@@ -44,7 +44,7 @@ public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient ac
throw new InvalidOperationException( throw new InvalidOperationException(
$"Registration {command.RegistrationId} has no zaak yet; it cannot be approved."); $"Registration {command.RegistrationId} has no zaak yet; it cannot be approved.");
await acl.ApproveZaakAsync(registration.ZaakUrl, ct); await acl.ApproveZaakAsync(registration.ZaakUrl, ct);
registration.Approve(clock.GetUtcNow()); registration.Approve();
break; break;
case BeoordelingsBesluit.Afwijzen: 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> /// <summary>Load a registration by id, or <c>null</c> if none exists.</summary>
Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default); 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> /// <summary>
+4 -53
View File
@@ -92,12 +92,11 @@ public sealed class Registration
/// <summary> /// <summary>
/// Approve the registration — the behandelaar's decision to enter it in the register. Advances a /// 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 /// submitted or in-behandeling registration to <see cref="RegistrationStatus.Ingeschreven"/>.
/// records <paramref name="ingeschrevenOp"/> as the moment of inscription, which starts the /// Requires an opened zaak (the approval sets that zaak's status via the ACL); a registration that
/// herregistratie clock (S-17). Requires an opened zaak (the approval sets that zaak's status via /// has already been decided cannot be approved again.
/// the ACL); a registration that has already been decided cannot be approved again.
/// </summary> /// </summary>
public void Approve(DateTimeOffset ingeschrevenOp) public void Approve()
{ {
if (ZaakUrl is null) if (ZaakUrl is null)
throw new InvalidOperationException( throw new InvalidOperationException(
@@ -105,54 +104,6 @@ public sealed class Registration
RequireOpenForDecision(nameof(Approve)); RequireOpenForDecision(nameof(Approve));
Status = RegistrationStatus.Ingeschreven; 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> /// <summary>
@@ -19,7 +19,6 @@
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.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="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Quartz" Version="3.18.2" />
</ItemGroup> </ItemGroup>
</Project> </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) public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
=> Task.FromResult(_byId.GetValueOrDefault(id)); => 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 acl = new FakeAclClient();
var registration = WithZaak(); var registration = WithZaak();
store.Seed(registration); 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));
@@ -36,7 +36,7 @@ public class ApproveRegistrationTests
{ {
var store = new FakeRegistrationStore(); var store = new FakeRegistrationStore();
var acl = new FakeAclClient(); 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!)); await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
Assert.Equal(0, acl.ApproveCallCount); Assert.Equal(0, acl.ApproveCallCount);
@@ -47,7 +47,7 @@ public class ApproveRegistrationTests
{ {
var store = new FakeRegistrationStore(); var store = new FakeRegistrationStore();
var acl = new FakeAclClient(); var acl = new FakeAclClient();
var handler = new ApproveRegistration(store, acl, TimeProvider.System); var handler = new ApproveRegistration(store, acl);
var ex = await Assert.ThrowsAsync<InvalidOperationException>( var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => handler.HandleAsync(new ApproveRegistrationCommand(RegistrationId.New()))); () => handler.HandleAsync(new ApproveRegistrationCommand(RegistrationId.New())));
@@ -62,7 +62,7 @@ public class ApproveRegistrationTests
var acl = new FakeAclClient(); var acl = new FakeAclClient();
var registration = Registration.Submit("123456782"); // no zaak yet var registration = Registration.Submit("123456782"); // no zaak yet
store.Seed(registration); store.Seed(registration);
var handler = new ApproveRegistration(store, acl, TimeProvider.System); var handler = new ApproveRegistration(store, acl);
var ex = await Assert.ThrowsAsync<InvalidOperationException>( var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => handler.HandleAsync(new ApproveRegistrationCommand(registration.Id))); () => handler.HandleAsync(new ApproveRegistrationCommand(registration.Id)));
@@ -77,7 +77,7 @@ public class ApproveRegistrationTests
var acl = new FakeAclClient(); var acl = new FakeAclClient();
var registration = WithZaak(); var registration = WithZaak();
store.Seed(registration); 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));
await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id)); await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id));
@@ -29,7 +29,7 @@ public class BeoordeelRegistratieTests
var registration = WithZaak(); var registration = WithZaak();
store.Seed(registration); store.Seed(registration);
var tasks = TaskFor(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)); await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
@@ -50,7 +50,7 @@ public class BeoordeelRegistratieTests
var registration = WithZaak(); var registration = WithZaak();
store.Seed(registration); store.Seed(registration);
var tasks = TaskFor(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)); await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
@@ -69,7 +69,7 @@ public class BeoordeelRegistratieTests
var registration = WithZaak(); var registration = WithZaak();
registration.TakeIntoBehandeling(); registration.TakeIntoBehandeling();
store.Seed(registration); store.Seed(registration);
var handler = new BeoordeelRegistratie(store, acl, 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));
@@ -81,7 +81,7 @@ public class BeoordeelRegistratieTests
{ {
var store = new FakeRegistrationStore(); var store = new FakeRegistrationStore();
var acl = new FakeAclClient(); var acl = new FakeAclClient();
var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]), TimeProvider.System); var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]));
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!)); await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
Assert.Equal(0, acl.ApproveCallCount); Assert.Equal(0, acl.ApproveCallCount);
@@ -93,7 +93,7 @@ public class BeoordeelRegistratieTests
{ {
var store = new FakeRegistrationStore(); var store = new FakeRegistrationStore();
var acl = new FakeAclClient(); var acl = new FakeAclClient();
var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]), TimeProvider.System); var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
handler.HandleAsync(new BeoordeelRegistratieCommand(RegistrationId.New(), BeoordelingsBesluit.Goedkeuren))); handler.HandleAsync(new BeoordeelRegistratieCommand(RegistrationId.New(), BeoordelingsBesluit.Goedkeuren)));
@@ -108,7 +108,7 @@ public class BeoordeelRegistratieTests
var acl = new FakeAclClient(); var acl = new FakeAclClient();
var registration = Registration.Submit("123456782"); // no zaak yet var registration = Registration.Submit("123456782"); // no zaak yet
store.Seed(registration); store.Seed(registration);
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration), TimeProvider.System); var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren))); handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren)));
@@ -123,7 +123,7 @@ public class BeoordeelRegistratieTests
var acl = new FakeAclClient(); var acl = new FakeAclClient();
var registration = WithZaak(); var registration = WithZaak();
store.Seed(registration); store.Seed(registration);
var handler = new BeoordeelRegistratie(store, acl, 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));
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 acl = new FakeAclClient();
var registration = WithZaak(); var registration = WithZaak();
store.Seed(registration); 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));
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(); var registration = WithZaak();
store.Seed(registration); store.Seed(registration);
var tasks = new FakeUserTaskClient([]); // no open task for this 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)); 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) public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
=> Task.FromResult(_byId.GetValueOrDefault(id)); => 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; 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 /// <summary>A fake ACL client that records the bsn it was asked to open a zaak for and returns a
/// fixed zaak URL.</summary> /// fixed zaak URL.</summary>
internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient
@@ -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!)); 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 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] [Fact]
public void Submitting_a_registration_starts_in_ingediend() public void Submitting_a_registration_starts_in_ingediend()
{ {
@@ -106,7 +103,7 @@ public class RegistrationTests
var registration = Registration.Submit("123456782"); var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve(Ingeschreven); registration.Approve();
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status); Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
} }
@@ -116,7 +113,7 @@ public class RegistrationTests
{ {
var registration = Registration.Submit("123456782"); 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.Contains("no zaak", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal(RegistrationStatus.Ingediend, registration.Status); Assert.Equal(RegistrationStatus.Ingediend, registration.Status);
@@ -127,9 +124,9 @@ public class RegistrationTests
{ {
var registration = Registration.Submit("123456782"); var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); 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.Contains("only an INGEDIEND", ex.Message);
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status); Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
} }
@@ -160,7 +157,7 @@ public class RegistrationTests
{ {
var registration = Registration.Submit("123456782"); var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve(Ingeschreven); registration.Approve();
var ex = Assert.Throws<InvalidOperationException>(() => registration.TakeIntoBehandeling()); var ex = Assert.Throws<InvalidOperationException>(() => registration.TakeIntoBehandeling());
Assert.Contains("only an INGEDIEND", ex.Message); 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.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.TakeIntoBehandeling(); registration.TakeIntoBehandeling();
registration.Approve(Ingeschreven); registration.Approve();
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status); Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
} }
@@ -221,7 +218,7 @@ public class RegistrationTests
var approveEx = Assert.Throws<InvalidOperationException>(() => var approveEx = Assert.Throws<InvalidOperationException>(() =>
{ {
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve(Ingeschreven); registration.Approve();
}); });
Assert.Contains("IN_BEHANDELING", approveEx.Message); Assert.Contains("IN_BEHANDELING", approveEx.Message);
@@ -280,7 +277,7 @@ public class RegistrationTests
{ {
var registration = Registration.Submit("123456782"); var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve(Ingeschreven); registration.Approve();
var ex = Assert.Throws<InvalidOperationException>(() => registration.Withdraw()); var ex = Assert.Throws<InvalidOperationException>(() => registration.Withdraw());
Assert.Contains("only an INGEDIEND", ex.Message); Assert.Contains("only an INGEDIEND", ex.Message);
@@ -339,7 +336,7 @@ public class RegistrationTests
{ {
var registration = Registration.Submit("123456782"); var registration = Registration.Submit("123456782");
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
registration.Approve(Ingeschreven); registration.Approve();
var ex = Assert.Throws<InvalidOperationException>(() => registration.Expire()); var ex = Assert.Throws<InvalidOperationException>(() => registration.Expire());
Assert.Contains("only an INGEDIEND", ex.Message); Assert.Contains("only an INGEDIEND", ex.Message);
+1 -2
View File
@@ -6,8 +6,7 @@
"mutate": [ "mutate": [
"!**/OpenZaakJobPump.cs", "!**/OpenZaakJobPump.cs",
"!**/BeoordelingEscalatiePump.cs", "!**/BeoordelingEscalatiePump.cs",
"!**/RegistratieVerlopenPump.cs", "!**/RegistratieVerlopenPump.cs"
"!**/HerregistratieReminderJob.cs"
], ],
"thresholds": { "thresholds": {
"high": 95, "high": 95,
@@ -5,14 +5,6 @@
<ProjectReference Include="..\..\projection-api\Projection.ReadModel\Projection.ReadModel.csproj" /> <ProjectReference Include="..\..\projection-api\Projection.ReadModel\Projection.ReadModel.csproj" />
</ItemGroup> </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> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
@@ -1,32 +1,9 @@
using System.Text.Json; using System.Text.Json;
using EventSubscriber.Application; using EventSubscriber.Application;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using Projection.ReadModel; using Projection.ReadModel;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// OpenTelemetry tracing (S-16b, ADR-0023): auto-instrument the incoming NRC notification callback and
// the outgoing ACL enrichment call, 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());
var connectionString = builder.Configuration.GetConnectionString("Projection") var connectionString = builder.Configuration.GetConnectionString("Projection")
?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Projection'"); ?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Projection'");
// The exact Authorization header value Open Notificaties sends on each abonnement callback. // The exact Authorization header value Open Notificaties sends on each abonnement callback.
@@ -51,9 +28,6 @@ await app.Services.MigrateProjectionAsync();
app.MapGet("/health", () => "Healthy"); app.MapGet("/health", () => "Healthy");
// Prometheus scrape endpoint (S-16c): exposes the OTel metrics above in Prometheus text format.
app.MapPrometheusScrapingEndpoint();
// The NRC abonnement callback. Open Notificaties POSTs a notification here; we project it. // The NRC abonnement callback. Open Notificaties POSTs a notification here; we project it.
// Auth-on-callback is mandatory: the auth check runs *before* the body is read, so NRC's // Auth-on-callback is mandatory: the auth check runs *before* the body is read, so NRC's
// registration probe (a POST without the configured Authorization, and without a valid // registration probe (a POST without the configured Authorization, and without a valid
@@ -1,31 +1,8 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using Projection.ReadModel; using Projection.ReadModel;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// OpenTelemetry tracing (S-16b, ADR-0023): auto-instrument incoming ASP.NET Core requests, exported
// over OTLP to Tempo, so a BFF → projection-api read is one connected trace. 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());
var connectionString = builder.Configuration.GetConnectionString("Projection") var connectionString = builder.Configuration.GetConnectionString("Projection")
?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Projection'"); ?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Projection'");
@@ -40,9 +17,6 @@ await app.Services.MigrateProjectionAsync();
app.MapGet("/health", () => "Healthy"); app.MapGet("/health", () => "Healthy");
// Prometheus scrape endpoint (S-16c): exposes the OTel metrics above in Prometheus text format.
app.MapPrometheusScrapingEndpoint();
// The read side of the projection. Public-safe field filtering is tightened in S-09; for now // The read side of the projection. Public-safe field filtering is tightened in S-09; for now
// the minimal projection only carries id + status (bsn/naam deferred — ADR-0008). // the minimal projection only carries id + status (bsn/naam deferred — ADR-0008).
app.MapGet("/register", async (ProjectionDbContext db, CancellationToken ct) => app.MapGet("/register", async (ProjectionDbContext db, CancellationToken ct) =>
@@ -4,14 +4,6 @@
<ProjectReference Include="..\Projection.ReadModel\Projection.ReadModel.csproj" /> <ProjectReference Include="..\Projection.ReadModel\Projection.ReadModel.csproj" />
</ItemGroup> </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> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>

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