Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b21f770e5 | ||
|
|
cfa1f182c6 | ||
|
|
8e1a820bd3 | ||
|
|
dc801f7979 | ||
|
|
80391b1730 | ||
|
|
fcfb8bbae2 | ||
|
|
849bf4723b | ||
|
|
4698c869f3 | ||
|
|
d5dfbdc0b2 | ||
|
|
6771fccf47 | ||
|
|
88338396f6 | ||
|
|
4274fd30d1 | ||
|
|
4fe9915816 | ||
|
|
5f8ab4dbcd | ||
|
|
5de8c1e292 | ||
|
|
183d0bce31 | ||
|
|
d5e5fa254c |
+118
-5
@@ -9,6 +9,12 @@ 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).
|
||||||
|
|
||||||
@@ -64,6 +70,12 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
nuget-${{ runner.os }}-
|
nuget-${{ runner.os }}-
|
||||||
- run: make unit
|
- run: make unit
|
||||||
|
# Job summary (#136): a per-service pass/fail table from the TRX `make unit` wrote.
|
||||||
|
- name: Unit test summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
||||||
|
python3 infra/trx-summary.py TestResults >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
# Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build.
|
# Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build.
|
||||||
frontend:
|
frontend:
|
||||||
@@ -78,6 +90,12 @@ jobs:
|
|||||||
node-version: '24'
|
node-version: '24'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
- run: make frontend
|
- run: make frontend
|
||||||
|
# Job summary (#136): a per-frontend (app) pass/fail table from the vitest JSON each app wrote.
|
||||||
|
- name: Frontend test summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
||||||
|
python3 infra/vitest-summary.py test-output >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
mutation:
|
mutation:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -93,6 +111,29 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
nuget-${{ runner.os }}-
|
nuget-${{ runner.os }}-
|
||||||
- run: make mutation
|
- run: make mutation
|
||||||
|
# Job summary (#136): render each service's Stryker Markdown report on the run page (Gitea
|
||||||
|
# 1.27 $GITHUB_STEP_SUMMARY). `if: always()` so a ratchet break still reports — and because
|
||||||
|
# `make mutation` stops at the first break, the summary also shows exactly where it stopped.
|
||||||
|
# Guarded so it no-ops on a runner/server without summary support. Strips the report's UTF-8 BOM.
|
||||||
|
- name: Mutation score summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
||||||
|
{
|
||||||
|
echo "## 🧬 Mutation testing"
|
||||||
|
echo
|
||||||
|
for svc in acl event-subscriber domain bff; do
|
||||||
|
echo "### $svc"
|
||||||
|
echo
|
||||||
|
report=$(ls services/"$svc"/StrykerOutput/*/reports/mutation-report.md 2>/dev/null | sort | tail -1)
|
||||||
|
if [ -n "$report" ]; then
|
||||||
|
sed '1s/^\xef\xbb\xbf//' "$report"
|
||||||
|
else
|
||||||
|
echo "_No report — \`make mutation\` stopped before \`$svc\` (earlier ratchet break)._"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
done
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
# Publish the Stryker HTML reports. `if: always()` uploads them even when the
|
# Publish the Stryker HTML reports. `if: always()` uploads them even when the
|
||||||
# ratchet fails — that is exactly when you want to inspect the survivors.
|
# ratchet fails — that is exactly when you want to inspect the survivors.
|
||||||
# `continue-on-error` keeps the upload best-effort: the mutation *gate* is the
|
# `continue-on-error` keeps the upload best-effort: the mutation *gate* is the
|
||||||
@@ -129,35 +170,107 @@ 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. On the single self-hosted
|
# One stage for every check that needs the live stack. Booting OpenZaak once (instead
|
||||||
# runner jobs run sequentially, so booting OpenZaak once (instead of once per job)
|
# of once per job) is the cheapest layout (issue #58). No setup-dotnet: the ACL test runs
|
||||||
# is the cheapest layout (issue #58). No setup-dotnet: the ACL test runs in a built
|
# in a built image and everything reaches services by container IP. Needs Docker + egress
|
||||||
# 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.
|
||||||
|
#
|
||||||
|
# No `if: ${{ !cancelled() }}` here (removed in #134): on Gitea 1.27 + act_runner 2.0.0, a job
|
||||||
|
# gated by a status-function `if` (always()/cancelled()) on top of `needs` routes through the new
|
||||||
|
# transitional "Cancelling" state + capability negotiation and never leaves `waiting` — it's never
|
||||||
|
# dispatched (gitea-actions-gotchas.md §7). Default `if: success()` dispatches normally. Cost: a
|
||||||
|
# failing mutation ratchet now skips verify-stack instead of running it anyway; the fix-and-re-push
|
||||||
|
# re-run exercises verify-stack, so we still get the signal.
|
||||||
verify-stack:
|
verify-stack:
|
||||||
|
needs: [mutation]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
# Bring the full stack up + wait for health — this also is the DoD "compose up
|
# Bring the full stack up + wait for health — this also is the DoD "compose up
|
||||||
# reaches green health" smoke (it replaces the old compose-smoke job).
|
# reaches green health" smoke (it replaces the old compose-smoke job).
|
||||||
|
# Each check carries an `id` so the summary step below can report its per-check outcome (#136).
|
||||||
|
# A failed check skips the rest (no step `if:`), so the table shows exactly where it stopped.
|
||||||
- name: Bring up the full stack & wait for health
|
- name: Bring up the full stack & wait for health
|
||||||
|
id: up
|
||||||
run: make verify-up
|
run: make verify-up
|
||||||
|
- name: Observability backplane (Grafana + Tempo + Prometheus datasources)
|
||||||
|
id: obs
|
||||||
|
run: OBS_TIMEOUT=180 make verify-observability
|
||||||
- name: ACL ↔ OpenZaak integration tests
|
- name: ACL ↔ OpenZaak integration tests
|
||||||
|
id: acl
|
||||||
run: make verify-acl
|
run: make verify-acl
|
||||||
- name: OpenZaak → NRC notification delivery
|
- name: OpenZaak → NRC notification delivery
|
||||||
|
id: nrc
|
||||||
run: make verify-nrc
|
run: make verify-nrc
|
||||||
- name: OpenZaak → NRC → Event Subscriber → projection-api
|
- name: OpenZaak → NRC → Event Subscriber → projection-api
|
||||||
|
id: projection
|
||||||
run: make verify-projection
|
run: make verify-projection
|
||||||
- name: Domain → Flowable → ACL → OpenZaak
|
- name: Domain → Flowable → ACL → OpenZaak
|
||||||
|
id: domain
|
||||||
run: make verify-domain
|
run: make verify-domain
|
||||||
- name: BFF → Keycloak + domain + projection
|
- name: BFF → Keycloak + domain + projection
|
||||||
|
id: bff
|
||||||
run: make verify-bff
|
run: make verify-bff
|
||||||
|
- name: Distributed traces reach Tempo (one connected trace across services)
|
||||||
|
id: tracing
|
||||||
|
run: TRACING_TIMEOUT=120 make verify-tracing
|
||||||
|
- name: Golden-signal metrics scraped by Prometheus (/metrics on every service)
|
||||||
|
id: metrics
|
||||||
|
run: METRICS_TIMEOUT=120 make verify-metrics
|
||||||
- name: Self-service e2e (Playwright, login → submit → success)
|
- name: Self-service e2e (Playwright, login → submit → success)
|
||||||
|
id: e2e
|
||||||
run: make verify-e2e
|
run: make verify-e2e
|
||||||
|
# Job summary (#136): a pass/fail table of every live-stack check, so a red verify-stack shows
|
||||||
|
# which check failed at a glance. `if: always()` (step-level — safe on runner 2.0.0, unlike the
|
||||||
|
# job-level status-function `if` of #134) so it renders even after a check fails.
|
||||||
|
- name: verify-stack check summary
|
||||||
|
if: always()
|
||||||
|
env:
|
||||||
|
UP: ${{ steps.up.outcome }}
|
||||||
|
OBS: ${{ steps.obs.outcome }}
|
||||||
|
ACL: ${{ steps.acl.outcome }}
|
||||||
|
NRC: ${{ steps.nrc.outcome }}
|
||||||
|
PROJECTION: ${{ steps.projection.outcome }}
|
||||||
|
DOMAIN: ${{ steps.domain.outcome }}
|
||||||
|
BFF: ${{ steps.bff.outcome }}
|
||||||
|
TRACING: ${{ steps.tracing.outcome }}
|
||||||
|
METRICS: ${{ steps.metrics.outcome }}
|
||||||
|
E2E: ${{ steps.e2e.outcome }}
|
||||||
|
run: |
|
||||||
|
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
||||||
|
icon() { case "$1" in success) echo "✅";; failure) echo "❌";; skipped) echo "⏭️";; cancelled) echo "🚫";; *) echo "❔ ${1:-—}";; esac; }
|
||||||
|
{
|
||||||
|
echo "## 🔌 verify-stack checks"
|
||||||
|
echo
|
||||||
|
echo "| Check | Result |"
|
||||||
|
echo "| ----- | :----: |"
|
||||||
|
echo "| Bring up + health | $(icon "$UP") |"
|
||||||
|
echo "| Observability backplane | $(icon "$OBS") |"
|
||||||
|
echo "| ACL ↔ OpenZaak | $(icon "$ACL") |"
|
||||||
|
echo "| OpenZaak → NRC | $(icon "$NRC") |"
|
||||||
|
echo "| NRC → Event Subscriber → projection | $(icon "$PROJECTION") |"
|
||||||
|
echo "| Domain → Flowable → ACL → OpenZaak | $(icon "$DOMAIN") |"
|
||||||
|
echo "| BFF → Keycloak + domain + projection | $(icon "$BFF") |"
|
||||||
|
echo "| Distributed traces (Tempo) | $(icon "$TRACING") |"
|
||||||
|
echo "| Golden-signal metrics (Prometheus) | $(icon "$METRICS") |"
|
||||||
|
echo "| Self-service e2e (Playwright) | $(icon "$E2E") |"
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
# Job summary (#136): per-spec Playwright results, from the JSON report run-e2e-check.sh copied
|
||||||
|
# out of the e2e container. Turns a red e2e into a one-glance "which spec" instead of a log dive.
|
||||||
|
- name: e2e spec summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
||||||
|
python3 infra/playwright-summary.py tests/e2e/playwright-report.json >> "$GITHUB_STEP_SUMMARY"
|
||||||
# Log dump must precede teardown (which removes the containers).
|
# Log dump must precede teardown (which removes the containers).
|
||||||
- name: Dump container logs on failure
|
- name: Dump container logs on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel 2>&1 || true
|
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer tempo prometheus grafana 2>&1 || true
|
||||||
- name: Tear down
|
- name: Tear down
|
||||||
if: always()
|
if: always()
|
||||||
run: make down
|
run: make down
|
||||||
|
|||||||
@@ -57,3 +57,7 @@ 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__/
|
||||||
|
TestResults/
|
||||||
|
test-output/
|
||||||
|
tests/e2e/playwright-report.json
|
||||||
|
|||||||
+16
-4
@@ -249,17 +249,29 @@ 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
|
### S-15 · Beheer-portal — catalogus & default-fill rules *(split — #16 closed)*
|
||||||
|
|
||||||
**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.
|
||||||
|
|
||||||
### S-16 · OpenTelemetry traces + Grafana dashboard
|
Split into independently deployable sub-slices (CLAUDE.md §13):
|
||||||
|
|
||||||
|
- **S-15a** (#130) · Beheer portal skeleton + read-only catalogi viewer — new beheer Angular app (medewerker-realm login) showing ZTC catalogi/zaaktypen read-only, via a BFF `/beheer/*` read endpoint proxying a read-only ACL Catalogi endpoint (§8.1, reuses the ADR-0021 Catalogi client).
|
||||||
|
- **S-15b** (#131) · ACL default-fill configuration CRUD — the `Acl__Defaults__*` config (ADR-0003) becomes a managed store with CRUD via the BFF + a portal UI. Depends on S-15a.
|
||||||
|
- **S-15c** (#132) · Enforce MFA (OTP) on the Keycloak medewerker realm.
|
||||||
|
|
||||||
|
### S-16 · OpenTelemetry traces + Grafana dashboard *(split — #17 closed)*
|
||||||
|
|
||||||
**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.
|
||||||
|
|
||||||
### S-17 · Quartz.NET scheduler — herregistratie reminder sweep
|
Split into independently deployable sub-slices (CLAUDE.md §13):
|
||||||
|
|
||||||
**Outcome:** Nightly job that finds entries within 90 days of expiry and emits a domain event. (No outbound notification in v1 — logged.)
|
- **S-16a** (#122) · Observability backplane — Grafana Tempo + Prometheus + Grafana in compose, datasources auto-provisioned (ADR-0023). No collector; config baked into built images.
|
||||||
|
- **S-16b** (#123) · Distributed traces across the five .NET services (OTLP → Tempo; traceparent propagates via the typed HttpClients). Depends on S-16a. ✅
|
||||||
|
- **S-16c** (#124) · Prometheus metrics + golden-signal Grafana dashboards. Depends on S-16a. ✅
|
||||||
|
|
||||||
|
### S-17 · Quartz.NET scheduler — herregistratie reminder sweep ✅
|
||||||
|
|
||||||
|
**Outcome:** Daily Quartz.NET cron job finds inscriptions within 90 days of their herregistratie deadline and reminds each (flag on the aggregate + log). No outbound notification and no domain event in v1 — the reminder is the persisted flag, surfaced on the read model (ADR-0022, #120). Quartz fires time-triggered sweeps; the existing pumps stay as queue-drainers.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml
|
|||||||
# Long-running services with a healthcheck — the smoke polls these for readiness
|
# Long-running services with a healthcheck — the smoke polls these for readiness
|
||||||
# (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init)
|
# (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init)
|
||||||
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
|
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
|
||||||
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel
|
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel beheer
|
||||||
# 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-notifications smoke up down local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
|
.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
|
||||||
|
|
||||||
## 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).
|
||||||
@@ -70,8 +70,9 @@ build:
|
|||||||
dotnet build $(SLN) -c Release
|
dotnet build $(SLN) -c Release
|
||||||
|
|
||||||
## unit: run unit tests (excludes the container-backed Integration lane)
|
## unit: run unit tests (excludes the container-backed Integration lane)
|
||||||
|
# TRX per test project (→ TestResults/) feeds the CI per-service summary (#136); harmless locally.
|
||||||
unit:
|
unit:
|
||||||
dotnet test $(SLN) -c Release --filter "Category!=Integration"
|
dotnet test $(SLN) -c Release --filter "Category!=Integration" --logger trx --results-directory TestResults
|
||||||
|
|
||||||
## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline)
|
## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline)
|
||||||
# Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore`
|
# Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore`
|
||||||
@@ -114,6 +115,11 @@ local:
|
|||||||
docker compose -f $(LOCAL_COMPOSE) up -d --build
|
docker compose -f $(LOCAL_COMPOSE) up -d --build
|
||||||
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
|
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
|
||||||
|
|
||||||
|
## verify-local: acceptance check for the local stack (S-B04) — a fresh `make local` completes the
|
||||||
|
## whole flow (zaaktype seeded + DMN deployed + NRC abonnement) with NO manual seeding.
|
||||||
|
verify-local:
|
||||||
|
bash infra/run-local-flow-check.sh
|
||||||
|
|
||||||
## local-down: stop and remove the bind-mount stack
|
## local-down: stop and remove the bind-mount stack
|
||||||
local-down:
|
local-down:
|
||||||
docker compose -f $(LOCAL_COMPOSE) down --volumes
|
docker compose -f $(LOCAL_COMPOSE) down --volumes
|
||||||
@@ -165,6 +171,21 @@ 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.
|
||||||
|
|||||||
@@ -64,7 +64,9 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"executor": "@angular/build:unit-test",
|
"executor": "@angular/build:unit-test",
|
||||||
"options": {
|
"options": {
|
||||||
"watch": false
|
"watch": false,
|
||||||
|
"reporters": ["default", "json"],
|
||||||
|
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"serve-static": {
|
"serve-static": {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import nx from '@nx/eslint-plugin';
|
||||||
|
import baseConfig from '../../eslint.config.mjs';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...nx.configs['flat/angular'],
|
||||||
|
...nx.configs['flat/angular-template'],
|
||||||
|
...baseConfig,
|
||||||
|
{
|
||||||
|
files: ['**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'@angular-eslint/directive-selector': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
type: 'attribute',
|
||||||
|
prefix: 'app',
|
||||||
|
style: 'camelCase',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'@angular-eslint/component-selector': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
type: 'element',
|
||||||
|
prefix: 'app',
|
||||||
|
style: 'kebab-case',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.html'],
|
||||||
|
// Override or add rules here
|
||||||
|
rules: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
|
||||||
|
# even before the BFF is up and picks up restarts — instead of failing to load the config.
|
||||||
|
resolver 127.0.0.11 ipv6=off valid=30s;
|
||||||
|
|
||||||
|
# Same-origin API: proxy the 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
{
|
||||||
|
"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,
|
||||||
|
"reporters": ["default", "json"],
|
||||||
|
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"serve-static": {
|
||||||
|
"continuous": true,
|
||||||
|
"executor": "@nx/web:file-server",
|
||||||
|
"options": {
|
||||||
|
"buildTarget": "beheer:build",
|
||||||
|
"staticFilePath": "dist/apps/beheer/browser",
|
||||||
|
"spa": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"authority": "http://localhost:8180/realms/medewerker"
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,65 @@
|
|||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { authInterceptor, provideMedewerkerAuth } from 'auth';
|
||||||
|
import { appRoutes } from './app.routes';
|
||||||
|
|
||||||
|
/** Environment-specific settings fetched from /config.json at startup (see main.ts). */
|
||||||
|
export interface RuntimeConfig {
|
||||||
|
/** The Keycloak `medewerker` realm issuer as the browser reaches it (dev: localhost; compose: keycloak:8080). */
|
||||||
|
authority: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route prefixes whose requests carry the medewerker token. These MUST match the **relative** URLs
|
||||||
|
* the api-client actually calls (same-origin via the nginx proxy) — the interceptor matches on
|
||||||
|
* `req.url`, which stays relative, so an absolute origin would never match and the token would go
|
||||||
|
* unattached. Only `/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,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<router-outlet></router-outlet>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Route } from '@angular/router';
|
||||||
|
import { authenticatedGuard } from 'auth';
|
||||||
|
import { CatalogusPage } from './catalogus/catalogus-page';
|
||||||
|
|
||||||
|
export const appRoutes: Route[] = [
|
||||||
|
{ path: '', component: CatalogusPage, canActivate: [authenticatedGuard] },
|
||||||
|
];
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { render, screen } from '@testing-library/angular';
|
||||||
|
import { App } from './app';
|
||||||
|
|
||||||
|
describe('App', () => {
|
||||||
|
it('renders the router outlet shell', async () => {
|
||||||
|
const { container } = await render(App, {
|
||||||
|
providers: [provideRouter([])],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The shell is a thin host for routed pages (the CatalogusPage owns the heading).
|
||||||
|
expect(container.querySelector('router-outlet')).toBeTruthy();
|
||||||
|
expect(screen).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
imports: [RouterModule],
|
||||||
|
selector: 'app-root',
|
||||||
|
templateUrl: './app.html',
|
||||||
|
styleUrl: './app.css',
|
||||||
|
})
|
||||||
|
export class App {
|
||||||
|
protected title = 'beheer';
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { App } from './app/app';
|
||||||
|
import { appConfig, type RuntimeConfig } from './app/app.config';
|
||||||
|
|
||||||
|
// Load environment config before bootstrap so the OIDC authority is set per environment
|
||||||
|
// (dev: localhost; compose: keycloak:8080) from a single build — 12-factor (S-08d).
|
||||||
|
fetch('config.json')
|
||||||
|
.then((response) => response.json() as Promise<RuntimeConfig>)
|
||||||
|
.then((config) => bootstrapApplication(App, appConfig(config)))
|
||||||
|
.catch((err) => console.error(err));
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/* NL Design System theme — Utrecht design tokens (docs/frontend-decisions.md). */
|
||||||
|
@import '@utrecht/design-tokens/dist/index.css';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"target": "es2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"emitDecoratorMetadata": false,
|
||||||
|
"module": "preserve"
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
},
|
||||||
|
"files": [],
|
||||||
|
"include": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.spec.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"types": ["vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts"]
|
||||||
|
}
|
||||||
@@ -64,7 +64,9 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"executor": "@angular/build:unit-test",
|
"executor": "@angular/build:unit-test",
|
||||||
"options": {
|
"options": {
|
||||||
"watch": false
|
"watch": false,
|
||||||
|
"reporters": ["default", "json"],
|
||||||
|
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"serve-static": {
|
"serve-static": {
|
||||||
|
|||||||
@@ -64,7 +64,9 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"executor": "@angular/build:unit-test",
|
"executor": "@angular/build:unit-test",
|
||||||
"options": {
|
"options": {
|
||||||
"watch": false
|
"watch": false,
|
||||||
|
"reporters": ["default", "json"],
|
||||||
|
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"serve-static": {
|
"serve-static": {
|
||||||
|
|||||||
@@ -21,16 +21,20 @@ 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,
|
||||||
@@ -56,6 +60,21 @@ 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, signal } from '@angular/core';
|
import { Component, inject, type OnInit, signal } from '@angular/core';
|
||||||
import { BffApiV1Service, type SubmitAccepted } from 'api-client';
|
import { BffApiV1Service, type CurrentRegistration, type SubmitAccepted } from 'api-client';
|
||||||
import { AuthService } from 'auth';
|
import { AuthService } from 'auth';
|
||||||
import { UtrechtComponentsModule } from 'ui';
|
import { UtrechtComponentsModule } from 'ui';
|
||||||
|
|
||||||
@@ -8,13 +8,16 @@ 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 {
|
export class RegistrationPage implements OnInit {
|
||||||
private readonly auth = inject(AuthService);
|
private readonly auth = inject(AuthService);
|
||||||
private readonly bff = inject(BffApiV1Service);
|
private readonly bff = inject(BffApiV1Service);
|
||||||
|
|
||||||
@@ -31,6 +34,23 @@ export class RegistrationPage {
|
|||||||
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);
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# ADR-0020: The local stack self-seeds the zaaktype, DMN, and NRC abonnement at bring-up
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-07-22
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** S-B04 (#110). Local-stack twin of the seeding the verify-* scripts do for CI
|
||||||
|
(`infra/run-domain-check.sh`, `infra/verify-notification-driver.py`). Superseded in part by S-27
|
||||||
|
(#113), which would let the ACL resolve its zaaktype by identificatie and remove the URL injection.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`infra/docker-compose.local.yml` is the host-browser-friendly stack (`make local`) — the one a
|
||||||
|
developer clicks through the portals with. It had drifted behind three slices, so a fresh bring-up
|
||||||
|
could not complete the flow:
|
||||||
|
|
||||||
|
1. The ACL pointed at a placeholder zaaktype (`…/00000000-…`), so zaak creation failed with OpenZaak
|
||||||
|
`400` and the registratie process stuck at `OpenZaakAanmaken` (S-05).
|
||||||
|
2. `flowable-init` deployed only `registratie.bpmn`, not `diploma-eligibility.dmn`, so completing
|
||||||
|
`WachtOpDocumenten` 404'd on the missing decision and never reached `Beoordelen` (S-10a/S-13).
|
||||||
|
3. No NRC abonnement was registered, so notifications reached NRC and went nowhere — the projection
|
||||||
|
and the openbaar register stayed empty (S-06).
|
||||||
|
|
||||||
|
The CI stack (`infra/docker-compose.yml`) does not hit this because its `verify-*` scripts seed the
|
||||||
|
zaaktype, deploy the DMN, and register the abonnement at *test* time. The local stack has no such
|
||||||
|
harness — a developer just runs `make local` and browses. The non-obvious wrinkle is (1): the
|
||||||
|
zaaktype **UUID is assigned by OpenZaak at creation**, so the ACL's zaaktype URL is not knowable when
|
||||||
|
the compose file is written and cannot be a static value.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Make the local stack self-seed at bring-up via one-shot init containers, and hand the ACL its
|
||||||
|
server-assigned zaaktype URL through a shared-volume env file it sources on startup.**
|
||||||
|
|
||||||
|
- **DMN (gap 2).** `flowable-init` now deploys `diploma-eligibility.dmn` to the DMN engine
|
||||||
|
(`/flowable-rest/dmn-api/dmn-repository/deployments`) as a separate deployment alongside the BPMN —
|
||||||
|
identical to the CI `flowable-init`. Idempotent.
|
||||||
|
- **Zaaktype + ACL wiring (gap 1).** A `local-seed` one-shot runs the existing
|
||||||
|
`infra/openzaak/seed_catalogus.py` (`OZ_PUBLISH=1`) against OpenZaak and writes the resulting
|
||||||
|
`Acl__Defaults__ZaaktypeUrl` / `…InformatieobjecttypeUrl` / `Acl__OpenZaak__BaseUrl` into
|
||||||
|
`seed-env:/out/acl.env`. The ACL mounts that volume read-only and overrides its entrypoint to
|
||||||
|
`sh -c 'set -a; . /seed/acl.env; set +a; exec dotnet Acl.Api.dll'`, so the real values override the
|
||||||
|
compose placeholders before the app reads config. The ACL `depends_on: local-seed
|
||||||
|
(service_completed_successfully)`.
|
||||||
|
- **Abonnement (gap 3).** A `nrc-subscribe` one-shot registers an abonnement on the `zaken` kanaal
|
||||||
|
pointing at the event-subscriber's `/notifications` callback (`infra/local/register-abonnement.py`).
|
||||||
|
It is a leaf — nothing depends on it — so it can wait for the event-subscriber without forming a
|
||||||
|
cycle with the ACL bootstrap.
|
||||||
|
- **Reach OpenZaak/NRC by container IP, not service name.** Both the seed's ZTC calls and the
|
||||||
|
abonnement's `callbackUrl` are validated by Django's URLValidator, which rejects a single-label host
|
||||||
|
like `openzaak` / `event-subscriber`. The scripts resolve the target's container IP at runtime (as
|
||||||
|
`infra/run-domain-check.sh` does), keeping the seeded URLs valid **and** host-consistent — the ACL's
|
||||||
|
base URL is set to the same OpenZaak IP that owns the zaaktype URL.
|
||||||
|
- **Acceptance.** `make verify-local` (`infra/run-local-flow-check.sh`) submits against a fresh stack
|
||||||
|
and asserts the zaak opens, the case reaches the werkbak after documents, and the reference appears
|
||||||
|
in the openbaar register — the red-to-green test for all three gaps.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- A fresh `make local` completes the full demo (submit → werkbak → openbaar) with no manual seeding —
|
||||||
|
the slice's stated outcome.
|
||||||
|
- Reuses the proven CI mechanisms (`seed_catalogus.py`, the DMN deploy, the abonnement driver) rather
|
||||||
|
than inventing new ones; the only genuinely new piece is the entrypoint-sourced env file.
|
||||||
|
- No service code changes — the fix is entirely in `infra/` (compose + two small scripts), so the ACL
|
||||||
|
image and the CI stack are untouched.
|
||||||
|
|
||||||
|
**Negative / costs**
|
||||||
|
|
||||||
|
- The two compose files diverge further: the CI stack seeds at test time, the local stack at bring-up.
|
||||||
|
Mitigated by reusing the same underlying scripts and cross-referencing them.
|
||||||
|
- The ACL entrypoint override couples the local ACL to the seed-written file path (`/seed/acl.env`);
|
||||||
|
if the seed fails, the ACL fails to start (loud, healthcheck-visible — preferred over silently
|
||||||
|
running with a placeholder).
|
||||||
|
- Container-IP-based URLs are re-derived on each bring-up; a keep-volumes restart with a changed
|
||||||
|
OpenZaak IP relies on OpenZaak rebuilding hyperlinked URLs from the request host (it does) so the
|
||||||
|
idempotent re-seed reports current-IP URLs.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **ACL resolves its zaaktype by identificatie (`BIG-REGISTRATIE`) at startup.** The cleaner,
|
||||||
|
less-brittle design — no server-assigned URL to capture — and it would help the CI stack too. But it
|
||||||
|
changes a service's runtime behaviour and its config contract, needs new ACL tests + mutation
|
||||||
|
coverage, and still needs a seed step to *create* the zaaktype. Deliberately split out as its own
|
||||||
|
slice with its own ADR (S-27 / #113) rather than folded into this infra-only fix.
|
||||||
|
- **A documented `make local-seed` step run after `make local`.** Smallest change, but it fails the
|
||||||
|
slice's "no manual seeding" outcome — the local stack is exactly the one meant to just work in a
|
||||||
|
browser. Rejected.
|
||||||
|
- **Fixed zaaktype UUID via OpenZaak `setup_configuration`/fixtures.** OpenZaak assigns UUIDs on POST;
|
||||||
|
declaratively creating a fully *published* zaaktype (statustypen + resultaattypen validated against
|
||||||
|
the Selectielijst + roltypen + iot relations) is not something `setup_configuration` supports
|
||||||
|
cleanly in 1.28.2. Rejected as more fragile than reusing `seed_catalogus.py`.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 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.
|
||||||
@@ -5,6 +5,190 @@ copy-pasteable walkthrough against a local `make up` stack.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## S-15a — Beheer-portal: read-only catalogus viewer (#130, ADR-0025)
|
||||||
|
|
||||||
|
**Outcome:** a new **beheer** portal (medewerker realm, like behandel) shows the ZTC catalogus —
|
||||||
|
the published zaaktypen — **read-only**. A beheerder logs in and sees the seeded BIG-REGISTRATIE
|
||||||
|
zaaktype. The read path is portal → BFF `GET /beheer/catalogi/zaaktypen` (medewerker realm +
|
||||||
|
`beheerder` role) → ACL `GET /catalogi/zaaktypen` → ZGW Catalogi API. The BFF reaches the ACL
|
||||||
|
directly (ADR-0025); managing the default-fill config (S-15b) and MFA (S-15c) come next.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make up
|
||||||
|
# 1. Log in as bram-beheerder / test123 → the catalogus lists the published zaaktypen.
|
||||||
|
open http://localhost:8143
|
||||||
|
#
|
||||||
|
# 2. Automated (a CI verify-stack e2e): a beheerder logs in and sees BIG-REGISTRATIE.
|
||||||
|
make verify-e2e # → catalogus.spec: "a beheerder sees the published zaaktypen in the catalogus"
|
||||||
|
#
|
||||||
|
# 3. The BFF endpoint is behind the beheerder role — a plain behandelaar gets 403 (BFF unit tests):
|
||||||
|
# Bff.Tests → BeheerEndpointTests.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auth:** the `beheerder` realm role + `bram-beheerder` user live in the medewerker realm
|
||||||
|
(`infra/keycloak/realms/medewerker-realm.json`); the BFF reuses the medewerker bearer scheme and its
|
||||||
|
realm-role lifting, requiring `beheerder` rather than `behandelaar`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S-16c — Prometheus metrics + golden-signal Grafana dashboard (#124, ADR-0023)
|
||||||
|
|
||||||
|
**Outcome:** the five .NET services now expose OpenTelemetry metrics in Prometheus format at `/metrics`
|
||||||
|
— ASP.NET Core + `HttpClient` instrumentation plus the built-in `System.Runtime` meter. Prometheus
|
||||||
|
scrapes each service (one job per service), and a **pre-built Grafana dashboard** — *Request path —
|
||||||
|
golden signals* — plots the four golden signals: **traffic** (req/s), **errors** (5xx/s), **latency**
|
||||||
|
(p95 request duration), and **saturation** (CPU cores in use), split by service. It populates under load.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Automated (a CI verify-stack step): generate BFF traffic and assert Prometheus scraped the
|
||||||
|
# golden-signal metric from every service.
|
||||||
|
make verify-metrics # → OK — targets up: [...]; request metric scraped from: [...]
|
||||||
|
|
||||||
|
# 2. By hand: drive the stack, generate some load, then open the dashboard.
|
||||||
|
make up
|
||||||
|
for i in $(seq 1 50); do curl -s localhost:8080/openbaar/register >/dev/null; done # BFF → projection-api
|
||||||
|
open http://localhost:3000 # Grafana → Dashboards → "Request path — golden signals"
|
||||||
|
open http://localhost:9090/targets # Prometheus → every service target UP
|
||||||
|
```
|
||||||
|
|
||||||
|
**The path:** each host adds `.WithMetrics(AddAspNetCoreInstrumentation + AddHttpClientInstrumentation +
|
||||||
|
AddMeter("System.Runtime") + AddPrometheusExporter)` and maps `/metrics`; Prometheus scrapes
|
||||||
|
`<service>:8080/metrics` (config in `infra/observability/prometheus/prometheus.yml`); Grafana ships the
|
||||||
|
dashboard via provisioning against the fixed `prometheus` datasource uid. No metrics are pushed over
|
||||||
|
OTLP — Prometheus pulls, so there is no collector hop (ADR-0023).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S-16b — distributed traces across the .NET services (#123, ADR-0023)
|
||||||
|
|
||||||
|
**Outcome:** the five .NET services (BFF, Domain, ACL, projection-api, event-subscriber) now emit
|
||||||
|
OpenTelemetry traces — ASP.NET Core + `HttpClient` auto-instrumentation, exported over OTLP to Tempo.
|
||||||
|
Because every cross-service call goes through a typed `HttpClient`, the W3C `traceparent` propagates for
|
||||||
|
free, so a request is **one connected trace** across the services (bff → domain → acl → openzaak;
|
||||||
|
bff → projection-api). `/health` is filtered out. No browser-side instrumentation yet, so the trace
|
||||||
|
begins at the BFF; the async Flowable-poll boundary is a separate trace (ADR-0023).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Automated (a CI verify-stack step): generate BFF traffic and assert Tempo holds one trace
|
||||||
|
# spanning multiple services.
|
||||||
|
make verify-tracing # → OK — trace <id> spans ['bff', 'projection-api']
|
||||||
|
|
||||||
|
# 2. By hand: drive the stack, then explore traces in Grafana.
|
||||||
|
make up
|
||||||
|
curl -s localhost:8080/openbaar/register >/dev/null # BFF → projection-api
|
||||||
|
open http://localhost:3000 # Grafana → Explore → Tempo → Search → service.name = bff → open a trace
|
||||||
|
```
|
||||||
|
|
||||||
|
**The path:** each host wires `AddOpenTelemetry().WithTracing(AddAspNetCoreInstrumentation +
|
||||||
|
AddHttpClientInstrumentation + AddOtlpExporter)`; `OTEL_SERVICE_NAME` / `OTEL_EXPORTER_OTLP_ENDPOINT`
|
||||||
|
come from compose; spans export to **tempo:4317** and render in Grafana against the provisioned Tempo
|
||||||
|
datasource.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S-16a — observability backplane: Tempo + Prometheus + Grafana (#122, ADR-0023)
|
||||||
|
|
||||||
|
**Outcome:** the compose stack now includes a Grafana-native observability backplane — **Tempo** (OTLP
|
||||||
|
trace ingest on 4317/4318), **Prometheus**, and **Grafana** with both datasources auto-provisioned.
|
||||||
|
Nothing is instrumented yet (traces land in S-16b, metrics + dashboards in S-16c); this slice stands the
|
||||||
|
backplane up and proves Grafana can reach both datasources. Config is baked into small built images
|
||||||
|
(`infra/observability/`) — no collector, no config-volume seeding.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Bring the stack up, then assert the backplane is live (Grafana healthy + Tempo/Prometheus
|
||||||
|
# datasources reachable through Grafana). This is a CI verify-stack step.
|
||||||
|
make up
|
||||||
|
make verify-observability # → ✓ Grafana healthy ✓ Prometheus reachable ✓ Tempo reachable
|
||||||
|
|
||||||
|
# 2. Or just the backplane, no full stack needed (no external egress):
|
||||||
|
docker compose -f infra/docker-compose.yml up -d --build tempo prometheus grafana
|
||||||
|
open http://localhost:3000 # Grafana (admin/admin) → Connections → Data sources: Prometheus + Tempo
|
||||||
|
open http://localhost:9090 # Prometheus
|
||||||
|
```
|
||||||
|
|
||||||
|
**The path:** services will export OTLP → **Tempo:4317** and expose `/metrics` ← **Prometheus** scrapes;
|
||||||
|
**Grafana** (:3000) reads both via provisioned datasources with fixed uids `tempo` / `prometheus`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S-17 — herregistratie reminder sweep on a Quartz cron (#18, ADR-0022)
|
||||||
|
|
||||||
|
**Outcome:** an inscription (INGESCHREVEN) now carries the moment it was entered in the register, from
|
||||||
|
which its herregistratie deadline is derived (inscription + 5-year validity). A **Quartz.NET** cron job
|
||||||
|
in the Domain Service sweeps once a day (03:00, overridable via `Quartz__Cron`): every inscription
|
||||||
|
inside the 90-day window before its deadline is flagged `HerregistratieReminderVerstuurd` and logged.
|
||||||
|
The sweep is idempotent — a re-fire reminds no one twice — and is a deliberately different mechanism
|
||||||
|
from the queue-draining pumps (Quartz fires time-triggered sweeps; pumps drain Flowable queues,
|
||||||
|
ADR-0022). There is no outbound notification in v1: the reminder is the flag on the aggregate plus a
|
||||||
|
log line.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. The domain unit tests prove the rule and the sweep end to end (rule → store query → sweep):
|
||||||
|
cd services/domain && dotnet test Big.Tests/Big.Tests.csproj \
|
||||||
|
--filter "FullyQualifiedName~Herregistratie|FullyQualifiedName~ReminderSweep"
|
||||||
|
# → the reminder is due once the 90-day window opens, not before; a reminded inscription is skipped
|
||||||
|
# on the next sweep; the sweep flags + persists every due inscription and returns their ids.
|
||||||
|
|
||||||
|
# 2. The read model surfaces the deadline once a registration is approved — the field the sweep acts on:
|
||||||
|
curl -s localhost:8000/registrations/<id> | jq '{status, herregistratieVoor, herregistratieReminderVerstuurd}'
|
||||||
|
# → after approval: herregistratieVoor is inscription + 5 years; the flag flips true once swept.
|
||||||
|
```
|
||||||
|
|
||||||
|
**The path:** `Registration.Approve(now)` stamps `IngeschrevenOp` → daily Quartz `HerregistratieReminderJob`
|
||||||
|
→ `HerregistratieReminderSweep` → `IRegistrationStore.FindDueForHerregistratieReminderAsync` (filtered by
|
||||||
|
the aggregate's own `HerregistratieReminderDue` rule) → `MarkHerregistratieReminderVerstuurd` + log.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S-B04 — `make local` completes the whole flow with no manual seeding (#110, ADR-0020)
|
||||||
|
|
||||||
|
**Outcome:** the host-browser stack (`make local`) now self-seeds at bring-up — it publishes the BIG
|
||||||
|
zaaktype and wires the ACL to it, deploys the `diploma-eligibility` DMN, and registers the NRC
|
||||||
|
abonnement — so a fresh bring-up runs submit → werkbak → openbaar without the manual seeding the
|
||||||
|
`verify-*` scripts do for CI. (Previously the process stuck at `OpenZaakAanmaken`, the werkbak stayed
|
||||||
|
empty, and the openbaar register showed nothing.)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Fresh bring-up (self-seeding init containers: local-seed, nrc-subscribe; DMN in flowable-init).
|
||||||
|
make local
|
||||||
|
|
||||||
|
# 2. Assert the whole flow works with no manual seeding — submit opens a zaak, documents route it to
|
||||||
|
# the werkbak, and the reference appears in the openbaar register:
|
||||||
|
make verify-local # → "OK — a fresh local stack completed the flow with no manual seeding ..."
|
||||||
|
|
||||||
|
# 3. Or by hand in the browser: log in at http://localhost:8140 (jan-burger / test123), submit +
|
||||||
|
# upload a PDF, then approve it in the werkbak at http://localhost:8142 (merel-behandelaar /
|
||||||
|
# test123); it shows as INGESCHREVEN in the openbaar register at http://localhost:8141.
|
||||||
|
```
|
||||||
|
|
||||||
|
> The zaaktype is discovered by the ACL itself since S-27 (below); `local-seed`'s `acl.env` now
|
||||||
|
> carries only OpenZaak's IP base URL, which the ACL still needs because OpenZaak rejects a
|
||||||
|
> single-label host on zaak-create (ADR-0020 + ADR-0021).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S-27 — ACL resolves its zaaktype by identificatie, not a pinned URL (#113, ADR-0021)
|
||||||
|
|
||||||
|
**Outcome:** the ACL discovers its BIG zaaktype (by `identificatie`) and diploma informatieobjecttype
|
||||||
|
(by `omschrijving`) from OpenZaak's Catalogi API, instead of being handed the server-assigned URLs.
|
||||||
|
No user-visible behaviour change — the flow runs exactly as before — but no stack captures/injects a
|
||||||
|
zaaktype URL any more, and a missing catalogus now fails with a clear message instead of an opaque 400.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# The live ACL↔OpenZaak integration test proves resolution against a real seeded OpenZaak:
|
||||||
|
make verify-acl # → "resolves the published BIG-REGISTRATIE zaaktype + Diploma informatieobjecttype by business key"
|
||||||
|
|
||||||
|
# End-to-end unchanged (the ACL self-discovers the zaaktype during the flow):
|
||||||
|
make verify-local # local stack — still green, now with no zaaktype-URL injection
|
||||||
|
make verify-domain # CI stack — recreates the ACL pointed only at OpenZaak's IP (no URL to inject)
|
||||||
|
```
|
||||||
|
|
||||||
|
> The ACL still needs its OpenZaak base URL at a URL-valid host (a container IP): OpenZaak's
|
||||||
|
> URLValidator rejects a single-label host like `openzaak:8000` on zaak-create. So ADR-0020's base-URL
|
||||||
|
> injection stays; only the zaaktype/informatieobjecttype **URL** injection is gone (ADR-0021).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## S-08d — Walking skeleton complete: browser → submit, end-to-end
|
## S-08d — Walking skeleton complete: browser → submit, end-to-end
|
||||||
|
|
||||||
**Outcome:** the self-service portal is served in the stack and the full front-of-house happy path
|
**Outcome:** the self-service portal is served in the stack and the full front-of-house happy path
|
||||||
|
|||||||
@@ -196,3 +196,52 @@ service name; the notif verify harness also registers the sink callback by IP.
|
|||||||
abonnement is registered and refuses it (`no-auth-on-callback-url`) unless it returns
|
abonnement is registered and refuses it (`no-auth-on-callback-url`) unless it returns
|
||||||
**401** without the configured `Authorization`. The verify sink
|
**401** without the configured `Authorization`. The verify sink
|
||||||
(`infra/notification-sink.py`) enforces a bearer token for exactly this reason.
|
(`infra/notification-sink.py`) enforces a bearer token for exactly this reason.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. A job with `if: ${{ !cancelled() }}` (or `always()`) + `needs` sticks in "waiting"
|
||||||
|
|
||||||
|
**Symptom** — after upgrading to **Gitea 1.27** + **act_runner 2.0.0**, one job never
|
||||||
|
starts: the run sits in state `waiting` forever, the job has **no logs** (never
|
||||||
|
dispatched to a runner), and the other jobs finish normally. `main` stays pending/red.
|
||||||
|
Seen on the `verify-stack` job (#134).
|
||||||
|
|
||||||
|
**Why** — Gitea 1.27 reworked cancellation/aggregation: a job gated by a
|
||||||
|
**status-function `if`** (`always()` / `cancelled()` / `!cancelled()`) on top of
|
||||||
|
`needs` now routes through a new transitional **`Cancelling`** job state plus a
|
||||||
|
server↔runner **capability negotiation** ("Requires Gitea Runner 2.0.0"). On the
|
||||||
|
1.27 + 2.0.0 pairing that handshake doesn't resolve for such a job, so it's never
|
||||||
|
offered to a runner and never leaves `waiting`. Jobs with no `if`/`needs` are
|
||||||
|
unaffected. (Related upstream: go-gitea/gitea#31074, #27116, #35782.)
|
||||||
|
|
||||||
|
**Fix** — don't gate a `needs` job with a status-function `if`. Use the default
|
||||||
|
`if: success()` (i.e. omit the `if`). If you need "run even when an upstream job
|
||||||
|
fails", prefer serialising with a `concurrency` group over `needs` + `always()`.
|
||||||
|
|
||||||
|
**Also** — a run already stuck this way will **not** clear itself; force-cancel it
|
||||||
|
from the Actions UI (plain cancel can also stall on this version, #35782). Push the
|
||||||
|
workflow fix to produce a fresh run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Job summaries (`$GITHUB_STEP_SUMMARY`) need Gitea ≥1.27 + runner ≥2.0
|
||||||
|
|
||||||
|
Markdown a step appends to the `$GITHUB_STEP_SUMMARY` file renders on the run page
|
||||||
|
(no artifact download). We use it for per-run reports (#136): mutation scores
|
||||||
|
(Stryker `markdown` reporter), per-service unit results (`infra/trx-summary.py` over
|
||||||
|
TRX), per-frontend results (`infra/vitest-summary.py` over each app's vitest JSON),
|
||||||
|
the verify-stack check table, and per-spec e2e results (`infra/playwright-summary.py`).
|
||||||
|
|
||||||
|
**Requirements / conventions:**
|
||||||
|
|
||||||
|
- Requires **Gitea ≥ 1.27** (stores/renders summaries) and **act_runner ≥ 2.0.0**
|
||||||
|
(uploads them). Older pairings silently skip the upload.
|
||||||
|
- **Guard every write:** `[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0` — on a runner
|
||||||
|
without support the var is unset and `>> "$GITHUB_STEP_SUMMARY"` would be an
|
||||||
|
ambiguous-redirect error. The guard makes the step a no-op locally / on old runners.
|
||||||
|
- Use `if: always()` (step-level) on summary steps so they render even when the thing
|
||||||
|
they report on failed. Step-level `always()` is fine on 2.0.0 — unlike the *job*-level
|
||||||
|
status-function `if` of §7.
|
||||||
|
- Getting a report out of the e2e container: Playwright writes `playwright-report.json`
|
||||||
|
inside the container; `infra/run-e2e-check.sh` `docker cp`s it back to the host
|
||||||
|
(capturing the test exit code first) so the summary step can read it.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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` |
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
# LOCAL development stack — runs with a plain `docker compose up`, no make / no
|
# LOCAL development stack — runs with a plain `docker compose up`, no make / no
|
||||||
# seed step / no bash. Use this on a local engine (Docker Desktop on Windows or
|
# external seed step / no bash. Use this on a local engine (Docker Desktop on Windows or
|
||||||
# macOS, or rootless Podman on Linux).
|
# macOS, or rootless Podman on Linux).
|
||||||
#
|
#
|
||||||
|
# Self-seeding (S-B04, #110, ADR-0020): unlike the CI stack — where the verify-* scripts seed the
|
||||||
|
# zaaktype and register the NRC abonnement at test time — this stack does that itself, via one-shot
|
||||||
|
# init containers (local-seed, nrc-subscribe) + a DMN deploy in flowable-init, so a fresh bring-up
|
||||||
|
# completes the whole flow with no manual steps. `make verify-local` asserts it.
|
||||||
|
#
|
||||||
# docker compose -f infra/docker-compose.local.yml up -d --build # podman
|
# docker compose -f infra/docker-compose.local.yml up -d --build # podman
|
||||||
# docker compose -f infra/docker-compose.local.yml up -d --build --wait # Docker Desktop
|
# docker compose -f infra/docker-compose.local.yml up -d --build --wait # Docker Desktop
|
||||||
# docker compose -f infra/docker-compose.local.yml down --volumes
|
# docker compose -f infra/docker-compose.local.yml down --volumes
|
||||||
@@ -257,38 +262,79 @@ services:
|
|||||||
restart: "no"
|
restart: "no"
|
||||||
volumes:
|
volumes:
|
||||||
- ../workflows/registratie.bpmn:/work/registratie.bpmn:ro,z
|
- ../workflows/registratie.bpmn:/work/registratie.bpmn:ro,z
|
||||||
|
- ../workflows/diploma-eligibility.dmn:/work/diploma-eligibility.dmn:ro,z
|
||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
- |
|
- |
|
||||||
base=http://flowable-rest:8080/flowable-rest/service/repository/deployments
|
svc=http://flowable-rest:8080/flowable-rest/service/repository/deployments
|
||||||
until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
|
dmn=http://flowable-rest:8080/flowable-rest/dmn-api/dmn-repository/deployments
|
||||||
if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then
|
until curl -sf -u rest-admin:test "$$svc" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
|
||||||
echo "registratie already deployed; skip"
|
# Deploy the DMN to the DMN engine and the BPMN to the process engine as SEPARATE deployments:
|
||||||
|
# flowable-rest does NOT cascade a .dmn bundled in a process .bar into the DMN engine, so the DMN
|
||||||
|
# must go via dmn-api. The registratie process's DMN service task then resolves the decision across
|
||||||
|
# deployments by key (S-13, ADR-0016). Without this the WachtOpDocumenten completion 404s on the
|
||||||
|
# missing decision and the case never reaches Beoordelen (S-B04). Both steps are idempotent.
|
||||||
|
if curl -s -u rest-admin:test "$$dmn" | grep -q '"name":"diploma-eligibility.dmn"'; then
|
||||||
|
echo "diploma-eligibility DMN already deployed; skip"
|
||||||
else
|
else
|
||||||
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie"
|
curl -sf -u rest-admin:test -F 'file=@/work/diploma-eligibility.dmn;filename=diploma-eligibility.dmn' "$$dmn" >/dev/null && echo "deployed diploma-eligibility DMN"
|
||||||
|
fi
|
||||||
|
if curl -s -u rest-admin:test "$$svc?name=registratie" | grep -q '"name":"registratie"'; then
|
||||||
|
echo "registratie BPMN already deployed; skip"
|
||||||
|
else
|
||||||
|
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$svc" >/dev/null && echo "deployed registratie BPMN"
|
||||||
fi
|
fi
|
||||||
depends_on:
|
depends_on:
|
||||||
flowable-rest:
|
flowable-rest:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
|
# ── Local bootstrap: seed the zaaktype + wire the ACL (S-B04, #110, ADR-0020) ─────────────────
|
||||||
|
# The zaaktype UUID is assigned by OpenZaak at creation, so it can't be a static value in this
|
||||||
|
# file. This one-shot seeds + publishes the BIG zaaktype (and the Diploma informatieobjecttype)
|
||||||
|
# and writes their server-assigned URLs into a shared volume as acl.env, which the ACL sources on
|
||||||
|
# startup (below). It is the local-stack equivalent of what infra/run-domain-check.sh does for CI.
|
||||||
|
# Reaches OpenZaak by its container IP because a single-label host fails OpenZaak's URLValidator.
|
||||||
|
local-seed:
|
||||||
|
image: docker.io/library/python:3-slim
|
||||||
|
restart: "no"
|
||||||
|
volumes:
|
||||||
|
- ./openzaak/seed_catalogus.py:/work/seed_catalogus.py:ro,z
|
||||||
|
- ./local/seed-zaaktype.sh:/work/seed-zaaktype.sh:ro,z
|
||||||
|
- seed-env:/out
|
||||||
|
command: ["sh", "/work/seed-zaaktype.sh"]
|
||||||
|
depends_on:
|
||||||
|
openzaak:
|
||||||
|
condition: service_healthy
|
||||||
|
networks: [cg]
|
||||||
|
|
||||||
# ── ACL ──────────────────────────────────────────────────────────────────
|
# ── ACL ──────────────────────────────────────────────────────────────────
|
||||||
acl:
|
acl:
|
||||||
build:
|
build:
|
||||||
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
|
||||||
|
# keys below (S-27, ADR-0021), so no URL is injected. It still needs its OpenZaak BaseUrl pointed at
|
||||||
|
# a URL-valid host (OpenZaak rejects a single-label host like `openzaak` on zaak-create), so the
|
||||||
|
# local-seed one-shot writes that IP base into seed-env:/seed/acl.env, which the entrypoint sources
|
||||||
|
# (set -a) before the app starts. A runtime-generated env file is why we override the entrypoint here
|
||||||
|
# rather than use `env_file:` (which compose reads at parse time, before the seed has run).
|
||||||
|
entrypoint: ["/bin/sh", "-c", "set -a; . /seed/acl.env; set +a; exec dotnet Acl.Api.dll"]
|
||||||
environment:
|
environment:
|
||||||
Acl__OpenZaak__BaseUrl: http://openzaak:8000/
|
Acl__OpenZaak__BaseUrl: http://openzaak:8000/ # placeholder; seed-env/acl.env supplies the IP base
|
||||||
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__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000}
|
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
|
||||||
|
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
|
||||||
ports:
|
ports:
|
||||||
- "8100:8080"
|
- "8100:8080"
|
||||||
|
volumes:
|
||||||
|
- seed-env:/seed:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -298,6 +344,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
openzaak:
|
openzaak:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
local-seed:
|
||||||
|
condition: service_completed_successfully
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
# ── BFF ──────────────────────────────────────────────────────────────────
|
# ── BFF ──────────────────────────────────────────────────────────────────
|
||||||
@@ -400,6 +448,31 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
|
# ── Local bootstrap: register the NRC abonnement (S-B04, #110, ADR-0020) ──────────────────────
|
||||||
|
# Without a subscription, OpenZaak's notifications reach NRC and are delivered nowhere, so the
|
||||||
|
# projection (and the openbaar register) stay empty. This one-shot registers an abonnement on the
|
||||||
|
# `zaken` kanaal pointing at the event-subscriber's /notifications callback — the CI equivalent is
|
||||||
|
# infra/verify-notification-driver.py. The callback uses the event-subscriber's container IP (a
|
||||||
|
# single-label host fails NRC's URLValidator). It is a leaf (nothing depends on it), so it can wait
|
||||||
|
# for the event-subscriber without creating a cycle with the ACL bootstrap.
|
||||||
|
nrc-subscribe:
|
||||||
|
image: docker.io/library/python:3-slim
|
||||||
|
restart: "no"
|
||||||
|
volumes:
|
||||||
|
- ./local/register-abonnement.py:/work/register-abonnement.py:ro,z
|
||||||
|
environment:
|
||||||
|
NRC_BASE: http://nrc-web:8000
|
||||||
|
SINK_HOST: event-subscriber
|
||||||
|
SINK_PORT: "8080"
|
||||||
|
SINK_AUTH: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
|
||||||
|
command: ["python", "/work/register-abonnement.py"]
|
||||||
|
depends_on:
|
||||||
|
nrc-web:
|
||||||
|
condition: service_healthy
|
||||||
|
event-subscriber:
|
||||||
|
condition: service_started
|
||||||
|
networks: [cg]
|
||||||
|
|
||||||
projection-api:
|
projection-api:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
@@ -492,6 +565,8 @@ volumes:
|
|||||||
nrc-db:
|
nrc-db:
|
||||||
flowable-db:
|
flowable-db:
|
||||||
projection-db:
|
projection-db:
|
||||||
|
# Carries the seed-generated acl.env (server-assigned zaaktype URLs) from local-seed to the ACL.
|
||||||
|
seed-env:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
cg:
|
cg:
|
||||||
|
|||||||
+101
-11
@@ -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 the BIG catalogus and note the zaaktype URL:
|
# After first boot, seed + publish the BIG catalogus:
|
||||||
# python infra/openzaak/seed_catalogus.py
|
# OZ_PUBLISH=1 python infra/openzaak/seed_catalogus.py
|
||||||
# Then set ACL_ZAAKTYPE_URL in a .env file or your shell and re-up the acl
|
# The ACL discovers the zaaktype by identificatie (S-27, ADR-0021), so there is no URL to inject —
|
||||||
# service:
|
# just point its BaseUrl at an OpenZaak host OpenZaak accepts on zaak-create (a container IP; a
|
||||||
# export ACL_ZAAKTYPE_URL=http://openzaak:8000/catalogi/api/v1/zaaktypen/<uuid>
|
# single-label host is rejected):
|
||||||
# docker compose -f infra/docker-compose.yml up -d acl
|
# ACL_OPENZAAK_BASEURL=http://<openzaak-ip>:8000/ docker compose -f infra/docker-compose.yml up -d acl
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
@@ -296,6 +296,10 @@ 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/}
|
||||||
@@ -304,11 +308,12 @@ 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
|
||||||
# Override with the real zaaktype URL after running seed_catalogus.py.
|
# The ACL resolves the (server-assigned) zaaktype + diploma informatieobjecttype URLs from the
|
||||||
Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000}
|
# Catalogi API by these stable business keys (S-27, ADR-0021) — no URL to capture and inject.
|
||||||
# The informatieobjecttype a diploma is filed under (S-10b). Placeholder until seed_catalogus.py
|
# BaseUrl above stays overridable because OpenZaak rejects a single-label host on zaak creation,
|
||||||
# (OZ_PUBLISH=1) reports the real URL, which verify-domain injects like the zaaktype URL.
|
# so verify-domain still points the ACL at OpenZaak's container IP.
|
||||||
Acl__Defaults__InformatieobjecttypeUrl: ${ACL_INFORMATIEOBJECTTYPE_URL:-http://openzaak:8000/catalogi/api/v1/informatieobjecttypen/00000000-0000-0000-0000-000000000000}
|
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
|
||||||
|
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
|
||||||
ports:
|
ports:
|
||||||
- "8100:8080"
|
- "8100:8080"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -333,6 +338,10 @@ 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
|
||||||
@@ -359,6 +368,10 @@ 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.
|
||||||
@@ -367,6 +380,8 @@ 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:
|
||||||
@@ -411,6 +426,10 @@ 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).
|
||||||
@@ -440,6 +459,10 @@ 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"
|
||||||
@@ -523,6 +546,73 @@ 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:
|
||||||
|
|||||||
@@ -38,6 +38,36 @@
|
|||||||
"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"] }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"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": [
|
||||||
@@ -54,6 +55,16 @@
|
|||||||
"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"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+78
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Local-stack bootstrap (S-B04, #110, ADR-0020) — register the NRC abonnement.
|
||||||
|
|
||||||
|
Runs as the `nrc-subscribe` init container of infra/docker-compose.local.yml. Registers an
|
||||||
|
abonnement on the `zaken` kanaal pointing at the event-subscriber's /notifications callback, so
|
||||||
|
OpenZaak's notifications (zaak create + status set) reach the projection — without this the openbaar
|
||||||
|
(public) register stays empty. This is what infra/verify-notification-driver.py does for CI (minus
|
||||||
|
the test zaak it also creates).
|
||||||
|
|
||||||
|
The callback host is the event-subscriber's resolved **container IP**, not `event-subscriber`, because
|
||||||
|
NRC validates callbackUrl with Django's URLValidator (a single-label host is rejected — same reason the
|
||||||
|
zaaktype seed uses OpenZaak's IP). Idempotent + restart-safe: it removes any stale /notifications
|
||||||
|
abonnement first, then registers one for the current IP. Stdlib only.
|
||||||
|
|
||||||
|
Env: NRC_BASE, SINK_HOST, SINK_PORT, SINK_AUTH, OZ_CLIENT_ID, OZ_SECRET.
|
||||||
|
"""
|
||||||
|
import base64, hashlib, hmac, json, os, socket, sys, time, urllib.error, urllib.request
|
||||||
|
|
||||||
|
NRC = os.environ.get("NRC_BASE", "http://nrc-web:8000").rstrip("/")
|
||||||
|
SINK_HOST = os.environ.get("SINK_HOST", "event-subscriber")
|
||||||
|
SINK_PORT = os.environ.get("SINK_PORT", "8080")
|
||||||
|
SINK_AUTH = os.environ.get("SINK_AUTH", "Bearer big-reference-notifications")
|
||||||
|
CID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed")
|
||||||
|
SECRET = os.environ.get("OZ_SECRET", "insecure-dev-secret-change-me")
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=")
|
||||||
|
seg = (
|
||||||
|
b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
|
||||||
|
+ b"."
|
||||||
|
+ b64(json.dumps(
|
||||||
|
{"iss": CID, "iat": int(time.time()), "client_id": CID,
|
||||||
|
"user_id": "local-seed", "user_representation": "local-seed"},
|
||||||
|
separators=(",", ":")).encode())
|
||||||
|
)
|
||||||
|
return (seg + b"." + b64(hmac.new(SECRET.encode(), seg, hashlib.sha256).digest())).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def call(method, url, body=None):
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
req = urllib.request.Request(url, data=data, method=method, headers={
|
||||||
|
"Authorization": "Bearer " + token(),
|
||||||
|
"Content-Type": "application/json", "Accept": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return r.status, (json.loads(raw) if raw else None)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read()
|
||||||
|
return e.code, (json.loads(raw) if raw else None)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ip = socket.gethostbyname(SINK_HOST)
|
||||||
|
callback = f"http://{ip}:{SINK_PORT}/notifications"
|
||||||
|
|
||||||
|
# Restart-safe: drop any prior /notifications abonnement (its IP may be stale) before creating a
|
||||||
|
# fresh one for the current event-subscriber IP.
|
||||||
|
status, body = call("GET", f"{NRC}/api/v1/abonnement")
|
||||||
|
for ab in (body or []) if status == 200 else []:
|
||||||
|
if str(ab.get("callbackUrl", "")).endswith("/notifications"):
|
||||||
|
if ab.get("callbackUrl") == callback:
|
||||||
|
print(f"abonnement already current: {ab['url']}")
|
||||||
|
return
|
||||||
|
call("DELETE", ab["url"])
|
||||||
|
print(f"removed stale abonnement {ab['url']}")
|
||||||
|
|
||||||
|
status, ab = call("POST", f"{NRC}/api/v1/abonnement", {
|
||||||
|
"callbackUrl": callback, "auth": SINK_AUTH,
|
||||||
|
"kanalen": [{"naam": "zaken", "filters": {}}]})
|
||||||
|
if status != 201:
|
||||||
|
sys.exit(f"create abonnement -> {status}: {json.dumps(ab)}")
|
||||||
|
print(f"abonnement registered: {ab['url']} -> {callback}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+33
@@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Local-stack bootstrap (S-B04, #110, ADR-0020) — the "seed zaaktype + wire the ACL" step.
|
||||||
|
#
|
||||||
|
# Runs as the `local-seed` init container of infra/docker-compose.local.yml. It seeds + publishes
|
||||||
|
# the BIG zaaktype (and the Diploma informatieobjecttype) into OpenZaak, then writes the resulting
|
||||||
|
# **server-assigned** URLs into /out/acl.env, which the ACL entrypoint sources before starting. This
|
||||||
|
# is the local-stack equivalent of what infra/run-domain-check.sh does for CI: the zaaktype UUID is
|
||||||
|
# assigned by OpenZaak at creation, so it can't be a static value in the compose file.
|
||||||
|
#
|
||||||
|
# Why the container IP and not the `openzaak` service name: OpenZaak validates URL query params
|
||||||
|
# (e.g. ?catalogus=) with Django's URLValidator, which rejects a single-label host like `openzaak`.
|
||||||
|
# Seeding against the resolved IP keeps the seeded URLs valid AND host-consistent with the ACL, which
|
||||||
|
# we point at the same IP below. See docs/runbooks/gitea-actions-gotchas.md and ADR-0020.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
oz_ip="$(python3 -c "import socket;print(socket.gethostbyname('openzaak'))")"
|
||||||
|
OZ_BASE="http://${oz_ip}:8000"
|
||||||
|
export OZ_BASE OZ_PUBLISH=1
|
||||||
|
|
||||||
|
echo ">> seeding + publishing the BIG zaaktype at ${OZ_BASE} (idempotent)"
|
||||||
|
out="$(python3 /work/seed_catalogus.py)"
|
||||||
|
echo "$out"
|
||||||
|
|
||||||
|
# Sanity-check that the zaaktype was actually published (the ACL discovers it by identificatie, S-27).
|
||||||
|
printf '%s\n' "$out" | grep -q '^ZAAKTYPE_URL ' || { echo "ERROR: seed did not publish the zaaktype" >&2; exit 1; }
|
||||||
|
|
||||||
|
# The ACL resolves the zaaktype/informatieobjecttype URLs itself (S-27, ADR-0021); the only value it
|
||||||
|
# still needs injected is the OpenZaak base URL at a URL-valid host (the container IP), because OpenZaak
|
||||||
|
# rejects a single-label host on zaak-create. The ACL entrypoint sources this.
|
||||||
|
cat > /out/acl.env <<EOF
|
||||||
|
Acl__OpenZaak__BaseUrl=${OZ_BASE}/
|
||||||
|
EOF
|
||||||
|
echo ">> wrote /out/acl.env (base=${OZ_BASE}/)"
|
||||||
Executable
+75
@@ -0,0 +1,75 @@
|
|||||||
|
#!/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())
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# 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/
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"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}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
FROM prom/prometheus:v2.55.1
|
||||||
|
COPY prometheus.yml /etc/prometheus/prometheus.yml
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 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']
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 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
|
||||||
Binary file not shown.
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Render a per-spec table from a Playwright JSON report for a Gitea job summary (#136).
|
||||||
|
|
||||||
|
Reads the JSON report (default: tests/e2e/playwright-report.json) that run-e2e-check.sh copies out
|
||||||
|
of the e2e container, and prints a markdown table (one row per spec) to stdout. The CI step
|
||||||
|
redirects it into $GITHUB_STEP_SUMMARY. Stdlib only.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
STATUS_ICON = {"expected": "✅", "unexpected": "❌", "skipped": "⏭️", "flaky": "⚠️"}
|
||||||
|
|
||||||
|
|
||||||
|
def walk(suite, out):
|
||||||
|
for spec in suite.get("specs", []):
|
||||||
|
# A spec's status is carried on its test(s): expected/unexpected/skipped/flaky.
|
||||||
|
statuses = [t.get("status") for t in spec.get("tests", [])]
|
||||||
|
status = ("unexpected" if "unexpected" in statuses
|
||||||
|
else "flaky" if "flaky" in statuses
|
||||||
|
else "skipped" if statuses and all(s == "skipped" for s in statuses)
|
||||||
|
else "expected" if spec.get("ok", False)
|
||||||
|
else "unexpected")
|
||||||
|
out.append({"file": spec.get("file") or suite.get("file") or suite.get("title", ""),
|
||||||
|
"title": spec.get("title", ""), "status": status})
|
||||||
|
for child in suite.get("suites", []):
|
||||||
|
walk(child, out)
|
||||||
|
|
||||||
|
|
||||||
|
def main(path):
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print("## 🎭 e2e (Playwright)\n\n_No e2e report — the run did not reach the e2e step._")
|
||||||
|
return 0
|
||||||
|
with open(path) as fh:
|
||||||
|
report = json.load(fh)
|
||||||
|
specs = []
|
||||||
|
for suite in report.get("suites", []):
|
||||||
|
walk(suite, specs)
|
||||||
|
|
||||||
|
print("## 🎭 e2e (Playwright)\n")
|
||||||
|
stats = report.get("stats", {})
|
||||||
|
if stats:
|
||||||
|
print(f"**{stats.get('expected', 0)} passed · {stats.get('unexpected', 0)} failed · "
|
||||||
|
f"{stats.get('flaky', 0)} flaky · {stats.get('skipped', 0)} skipped** "
|
||||||
|
f"({round(stats.get('duration', 0) / 1000)}s)\n")
|
||||||
|
if not specs:
|
||||||
|
print("_No specs ran._")
|
||||||
|
return 0
|
||||||
|
print("| Spec | Result |")
|
||||||
|
print("| ---- | :----: |")
|
||||||
|
for s in specs:
|
||||||
|
print(f"| {s['file']} › {s['title']} | {STATUS_ICON.get(s['status'], '❔')} |")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "tests/e2e/playwright-report.json"))
|
||||||
@@ -30,21 +30,18 @@ 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 a published BIG zaaktype (idempotent) and capturing its URL"
|
echo ">> seeding + publishing a BIG zaaktype (idempotent)"
|
||||||
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
|
||||||
[ -n "$zt_url" ] || { echo "ERROR: seed did not report a ZAAKTYPE_URL" >&2; exit 1; }
|
printf '%s\n' "$seed_out" | grep -q '^ZAAKTYPE_URL ' || { echo "ERROR: seed did not publish the zaaktype" >&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"
|
|
||||||
|
|
||||||
echo ">> recreating the acl service pointed at the seeded zaaktype + informatieobjecttype (host-consistent)"
|
# The ACL resolves the zaaktype + informatieobjecttype by identificatie/omschrijving (S-27, ADR-0021),
|
||||||
ACL_ZAAKTYPE_URL="$zt_url" ACL_INFORMATIEOBJECTTYPE_URL="$iot_url" ACL_OPENZAAK_BASEURL="$oz_base/" \
|
# so there is no URL to inject — only the OpenZaak base URL, pointed at the same host's container IP
|
||||||
docker compose -f "$compose" up -d acl
|
# (OpenZaak rejects a single-label host on zaak-create).
|
||||||
|
echo ">> recreating the acl service pointed at OpenZaak's IP (it resolves the zaaktype itself, S-27)"
|
||||||
|
ACL_OPENZAAK_BASEURL="$oz_base/" docker compose -f "$compose" up -d acl
|
||||||
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"
|
||||||
|
|||||||
@@ -26,4 +26,8 @@ cid="$(docker create --network "$net" -w /e2e --ipc=host \
|
|||||||
mcr.microsoft.com/playwright:v1.61.1-noble sh -c 'npm install --no-audit --no-fund && npx playwright test')"
|
mcr.microsoft.com/playwright:v1.61.1-noble sh -c 'npm install --no-audit --no-fund && npx playwright test')"
|
||||||
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
|
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
|
||||||
docker cp "$root/tests/e2e/." "$cid:/e2e" >/dev/null
|
docker cp "$root/tests/e2e/." "$cid:/e2e" >/dev/null
|
||||||
docker start -a "$cid"
|
rc=0
|
||||||
|
docker start -a "$cid" || rc=$?
|
||||||
|
# Copy the Playwright JSON report out — regardless of pass/fail — for the CI job summary (#136).
|
||||||
|
docker cp "$cid:/e2e/playwright-report.json" "$root/tests/e2e/playwright-report.json" 2>/dev/null || true
|
||||||
|
exit $rc
|
||||||
|
|||||||
Executable
+67
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Acceptance check for the local stack (S-B04, #110): a fresh `make local` must complete the whole
|
||||||
|
# flow with NO manual seeding. Run against an already-up local stack (infra/docker-compose.local.yml)
|
||||||
|
# via the host-published ports. It exercises, and thereby covers, the three bring-up gaps the slice
|
||||||
|
# fixes:
|
||||||
|
#
|
||||||
|
# 1. zaaktype seeded + ACL wired -> a submitted registration opens a zaak (zaakUrl gets filled).
|
||||||
|
# 2. diploma-eligibility DMN deployed -> providing documents completes WachtOpDocumenten, routes
|
||||||
|
# through the DMN, and the case lands on Beoordelen (visible in the behandel werkbak).
|
||||||
|
# 3. NRC abonnement registered -> the zaak shows up in the openbaar (public) register.
|
||||||
|
#
|
||||||
|
# Before the fix this fails at step 1 (ACL points at a placeholder zaaktype -> OpenZaak 400).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DOM=${DOM:-http://localhost:8130} # domain
|
||||||
|
BFF=${BFF:-http://localhost:8080} # bff (openbaar register)
|
||||||
|
BSN=${BSN:-123456782}
|
||||||
|
# A minimal, valid PDF, base64-encoded (the diploma upload).
|
||||||
|
PDF_B64="$(printf '%%PDF-1.4\n1 0 obj<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>\n%%%%EOF\n' | base64 | tr -d '\n')"
|
||||||
|
|
||||||
|
echo ">> 1. submit a registration (no manual seeding expected)"
|
||||||
|
loc="$(curl -fsS -D - -o /dev/null -X POST "$DOM/registrations" \
|
||||||
|
-H 'Content-Type: application/json' -d "{\"bsn\":\"$BSN\"}" \
|
||||||
|
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
|
||||||
|
[ -n "$loc" ] || { echo "FAIL: POST /registrations returned no Location" >&2; exit 1; }
|
||||||
|
id="${loc##*/}"
|
||||||
|
echo " accepted: $id"
|
||||||
|
|
||||||
|
echo ">> 2. poll until the ACL opens the zaak (proves the zaaktype is seeded + wired)"
|
||||||
|
zaak=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
zaak="$(curl -fsS "$DOM$loc" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("zaakUrl") or "")' 2>/dev/null || true)"
|
||||||
|
[ -n "$zaak" ] && break
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
[ -n "$zaak" ] || { echo "FAIL: zaak never opened — ACL zaaktype not wired (gap 1)" >&2; exit 1; }
|
||||||
|
echo " zaak opened: $zaak"
|
||||||
|
|
||||||
|
echo ">> 3. provide documents (proves the diploma-eligibility DMN is deployed)"
|
||||||
|
code="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$DOM/registrations/$id/documents" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"bsn\":\"$BSN\",\"contentBase64\":\"$PDF_B64\",\"fileName\":\"diploma.pdf\",\"contentType\":\"application/pdf\"}")"
|
||||||
|
[ "$code" = "204" ] || { echo "FAIL: provide documents -> $code (DMN missing routes WachtOpDocumenten to a 404 — gap 2)" >&2; exit 1; }
|
||||||
|
echo " documents accepted (204)"
|
||||||
|
|
||||||
|
echo ">> 4. poll the werkbak until the registration awaits beoordeling (reached Beoordelen)"
|
||||||
|
in_werkbak=""
|
||||||
|
for _ in $(seq 1 20); do
|
||||||
|
in_werkbak="$(curl -fsS "$DOM/behandel/werkbak" | python3 -c "import sys,json;print(any(r.get('registrationId')=='$id' for r in json.load(sys.stdin)))" 2>/dev/null || true)"
|
||||||
|
[ "$in_werkbak" = "True" ] && break
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
[ "$in_werkbak" = "True" ] || { echo "FAIL: registration never reached the werkbak (gap 2)" >&2; exit 1; }
|
||||||
|
echo " in the werkbak"
|
||||||
|
|
||||||
|
echo ">> 5. poll the openbaar register until the reference is publicly visible (proves NRC abonnement)"
|
||||||
|
public=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
public="$(curl -fsS "$BFF/openbaar/register" | python3 -c "import sys,json;print(any(r.get('reference')=='$id' for r in json.load(sys.stdin)))" 2>/dev/null || true)"
|
||||||
|
[ "$public" = "True" ] && break
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
[ "$public" = "True" ] || { echo "FAIL: reference never appeared in the openbaar register — NRC abonnement not registered (gap 3)" >&2; exit 1; }
|
||||||
|
echo " visible in the openbaar register"
|
||||||
|
|
||||||
|
echo "OK — a fresh local stack completed the flow with no manual seeding (zaaktype + DMN + abonnement)"
|
||||||
Executable
+28
@@ -0,0 +1,28 @@
|
|||||||
|
#!/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
|
||||||
Executable
+45
@@ -0,0 +1,45 @@
|
|||||||
|
#!/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."
|
||||||
Executable
+27
@@ -0,0 +1,27 @@
|
|||||||
|
#!/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
|
||||||
Executable
+81
@@ -0,0 +1,81 @@
|
|||||||
|
#!/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())
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Render a per-test-project table from .trx files for a Gitea job summary (#136).
|
||||||
|
|
||||||
|
Reads every *.trx in the given directory (default: TestResults), pulls each project's
|
||||||
|
counters + assembly name, and prints a GitHub/Gitea-flavoured markdown table to stdout.
|
||||||
|
The CI step redirects that into $GITHUB_STEP_SUMMARY. Stdlib only.
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
NS = {"t": "http://microsoft.com/schemas/VisualStudio/TeamTest/2010"}
|
||||||
|
|
||||||
|
|
||||||
|
def project_name(root):
|
||||||
|
# The test assembly path, e.g. …/services/domain/Big.Tests/bin/…/big.tests.dll. Prefer the
|
||||||
|
# owning service folder (services/<name>) so "domain" shows rather than the opaque "big.tests";
|
||||||
|
# fall back to the assembly basename for projects outside services/ (e.g. tests/acceptance).
|
||||||
|
ut = root.find(".//t:TestDefinitions/t:UnitTest", NS)
|
||||||
|
storage = ut.get("storage") if ut is not None else None
|
||||||
|
if not storage:
|
||||||
|
return None
|
||||||
|
parts = storage.replace("\\", "/").split("/")
|
||||||
|
if "services" in parts:
|
||||||
|
return parts[parts.index("services") + 1]
|
||||||
|
base = os.path.basename(parts[-1])
|
||||||
|
return base[:-4] if base.lower().endswith(".dll") else base
|
||||||
|
|
||||||
|
|
||||||
|
def parse(path):
|
||||||
|
root = ET.parse(path).getroot()
|
||||||
|
c = root.find(".//t:ResultSummary/t:Counters", NS)
|
||||||
|
if c is None:
|
||||||
|
return None
|
||||||
|
total = int(c.get("total", 0))
|
||||||
|
if total == 0: # e.g. the Integration project, filtered out of the unit run
|
||||||
|
return None
|
||||||
|
executed = int(c.get("executed", 0))
|
||||||
|
passed = int(c.get("passed", 0))
|
||||||
|
failed = int(c.get("failed", 0)) + int(c.get("error", 0))
|
||||||
|
skipped = total - executed
|
||||||
|
return {
|
||||||
|
"name": project_name(root) or os.path.basename(path),
|
||||||
|
"passed": passed, "failed": failed, "skipped": skipped, "total": total,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main(results_dir):
|
||||||
|
rows = [r for r in (parse(p) for p in sorted(glob.glob(os.path.join(results_dir, "*.trx")))) if r]
|
||||||
|
if not rows:
|
||||||
|
print("_No test results found._")
|
||||||
|
return 0
|
||||||
|
rows.sort(key=lambda r: r["name"])
|
||||||
|
print("## ✅ Unit tests\n")
|
||||||
|
print("| Project | Result | Passed | Failed | Skipped | Total |")
|
||||||
|
print("| ------- | :----: | -----: | -----: | ------: | ----: |")
|
||||||
|
for r in rows:
|
||||||
|
status = "❌" if r["failed"] else "✅"
|
||||||
|
print(f"| {r['name']} | {status} | {r['passed']} | {r['failed']} | {r['skipped']} | {r['total']} |")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "TestResults"))
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Render a per-frontend test table from vitest JSON reports for a Gitea job summary (#136).
|
||||||
|
|
||||||
|
Reads every *.json in the given directory (default: test-output), each written by an app's
|
||||||
|
`test` target (reporters: json, outputFile: {workspaceRoot}/test-output/{projectName}.json), and
|
||||||
|
prints a markdown table to stdout — one row per frontend app. The CI step redirects it into
|
||||||
|
$GITHUB_STEP_SUMMARY. Stdlib only.
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main(results_dir):
|
||||||
|
rows = []
|
||||||
|
for path in sorted(glob.glob(os.path.join(results_dir, "*.json"))):
|
||||||
|
try:
|
||||||
|
with open(path) as fh:
|
||||||
|
d = json.load(fh)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
rows.append({
|
||||||
|
"name": os.path.splitext(os.path.basename(path))[0],
|
||||||
|
"passed": d.get("numPassedTests", 0),
|
||||||
|
"failed": d.get("numFailedTests", 0),
|
||||||
|
"skipped": d.get("numPendingTests", 0) + d.get("numTodoTests", 0),
|
||||||
|
"total": d.get("numTotalTests", 0),
|
||||||
|
"ok": d.get("success", False),
|
||||||
|
})
|
||||||
|
if not rows:
|
||||||
|
print("_No frontend test results found._")
|
||||||
|
return 0
|
||||||
|
print("## 🅰️ Frontend tests\n")
|
||||||
|
print("| Frontend | Result | Passed | Failed | Skipped | Total |")
|
||||||
|
print("| -------- | :----: | -----: | -----: | ------: | ----: |")
|
||||||
|
for r in rows:
|
||||||
|
status = "✅" if r["ok"] and not r["failed"] else "❌"
|
||||||
|
print(f"| {r['name']} | {status} | {r['passed']} | {r['failed']} | {r['skipped']} | {r['total']} |")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "test-output"))
|
||||||
@@ -24,6 +24,16 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -200,6 +210,37 @@ 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>>;
|
||||||
@@ -374,4 +415,35 @@ 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',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,14 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -1,8 +1,31 @@
|
|||||||
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>()
|
||||||
@@ -11,12 +34,17 @@ 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) =>
|
||||||
{
|
{
|
||||||
@@ -57,6 +85,12 @@ 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);
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ 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 informatieobjecttype an uploaded diploma is filed under (S-10b). Seeded in the
|
/// <summary>The BIG zaaktype's stable business key. The ACL resolves the (server-assigned) zaaktype
|
||||||
/// catalogus and injected like <see cref="ZaaktypeUrl"/>.</summary>
|
/// URL from this via the Catalogi API instead of being handed a pinned URL (S-27, ADR-0021).</summary>
|
||||||
public required Uri InformatieobjecttypeUrl { get; init; }
|
public required string ZaaktypeIdentificatie { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The omschrijving of the informatieobjecttype an uploaded diploma is filed under (S-10b);
|
||||||
|
/// resolved to a URL by the Catalogi API, like <see cref="ZaaktypeIdentificatie"/>.</summary>
|
||||||
|
public required string InformatieobjecttypeOmschrijving { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, IClock clock)
|
public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaaktypeCatalog catalog, IClock clock)
|
||||||
{
|
{
|
||||||
public Task<Uri> OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default)
|
public async Task<Uri> OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(registration);
|
ArgumentNullException.ThrowIfNull(registration);
|
||||||
|
|
||||||
@@ -12,36 +12,41 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
|
|||||||
defaults.Bronorganisatie,
|
defaults.Bronorganisatie,
|
||||||
defaults.VerantwoordelijkeOrganisatie,
|
defaults.VerantwoordelijkeOrganisatie,
|
||||||
defaults.Vertrouwelijkheidaanduiding,
|
defaults.Vertrouwelijkheidaanduiding,
|
||||||
defaults.ZaaktypeUrl,
|
await catalog.GetZaaktypeUrlAsync(ct),
|
||||||
clock.Today,
|
clock.Today,
|
||||||
registration.Reference);
|
registration.Reference);
|
||||||
|
|
||||||
return gateway.OpenZaakAsync(request, ct);
|
return await gateway.OpenZaakAsync(request, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Approve a zaak: set it to the eindstatus of the configured BIG zaaktype (ADR-0003 default). The
|
/// Approve a zaak: set it to the eindstatus of the BIG zaaktype (resolved by identificatie, S-27).
|
||||||
/// domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1).
|
/// The domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
public async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||||
|
|
||||||
return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
|
await gateway.SetZaakToEindstatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cancel a zaak on document-timeout expiry (S-10c): set it to the configured BIG zaaktype's
|
/// Cancel a zaak on document-timeout expiry (S-10c): set it to the BIG zaaktype's cancellation
|
||||||
/// cancellation statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which
|
/// 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 Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
public async Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||||
|
|
||||||
return gateway.SetZaakToCancellationStatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
|
await gateway.SetZaakToCancellationStatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), 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)
|
||||||
{
|
{
|
||||||
@@ -56,7 +61,7 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
|
|||||||
/// 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 Task<Uri> StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default)
|
public async 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);
|
||||||
@@ -65,7 +70,7 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
|
|||||||
|
|
||||||
var request = new DocumentRequest(
|
var request = new DocumentRequest(
|
||||||
defaults.Bronorganisatie,
|
defaults.Bronorganisatie,
|
||||||
defaults.InformatieobjecttypeUrl,
|
await catalog.GetInformatieobjecttypeUrlAsync(ct),
|
||||||
defaults.Vertrouwelijkheidaanduiding,
|
defaults.Vertrouwelijkheidaanduiding,
|
||||||
zaakUrl,
|
zaakUrl,
|
||||||
clock.Today,
|
clock.Today,
|
||||||
@@ -76,6 +81,6 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
|
|||||||
Formaat: contentType,
|
Formaat: contentType,
|
||||||
Inhoud: content);
|
Inhoud: content);
|
||||||
|
|
||||||
return gateway.StoreDocumentAsync(request, ct);
|
return await gateway.StoreDocumentAsync(request, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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,4 +32,16 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
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,6 +142,58 @@ 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)
|
||||||
@@ -298,4 +350,19 @@ 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,4 +161,32 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ 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");
|
||||||
|
|
||||||
@@ -47,6 +53,26 @@ 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()
|
||||||
@@ -54,28 +80,23 @@ public class AclServiceTests
|
|||||||
Bronorganisatie = "517439943",
|
Bronorganisatie = "517439943",
|
||||||
VerantwoordelijkeOrganisatie = "517439943",
|
VerantwoordelijkeOrganisatie = "517439943",
|
||||||
Vertrouwelijkheidaanduiding = "openbaar",
|
Vertrouwelijkheidaanduiding = "openbaar",
|
||||||
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
|
ZaaktypeIdentificatie = "BIG-REGISTRATIE",
|
||||||
InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"),
|
InformatieobjecttypeOmschrijving = "Diploma",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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_returns_the_zaak_url()
|
public async Task Opening_a_zaak_default_fills_zgw_fields_and_uses_the_resolved_zaaktype()
|
||||||
{
|
{
|
||||||
var gateway = new FakeGateway();
|
var gateway = new FakeGateway();
|
||||||
var defaults = new AclDefaults
|
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||||
{
|
|
||||||
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"));
|
||||||
|
|
||||||
@@ -84,7 +105,9 @@ 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);
|
||||||
Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype);
|
// The zaaktype is resolved from the configured identificatie, not a pinned URL (S-27).
|
||||||
|
Assert.Equal("BIG-REGISTRATIE", gateway.ResolvedByIdentificatie);
|
||||||
|
Assert.Equal(gateway.ResolvedZaaktype, req.Zaaktype);
|
||||||
Assert.Equal(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);
|
||||||
@@ -94,33 +117,24 @@ 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 defaults = new AclDefaults
|
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||||
{
|
|
||||||
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_zaaktypes_eindstatus_dated_today()
|
public async Task Approving_a_zaak_sets_it_to_its_resolved_zaaktypes_eindstatus_dated_today()
|
||||||
{
|
{
|
||||||
var gateway = new FakeGateway();
|
var gateway = new FakeGateway();
|
||||||
var defaults = Defaults();
|
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");
|
||||||
|
|
||||||
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(defaults.ZaaktypeUrl, gateway.Approved.Value.Zaaktype);
|
Assert.Equal(gateway.ResolvedZaaktype, 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +142,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 = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
var service = ServiceWith(gateway, Defaults(), 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);
|
||||||
@@ -138,15 +152,14 @@ 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 defaults = Defaults();
|
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");
|
||||||
|
|
||||||
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(defaults.ZaaktypeUrl, gateway.Cancelled.Value.Zaaktype);
|
Assert.Equal(gateway.ResolvedZaaktype, 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);
|
||||||
@@ -156,18 +169,17 @@ 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 = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
var service = ServiceWith(gateway, Defaults(), 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_returns_its_url()
|
public async Task Storing_a_diploma_default_fills_the_document_fields_and_uses_the_resolved_informatieobjecttype()
|
||||||
{
|
{
|
||||||
var gateway = new FakeGateway();
|
var gateway = new FakeGateway();
|
||||||
var defaults = Defaults();
|
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 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");
|
||||||
@@ -175,7 +187,9 @@ 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);
|
||||||
Assert.Equal(defaults.InformatieobjecttypeUrl, req.Informatieobjecttype);
|
// The informatieobjecttype is resolved from the configured omschrijving (S-27).
|
||||||
|
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);
|
||||||
@@ -188,7 +202,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 = new AclService(new FakeGateway(), Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
var service = ServiceWith(new FakeGateway(), Defaults(), 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"));
|
||||||
@@ -201,7 +215,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 = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
var service = ServiceWith(gateway, Defaults(), 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);
|
||||||
@@ -214,9 +228,23 @@ 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 = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
var service = ServiceWith(gateway, Defaults(), 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,4 +675,200 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"stryker-config": {
|
"stryker-config": {
|
||||||
"solution": "Acl.slnx",
|
"solution": "Acl.slnx",
|
||||||
"test-projects": ["Acl.Tests/Acl.Tests.csproj"],
|
"test-projects": ["Acl.Tests/Acl.Tests.csproj"],
|
||||||
"reporters": ["progress", "html"],
|
"reporters": ["progress", "html", "markdown"],
|
||||||
"thresholds": {
|
"thresholds": {
|
||||||
"high": 95,
|
"high": 95,
|
||||||
"low": 90,
|
"low": 90,
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
<!-- 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>
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ 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>
|
||||||
@@ -22,6 +26,10 @@ 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>
|
||||||
@@ -46,6 +54,19 @@ 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
|
||||||
{
|
{
|
||||||
@@ -58,6 +79,18 @@ 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(
|
||||||
@@ -101,3 +134,11 @@ 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) ?? [];
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,9 +3,33 @@ 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),
|
||||||
@@ -16,6 +40,10 @@ 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.
|
||||||
@@ -43,14 +71,24 @@ 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).
|
// The BFF is the portals' only backend; it fans out to the domain and projection (§8.3), and reaches
|
||||||
|
// the ACL for the beheer catalogus read (ADR-0025).
|
||||||
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
|
||||||
@@ -68,6 +106,9 @@ 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,
|
||||||
@@ -86,6 +127,24 @@ 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).
|
||||||
@@ -160,6 +219,15 @@ 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>
|
||||||
@@ -212,5 +280,13 @@ 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;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
},
|
},
|
||||||
"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/" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ 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 =>
|
||||||
@@ -54,11 +55,13 @@ 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
|
||||||
@@ -82,6 +85,18 @@ 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
|
||||||
@@ -126,3 +141,12 @@ 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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,6 +1,7 @@
|
|||||||
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;
|
||||||
|
|
||||||
@@ -168,5 +169,52 @@ 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,32 @@
|
|||||||
"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": {
|
||||||
@@ -201,10 +227,68 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/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"
|
||||||
@@ -302,4 +386,4 @@
|
|||||||
"name": "Bff.Api"
|
"name": "Bff.Api"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"stryker-config": {
|
"stryker-config": {
|
||||||
"solution": "Bff.slnx",
|
"solution": "Bff.slnx",
|
||||||
"test-projects": ["Bff.Tests/Bff.Tests.csproj"],
|
"test-projects": ["Bff.Tests/Bff.Tests.csproj"],
|
||||||
"reporters": ["progress", "html"],
|
"reporters": ["progress", "html", "markdown"],
|
||||||
"mutate": [
|
"mutate": [
|
||||||
"!**/Program.cs",
|
"!**/Program.cs",
|
||||||
"!**/DownstreamClients.cs"
|
"!**/DownstreamClients.cs"
|
||||||
|
|||||||
@@ -5,6 +5,15 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -1,9 +1,35 @@
|
|||||||
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>()
|
||||||
@@ -15,6 +41,10 @@ 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>();
|
||||||
@@ -36,6 +66,7 @@ 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>();
|
||||||
@@ -46,10 +77,26 @@ 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).
|
||||||
@@ -141,6 +188,21 @@ 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))
|
||||||
@@ -150,7 +212,8 @@ 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();
|
||||||
@@ -163,6 +226,11 @@ 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(string RegistrationId, string Status, string? ZaakUrl);
|
public sealed record RegistrationResponse(
|
||||||
|
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)
|
public sealed class ApproveRegistration(IRegistrationStore store, IAclClient acl, TimeProvider clock)
|
||||||
{
|
{
|
||||||
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();
|
registration.Approve(clock.GetUtcNow());
|
||||||
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)
|
public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient acl, IUserTaskClient tasks, TimeProvider clock)
|
||||||
{
|
{
|
||||||
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();
|
registration.Approve(clock.GetUtcNow());
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case BeoordelingsBesluit.Afwijzen:
|
case BeoordelingsBesluit.Afwijzen:
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,6 +102,18 @@ 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>
|
||||||
|
|||||||
@@ -92,11 +92,12 @@ 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"/>.
|
/// submitted or in-behandeling registration to <see cref="RegistrationStatus.Ingeschreven"/> and
|
||||||
/// Requires an opened zaak (the approval sets that zaak's status via the ACL); a registration that
|
/// records <paramref name="ingeschrevenOp"/> as the moment of inscription, which starts the
|
||||||
/// has already been decided cannot be approved again.
|
/// herregistratie clock (S-17). Requires an opened zaak (the approval sets that zaak's status via
|
||||||
|
/// the ACL); a registration that has already been decided cannot be approved again.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Approve()
|
public void Approve(DateTimeOffset ingeschrevenOp)
|
||||||
{
|
{
|
||||||
if (ZaakUrl is null)
|
if (ZaakUrl is null)
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
@@ -104,6 +105,54 @@ 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,6 +19,7 @@
|
|||||||
<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>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user