Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d783448b7 | ||
|
|
10b784cc05 | ||
|
|
2bb7d9c165 | ||
|
|
4a047c618c | ||
|
|
43b45ad756 | ||
|
|
5502e4c099 | ||
|
|
3705a18e18 | ||
|
|
400bdcafc4 | ||
|
|
c67ee7d3f5 | ||
|
|
d14f379358 | ||
|
|
66f8322580 | ||
|
|
d37d4c96c6 | ||
|
|
159f014c1e | ||
|
|
dd54688f86 | ||
|
|
0a97fa4bf7 | ||
|
|
23ea91de32 | ||
|
|
0494730223 | ||
|
|
fff88ca23d | ||
|
|
849bf4723b | ||
|
|
4698c869f3 | ||
|
|
d5dfbdc0b2 | ||
|
|
6771fccf47 | ||
|
|
88338396f6 | ||
|
|
4274fd30d1 | ||
|
|
4fe9915816 | ||
|
|
5f8ab4dbcd |
+133
-5
@@ -9,6 +9,12 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Supersede stale runs: a new push to the same branch/PR cancels the previous run, so the runner's
|
||||
# concurrency slots aren't spent on commits nobody is waiting for (refs #127).
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Self-hosted runner — see docs/runbooks/ci.md for the runner setup.
|
||||
# `uses:` are absolute, tag-pinned URLs (CLAUDE.md §8.7 / §15).
|
||||
|
||||
@@ -64,6 +70,12 @@ jobs:
|
||||
restore-keys: |
|
||||
nuget-${{ runner.os }}-
|
||||
- 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:
|
||||
@@ -78,6 +90,12 @@ jobs:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
- 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:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -93,6 +111,29 @@ jobs:
|
||||
restore-keys: |
|
||||
nuget-${{ runner.os }}-
|
||||
- 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
|
||||
# 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
|
||||
@@ -129,35 +170,122 @@ jobs:
|
||||
path: services/bff/StrykerOutput/**/reports/mutation-report.html
|
||||
if-no-files-found: warn
|
||||
|
||||
# One stage for every check that needs the live stack. On the single self-hosted
|
||||
# runner jobs run sequentially, so booting OpenZaak once (instead of once per job)
|
||||
# is the cheapest layout (issue #58). No setup-dotnet: the ACL test runs in a built
|
||||
# image and everything reaches services by container IP. Needs Docker + egress
|
||||
# One stage for every check that needs the live stack. Booting OpenZaak once (instead
|
||||
# of once per job) is the cheapest layout (issue #58). No setup-dotnet: the ACL test runs
|
||||
# in a built image and everything reaches services by container IP. Needs Docker + egress
|
||||
# (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:
|
||||
needs: [mutation]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
# 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).
|
||||
# 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
|
||||
id: up
|
||||
run: make verify-up
|
||||
- name: Observability backplane (Grafana + Tempo + Prometheus datasources)
|
||||
id: obs
|
||||
run: OBS_TIMEOUT=180 make verify-observability
|
||||
- name: Objecttypen API up + token authenticates
|
||||
id: objecttypen
|
||||
run: OBJECTTYPEN_TIMEOUT=120 make verify-objecttypen
|
||||
- name: Objecten API up + token authenticates + trusts Objecttypen
|
||||
id: objecten
|
||||
run: OBJECTEN_TIMEOUT=120 make verify-objecten
|
||||
- name: RegisterRecord objecttype registered + published
|
||||
id: registerrecord
|
||||
run: REGISTERRECORD_TIMEOUT=120 make verify-registerrecord
|
||||
- name: ACL ↔ OpenZaak integration tests
|
||||
id: acl
|
||||
run: make verify-acl
|
||||
- name: OpenZaak → NRC notification delivery
|
||||
id: nrc
|
||||
run: make verify-nrc
|
||||
- name: OpenZaak → NRC → Event Subscriber → projection-api
|
||||
id: projection
|
||||
run: make verify-projection
|
||||
- name: Domain → Flowable → ACL → OpenZaak
|
||||
id: domain
|
||||
run: make verify-domain
|
||||
- name: BFF → Keycloak + domain + projection
|
||||
id: 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)
|
||||
id: 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 }}
|
||||
OBJECTTYPEN: ${{ steps.objecttypen.outcome }}
|
||||
OBJECTEN: ${{ steps.objecten.outcome }}
|
||||
REGISTERRECORD: ${{ steps.registerrecord.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 "| Objecttypen API + token | $(icon "$OBJECTTYPEN") |"
|
||||
echo "| Objecten API + token | $(icon "$OBJECTEN") |"
|
||||
echo "| RegisterRecord objecttype | $(icon "$REGISTERRECORD") |"
|
||||
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).
|
||||
- name: Dump container logs on failure
|
||||
if: failure()
|
||||
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel 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 objecttypen-db objecttypen-redis objecttypen-init objecttypen objecten-db objecten-redis objecten-init objecten registerrecord-init tempo prometheus grafana 2>&1 || true
|
||||
- name: Tear down
|
||||
if: always()
|
||||
run: make down
|
||||
|
||||
@@ -57,3 +57,7 @@ vitest.config.*.timestamp*
|
||||
tests/e2e/node_modules/
|
||||
tests/e2e/test-results/
|
||||
tests/e2e/playwright-report/
|
||||
__pycache__/
|
||||
TestResults/
|
||||
test-output/
|
||||
tests/e2e/playwright-report.json
|
||||
|
||||
+29
-6
@@ -249,32 +249,55 @@ Split (issue #11 closed) into two independently-demoable slices per §13 — the
|
||||
|
||||
## Iteration 3 — Maintenance portal and observability *(milestone: `Iteration 3 — Beheer & Observability`)*
|
||||
|
||||
### S-15 · Beheer-portal — catalogus & default-fill rules
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
## Iteration 4 — Objecten and the authoritative register *(milestone: `Iteration 4 — Objecten`)*
|
||||
|
||||
### S-18 · Objecten + Objecttypen up in compose; Register objecttype defined
|
||||
### S-18 · Objecten + Objecttypen up in compose; Register objecttype defined *(split — #19 closed)*
|
||||
|
||||
**Outcome:** Objecten and Objecttypen running. A `RegisterRecord` objecttype defined with the public-safe schema.
|
||||
|
||||
### S-19 · ACL extension: write register-record to Objecten on approval
|
||||
Split into independently deployable sub-slices (CLAUDE.md §13):
|
||||
|
||||
- **S-18a** (#139, ✅) · Objecttypen API up in compose (own DB + seeded config + health + static token).
|
||||
- **S-18b** (#140, ✅) · Objecten API up in compose, wired to Objecttypen. Depends on S-18a.
|
||||
- **S-18c** (#141, ✅) · RegisterRecord objecttype defined + registered (public-safe JSON schema). Depends on S-18a/b.
|
||||
|
||||
### S-19 · ACL extension: write register-record to Objecten on approval *(split — #20 closed)*
|
||||
|
||||
**Outcome:** Approval path writes the canonical register record to Objecten, not OpenZaak eigenschappen. Projection now sourced from Objecten events.
|
||||
|
||||
**ADR required:** "Why Objecten holds the register, OpenZaak holds the process."
|
||||
|
||||
Split into independently deployable sub-slices (CLAUDE.md §13):
|
||||
|
||||
- **S-19a** (#149, ✅) · ACL writes the `RegisterRecord` to Objecten on approval, idempotently, alongside the ZGW eindstatus. Carries the ADR (ADR-0028).
|
||||
- **S-19b** (#150) · Read projection sourced from Objecten instead of NRC zaak events. Depends on S-19a.
|
||||
|
||||
---
|
||||
|
||||
## Iteration 5 — Data governance module *(milestone: `Iteration 5 — Data Governance`)*
|
||||
|
||||
@@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml
|
||||
# Long-running services with a healthcheck — the smoke polls these for readiness
|
||||
# (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init)
|
||||
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
|
||||
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel
|
||||
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel beheer objecttypen objecten
|
||||
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
|
||||
# into external named volumes via `docker cp` (infra/seed-config.sh) instead of
|
||||
# bind-mounted, because bind mounts don't reach sibling containers on the
|
||||
@@ -18,7 +18,7 @@ WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api se
|
||||
# volumes are `external`, so compose won't remove them — CFG_VOLS lists them for
|
||||
# explicit teardown. See docs/runbooks/gitea-actions-gotchas.md.
|
||||
SEED := bash infra/seed-config.sh
|
||||
CFG_VOLS := rr-oz-config rr-nrc-config rr-kc-realms rr-fl-bpmn
|
||||
CFG_VOLS := rr-oz-config rr-nrc-config rr-kc-realms rr-fl-bpmn rr-objecttypen-config rr-objecten-config rr-registerrecord-config
|
||||
# Local-only stack: same services but config is bind-mounted (no seed step), so a
|
||||
# plain `docker compose -f infra/docker-compose.local.yml up` works on any local
|
||||
# engine. This is the no-make / Windows-friendly path. See that file's header.
|
||||
@@ -43,7 +43,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
|
||||
endif
|
||||
endif
|
||||
|
||||
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
|
||||
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-objecttypen verify-objecten verify-registerrecord 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)
|
||||
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
|
||||
@@ -70,8 +70,9 @@ build:
|
||||
dotnet build $(SLN) -c Release
|
||||
|
||||
## 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:
|
||||
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)
|
||||
# Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore`
|
||||
@@ -93,14 +94,14 @@ mutation:
|
||||
# podman-compose, and needing no `--wait` flag or host port access. The one-shots
|
||||
# (oz-init, flowable-init) aren't polled; they just need to have run.
|
||||
smoke:
|
||||
$(SEED) oz nrc kc fl
|
||||
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
|
||||
docker compose -f $(COMPOSE) up -d --build
|
||||
bash -c 'WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS); rc=$$?; docker compose -f $(COMPOSE) down --volumes; docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; exit $$rc'
|
||||
|
||||
## up: seed config volumes and start the full stack (use instead of bare
|
||||
## `docker compose up`, which can't self-seed the external config volumes)
|
||||
up:
|
||||
$(SEED) oz nrc kc fl
|
||||
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
|
||||
docker compose -f $(COMPOSE) up -d --build
|
||||
|
||||
## down: stop and remove the local stack (incl. the external config volumes)
|
||||
@@ -138,7 +139,7 @@ changelog:
|
||||
## verify-up: bring the FULL stack up and wait for health (CI verify-stack step 1;
|
||||
## subsumes the old compose-smoke health gate — the DoD "up reaches green" check).
|
||||
verify-up:
|
||||
$(SEED) oz nrc kc fl
|
||||
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
|
||||
docker compose -f $(COMPOSE) up -d --build
|
||||
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
|
||||
|
||||
@@ -170,11 +171,41 @@ verify-bff:
|
||||
verify-e2e:
|
||||
bash infra/run-e2e-check.sh
|
||||
|
||||
## verify-observability: assert the observability backplane (Grafana + provisioned Tempo &
|
||||
## Prometheus datasources) is live, against the already-running stack (S-16a).
|
||||
verify-observability:
|
||||
bash infra/run-observability-check.sh
|
||||
|
||||
## verify-tracing: assert one connected distributed trace spans the .NET services in Tempo
|
||||
## (S-16b), against the already-running stack.
|
||||
verify-tracing:
|
||||
bash infra/run-tracing-check.sh
|
||||
|
||||
## verify-metrics: assert the services expose /metrics and Prometheus scrapes the golden
|
||||
## signals (S-16c), against the already-running stack.
|
||||
verify-metrics:
|
||||
bash infra/run-metrics-check.sh
|
||||
|
||||
## verify-objecttypen: assert the Objecttypen API is up + its static token authenticates
|
||||
## (S-18a), against the already-running stack.
|
||||
verify-objecttypen:
|
||||
bash infra/run-objecttypen-check.sh
|
||||
|
||||
## verify-objecten: assert the Objecten API is up + its static token authenticates and it
|
||||
## trusts the Objecttypen API (S-18b), against the already-running stack.
|
||||
verify-objecten:
|
||||
bash infra/run-objecten-check.sh
|
||||
|
||||
## verify-registerrecord: assert the RegisterRecord objecttype is registered + published in the
|
||||
## Objecttypen API (S-18c), against the already-running stack.
|
||||
verify-registerrecord:
|
||||
bash infra/run-registerrecord-check.sh
|
||||
|
||||
## verify: local mirror of the CI verify-stack job — full stack up once, all checks,
|
||||
## tear down (always). For fast single-concern local iteration use `integration`
|
||||
## (oz-only) or `verify-notifications` (oz+nrc) instead.
|
||||
verify:
|
||||
$(SEED) oz nrc kc fl
|
||||
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
|
||||
docker compose -f $(COMPOSE) up -d --build
|
||||
@bash -c 'set -e; rc=0; \
|
||||
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS) \
|
||||
|
||||
@@ -64,7 +64,9 @@
|
||||
"test": {
|
||||
"executor": "@angular/build:unit-test",
|
||||
"options": {
|
||||
"watch": false
|
||||
"watch": false,
|
||||
"reporters": ["default", "json"],
|
||||
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
|
||||
}
|
||||
},
|
||||
"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,5 @@
|
||||
<nav aria-label="Beheer" class="utrecht-theme">
|
||||
<a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Catalogus</a>
|
||||
<a routerLink="/default-fill" routerLinkActive="active">Default-fill</a>
|
||||
</nav>
|
||||
<router-outlet></router-outlet>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Route } from '@angular/router';
|
||||
import { authenticatedGuard } from 'auth';
|
||||
import { CatalogusPage } from './catalogus/catalogus-page';
|
||||
import { DefaultFillPage } from './default-fill/default-fill-page';
|
||||
|
||||
export const appRoutes: Route[] = [
|
||||
{ path: '', component: CatalogusPage, canActivate: [authenticatedGuard] },
|
||||
{ path: 'default-fill', component: DefaultFillPage, 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,60 @@
|
||||
<main utrecht-document class="utrecht-theme">
|
||||
<utrecht-article>
|
||||
<utrecht-heading-1>Default-fill</utrecht-heading-1>
|
||||
<p utrecht-paragraph>
|
||||
De ZGW-standaardwaarden die de ACL op elke nieuwe zaak invult (ADR-0003). Een wijziging geldt
|
||||
voor de eerstvolgende zaak.
|
||||
</p>
|
||||
|
||||
@if (loading()) {
|
||||
<p utrecht-paragraph role="status">Bezig met laden…</p>
|
||||
} @else if (loaded()) {
|
||||
<form (submit)="save(); $event.preventDefault()">
|
||||
<p>
|
||||
<label for="bronorganisatie">Bronorganisatie</label><br />
|
||||
<input
|
||||
id="bronorganisatie"
|
||||
name="bronorganisatie"
|
||||
[value]="bronorganisatie()"
|
||||
(input)="bronorganisatie.set($any($event.target).value)"
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<label for="verantwoordelijkeOrganisatie">Verantwoordelijke organisatie</label><br />
|
||||
<input
|
||||
id="verantwoordelijkeOrganisatie"
|
||||
name="verantwoordelijkeOrganisatie"
|
||||
[value]="verantwoordelijkeOrganisatie()"
|
||||
(input)="verantwoordelijkeOrganisatie.set($any($event.target).value)"
|
||||
/>
|
||||
</p>
|
||||
<p>
|
||||
<label for="vertrouwelijkheidaanduiding">Vertrouwelijkheidaanduiding</label><br />
|
||||
<input
|
||||
id="vertrouwelijkheidaanduiding"
|
||||
name="vertrouwelijkheidaanduiding"
|
||||
[value]="vertrouwelijkheidaanduiding()"
|
||||
(input)="vertrouwelijkheidaanduiding.set($any($event.target).value)"
|
||||
/>
|
||||
</p>
|
||||
<button utrecht-button appearance="primary-action-button" type="submit" [disabled]="saving()">
|
||||
Opslaan
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@if (saved()) {
|
||||
<p utrecht-paragraph role="status">De standaardwaarden zijn opgeslagen.</p>
|
||||
}
|
||||
@if (failed()) {
|
||||
<p utrecht-paragraph role="alert">
|
||||
Opslaan is niet gelukt. Controleer of je als beheerder bent ingelogd en probeer het opnieuw.
|
||||
</p>
|
||||
}
|
||||
} @else if (failed()) {
|
||||
<p utrecht-paragraph role="alert">
|
||||
Kon de standaardwaarden niet laden. Controleer of je als beheerder bent ingelogd en probeer
|
||||
het opnieuw.
|
||||
</p>
|
||||
}
|
||||
</utrecht-article>
|
||||
</main>
|
||||
@@ -0,0 +1,90 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { fireEvent, render, screen } from '@testing-library/angular';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { BeheerDefaultFill, BffApiV1Service } from 'api-client';
|
||||
import { AuthService } from 'auth';
|
||||
import { axe } from 'vitest-axe';
|
||||
import { DefaultFillPage } from './default-fill-page';
|
||||
|
||||
const current: BeheerDefaultFill = {
|
||||
bronorganisatie: '517439943',
|
||||
verantwoordelijkeOrganisatie: '517439943',
|
||||
vertrouwelijkheidaanduiding: 'openbaar',
|
||||
};
|
||||
|
||||
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 */
|
||||
}
|
||||
logout(): void {
|
||||
/* not exercised */
|
||||
}
|
||||
}
|
||||
|
||||
function setup(
|
||||
overrides: {
|
||||
getBeheerDefaultFill?: ReturnType<typeof vi.fn>;
|
||||
putBeheerDefaultFill?: ReturnType<typeof vi.fn>;
|
||||
} = {},
|
||||
) {
|
||||
const getBeheerDefaultFill = overrides.getBeheerDefaultFill ?? vi.fn().mockReturnValue(of(current));
|
||||
const putBeheerDefaultFill = overrides.putBeheerDefaultFill ?? vi.fn().mockReturnValue(of(undefined));
|
||||
return {
|
||||
getBeheerDefaultFill,
|
||||
putBeheerDefaultFill,
|
||||
providers: [
|
||||
{ provide: BffApiV1Service, useValue: { getBeheerDefaultFill, putBeheerDefaultFill } },
|
||||
{ provide: AuthService, useClass: FakeAuth },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('DefaultFillPage', () => {
|
||||
it('loads the current default-fill into the form on open', async () => {
|
||||
const { getBeheerDefaultFill, providers } = setup();
|
||||
await render(DefaultFillPage, { providers });
|
||||
|
||||
expect(getBeheerDefaultFill).toHaveBeenCalled();
|
||||
const bron = (await screen.findByLabelText('Bronorganisatie')) as HTMLInputElement;
|
||||
expect(bron.value).toBe('517439943');
|
||||
});
|
||||
|
||||
it('saves the edited values via the BFF', async () => {
|
||||
const { putBeheerDefaultFill, providers } = setup();
|
||||
await render(DefaultFillPage, { providers });
|
||||
|
||||
const bron = (await screen.findByLabelText('Bronorganisatie')) as HTMLInputElement;
|
||||
fireEvent.input(bron, { target: { value: '999999999' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /opslaan/i }));
|
||||
|
||||
expect(putBeheerDefaultFill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ bronorganisatie: '999999999', vertrouwelijkheidaanduiding: 'openbaar' }),
|
||||
);
|
||||
expect(await screen.findByText(/standaardwaarden zijn opgeslagen/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('surfaces a save failure instead of swallowing it', async () => {
|
||||
const { providers } = setup({
|
||||
putBeheerDefaultFill: vi.fn().mockReturnValue(throwError(() => new Error('403'))),
|
||||
});
|
||||
await render(DefaultFillPage, { providers });
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /opslaan/i }));
|
||||
|
||||
expect(await screen.findByText(/opslaan is niet gelukt/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has no WCAG 2.1 AA violations', async () => {
|
||||
document.documentElement.lang = 'nl';
|
||||
const { container } = await render(DefaultFillPage, { providers: setup().providers });
|
||||
|
||||
const results = await axe(container, {
|
||||
runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] },
|
||||
});
|
||||
|
||||
expect(results.violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { BeheerDefaultFill, BffApiV1Service } from 'api-client';
|
||||
import { UtrechtComponentsModule } from 'ui';
|
||||
|
||||
/**
|
||||
* The beheer default-fill editor (S-15b): a beheerder reads and edits the ZGW default-fill values the
|
||||
* ACL stamps on every zaak (ADR-0003). Load and save go through the BFF (`/beheer/default-fill`),
|
||||
* which proxies the ACL (ADR-0025). A save takes effect on the next zaak (the ACL reads it per zaak).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-default-fill-page',
|
||||
imports: [UtrechtComponentsModule],
|
||||
templateUrl: './default-fill-page.html',
|
||||
})
|
||||
export class DefaultFillPage {
|
||||
private readonly bff = inject(BffApiV1Service);
|
||||
|
||||
protected readonly bronorganisatie = signal('');
|
||||
protected readonly verantwoordelijkeOrganisatie = signal('');
|
||||
protected readonly vertrouwelijkheidaanduiding = signal('');
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly loaded = signal(false);
|
||||
protected readonly saving = signal(false);
|
||||
protected readonly failed = signal(false);
|
||||
protected readonly saved = signal(false);
|
||||
|
||||
constructor() {
|
||||
this.load();
|
||||
}
|
||||
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.failed.set(false);
|
||||
this.saved.set(false);
|
||||
this.bff.getBeheerDefaultFill().subscribe({
|
||||
next: (d: BeheerDefaultFill) => {
|
||||
this.bronorganisatie.set(d.bronorganisatie);
|
||||
this.verantwoordelijkeOrganisatie.set(d.verantwoordelijkeOrganisatie);
|
||||
this.vertrouwelijkheidaanduiding.set(d.vertrouwelijkheidaanduiding);
|
||||
this.loading.set(false);
|
||||
this.loaded.set(true);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
this.loaded.set(true);
|
||||
this.failed.set(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
save(): void {
|
||||
this.saving.set(true);
|
||||
this.failed.set(false);
|
||||
this.saved.set(false);
|
||||
this.bff
|
||||
.putBeheerDefaultFill({
|
||||
bronorganisatie: this.bronorganisatie(),
|
||||
verantwoordelijkeOrganisatie: this.verantwoordelijkeOrganisatie(),
|
||||
vertrouwelijkheidaanduiding: this.vertrouwelijkheidaanduiding(),
|
||||
})
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.saving.set(false);
|
||||
this.saved.set(true);
|
||||
},
|
||||
error: () => {
|
||||
this.saving.set(false);
|
||||
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": {
|
||||
"executor": "@angular/build:unit-test",
|
||||
"options": {
|
||||
"watch": false
|
||||
"watch": false,
|
||||
"reporters": ["default", "json"],
|
||||
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
|
||||
}
|
||||
},
|
||||
"serve-static": {
|
||||
|
||||
@@ -64,7 +64,9 @@
|
||||
"test": {
|
||||
"executor": "@angular/build:unit-test",
|
||||
"options": {
|
||||
"watch": false
|
||||
"watch": false,
|
||||
"reporters": ["default", "json"],
|
||||
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
|
||||
}
|
||||
},
|
||||
"serve-static": {
|
||||
|
||||
+1
-1
@@ -207,7 +207,7 @@ A slice is done when:
|
||||
## 15. Out of scope for v1
|
||||
|
||||
- OpenMetadata data governance module (v3 slice).
|
||||
- Objecten as the authoritative register record store (v2 slice — v1 uses OpenZaak zaak-eigenschappen as a placeholder).
|
||||
- ~~Objecten as the authoritative register record store~~ — **delivered** in S-19a (#149, ADR-0028); the approval path writes a `RegisterRecord` object to Objecten rather than the planned zaak-eigenschappen placeholder.
|
||||
- Production-grade Helm chart (sketch only).
|
||||
- Multi-tenancy.
|
||||
- Real outbound notifications (email/SMS) — logged to console in v1.
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,61 @@
|
||||
# ADR-0026: Runtime-mutable ACL default-fill (in-memory store, seeded from config)
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-24
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-15b (#131), second of the S-15 (#16) split
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0003 made the ACL *default-fill* the ZGW-mandatory fields it stamps on every
|
||||
zaak, supplied as static configuration (`Acl:Defaults`, read once at startup as an
|
||||
immutable singleton). S-15b lets a beheerder **edit** those values from the portal
|
||||
and have the next zaak reflect them — so the defaults must become mutable at runtime.
|
||||
|
||||
Two questions: **what** is editable, and **where** the mutable state lives.
|
||||
|
||||
## Decision
|
||||
|
||||
**Make the three ZGW default-fill fields a runtime-mutable, in-memory store
|
||||
(`IDefaultFillStore`), seeded from `Acl:Defaults` at startup. The ACL reads it per
|
||||
zaak; the beheer `PUT /default-fill` replaces it.**
|
||||
|
||||
### Only the three ZGW fill fields are editable
|
||||
|
||||
`Acl:Defaults` also carries the S-27 catalog-resolution keys (`ZaaktypeIdentificatie`,
|
||||
`InformatieobjecttypeOmschrijving`). Those feed the resolved-URL cache
|
||||
(`CachedZaaktypeCatalog`, ADR-0021); editing them at runtime would leave a stale cache
|
||||
and is catalogus *wiring*, not "default fill". So they **stay static config** and are
|
||||
out of scope for the CRUD. The editable set is exactly `Bronorganisatie`,
|
||||
`VerantwoordelijkeOrganisatie`, `Vertrouwelijkheidaanduiding` (`DefaultFillSettings`).
|
||||
|
||||
### In-memory, not persisted
|
||||
|
||||
The store is a thread-safe in-memory singleton. **An edit is lost on restart**, when it
|
||||
reverts to the configured env. That is acceptable for this reference app: the slice
|
||||
demonstrates the *pattern* (beheer edits config that the ACL honours), not durable
|
||||
config management. The ACL stays stateless — no DB, no EF, no migration, no extra
|
||||
compose service.
|
||||
|
||||
- ponytail ceiling: no persistence, no audit trail, no optimistic concurrency.
|
||||
- Upgrade path: back `IDefaultFillStore` with a DB (or an Objecten record) if durable,
|
||||
audited, multi-instance config is needed — the port stays the same.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- Demoable end to end (edit in portal → next zaak reflects it) with minimal moving parts.
|
||||
- The read path is per-zaak, so no restart and no cache concerns for the ZGW fields.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- Edits don't survive a restart and aren't shared across replicas (single-instance
|
||||
assumption). Documented ceiling above.
|
||||
- Two sources of default config now (static keys on `AclDefaults`, mutable fields in the
|
||||
store) — a deliberate split by editability.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None new. The BFF→ACL edge already exists (ADR-0025); this adds a read/write pair on it.
|
||||
The ACL remains the owner of the ZGW-facing config.
|
||||
@@ -0,0 +1,81 @@
|
||||
# ADR-0027: The RegisterRecord objecttype is public-safe by construction
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-27
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-18c (#141), third of the S-18 (#19) split
|
||||
|
||||
## Context
|
||||
|
||||
S-18 stands up Objecttypen (S-18a) and Objecten (S-18b) as the authoritative
|
||||
register-record store (PRD §"Objecten as the authoritative register record store").
|
||||
S-19 (#20) will, on approval, write the canonical register record to the Objecten API
|
||||
instead of OpenZaak zaak-eigenschappen, and the openbaar (public) register will read it.
|
||||
|
||||
Objecten validates every object against a **objecttype version's JSON schema**. So the
|
||||
schema is a contract: it fixes which fields a register record may carry. The register is
|
||||
read **anonymously** by the openbaar portal (ADR-0010), so the schema is also a
|
||||
disclosure boundary — anything the schema allows can end up public.
|
||||
|
||||
Two questions: **which fields** the schema defines, and **how** the objecttype gets into
|
||||
the Objecttypen API (which has no declarative objecttype step).
|
||||
|
||||
## Decision
|
||||
|
||||
**Define a `RegisterRecord` objecttype whose published schema carries exactly the
|
||||
public-safe fields — `id`, `status`, `reference` — and register it over the API at
|
||||
startup with a one-shot, idempotently.**
|
||||
|
||||
### The schema mirrors the BFF's public projection, not the internal one
|
||||
|
||||
The public-safe field set already exists: the BFF's `OpenbaarEntry`
|
||||
(`services/bff/Bff.Api/DownstreamClients.cs`) — `id`, `status`, `reference` — is what
|
||||
`OpenbaarProjection.PublicView` narrows every row down to, dropping `bsn` and
|
||||
`naamPlaceholder` at the boundary (S-09). The RegisterRecord schema mirrors that record,
|
||||
**not** the internal `RegisterEntry` / `RegisterEntryRow` (which carry bsn/naam):
|
||||
|
||||
| field | type | notes |
|
||||
|-------|------|-------|
|
||||
| `id` | string (required) | zaak id — the entry's stable key |
|
||||
| `status` | string (required) | enum `INGEDIEND` \| `INGESCHREVEN` (`RegistrationStatus`) |
|
||||
| `reference` | string \| null | citizen-facing zaak identificatie (ADR-0012) |
|
||||
|
||||
`additionalProperties: false` so a record can't smuggle a field the schema didn't
|
||||
sanction, and `dataClassification: "open"` records the intent that this objecttype is
|
||||
public. **`bsn` and `naamPlaceholder` are deliberately absent** — public-safe by
|
||||
construction, so S-19 cannot write a personal-data field into the public register even by
|
||||
mistake.
|
||||
|
||||
### Registered over the API by a one-shot, not setup_configuration
|
||||
|
||||
The Objecttypen API's `setup_configuration` (3.4.2) provisions only tokens — it has no
|
||||
declarative step to create an objecttype with a schema. So a `registerrecord-init`
|
||||
compose one-shot (stdlib Python, on the stack network) creates the objecttype + a
|
||||
**published** version over the API once Objecttypen is healthy, following the ADR-0020
|
||||
self-seed pattern. It is **idempotent**: if a `RegisterRecord` with a version already
|
||||
exists it is a no-op, so it is safe on every `up`.
|
||||
|
||||
- ponytail ceiling: no schema-migration/versioning story — a schema change means editing
|
||||
`registerrecord.schema.json` and bumping the version by hand; the one-shot only ever
|
||||
adds v1 if none exists.
|
||||
- Upgrade path: if the schema evolves, have the one-shot diff the published schema and
|
||||
POST a new version, or move to a declarative step once the upstream supports one.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- The public register's disclosure surface is fixed in one reviewed artifact
|
||||
(`registerrecord.schema.json`) and enforced by Objecten's own validation.
|
||||
- Self-seeds on a fresh `make up` / bare local compose; no manual step, no built image.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- The public-safe field set now lives in two places — the BFF's `OpenbaarEntry` and this
|
||||
schema — that must be kept in sync by hand (a drift check is a candidate for later).
|
||||
- Hand-managed schema version (ceiling above).
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None new. Registration talks to the Objecttypen API over its documented API. S-19 will
|
||||
write records via the ACL (§8.1) — this ADR only fixes the schema they conform to.
|
||||
@@ -0,0 +1,174 @@
|
||||
# ADR-0028: Objecten holds the register, OpenZaak holds the process
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-14
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-19a (#149), first of the S-19 (#20) split
|
||||
|
||||
## Context
|
||||
|
||||
Until this slice the register existed only as a **derived** thing: the read projection
|
||||
rows the Event Subscriber builds from NRC zaak notifications (ADR-0008). There is no
|
||||
system anywhere that holds "who is registered" as a first-class record — drop the
|
||||
projection database and the only way back is to replay ZGW history and re-derive it.
|
||||
|
||||
That is the wrong shape for a register. A BIG registration is a **fact about a person**
|
||||
that outlives the case that produced it: it is looked up, corrected, superseded, and
|
||||
retained on its own schedule. The zaak that produced it is a **process record** — it
|
||||
opens, moves through statussen, and closes. Storing the fact inside the process record
|
||||
(as zaak `eigenschappen`, the v1 placeholder PRD §"Registration" mentions) welds the two
|
||||
lifecycles together: the register can then never be read, retained, or corrected without
|
||||
going through the case system that happened to create it.
|
||||
|
||||
S-18 stood up Objecten + Objecttypen and registered the public-safe `RegisterRecord`
|
||||
objecttype (ADR-0027). The open question this ADR closes: **where the authoritative
|
||||
register record lives, and who writes it.**
|
||||
|
||||
## Decision
|
||||
|
||||
**The register record lives in the Objecten API as a `RegisterRecord` object. OpenZaak
|
||||
keeps only the process. On approval the ACL writes both: the ZGW eindstatus, then the
|
||||
register record.**
|
||||
|
||||
### Not zaak eigenschappen
|
||||
|
||||
Eigenschappen are per-zaaktype, untyped strings, and readable only by walking the zaak.
|
||||
They inherit the zaak's lifecycle and its archiving regime, and they give the public
|
||||
register no queryable surface of its own. Objecten gives a JSON-schema-validated record
|
||||
(ADR-0027 makes that schema the disclosure boundary), a queryable collection, and a
|
||||
lifecycle the zaak cannot drag around with it.
|
||||
|
||||
### The ACL writes it, not the domain or the Event Subscriber
|
||||
|
||||
CLAUDE.md §8.1 keeps upstream Common Ground modules behind the ACL. Objecten is such a
|
||||
module, so the same rule applies: `ObjectenGateway` is the only code that talks to it,
|
||||
and the domain keeps handing the ACL nothing but a zaak URL. The alternative — having the
|
||||
Event Subscriber write the record when it sees the status notification — would make the
|
||||
register a *second* derived artefact of ZGW, which is exactly the coupling this ADR
|
||||
removes.
|
||||
|
||||
### Two writes, converging rather than transactional
|
||||
|
||||
Approval is now two writes across two modules, so it cannot be atomic. Both are made
|
||||
idempotent instead:
|
||||
|
||||
- a ZGW status is an append-only log entry, so re-setting the eindstatus is harmless;
|
||||
- the register write is an **upsert keyed on the zaak id** — search Objecten for an
|
||||
existing object with that `id`, then PATCH it or POST a new one.
|
||||
|
||||
A caller that retries a half-failed approval therefore converges. This is the same
|
||||
eventual-consistency posture as everywhere else in the system (CLAUDE.md §2.2, §8.6),
|
||||
not an exception carved out for this path.
|
||||
|
||||
### The objecttype is resolved by name, lazily
|
||||
|
||||
The objecttype URL and version number are assigned by Objecttypen at seed time, so they
|
||||
cannot be pinned in config — the ACL resolves them by the configured name
|
||||
(`Acl__Objecten__ObjecttypeName`), taking the highest **published** version. This is the
|
||||
same reasoning as ADR-0021 for zaaktypen.
|
||||
|
||||
Resolution happens on the first approval, not at startup, so the ACL needs no `depends_on`
|
||||
on Objecten and will not crash-loop when it boots ahead of the seed. A failed resolution
|
||||
is not cached, so it is retried on the next approval.
|
||||
|
||||
- ponytail ceiling: the resolution is memoised per gateway instance, and the gateway is a
|
||||
transient typed `HttpClient` — in practice one extra GET per approval against a
|
||||
neighbouring container.
|
||||
- Upgrade path: lift it into a singleton cache (as `CachedZaaktypeCatalog` does for ZGW)
|
||||
if approvals ever get hot enough for that GET to matter.
|
||||
|
||||
### The objecttype's UUID is pinned, not server-assigned
|
||||
|
||||
Objecten refuses to store an object whose objecttype it has not been configured with
|
||||
(`ObjectType with url=… is not configured`), and its configuration identifies an
|
||||
objecttype **by UUID** — supplied through a static `setup_configuration` file applied
|
||||
when the container starts, before the `registerrecord-init` one-shot has run.
|
||||
|
||||
Rather than thread a seed-time UUID from one container into another's config, the UUID is
|
||||
**pinned**: `infra/objecttypen-registerrecord/register.py` creates the objecttype with a
|
||||
fixed UUID (the Objecttypen API accepts a client-supplied one), and
|
||||
`infra/objecten/setup_configuration/data.yaml` declares that same UUID. Both sides are
|
||||
declared up front, both stay idempotent, and neither has to wait for the other.
|
||||
|
||||
The cost is a constant duplicated across two files that must be kept in step; each carries
|
||||
a comment pointing at the other.
|
||||
|
||||
### The ACL must reach Objecttypen at the URL Objecten knows it by
|
||||
|
||||
Objecttypen builds the `url` it returns from the request's own Host header, and Objecten
|
||||
matches an incoming object's `type` against the `api_root` it was configured with. So an
|
||||
ACL that reads Objecttypen at `http://localhost:8020` gets back a `localhost` objecttype
|
||||
URL that Objecten then rejects as "not one of the available choices" — even though it is
|
||||
the same objecttype.
|
||||
|
||||
`Acl__Objecten__ObjecttypenBaseUrl` must therefore match Objecten's configured
|
||||
`api_root` (`http://objecttypen:8000/api/v2/`). This is the same class of constraint as
|
||||
ADR-0006's "point the ACL at OpenZaak's container IP", and it is why the Objecten
|
||||
integration tests only pass from inside the compose network.
|
||||
|
||||
### Objecten's notifications are off for this slice
|
||||
|
||||
Objecten publishes to a Notificaties API on every write, and `notifications_api_common`
|
||||
**raises** rather than skipping when that configuration is absent — so with no NRC wiring,
|
||||
every `POST /api/v2/objects` returns 500 after creating and rolling back the object.
|
||||
|
||||
Objecten → NRC is not wired: there is no broker, no Celery worker, no `objecten` kanaal and
|
||||
no abonnement for it. Configuring only the client side would make writes succeed while
|
||||
every message was dropped on the floor — a delivery path that looks wired and isn't. So
|
||||
`NOTIFICATIONS_DISABLED` is set for Objecten in both compose files instead.
|
||||
|
||||
- ponytail ceiling: Objecten emits no notifications, so nothing downstream can react to a
|
||||
register write yet.
|
||||
- Upgrade path: S-19b (#150) needs those notifications to source the projection from
|
||||
Objecten, and turns them on together with the broker, worker, kanaal and abonnement.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- The register is a first-class record with its own schema, lifecycle and query surface,
|
||||
independent of the case that produced it.
|
||||
- The disclosure boundary is enforced by Objecten's schema validation (ADR-0027), not by
|
||||
discipline in projection code.
|
||||
- The read projection can become a cache of Objecten rather than a re-derivation of ZGW
|
||||
(S-19b, #150).
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- Approval writes to two modules and is eventually consistent; a failure between them
|
||||
leaves a zaak in eindstatus without a register record until the approval is retried.
|
||||
Nothing repairs that automatically yet.
|
||||
- One more upstream module on the approval path, and one more dev credential
|
||||
(`Acl__Objecten__Token`) in compose.
|
||||
- Two new hand-kept constants: the pinned objecttype UUID (two files) and the objecttype
|
||||
name (compose + `register.py`).
|
||||
- Until S-19b lands, the public register is still read from the NRC-derived projection, so
|
||||
the register record is written but not yet read — the two must agree.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None bent. §8.1 is extended in spirit — the ACL is the only code that talks to Objecten,
|
||||
exactly as it is the only code that talks to ZGW. The domain still passes only a zaak URL,
|
||||
and no service reaches Objecten's database.
|
||||
|
||||
## Verification
|
||||
|
||||
The end-to-end assertion lives in the Playwright happy path
|
||||
(`tests/e2e/registration.spec.ts`, run by `verify-e2e`): after the behandelaar approves and
|
||||
the openbaar register shows `INGESCHREVEN`, it asserts Objecten holds exactly one
|
||||
`RegisterRecord` for *that* reference, with status `INGESCHREVEN` and no field outside the
|
||||
public-safe schema.
|
||||
|
||||
It belongs there and not in `verify-domain`, which looks like the obvious home: that check
|
||||
completes the Beoordelen task straight through Flowable REST (deliberately — it exists to
|
||||
exercise the Workflow Client's REST contract), which bypasses the domain `decide` path that
|
||||
calls the ACL. The e2e is the only check that drives a real approval.
|
||||
|
||||
`ObjectenGatewayIntegrationTests` (`Category=Integration`, so it runs under `verify-acl`
|
||||
inside the compose network) drives the real gateway against a live Objecten + Objecttypen
|
||||
pair: two writes for the same id leave exactly one object, carrying the second write's
|
||||
status and nothing outside the public-safe schema.
|
||||
|
||||
All three findings above — the pinned UUID, the notifications block, and the base-URL
|
||||
constraint — came out of running the gateway against those live modules while writing the
|
||||
slice, not out of CI.
|
||||
@@ -5,6 +5,287 @@ copy-pasteable walkthrough against a local `make up` stack.
|
||||
|
||||
---
|
||||
|
||||
## S-19a — approval writes the register record to Objecten (#149, ADR-0028)
|
||||
|
||||
**Outcome:** approving a registration no longer only moves the ZGW zaak to its eindstatus — it also
|
||||
writes the canonical **register record** into the **Objecten** API. OpenZaak keeps the process,
|
||||
Objecten holds the register. The write goes through the ACL (§8.1) and is **idempotent**: replaying an
|
||||
approval updates the existing object instead of creating a second one.
|
||||
|
||||
```bash
|
||||
# 1. Bring the stack up (Objecten, Objecttypen and the RegisterRecord objecttype come with it).
|
||||
make up
|
||||
#
|
||||
# 2. End-to-end: the walking-skeleton e2e submits, approves via the behandel portal, and then
|
||||
# asserts Objecten holds exactly one RegisterRecord for *that* registration:
|
||||
make verify-e2e # → "DigiD submit → … → behandelaar goedkeurt → public INGESCHREVEN"
|
||||
#
|
||||
# 3. The ACL integration test proves the same writes against a live Objecten (upsert stays one object):
|
||||
make verify-acl # → "Writes a register record and updates it in place on a second write"
|
||||
#
|
||||
# 4. See it for yourself — every register record currently in Objecten:
|
||||
curl -s -H 'Authorization: Token 1234567890abcdef1234567890abcdef12345678' \
|
||||
-H 'Accept-Crs: EPSG:4326' \
|
||||
'http://localhost:8021/api/v2/objects' | python3 -m json.tool
|
||||
```
|
||||
|
||||
Each object's `record.data` carries exactly `id`, `status`, `reference` — the schema forbids anything
|
||||
else (ADR-0027), so no personal data can reach the world-readable register even by mistake.
|
||||
|
||||
**The path:** behandel portal → BFF → domain `BeoordeelRegistratie` → ACL `POST /statussen` → ZGW
|
||||
`resultaten` + `statussen` (the process), **then** ACL → Objecten `POST`/`PATCH /api/v2/objects` (the
|
||||
register). The objecttype URL is resolved by name from Objecttypen on first use, so nothing seed-time
|
||||
is pinned in config (ADR-0028, same reasoning as ADR-0021).
|
||||
|
||||
**Not yet:** the public register still reads the NRC-derived projection — re-sourcing it from Objecten
|
||||
is S-19b (#150).
|
||||
|
||||
---
|
||||
|
||||
## S-18c — RegisterRecord objecttype defined + registered (#141, ADR-0027)
|
||||
|
||||
**Outcome:** a **RegisterRecord** objecttype with a **published** JSON schema is registered in the
|
||||
Objecttypen API at startup. The schema is public-safe by construction — `id`, `status`, `reference`
|
||||
only, mirroring the BFF's `OpenbaarEntry` (no `bsn`/`naam`), `dataClassification: open`. This is the
|
||||
schema S-19 writes register records against on approval. A `registerrecord-init` one-shot creates it
|
||||
over the API once Objecttypen is healthy (the Objecttypen `setup_configuration` has no objecttype
|
||||
step), idempotently.
|
||||
|
||||
```bash
|
||||
make up
|
||||
# The RegisterRecord objecttype exists with a published version:
|
||||
curl -s -H "Authorization: Token 0123456789abcdef0123456789abcdef01234567" \
|
||||
"http://localhost:8020/api/v2/objecttypes" | python3 -c \
|
||||
'import sys,json; o=[x for x in json.load(sys.stdin)["results"] if x["name"]=="RegisterRecord"][0]; print(o["name"], o["dataClassification"], o["versions"])'
|
||||
# → RegisterRecord open ['http://.../objecttypes/<uuid>/versions/1']
|
||||
#
|
||||
# Automated (a CI verify-stack step): asserts the objecttype exists, has a published version, and
|
||||
# that version's schema carries id/status/reference.
|
||||
make verify-registerrecord # → OK — RegisterRecord v1 published, fields=['id', 'reference', 'status']
|
||||
```
|
||||
|
||||
**The path:** `infra/objecttypen-registerrecord/registerrecord.schema.json` (the reviewed public-safe
|
||||
contract) + `register.py` are streamed into an external config volume by `infra/seed-config.sh
|
||||
registerrecord` (bind-mounted locally); the `registerrecord-init` one-shot POSTs the objecttype + a
|
||||
published version. Re-running is a no-op. S-19 (#20) writes records against this schema in Objecten.
|
||||
|
||||
---
|
||||
|
||||
## S-18b — Objecten API up in compose, wired to Objecttypen (#140)
|
||||
|
||||
**Outcome:** the upstream Maykin **Objecten API** runs in the stack — own **PostGIS** DB + redis,
|
||||
config seeded like the other CG modules (`objecten-init` runs `setup_configuration` from the
|
||||
`rr-objecten-config` volume: migrate + provision a dev **static API token** + register the
|
||||
**Objecttypen API** (S-18a) as a trusted service), a health-checked `objecten` web on host `:8021`.
|
||||
An object can now reference its objecttype; the ACL writes register records here on approval (S-19).
|
||||
|
||||
```bash
|
||||
make up
|
||||
# 1. The API is up; the seeded token authenticates (401 without, 200 with):
|
||||
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8021/api/v2/objects # 401
|
||||
curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Token 1234567890abcdef1234567890abcdef12345678" \
|
||||
http://localhost:8021/api/v2/objects # 200
|
||||
#
|
||||
# 2. It trusts Objecttypen — the seeded zgw_consumers service points at the Objecttypen API:
|
||||
docker exec infra-objecten-1 python src/manage.py shell -c \
|
||||
"from zgw_consumers.models import Service; print(*[(s.slug,s.api_root) for s in Service.objects.all()])"
|
||||
# → ('objecttypen', 'http://objecttypen:8000/api/v2/')
|
||||
#
|
||||
# 3. Automated (a CI verify-stack step): asserts unauth 401 + token 200, against the running stack.
|
||||
make verify-objecten # → OK — no-auth 401, token 200
|
||||
```
|
||||
|
||||
**The path:** verbatim upstream image (`maykinmedia/objects-api`, pinned 3.4.0) + the same seed
|
||||
pattern as S-18a — `infra/seed-config.sh objecten` streams `data.yaml` into an external config
|
||||
volume, `objecten-init` (RUN_SETUP_CONFIG) applies it. Its `zgw_consumers` step registers Objecttypen
|
||||
(`api_type: orc`, api-key auth with the S-18a dev token). The RegisterRecord objecttype (S-18c) and
|
||||
the ACL write path (S-19) build on this.
|
||||
|
||||
---
|
||||
|
||||
## S-18a — Objecttypen API up in compose (#139)
|
||||
|
||||
**Outcome:** the upstream Maykin **Objecttypen API** runs in the stack — own Postgres + redis, config
|
||||
seeded like the other CG modules (`objecttypen-init` runs `setup_configuration` from the
|
||||
`rr-objecttypen-config` volume: migrate + provision a dev **static API token**), a health-checked
|
||||
`objecttypen` web service on host `:8020`. This is the objecttype catalogue the register record
|
||||
(S-18b/S-18c, S-19) will use.
|
||||
|
||||
```bash
|
||||
make up
|
||||
# 1. The API is up; the seeded token authenticates (401 without, 200 with):
|
||||
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8020/api/v2/objecttypes # 401
|
||||
curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Token 0123456789abcdef0123456789abcdef01234567" \
|
||||
http://localhost:8020/api/v2/objecttypes # 200
|
||||
#
|
||||
# 2. Automated (a CI verify-stack step): asserts both, against the running stack.
|
||||
make verify-objecttypen # → OK — no-auth 401, token 200
|
||||
```
|
||||
|
||||
**The path:** verbatim upstream image (`maykinmedia/objecttypes-api`, pinned) + the same seed pattern
|
||||
as OpenZaak/NRC — `infra/seed-config.sh objecttypen` streams `data.yaml` into an external config
|
||||
volume, `objecttypen-init` (RUN_SETUP_CONFIG) applies it. Objecten (S-18b) and the RegisterRecord
|
||||
objecttype (S-18c) build on this.
|
||||
|
||||
---
|
||||
|
||||
## S-15b — Beheer-portal: default-fill configuration editor (#131, ADR-0026)
|
||||
|
||||
**Outcome:** a beheerder edits the ACL's ZGW **default-fill** values (bronorganisatie,
|
||||
verantwoordelijke organisatie, vertrouwelijkheidaanduiding) from the beheer portal, and the next zaak
|
||||
is stamped with the new values — no restart. Path: portal → BFF `GET/PUT /beheer/default-fill`
|
||||
(beheerder role) → ACL `GET/PUT /default-fill` → a runtime-mutable in-memory store the ACL reads per
|
||||
zaak (ADR-0026). The S-27 catalog-resolution keys stay static config (editing them would desync the
|
||||
zaaktype cache). Store is in-memory: an edit reverts to the configured env on restart.
|
||||
|
||||
```bash
|
||||
make up
|
||||
# 1. Log in as bram-beheerder / test123 → "Default-fill" tab → change a value → Opslaan.
|
||||
open http://localhost:8143/default-fill
|
||||
#
|
||||
# 2. Automated: the ACL uses the current default-fill per zaak (unit) and the endpoints are behind the
|
||||
# beheerder role (BFF unit):
|
||||
# Acl.Tests → AclServiceTests.Opening_a_zaak_reflects_a_default_fill_update
|
||||
# Bff.Tests → BeheerDefaultFillEndpointTests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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
|
||||
**401** without the configured `Authorization`. The verify sink
|
||||
(`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.
|
||||
|
||||
@@ -56,6 +56,10 @@ services:
|
||||
oz-init:
|
||||
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
|
||||
environment: &oz-env
|
||||
# 1 uWSGI worker, not the image default of 4×4 (#147) — idle workers pressure the runner; the
|
||||
# -init/-celery containers share this anchor and ignore it (they don't run uwsgi).
|
||||
UWSGI_PROCESSES: "1"
|
||||
UWSGI_THREADS: "2"
|
||||
DJANGO_SETTINGS_MODULE: openzaak.conf.docker
|
||||
SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production}
|
||||
DB_HOST: oz-db
|
||||
@@ -138,6 +142,9 @@ services:
|
||||
# bind-mounted here (this twin is the local/no-make path). See ADR-0007.
|
||||
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
|
||||
environment: &nrc-env
|
||||
# 1 uWSGI worker, not the image default of 4×4 (#147) — see the oz-env note above.
|
||||
UWSGI_PROCESSES: "1"
|
||||
UWSGI_THREADS: "2"
|
||||
DJANGO_SETTINGS_MODULE: nrc.conf.docker
|
||||
SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production}
|
||||
DB_HOST: nrc-db
|
||||
@@ -331,6 +338,14 @@ services:
|
||||
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
|
||||
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
|
||||
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
|
||||
# Objecten holds the register, OpenZaak holds the process (S-19a, ADR-0028). Both APIs take a
|
||||
# static token, not a ZGW JWT. The objecttype URL is assigned at seed time, so the ACL resolves
|
||||
# it by name — lazily, on the first approval, so no depends_on is needed here.
|
||||
Acl__Objecten__BaseUrl: http://objecten:8000/
|
||||
Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}
|
||||
Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/
|
||||
Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
|
||||
Acl__Objecten__ObjecttypeName: RegisterRecord
|
||||
ports:
|
||||
- "8100:8080"
|
||||
volumes:
|
||||
@@ -560,11 +575,164 @@ services:
|
||||
condition: service_started
|
||||
networks: [cg]
|
||||
|
||||
# ── Objecttypen API (S-18a) — bind-mounted config (local variant) ──────────
|
||||
objecttypen-db:
|
||||
image: docker.io/library/postgres:17-alpine
|
||||
environment:
|
||||
POSTGRES_USER: objecttypes
|
||||
POSTGRES_PASSWORD: objecttypes
|
||||
POSTGRES_DB: objecttypes
|
||||
volumes:
|
||||
- objecttypen-db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U objecttypes"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
networks: [cg]
|
||||
|
||||
objecttypen-redis:
|
||||
image: docker.io/library/redis:7
|
||||
networks: [cg]
|
||||
|
||||
objecttypen-init:
|
||||
image: docker.io/maykinmedia/objecttypes-api:${OBJECTTYPES_TAG:-3.4.2}
|
||||
environment: &objecttypen-env-local
|
||||
# 1 uWSGI worker, not the image default of 4×4 (#144) — idle workers starve the CI runner.
|
||||
UWSGI_PROCESSES: "1"
|
||||
UWSGI_THREADS: "2"
|
||||
DJANGO_SETTINGS_MODULE: objecttypes.conf.docker
|
||||
SECRET_KEY: ${OBJECTTYPES_SECRET_KEY:-dev-only-not-for-production}
|
||||
DB_HOST: objecttypen-db
|
||||
DB_NAME: objecttypes
|
||||
DB_USER: objecttypes
|
||||
DB_PASSWORD: objecttypes
|
||||
ALLOWED_HOSTS: "*"
|
||||
CACHE_DEFAULT: objecttypen-redis:6379/0
|
||||
CACHE_AXES: objecttypen-redis:6379/0
|
||||
DISABLE_2FA: "true"
|
||||
OTEL_SDK_DISABLED: "true"
|
||||
RUN_SETUP_CONFIG: "true"
|
||||
command: /setup_configuration.sh
|
||||
volumes:
|
||||
- ./objecttypen/setup_configuration:/app/setup_configuration:ro,z
|
||||
depends_on:
|
||||
objecttypen-db:
|
||||
condition: service_healthy
|
||||
objecttypen-redis:
|
||||
condition: service_started
|
||||
networks: [cg]
|
||||
|
||||
objecttypen:
|
||||
image: docker.io/maykinmedia/objecttypes-api:${OBJECTTYPES_TAG:-3.4.2}
|
||||
environment: *objecttypen-env-local
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
ports:
|
||||
- "8020:8000"
|
||||
depends_on:
|
||||
objecttypen-init:
|
||||
condition: service_completed_successfully
|
||||
networks: [cg]
|
||||
|
||||
# ── RegisterRecord objecttype (S-18c) — API-seeded one-shot (local variant) ─
|
||||
registerrecord-init:
|
||||
image: docker.io/library/python:3-slim
|
||||
environment:
|
||||
OBJECTTYPEN: http://objecttypen:8000
|
||||
OBJECTTYPEN_TOKEN: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
|
||||
SCHEMA: /config/registerrecord.schema.json
|
||||
command: python /config/register.py
|
||||
volumes:
|
||||
- ./objecttypen-registerrecord:/config:ro,z
|
||||
depends_on:
|
||||
objecttypen:
|
||||
condition: service_healthy
|
||||
networks: [cg]
|
||||
|
||||
# ── Objecten API (S-18b) — bind-mounted config (local variant) ─────────────
|
||||
objecten-db:
|
||||
image: docker.io/postgis/postgis:17-3.5
|
||||
environment:
|
||||
POSTGRES_USER: objects
|
||||
POSTGRES_PASSWORD: objects
|
||||
POSTGRES_DB: objects
|
||||
volumes:
|
||||
- objecten-db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U objects"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
networks: [cg]
|
||||
|
||||
objecten-redis:
|
||||
image: docker.io/library/redis:7
|
||||
networks: [cg]
|
||||
|
||||
objecten-init:
|
||||
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
|
||||
environment: &objecten-env-local
|
||||
# 1 uWSGI worker, not the image default of 4×4 (#144) — idle workers starve the CI runner.
|
||||
UWSGI_PROCESSES: "1"
|
||||
UWSGI_THREADS: "2"
|
||||
DJANGO_SETTINGS_MODULE: objects.conf.docker
|
||||
SECRET_KEY: ${OBJECTS_SECRET_KEY:-dev-only-not-for-production}
|
||||
DB_HOST: objecten-db
|
||||
DB_NAME: objects
|
||||
DB_USER: objects
|
||||
DB_PASSWORD: objects
|
||||
ALLOWED_HOSTS: "*"
|
||||
CACHE_DEFAULT: objecten-redis:6379/0
|
||||
CACHE_AXES: objecten-redis:6379/0
|
||||
DISABLE_2FA: "true"
|
||||
OTEL_SDK_DISABLED: "true"
|
||||
# S-19a: Objecten refuses every write while its Notificaties config is absent
|
||||
# (notifications_api_common raises rather than skipping, so POST /objects 500s). Objecten →
|
||||
# NRC is not wired yet — there is no broker, worker, kanaal or abonnement for it — so turn
|
||||
# notifications off rather than fake a delivery path that silently drops every message.
|
||||
# S-19b (#150) sources the projection from Objecten and turns this back on for real.
|
||||
NOTIFICATIONS_DISABLED: "true"
|
||||
RUN_SETUP_CONFIG: "true"
|
||||
command: /setup_configuration.sh
|
||||
volumes:
|
||||
- ./objecten/setup_configuration:/app/setup_configuration:ro,z
|
||||
depends_on:
|
||||
objecten-db:
|
||||
condition: service_healthy
|
||||
objecten-redis:
|
||||
condition: service_started
|
||||
objecttypen:
|
||||
condition: service_healthy
|
||||
networks: [cg]
|
||||
|
||||
objecten:
|
||||
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
|
||||
environment: *objecten-env-local
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
ports:
|
||||
- "8021:8000"
|
||||
depends_on:
|
||||
objecten-init:
|
||||
condition: service_completed_successfully
|
||||
networks: [cg]
|
||||
|
||||
volumes:
|
||||
oz-db:
|
||||
nrc-db:
|
||||
flowable-db:
|
||||
projection-db:
|
||||
objecttypen-db:
|
||||
objecten-db:
|
||||
# Carries the seed-generated acl.env (server-assigned zaaktype URLs) from local-seed to the ACL.
|
||||
seed-env:
|
||||
|
||||
|
||||
@@ -51,6 +51,12 @@ services:
|
||||
oz-init:
|
||||
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
|
||||
environment: &oz-env
|
||||
# 1 uWSGI worker, not the image default of 4×4 (#147, same lever as #145): OpenZaak serves
|
||||
# single-request smoke checks here and is not load-tested, so 4 idle Django workers just pin
|
||||
# ~800 MB and pressure the shared runner. The -init (setup_configuration) and -celery containers
|
||||
# share this anchor and ignore it — they don't run uwsgi.
|
||||
UWSGI_PROCESSES: "1"
|
||||
UWSGI_THREADS: "2"
|
||||
DJANGO_SETTINGS_MODULE: openzaak.conf.docker
|
||||
SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production}
|
||||
DB_HOST: oz-db
|
||||
@@ -135,6 +141,9 @@ services:
|
||||
# needs no baked config.
|
||||
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
|
||||
environment: &nrc-env
|
||||
# 1 uWSGI worker, not the image default of 4×4 (#147) — see the oz-env note above.
|
||||
UWSGI_PROCESSES: "1"
|
||||
UWSGI_THREADS: "2"
|
||||
DJANGO_SETTINGS_MODULE: nrc.conf.docker
|
||||
SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production}
|
||||
DB_HOST: nrc-db
|
||||
@@ -296,6 +305,10 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
image: register-referentie/acl:dev
|
||||
environment:
|
||||
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
|
||||
OTEL_SERVICE_NAME: acl
|
||||
# Overridable so verify-domain can point the ACL at the same OpenZaak host that
|
||||
# owns the seeded zaaktype URL (host-consistent zaak creation, ADR-0009).
|
||||
Acl__OpenZaak__BaseUrl: ${ACL_OPENZAAK_BASEURL:-http://openzaak:8000/}
|
||||
@@ -310,6 +323,14 @@ services:
|
||||
# so verify-domain still points the ACL at OpenZaak's container IP.
|
||||
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
|
||||
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
|
||||
# Objecten holds the register, OpenZaak holds the process (S-19a, ADR-0028). Both APIs take a
|
||||
# static token, not a ZGW JWT. The objecttype URL is assigned at seed time, so the ACL resolves
|
||||
# it by name — lazily, on the first approval, so no depends_on is needed here.
|
||||
Acl__Objecten__BaseUrl: http://objecten:8000/
|
||||
Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}
|
||||
Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/
|
||||
Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
|
||||
Acl__Objecten__ObjecttypeName: RegisterRecord
|
||||
ports:
|
||||
- "8100:8080"
|
||||
healthcheck:
|
||||
@@ -334,6 +355,10 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
image: register-referentie/domain:dev
|
||||
environment:
|
||||
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
|
||||
OTEL_SERVICE_NAME: domain
|
||||
Flowable__BaseUrl: http://flowable-rest:8080/flowable-rest/
|
||||
Flowable__Username: rest-admin
|
||||
Flowable__Password: test
|
||||
@@ -360,6 +385,10 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
image: register-referentie/bff:dev
|
||||
environment:
|
||||
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
|
||||
OTEL_SERVICE_NAME: bff
|
||||
# The BFF is the portals' only backend; it validates digid tokens and fans out (ADR-0010).
|
||||
# Keycloak (start-dev) derives the issuer from the request host, so the BFF authority and the
|
||||
# verify token request both use keycloak:8080 to keep the issuer consistent.
|
||||
@@ -368,6 +397,8 @@ services:
|
||||
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
|
||||
Downstream__Domain__BaseUrl: http://domain:8080/
|
||||
Downstream__Projection__BaseUrl: http://projection-api:8080/
|
||||
# The beheer catalogus read reaches the ACL directly (S-15a, ADR-0025).
|
||||
Downstream__Acl__BaseUrl: http://acl:8080/
|
||||
ports:
|
||||
- "8080:8080"
|
||||
healthcheck:
|
||||
@@ -412,6 +443,10 @@ services:
|
||||
dockerfile: services/event-subscriber/Dockerfile
|
||||
image: register-referentie/event-subscriber:dev
|
||||
environment:
|
||||
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
|
||||
OTEL_SERVICE_NAME: event-subscriber
|
||||
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
|
||||
# The subscriber enriches the projection with each zaak's reference (identificatie) by asking
|
||||
# the ACL — the only code allowed to read ZGW (§8.1, #78).
|
||||
@@ -441,6 +476,10 @@ services:
|
||||
dockerfile: services/projection-api/Dockerfile
|
||||
image: register-referentie/projection-api:dev
|
||||
environment:
|
||||
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
|
||||
OTEL_SERVICE_NAME: projection-api
|
||||
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
|
||||
ports:
|
||||
- "8120:8080"
|
||||
@@ -524,11 +563,249 @@ services:
|
||||
condition: service_started
|
||||
networks: [cg]
|
||||
|
||||
# The beheer portal: nginx serves the Angular app and reverse-proxies /beheer to the BFF.
|
||||
# Beheerders log in against the Keycloak medewerker realm (same realm as behandel, S-15a).
|
||||
beheer:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: apps/beheer/Dockerfile
|
||||
image: register-referentie/beheer:dev
|
||||
ports:
|
||||
- "8143:80"
|
||||
healthcheck:
|
||||
# 127.0.0.1, not localhost: nginx listens on IPv4 only, but localhost resolves to ::1 first.
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
depends_on:
|
||||
bff:
|
||||
condition: service_healthy
|
||||
keycloak:
|
||||
condition: service_started
|
||||
networks: [cg]
|
||||
|
||||
# ── Objecttypen API (S-18a) — upstream Maykin image, verbatim ──────────────
|
||||
# The register's objecttype catalogue. Same shape as the other CG modules: own DB + redis, an
|
||||
# `-init` that runs setup_configuration (RUN_SETUP_CONFIG → migrate + provision a static API token)
|
||||
# from the external config volume streamed in by infra/seed-config.sh, and a health-checked web
|
||||
# service that depends on init completing.
|
||||
objecttypen-db:
|
||||
image: docker.io/library/postgres:17-alpine
|
||||
environment:
|
||||
POSTGRES_USER: objecttypes
|
||||
POSTGRES_PASSWORD: objecttypes
|
||||
POSTGRES_DB: objecttypes
|
||||
volumes:
|
||||
- objecttypen-db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U objecttypes"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
networks: [cg]
|
||||
|
||||
objecttypen-redis:
|
||||
image: docker.io/library/redis:7
|
||||
networks: [cg]
|
||||
|
||||
objecttypen-init:
|
||||
image: docker.io/maykinmedia/objecttypes-api:${OBJECTTYPES_TAG:-3.4.2}
|
||||
environment: &objecttypen-env
|
||||
# 1 uWSGI worker, not the image default of 4×4: this API only serves single-request smoke
|
||||
# checks and sits idle during the e2e step — 4 idle Django workers each pin ~200 MB and starve
|
||||
# the shared CI runner (#144). Init ignores this (it runs setup_configuration, not uwsgi).
|
||||
UWSGI_PROCESSES: "1"
|
||||
UWSGI_THREADS: "2"
|
||||
DJANGO_SETTINGS_MODULE: objecttypes.conf.docker
|
||||
SECRET_KEY: ${OBJECTTYPES_SECRET_KEY:-dev-only-not-for-production}
|
||||
DB_HOST: objecttypen-db
|
||||
DB_NAME: objecttypes
|
||||
DB_USER: objecttypes
|
||||
DB_PASSWORD: objecttypes
|
||||
ALLOWED_HOSTS: "*"
|
||||
CACHE_DEFAULT: objecttypen-redis:6379/0
|
||||
CACHE_AXES: objecttypen-redis:6379/0
|
||||
DISABLE_2FA: "true"
|
||||
OTEL_SDK_DISABLED: "true"
|
||||
RUN_SETUP_CONFIG: "true"
|
||||
command: /setup_configuration.sh
|
||||
# data.yaml is streamed into this external volume by infra/seed-config.sh before start.
|
||||
volumes:
|
||||
- objecttypen-config:/app/setup_configuration:ro
|
||||
depends_on:
|
||||
objecttypen-db:
|
||||
condition: service_healthy
|
||||
objecttypen-redis:
|
||||
condition: service_started
|
||||
networks: [cg]
|
||||
|
||||
objecttypen:
|
||||
image: docker.io/maykinmedia/objecttypes-api:${OBJECTTYPES_TAG:-3.4.2}
|
||||
environment: *objecttypen-env
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
ports:
|
||||
- "8020:8000"
|
||||
depends_on:
|
||||
objecttypen-init:
|
||||
condition: service_completed_successfully
|
||||
networks: [cg]
|
||||
|
||||
# ── RegisterRecord objecttype (S-18c) — API-seeded one-shot ────────────────
|
||||
# The Objecttypen setup_configuration (3.4.2) can only provision tokens — no declarative objecttype
|
||||
# step — so this one-shot creates the RegisterRecord objecttype + a published version over the API
|
||||
# once Objecttypen is healthy (idempotent; ADR-0020 self-seed, ADR-0027 schema). The schema + script
|
||||
# are streamed into the external config volume by infra/seed-config.sh, like the *-init volumes.
|
||||
registerrecord-init:
|
||||
image: docker.io/library/python:3-slim
|
||||
environment:
|
||||
OBJECTTYPEN: http://objecttypen:8000
|
||||
OBJECTTYPEN_TOKEN: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
|
||||
SCHEMA: /config/registerrecord.schema.json
|
||||
command: python /config/register.py
|
||||
volumes:
|
||||
- registerrecord-config:/config:ro
|
||||
depends_on:
|
||||
objecttypen:
|
||||
condition: service_healthy
|
||||
networks: [cg]
|
||||
|
||||
# ── Objecten API (S-18b) — upstream Maykin image, verbatim ─────────────────
|
||||
# The authoritative object store. Same shape as Objecttypen (own DB + redis, an `-init` that runs
|
||||
# setup_configuration from the external config volume, a health-checked web). Two differences: the
|
||||
# DB is PostGIS (objects carry geometry), and setup_configuration registers the Objecttypen API
|
||||
# (S-18a) as a trusted service so an object can reference its objecttype.
|
||||
objecten-db:
|
||||
image: docker.io/postgis/postgis:17-3.5
|
||||
environment:
|
||||
POSTGRES_USER: objects
|
||||
POSTGRES_PASSWORD: objects
|
||||
POSTGRES_DB: objects
|
||||
volumes:
|
||||
- objecten-db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U objects"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
networks: [cg]
|
||||
|
||||
objecten-redis:
|
||||
image: docker.io/library/redis:7
|
||||
networks: [cg]
|
||||
|
||||
objecten-init:
|
||||
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
|
||||
environment: &objecten-env
|
||||
# 1 uWSGI worker, not the image default of 4×4 — see the objecttypen note above (#144).
|
||||
UWSGI_PROCESSES: "1"
|
||||
UWSGI_THREADS: "2"
|
||||
DJANGO_SETTINGS_MODULE: objects.conf.docker
|
||||
SECRET_KEY: ${OBJECTS_SECRET_KEY:-dev-only-not-for-production}
|
||||
DB_HOST: objecten-db
|
||||
DB_NAME: objects
|
||||
DB_USER: objects
|
||||
DB_PASSWORD: objects
|
||||
ALLOWED_HOSTS: "*"
|
||||
CACHE_DEFAULT: objecten-redis:6379/0
|
||||
CACHE_AXES: objecten-redis:6379/0
|
||||
DISABLE_2FA: "true"
|
||||
OTEL_SDK_DISABLED: "true"
|
||||
# S-19a: Objecten refuses every write while its Notificaties config is absent
|
||||
# (notifications_api_common raises rather than skipping, so POST /objects 500s). Objecten →
|
||||
# NRC is not wired yet — there is no broker, worker, kanaal or abonnement for it — so turn
|
||||
# notifications off rather than fake a delivery path that silently drops every message.
|
||||
# S-19b (#150) sources the projection from Objecten and turns this back on for real.
|
||||
NOTIFICATIONS_DISABLED: "true"
|
||||
RUN_SETUP_CONFIG: "true"
|
||||
command: /setup_configuration.sh
|
||||
# data.yaml is streamed into this external volume by infra/seed-config.sh before start.
|
||||
volumes:
|
||||
- objecten-config:/app/setup_configuration:ro
|
||||
depends_on:
|
||||
objecten-db:
|
||||
condition: service_healthy
|
||||
objecten-redis:
|
||||
condition: service_started
|
||||
# Objecten's setup_configuration registers the Objecttypen service; that service only needs to
|
||||
# exist as config, but wait for Objecttypen to be up so the register is meaningful end to end.
|
||||
objecttypen:
|
||||
condition: service_healthy
|
||||
networks: [cg]
|
||||
|
||||
objecten:
|
||||
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
|
||||
environment: *objecten-env
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
ports:
|
||||
- "8021:8000"
|
||||
depends_on:
|
||||
objecten-init:
|
||||
condition: service_completed_successfully
|
||||
networks: [cg]
|
||||
|
||||
# ── Observability backplane (S-16a, ADR-0023) ──────────────────────────────
|
||||
# Grafana-native stack: Tempo ingests OTLP traces (the .NET services export
|
||||
# straight to it — no collector hop, S-16b), Prometheus scrapes service
|
||||
# /metrics (S-16c), and Grafana reads both with datasources auto-provisioned.
|
||||
# Config is baked into small built images (COPY) rather than streamed into
|
||||
# external config volumes like the upstream CG modules — these aren't verbatim
|
||||
# peer images, so a built image is the simpler path that still reaches sibling
|
||||
# containers on the CI runner. Not in WAIT_SVCS: run-observability-check.sh
|
||||
# polls Grafana itself, so no in-image healthcheck tool is needed.
|
||||
tempo:
|
||||
build:
|
||||
context: ./observability/tempo
|
||||
image: register-referentie/tempo:dev
|
||||
command: ["-config.file=/etc/tempo.yaml"]
|
||||
# Cap the backplane's footprint so it can't starve the app stack + the Playwright browser on the
|
||||
# memory-tight CI runner (verify-e2e OOM history, commit d5e5fa2). Generous vs idle (~150M).
|
||||
mem_limit: 400m
|
||||
networks: [cg]
|
||||
|
||||
prometheus:
|
||||
build:
|
||||
context: ./observability/prometheus
|
||||
image: register-referentie/prometheus:dev
|
||||
mem_limit: 400m
|
||||
ports:
|
||||
- "9090:9090"
|
||||
networks: [cg]
|
||||
|
||||
grafana:
|
||||
build:
|
||||
context: ./observability/grafana
|
||||
image: register-referentie/grafana:dev
|
||||
mem_limit: 512m
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_USER: admin
|
||||
GF_SECURITY_ADMIN_PASSWORD: admin
|
||||
GF_AUTH_ANONYMOUS_ENABLED: "true"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- tempo
|
||||
- prometheus
|
||||
networks: [cg]
|
||||
|
||||
volumes:
|
||||
oz-db:
|
||||
nrc-db:
|
||||
flowable-db:
|
||||
projection-db:
|
||||
objecttypen-db:
|
||||
objecten-db:
|
||||
# Config volumes — created and populated out-of-band by infra/seed-config.sh
|
||||
# (docker cp), because bind mounts don't reach sibling containers on the CI
|
||||
# runner. `external` keeps the names deterministic; the seed step manages them.
|
||||
@@ -544,6 +821,15 @@ volumes:
|
||||
fl-bpmn:
|
||||
external: true
|
||||
name: rr-fl-bpmn
|
||||
objecttypen-config:
|
||||
external: true
|
||||
name: rr-objecttypen-config
|
||||
registerrecord-config:
|
||||
external: true
|
||||
name: rr-registerrecord-config
|
||||
objecten-config:
|
||||
external: true
|
||||
name: rr-objecten-config
|
||||
|
||||
networks:
|
||||
cg:
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"roles": {
|
||||
"realm": [
|
||||
{ "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": [
|
||||
@@ -54,6 +55,16 @@
|
||||
"emailVerified": true,
|
||||
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
||||
"realmRoles": ["behandelaar", "teamlead"]
|
||||
},
|
||||
{
|
||||
"username": "bram-beheerder",
|
||||
"enabled": true,
|
||||
"firstName": "Bram",
|
||||
"lastName": "Beheerder",
|
||||
"email": "bram@big.example.nl",
|
||||
"emailVerified": true,
|
||||
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
||||
"realmRoles": ["beheerder"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
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,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""S-18b (#140): prove the Objecten API is up and its static token authenticates.
|
||||
|
||||
Assert an unauthenticated call to /api/v2/objects is 401 and an authenticated one (the seeded dev
|
||||
token) is 200 — i.e. the service migrated, booted, and setup_configuration provisioned the token
|
||||
and the Objecttypen service it trusts. Stdlib only so it runs in a bare python:3-slim container on
|
||||
the compose network.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
BASE = os.environ["OBJECTEN"] # http://<ip>:8000
|
||||
TOKEN = os.environ["OBJECTEN_TOKEN"]
|
||||
TIMEOUT = int(os.environ.get("OBJECTEN_TIMEOUT", "60"))
|
||||
|
||||
|
||||
def status(url, token=None):
|
||||
req = urllib.request.Request(url)
|
||||
if token:
|
||||
req.add_header("Authorization", f"Token {token}")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return r.status
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
url = f"{BASE}/api/v2/objects"
|
||||
deadline = time.time() + TIMEOUT
|
||||
while time.time() < deadline:
|
||||
unauth = status(url)
|
||||
authed = status(url, TOKEN)
|
||||
if unauth == 401 and authed == 200:
|
||||
print(f"OK — {url}: no-auth {unauth}, token {authed}")
|
||||
return 0
|
||||
time.sleep(3)
|
||||
print(f"FAIL — {url}: expected no-auth 401 + token 200, got {status(url)} / {status(url, TOKEN)}",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,42 @@
|
||||
# Objecten API setup_configuration (S-18b). Streamed into the external rr-objecten-config volume by
|
||||
# infra/seed-config.sh and applied by objecten-init (RUN_SETUP_CONFIG). Declarative + idempotent.
|
||||
#
|
||||
# Two things: (1) register the Objecttypen API (S-18a) as a trusted service so an object can
|
||||
# reference its objecttype — authenticating with the dev static token Objecttypen provisioned; and
|
||||
# (2) a dev static token so peers (the ACL, S-19) can write objects here. Dev-only, not for prod.
|
||||
|
||||
# (1) Trust the Objecttypen API. `orc` = overige RESTful component (how zgw_consumers classifies the
|
||||
# Objecttypen API). The RegisterRecord objecttype (S-18c) will reference an objecttype under this
|
||||
# service by uuid.
|
||||
zgw_consumers_config_enable: true
|
||||
zgw_consumers:
|
||||
services:
|
||||
- identifier: objecttypen
|
||||
label: Objecttypen API
|
||||
api_type: orc
|
||||
api_root: http://objecttypen:8000/api/v2/
|
||||
auth_type: api_key
|
||||
header_key: Authorization
|
||||
header_value: Token 0123456789abcdef0123456789abcdef01234567
|
||||
|
||||
# (2) Permit the RegisterRecord objecttype (S-19a). Objecten refuses to store an object whose
|
||||
# objecttype it has not been configured with ("ObjectType with url=… is not configured"), and it
|
||||
# identifies one by uuid — which is why infra/objecttypen-registerrecord/register.py pins that uuid
|
||||
# instead of letting Objecttypen assign one. Keep the two in step.
|
||||
objecttypes_config_enable: true
|
||||
objecttypes:
|
||||
items:
|
||||
- uuid: 1f4b4e26-8b1f-4e2f-9d6c-6a1b7a2f0e01
|
||||
name: RegisterRecord
|
||||
service_identifier: objecttypen
|
||||
|
||||
# (3) Static API token peers use to write/read objects.
|
||||
tokenauth_config_enable: true
|
||||
tokenauth:
|
||||
items:
|
||||
- identifier: register-referentie
|
||||
token: 1234567890abcdef1234567890abcdef12345678
|
||||
contact_person: Register Referentie
|
||||
email: admin@localhost
|
||||
organization: Respellion
|
||||
is_superuser: true
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
"""S-18a (#139): prove the Objecttypen API is up and its static token authenticates.
|
||||
|
||||
Assert an unauthenticated call to /api/v2/objecttypes is 401 and an authenticated one (the seeded
|
||||
dev token) is 200 — i.e. the service migrated, booted, and setup_configuration provisioned the token.
|
||||
Stdlib only so it runs in a bare python:3-slim container on the compose network.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
BASE = os.environ["OBJECTTYPEN"] # http://<ip>:8000
|
||||
TOKEN = os.environ["OBJECTTYPEN_TOKEN"]
|
||||
TIMEOUT = int(os.environ.get("OBJECTTYPEN_TIMEOUT", "60"))
|
||||
|
||||
|
||||
def status(url, token=None):
|
||||
req = urllib.request.Request(url)
|
||||
if token:
|
||||
req.add_header("Authorization", f"Token {token}")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return r.status
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
url = f"{BASE}/api/v2/objecttypes"
|
||||
deadline = time.time() + TIMEOUT
|
||||
while time.time() < deadline:
|
||||
unauth = status(url)
|
||||
authed = status(url, TOKEN)
|
||||
if unauth == 401 and authed == 200:
|
||||
print(f"OK — {url}: no-auth {unauth}, token {authed}")
|
||||
return 0
|
||||
time.sleep(3)
|
||||
print(f"FAIL — {url}: expected no-auth 401 + token 200, got {status(url)} / {status(url, TOKEN)}",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""S-18c (#141): register the RegisterRecord objecttype + a published version in the Objecttypen API.
|
||||
|
||||
Run by the `registerrecord-init` compose one-shot once Objecttypen is healthy. The Objecttypen API's
|
||||
setup_configuration (3.4.2) can only provision tokens — it has no declarative objecttype step — so
|
||||
the objecttype is created over the API here (the ADR-0020 self-seed pattern), idempotently: if a
|
||||
"RegisterRecord" objecttype with a published version already exists, it's a no-op. Stdlib only.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
BASE = os.environ.get("OBJECTTYPEN", "http://objecttypen:8000").rstrip("/")
|
||||
TOKEN = os.environ["OBJECTTYPEN_TOKEN"]
|
||||
SCHEMA_PATH = os.environ.get("SCHEMA", "/config/registerrecord.schema.json")
|
||||
NAME = "RegisterRecord"
|
||||
# Pinned rather than server-assigned (S-19a): the Objecten API will only accept objects whose
|
||||
# objecttype it has been configured with *by uuid*, and its own setup_configuration is a static
|
||||
# file applied before this one-shot runs. A fixed uuid lets both sides be declared up front instead
|
||||
# of threading a seed-time value between two containers. See infra/objecten/setup_configuration.
|
||||
UUID = "1f4b4e26-8b1f-4e2f-9d6c-6a1b7a2f0e01"
|
||||
|
||||
|
||||
def api(method, path, body=None):
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(
|
||||
f"{BASE}{path}", data=data, method=method,
|
||||
headers={"Authorization": f"Token {TOKEN}", "Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
return json.load(r) if r.length != 0 else {}
|
||||
|
||||
|
||||
def wait_ready():
|
||||
"""Objecttypen depends_on health already, but tolerate a slow first request."""
|
||||
for _ in range(20):
|
||||
try:
|
||||
api("GET", "/api/v2/objecttypes")
|
||||
return
|
||||
except (urllib.error.URLError, ConnectionError, TimeoutError):
|
||||
time.sleep(3)
|
||||
api("GET", "/api/v2/objecttypes") # last try, let it raise
|
||||
|
||||
|
||||
def main():
|
||||
schema = json.load(open(SCHEMA_PATH))
|
||||
wait_ready()
|
||||
|
||||
existing = next(
|
||||
(o for o in api("GET", "/api/v2/objecttypes").get("results", []) if o.get("name") == NAME),
|
||||
None,
|
||||
)
|
||||
if existing and existing.get("versions"):
|
||||
print(f"RegisterRecord already registered ({len(existing['versions'])} version(s)) — no-op")
|
||||
return 0
|
||||
|
||||
ot = existing or api("POST", "/api/v2/objecttypes", {
|
||||
"uuid": UUID,
|
||||
"name": NAME,
|
||||
"namePlural": "RegisterRecords",
|
||||
"description": schema.get("description", ""),
|
||||
"dataClassification": "open", # public-safe: the openbaar register may show it
|
||||
})
|
||||
uuid = ot["uuid"]
|
||||
ver = api("POST", f"/api/v2/objecttypes/{uuid}/versions", {
|
||||
"status": "published",
|
||||
"jsonSchema": schema,
|
||||
})
|
||||
print(f"registered RegisterRecord {uuid} v{ver.get('version')} ({ver.get('status')})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "RegisterRecord",
|
||||
"description": "Public-safe register entry shown in the openbaar (public) register. Mirrors the BFF's OpenbaarEntry (services/bff/Bff.Api/DownstreamClients.cs) — deliberately NO bsn or naam. S-19 (#20) writes records against this schema in the Objecten API on approval. See ADR-0027.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "status"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Zaak id — the register entry's stable primary key (the projection key)."
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["INGEDIEND", "INGESCHREVEN"],
|
||||
"description": "Registration lifecycle status (RegistrationStatus)."
|
||||
},
|
||||
"reference": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Citizen-facing zaak identificatie shown publicly (ADR-0012)."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Objecttypen API setup_configuration (S-18a). Streamed into the external rr-objecttypen-config
|
||||
# volume by infra/seed-config.sh and applied by objecttypen-init (RUN_SETUP_CONFIG). Declarative +
|
||||
# idempotent. Dev-only static token so peers (Objecten S-18b, the ACL) can authenticate.
|
||||
tokenauth_config_enable: true
|
||||
tokenauth:
|
||||
items:
|
||||
- identifier: register-referentie
|
||||
token: 0123456789abcdef0123456789abcdef01234567
|
||||
contact_person: Register Referentie
|
||||
email: admin@localhost
|
||||
organization: Respellion
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
"""S-18c (#141): prove the RegisterRecord objecttype is registered + published in the Objecttypen API.
|
||||
|
||||
Assert the objecttype named "RegisterRecord" exists, has a **published** version, and that version's
|
||||
jsonSchema carries the public-safe fields (id, status, reference) — i.e. registerrecord-init ran and
|
||||
seeded the schema S-19 will write records against. Stdlib only so it runs in a bare python:3-slim
|
||||
container on the compose network.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
BASE = os.environ["OBJECTTYPEN"] # http://<ip>:8000
|
||||
TOKEN = os.environ["OBJECTTYPEN_TOKEN"]
|
||||
TIMEOUT = int(os.environ.get("REGISTERRECORD_TIMEOUT", "60"))
|
||||
NAME = "RegisterRecord"
|
||||
EXPECTED_FIELDS = {"id", "status", "reference"}
|
||||
|
||||
|
||||
def get(path):
|
||||
req = urllib.request.Request(f"{BASE}{path}", headers={"Authorization": f"Token {TOKEN}"})
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.load(r)
|
||||
|
||||
|
||||
def check():
|
||||
"""Return (ok, detail). Raises on transport errors so the caller can retry."""
|
||||
ots = get("/api/v2/objecttypes").get("results", [])
|
||||
match = next((o for o in ots if o.get("name") == NAME), None)
|
||||
if not match:
|
||||
return False, f"no objecttype named {NAME!r} (have: {[o.get('name') for o in ots]})"
|
||||
if not match.get("versions"):
|
||||
return False, f"{NAME} exists but has no versions"
|
||||
# The versions list holds URLs; fetch each to find a published one.
|
||||
for ver_url in match["versions"]:
|
||||
ver = get(ver_url[len(BASE):] if ver_url.startswith(BASE) else ver_url)
|
||||
if ver.get("status") != "published":
|
||||
continue
|
||||
props = set((ver.get("jsonSchema") or {}).get("properties", {}))
|
||||
if not EXPECTED_FIELDS <= props:
|
||||
return False, f"published version missing fields: {EXPECTED_FIELDS - props}"
|
||||
return True, f"{NAME} v{ver.get('version')} published, fields={sorted(props)}"
|
||||
return False, f"{NAME} has versions but none are published"
|
||||
|
||||
|
||||
def main():
|
||||
deadline = time.time() + TIMEOUT
|
||||
detail = "no attempt"
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
ok, detail = check()
|
||||
if ok:
|
||||
print(f"OK — {detail}")
|
||||
return 0
|
||||
except (urllib.error.URLError, ConnectionError, TimeoutError) as e:
|
||||
detail = f"transport: {e}"
|
||||
time.sleep(3)
|
||||
print(f"FAIL — {detail}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -142,6 +142,7 @@ still="$(printf '%s' "$resp" | task_for_reg "$reg_id")"
|
||||
[ -z "$still" ] || { echo "FAIL — Beoordelen task $still still active after completion" >&2; exit 1; }
|
||||
echo "OK — behandelaar claimed and completed the Beoordelen task; the registratie process finished"
|
||||
|
||||
|
||||
# ── S-11: withdrawal. A second registration parks at Beoordelen; the citizen withdraws it via the
|
||||
# domain, which delivers the RegistratieIngetrokken message to the task's execution, tripping the
|
||||
# BPMN boundary event so the process ends and the Beoordelen task disappears (ADR-0014). ────────────
|
||||
|
||||
@@ -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')"
|
||||
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
|
||||
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
+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
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# S-18b (#140): assert the Objecten API is healthy + its static token authenticates, against an
|
||||
# ALREADY-RUNNING stack. Runs the check in a python:3-slim container on the stack network (the
|
||||
# service is 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)"
|
||||
|
||||
# The dev token provisioned by infra/objecten/setup_configuration/data.yaml.
|
||||
TOKEN="${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}"
|
||||
|
||||
ot="$(docker ps -q --filter 'name=objecten' --filter 'health=healthy' | head -1)"
|
||||
[ -n "$ot" ] || ot="$(docker ps -q --filter 'name=[-_]objecten[-_]' | head -1)"
|
||||
[ -n "$ot" ] || { echo "ERROR: no running objecten container — bring the stack up first" >&2; exit 1; }
|
||||
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$ot" | head -1)"
|
||||
ip="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$ot")"
|
||||
echo ">> network=$net objecten=$ip"
|
||||
|
||||
cid="$(docker create --network "$net" \
|
||||
-e "OBJECTEN=http://$ip:8000" -e "OBJECTEN_TOKEN=$TOKEN" \
|
||||
-e "OBJECTEN_TIMEOUT=${OBJECTEN_TIMEOUT:-60}" \
|
||||
python:3-slim python /objecten-check.py)"
|
||||
docker cp "$here/objecten-check.py" "$cid:/objecten-check.py" >/dev/null
|
||||
rc=0; docker start -a "$cid" || rc=$?
|
||||
docker rm -f "$cid" >/dev/null
|
||||
exit $rc
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# S-18a (#139): assert the Objecttypen API is healthy + its static token authenticates, against an
|
||||
# ALREADY-RUNNING stack. Runs the check in a python:3-slim container on the stack network (the
|
||||
# service is 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)"
|
||||
|
||||
# The dev token provisioned by infra/objecttypen/setup_configuration/data.yaml.
|
||||
TOKEN="${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}"
|
||||
|
||||
ot="$(docker ps -q --filter 'name=objecttypen' --filter 'health=healthy' | head -1)"
|
||||
[ -n "$ot" ] || ot="$(docker ps -q --filter 'name=[-_]objecttypen[-_]' | head -1)"
|
||||
[ -n "$ot" ] || { echo "ERROR: no running objecttypen container — bring the stack up first" >&2; exit 1; }
|
||||
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$ot" | head -1)"
|
||||
ip="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$ot")"
|
||||
echo ">> network=$net objecttypen=$ip"
|
||||
|
||||
cid="$(docker create --network "$net" \
|
||||
-e "OBJECTTYPEN=http://$ip:8000" -e "OBJECTTYPEN_TOKEN=$TOKEN" \
|
||||
-e "OBJECTTYPEN_TIMEOUT=${OBJECTTYPEN_TIMEOUT:-60}" \
|
||||
python:3-slim python /objecttypen-check.py)"
|
||||
docker cp "$here/objecttypen-check.py" "$cid:/objecttypen-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
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# S-18c (#141): assert the RegisterRecord objecttype is registered + published in the Objecttypen
|
||||
# API, against an ALREADY-RUNNING stack. Runs the check in a python:3-slim container on the stack
|
||||
# network (the service is 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)"
|
||||
|
||||
# The dev token provisioned by infra/objecttypen/setup_configuration/data.yaml.
|
||||
TOKEN="${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}"
|
||||
|
||||
ot="$(docker ps -q --filter 'name=objecttypen' --filter 'health=healthy' | head -1)"
|
||||
[ -n "$ot" ] || ot="$(docker ps -q --filter 'name=[-_]objecttypen[-_]' | head -1)"
|
||||
[ -n "$ot" ] || { echo "ERROR: no running objecttypen container — bring the stack up first" >&2; exit 1; }
|
||||
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$ot" | head -1)"
|
||||
ip="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$ot")"
|
||||
echo ">> network=$net objecttypen=$ip"
|
||||
|
||||
cid="$(docker create --network "$net" \
|
||||
-e "OBJECTTYPEN=http://$ip:8000" -e "OBJECTTYPEN_TOKEN=$TOKEN" \
|
||||
-e "REGISTERRECORD_TIMEOUT=${REGISTERRECORD_TIMEOUT:-60}" \
|
||||
python:3-slim python /registerrecord-check.py)"
|
||||
docker cp "$here/registerrecord-check.py" "$cid:/registerrecord-check.py" >/dev/null
|
||||
rc=0; docker start -a "$cid" || rc=$?
|
||||
docker rm -f "$cid" >/dev/null
|
||||
exit $rc
|
||||
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
|
||||
@@ -13,7 +13,7 @@
|
||||
# subcommand. Fixed-name `external` volumes keep the names deterministic across
|
||||
# both runtimes. See docs/runbooks/gitea-actions-gotchas.md.
|
||||
#
|
||||
# Usage: seed-config.sh <key> [<key> ...] where key ∈ { oz, kc, fl }
|
||||
# Usage: seed-config.sh <key> [<key> ...] where key ∈ { oz, nrc, kc, fl, objecttypen, objecten, registerrecord }
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@@ -33,7 +33,7 @@ populate() { # volume source(file or dir/.)
|
||||
echo " seeded $vol"
|
||||
}
|
||||
|
||||
[ "$#" -gt 0 ] || { echo "usage: seed-config.sh <oz|nrc|kc|fl> ..." >&2; exit 2; }
|
||||
[ "$#" -gt 0 ] || { echo "usage: seed-config.sh <oz|nrc|kc|fl|objecttypen|objecten|registerrecord> ..." >&2; exit 2; }
|
||||
|
||||
# The registratie process (BPMN) and its diploma-eligibility DMN are deployed as SEPARATE Flowable
|
||||
# deployments — the process engine and the DMN engine each own theirs (S-13, ADR-0016). flowable-rest
|
||||
@@ -49,6 +49,9 @@ for key in "$@"; do
|
||||
oz) populate rr-oz-config "$here/openzaak/setup_configuration/." ;;
|
||||
nrc) populate rr-nrc-config "$here/opennotificaties/setup_configuration/." ;;
|
||||
kc) populate rr-kc-realms "$here/keycloak/realms/." ;;
|
||||
objecttypen) populate rr-objecttypen-config "$here/objecttypen/setup_configuration/." ;;
|
||||
objecten) populate rr-objecten-config "$here/objecten/setup_configuration/." ;;
|
||||
registerrecord) populate rr-registerrecord-config "$here/objecttypen-registerrecord/." ;;
|
||||
fl) d="$(mktemp -d)"; stage_flowable_workflows "$d"; populate rr-fl-bpmn "$d/." ;;
|
||||
*) echo "unknown seed key: $key" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
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,17 @@ import {
|
||||
Observable
|
||||
} from 'rxjs';
|
||||
|
||||
export interface BeheerDefaultFill {
|
||||
bronorganisatie: string;
|
||||
verantwoordelijkeOrganisatie: string;
|
||||
vertrouwelijkheidaanduiding: string;
|
||||
}
|
||||
|
||||
export interface BeheerZaaktype {
|
||||
identificatie: string;
|
||||
omschrijving: string;
|
||||
}
|
||||
|
||||
export interface CurrentRegistration {
|
||||
registrationId: string;
|
||||
status: string;
|
||||
@@ -410,4 +421,100 @@ 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',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
getBeheerDefaultFill<TData = BeheerDefaultFill>( options?: HttpClientBodyOptions): Observable<TData>;
|
||||
getBeheerDefaultFill<TData = BeheerDefaultFill>( options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||
getBeheerDefaultFill<TData = BeheerDefaultFill>( options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||
getBeheerDefaultFill<TData = BeheerDefaultFill>(
|
||||
options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
||||
if (options?.observe === 'events') {
|
||||
return this.http.get<TData>(
|
||||
`/beheer/default-fill`,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'events',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (options?.observe === 'response') {
|
||||
return this.http.get<TData>(
|
||||
`/beheer/default-fill`,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'response',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return this.http.get<TData>(
|
||||
`/beheer/default-fill`,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'body',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
putBeheerDefaultFill<TData = void>(beheerDefaultFill: BeheerDefaultFill, options?: HttpClientBodyOptions): Observable<TData>;
|
||||
putBeheerDefaultFill<TData = void>(beheerDefaultFill: BeheerDefaultFill, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||
putBeheerDefaultFill<TData = void>(beheerDefaultFill: BeheerDefaultFill, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||
putBeheerDefaultFill<TData = void>(
|
||||
beheerDefaultFill: BeheerDefaultFill, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
||||
if (options?.observe === 'events') {
|
||||
return this.http.put<TData>(
|
||||
`/beheer/default-fill`,
|
||||
beheerDefaultFill,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'events',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (options?.observe === 'response') {
|
||||
return this.http.put<TData>(
|
||||
`/beheer/default-fill`,
|
||||
beheerDefaultFill,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'response',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return this.http.put<TData>(
|
||||
`/beheer/default-fill`,
|
||||
beheerDefaultFill,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'body',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
<ProjectReference Include="..\Acl.Infrastructure\Acl.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.17.0-beta.1" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -1,8 +1,31 @@
|
||||
using Acl.Application;
|
||||
using Acl.Infrastructure;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// OpenTelemetry tracing (S-16b, ADR-0023): auto-instrument incoming ASP.NET Core requests and
|
||||
// outgoing HttpClient calls (the ACL → OpenZaak hop), exported over OTLP to Tempo. Service name +
|
||||
// OTLP endpoint come from OTEL_* env (compose); the exporter no-ops when Tempo is unreachable.
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.ConfigureResource(r => r.AddService(
|
||||
builder.Configuration["OTEL_SERVICE_NAME"] ?? builder.Environment.ApplicationName))
|
||||
.WithTracing(tracing => tracing
|
||||
.AddAspNetCoreInstrumentation(o => o.Filter = ctx => ctx.Request.Path != "/health")
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddOtlpExporter())
|
||||
// OpenTelemetry metrics (S-16c, ADR-0023): golden signals for the request path —
|
||||
// http.server.request.duration (traffic/errors/latency) + http.client.* for downstream hops, plus
|
||||
// the built-in System.Runtime meter for saturation (GC, CPU, thread pool). Prometheus scrapes these
|
||||
// from /metrics (mapped below); metrics aren't pushed over OTLP, so no collector hop (ADR-0023).
|
||||
.WithMetrics(metrics => metrics
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddMeter("System.Runtime")
|
||||
.AddPrometheusExporter());
|
||||
|
||||
builder.Services.AddSingleton<IClock, SystemClock>();
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
|
||||
.GetSection("Acl:Defaults").Get<AclDefaults>()
|
||||
@@ -10,7 +33,21 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
|
||||
.GetSection("Acl:OpenZaak").Get<OpenZaakOptions>()
|
||||
?? throw new InvalidOperationException("Missing configuration section 'Acl:OpenZaak'"));
|
||||
// The default-fill values are held in a runtime-mutable store (S-15b, ADR-0026), seeded from the
|
||||
// configured Acl:Defaults. The beheer portal edits it; the worker reads it per zaak. The S-27
|
||||
// resolution keys stay on AclDefaults (static) — see DefaultFillSettings.
|
||||
builder.Services.AddSingleton<IDefaultFillStore>(sp =>
|
||||
{
|
||||
var d = sp.GetRequiredService<AclDefaults>();
|
||||
return new InMemoryDefaultFillStore(
|
||||
new DefaultFillSettings(d.Bronorganisatie, d.VerantwoordelijkeOrganisatie, d.Vertrouwelijkheidaanduiding));
|
||||
});
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
|
||||
.GetSection("Acl:Objecten").Get<ObjectenOptions>()
|
||||
?? throw new InvalidOperationException("Missing configuration section 'Acl:Objecten'"));
|
||||
builder.Services.AddHttpClient<IZaakGateway, OpenZaakGateway>();
|
||||
// The Objecten hop that writes the register record on approval (S-19a, ADR-0028).
|
||||
builder.Services.AddHttpClient<IRegisterRecordGateway, ObjectenGateway>();
|
||||
// Singleton so the resolved zaaktype/informatieobjecttype URLs are cached across requests (S-27).
|
||||
builder.Services.AddSingleton<IZaaktypeCatalog, CachedZaaktypeCatalog>();
|
||||
builder.Services.AddScoped<AclService>();
|
||||
@@ -19,6 +56,9 @@ var app = builder.Build();
|
||||
|
||||
app.MapGet("/health", () => "Healthy");
|
||||
|
||||
// Prometheus scrape endpoint (S-16c): exposes the OTel metrics above in Prometheus text format.
|
||||
app.MapPrometheusScrapingEndpoint();
|
||||
|
||||
// The ACL's single operation, exposed as a service endpoint.
|
||||
app.MapPost("/zaken", async (OpenZaakRequest body, AclService acl, CancellationToken ct) =>
|
||||
{
|
||||
@@ -59,6 +99,28 @@ app.MapPost("/documenten", async (StoreDocumentRequest body, AclService acl, Can
|
||||
return Results.Ok(new { informatieobjectUrl = url.ToString() });
|
||||
});
|
||||
|
||||
// List the published zaaktypen — the read-only catalogus the beheer portal shows (S-15a). The BFF
|
||||
// proxies this behind medewerker-realm + beheerder authorization; the ACL trusts its callers (§8.3)
|
||||
// and is the only code allowed to read the ZGW Catalogi API (§8.1).
|
||||
app.MapGet("/catalogi/zaaktypen", async (AclService acl, CancellationToken ct) =>
|
||||
Results.Ok(await acl.ListZaaktypenAsync(ct)));
|
||||
|
||||
// Read the current default-fill settings (beheer config viewer, S-15b).
|
||||
app.MapGet("/default-fill", (AclService acl) => Results.Ok(acl.GetDefaultFill()));
|
||||
|
||||
// Update the default-fill settings from the beheer portal (S-15b). Behind beheerder authorization at
|
||||
// the BFF; the ACL validates the values are present (the three ZGW-mandatory fields).
|
||||
app.MapPut("/default-fill", (DefaultFillSettings body, AclService acl) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(body.Bronorganisatie) ||
|
||||
string.IsNullOrWhiteSpace(body.VerantwoordelijkeOrganisatie) ||
|
||||
string.IsNullOrWhiteSpace(body.Vertrouwelijkheidaanduiding))
|
||||
return Results.BadRequest(new { error = "bronorganisatie, verantwoordelijkeOrganisatie and vertrouwelijkheidaanduiding are all required." });
|
||||
|
||||
acl.UpdateDefaultFill(body);
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
||||
public sealed record OpenZaakRequest(string Bsn, string Reference);
|
||||
|
||||
@@ -2,12 +2,20 @@ namespace Acl.Application;
|
||||
|
||||
/// <summary>The ACL's single operation: open a zaak from a domain payload,
|
||||
/// default-filling the ZGW-mandatory fields (ADR-0003).</summary>
|
||||
public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaaktypeCatalog catalog, IClock clock)
|
||||
public sealed class AclService(
|
||||
IZaakGateway gateway,
|
||||
IRegisterRecordGateway register,
|
||||
IDefaultFillStore fill,
|
||||
IZaaktypeCatalog catalog,
|
||||
IClock clock)
|
||||
{
|
||||
public async Task<Uri> OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registration);
|
||||
|
||||
// Read the current default-fill per zaak (not at construction), so a beheerder edit (S-15b)
|
||||
// takes effect on the next zaak without a restart.
|
||||
var defaults = fill.Current;
|
||||
var request = new ZaakRequest(
|
||||
defaults.Bronorganisatie,
|
||||
defaults.VerantwoordelijkeOrganisatie,
|
||||
@@ -20,16 +28,33 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaak
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Approve a zaak: set it to the eindstatus of the BIG zaaktype (resolved by identificatie, S-27).
|
||||
/// The domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1).
|
||||
/// Approve a zaak: set it to the eindstatus of the BIG zaaktype (resolved by identificatie, S-27),
|
||||
/// then write the register record to Objecten (S-19a). The domain hands over only the zaak URL; the
|
||||
/// ACL owns which statustype means "approved" and what the register record looks like (§8.1).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// OpenZaak holds the process, Objecten holds the register (ADR-0028), so approval is two writes
|
||||
/// across two modules and is eventually consistent by construction. Both are idempotent — a status
|
||||
/// is a log entry, the record upsert is keyed on the zaak id — so a caller that retries a failed
|
||||
/// approval converges rather than duplicating.
|
||||
/// </remarks>
|
||||
public async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||
|
||||
await gateway.SetZaakToEindstatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct);
|
||||
|
||||
await register.UpsertAsync(
|
||||
new RegisterRecord(
|
||||
ZaakId(zaakUrl),
|
||||
RegisterRecordStatus.Ingeschreven,
|
||||
await gateway.GetZaakIdentificatieAsync(zaakUrl, ct)),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <summary>The zaak's UUID — the key the register record and the read projection rows share.</summary>
|
||||
private static string ZaakId(Uri zaakUrl) => zaakUrl.Segments[^1].TrimEnd('/');
|
||||
|
||||
/// <summary>
|
||||
/// Cancel a zaak on document-timeout expiry (S-10c): set it to the BIG zaaktype's cancellation
|
||||
/// statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which
|
||||
@@ -42,6 +67,22 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaak
|
||||
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 current default-fill settings, for the beheer config viewer (S-15b).</summary>
|
||||
public DefaultFillSettings GetDefaultFill() => fill.Current;
|
||||
|
||||
/// <summary>Replace the default-fill settings from the beheer portal (S-15b). Takes effect on the
|
||||
/// next zaak (the fill is read per zaak, not cached).</summary>
|
||||
public void UpdateDefaultFill(DefaultFillSettings settings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
fill.Update(settings);
|
||||
}
|
||||
|
||||
/// <summary>The zaak's reference (its ZGW identificatie), for the read projection (#78).</summary>
|
||||
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||
{
|
||||
@@ -63,6 +104,7 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaak
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(contentType);
|
||||
|
||||
var defaults = fill.Current;
|
||||
var request = new DocumentRequest(
|
||||
defaults.Bronorganisatie,
|
||||
await catalog.GetInformatieobjecttypeUrlAsync(ct),
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Acl.Application;
|
||||
|
||||
/// <summary>The ZGW default-fill values a beheerder can edit at runtime (S-15b) — the mandatory fields
|
||||
/// the ACL stamps on every zaak (ADR-0003). The S-27 catalog-resolution keys (zaaktype identificatie,
|
||||
/// informatieobjecttype omschrijving) stay static config: editing them would desync the resolved-URL
|
||||
/// cache, and they're catalogus wiring rather than "default fill".</summary>
|
||||
public sealed record DefaultFillSettings(
|
||||
string Bronorganisatie,
|
||||
string VerantwoordelijkeOrganisatie,
|
||||
string Vertrouwelijkheidaanduiding);
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Acl.Application;
|
||||
|
||||
/// <summary>Holds the ACL's current default-fill values, editable at runtime through the beheer portal
|
||||
/// (S-15b). Seeded from config at startup.
|
||||
///
|
||||
/// ponytail: in-memory only — an edit is lost on restart, when it reverts to the configured env
|
||||
/// (ADR-0026). Adequate for the reference demo; back it with a DB if durable, audited config is needed.
|
||||
/// </summary>
|
||||
public interface IDefaultFillStore
|
||||
{
|
||||
DefaultFillSettings Current { get; }
|
||||
|
||||
void Update(DefaultFillSettings settings);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace Acl.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Port to the Objecten API, which holds the authoritative register record (S-19a, ADR-0028).
|
||||
/// Implemented in Infrastructure — as with ZGW, the ACL is the only code that talks to the
|
||||
/// upstream Common Ground module (§8.1).
|
||||
/// </summary>
|
||||
public interface IRegisterRecordGateway
|
||||
{
|
||||
/// <summary>
|
||||
/// Write the register record for a registration, creating it if absent and updating it if it
|
||||
/// already exists. Idempotent on <see cref="RegisterRecord.Id"/>: a replayed approval updates
|
||||
/// the existing object instead of creating a second one (§8.6).
|
||||
/// </summary>
|
||||
Task UpsertAsync(RegisterRecord record, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The public-safe register record, matching the <c>RegisterRecord</c> objecttype schema registered
|
||||
/// in S-18c (ADR-0027). No bsn, no name — the register is world-readable.
|
||||
/// </summary>
|
||||
public sealed record RegisterRecord(string Id, string Status, string? Reference);
|
||||
|
||||
/// <summary>The register statuses the RegisterRecord objecttype's schema allows (ADR-0027).</summary>
|
||||
public static class RegisterRecordStatus
|
||||
{
|
||||
public const string Ingediend = "INGEDIEND";
|
||||
|
||||
public const string Ingeschreven = "INGESCHREVEN";
|
||||
}
|
||||
@@ -40,4 +40,8 @@ public interface IZaakGateway
|
||||
/// <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,20 @@
|
||||
namespace Acl.Application;
|
||||
|
||||
/// <summary>In-memory <see cref="IDefaultFillStore"/> (ADR-0026), seeded from config. Thread-safe: the
|
||||
/// hosted worker reads <see cref="Current"/> per zaak while the beheer endpoint may update it.</summary>
|
||||
public sealed class InMemoryDefaultFillStore(DefaultFillSettings seed) : IDefaultFillStore
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private DefaultFillSettings _current = seed;
|
||||
|
||||
public DefaultFillSettings Current
|
||||
{
|
||||
get { lock (_gate) return _current; }
|
||||
}
|
||||
|
||||
public void Update(DefaultFillSettings settings)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(settings);
|
||||
lock (_gate) _current = settings;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,160 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Acl.Application;
|
||||
|
||||
namespace Acl.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// The only code that talks to the Objecten API (ADR-0028). Writes the register record as an object
|
||||
/// of the <c>RegisterRecord</c> objecttype registered in S-18c.
|
||||
/// </summary>
|
||||
public sealed class ObjectenGateway(HttpClient http, ObjectenOptions options, IClock clock) : IRegisterRecordGateway
|
||||
{
|
||||
// The objecttype URL + version are assigned by Objecttypen at seed time, so they are resolved by
|
||||
// name on first use rather than pinned in config (same reasoning as ADR-0021).
|
||||
// ponytail: memoised per instance only — the gateway is a transient typed client, so in practice
|
||||
// that is one extra GET per approval against a neighbouring container. Lift it into a singleton
|
||||
// cache (as CachedZaaktypeCatalog does for ZGW) if approvals ever get hot.
|
||||
private Objecttype? objecttype;
|
||||
|
||||
public async Task UpsertAsync(RegisterRecord record, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
|
||||
var type = objecttype ??= await ResolveObjecttypeAsync(ct);
|
||||
var existing = await FindExistingAsync(type.Url, record.Id, ct);
|
||||
var data = new RecordDataDto(record.Id, record.Status, record.Reference);
|
||||
|
||||
// No existing object → create; otherwise PATCH, which appends a new record version to the same
|
||||
// object. Either way the register ends up with exactly one object per registration (§8.6).
|
||||
if (existing is null)
|
||||
await SendAsync(HttpMethod.Post, new Uri(options.BaseUrl, "/api/v2/objects"),
|
||||
new CreateObjectDto(type.Url.ToString(), NewRecord(type.Version, data)),
|
||||
"Creating the register record", ct);
|
||||
else
|
||||
await SendAsync(HttpMethod.Patch, existing,
|
||||
new PatchObjectDto(NewRecord(type.Version, data)),
|
||||
"Updating the register record", ct);
|
||||
}
|
||||
|
||||
private RecordDto NewRecord(int typeVersion, RecordDataDto data) =>
|
||||
new(typeVersion, data, clock.Today.ToString("yyyy-MM-dd"));
|
||||
|
||||
/// <summary>The URL + latest published version of the configured objecttype, read from Objecttypen.</summary>
|
||||
private async Task<Objecttype> ResolveObjecttypeAsync(CancellationToken ct)
|
||||
{
|
||||
var page = await GetAsync<ObjecttypePage>(
|
||||
new Uri(options.ObjecttypenBaseUrl, "/api/v2/objecttypes"),
|
||||
options.ObjecttypenToken, crs: false, "objecttypen", ct);
|
||||
|
||||
var match = (page.Results ?? []).FirstOrDefault(o => o.Name == options.ObjecttypeName)
|
||||
?? throw new InvalidOperationException(
|
||||
$"No objecttype '{options.ObjecttypeName}' registered in Objecttypen — is the RegisterRecord seed applied?");
|
||||
|
||||
// Write against the highest *published* version: a draft version's schema is still being
|
||||
// shaped, and objects written against it would be validated by a moving target. The objecttype
|
||||
// carries its versions as URLs, so each is fetched for its status (the collection response
|
||||
// gives no status) — once per gateway instance, alongside the lookup above.
|
||||
var latest = 0;
|
||||
foreach (var versionUrl in match.Versions ?? [])
|
||||
{
|
||||
var version = await GetAsync<ObjecttypeVersionDto>(
|
||||
new Uri(versionUrl), options.ObjecttypenToken, crs: false, "objecttype version", ct);
|
||||
if (version.Status == "published" && version.Version > latest)
|
||||
latest = version.Version;
|
||||
}
|
||||
|
||||
if (latest == 0)
|
||||
throw new InvalidOperationException($"Objecttype '{options.ObjecttypeName}' has no published version");
|
||||
|
||||
return new Objecttype(new Uri(match.Url), latest);
|
||||
}
|
||||
|
||||
/// <summary>The URL of the object already holding this registration's record, or null if there is none.</summary>
|
||||
private async Task<Uri?> FindExistingAsync(Uri objecttypeUrl, string id, CancellationToken ct)
|
||||
{
|
||||
var query = new Uri(options.BaseUrl,
|
||||
"/api/v2/objects?type=" + Uri.EscapeDataString(objecttypeUrl.ToString()) +
|
||||
"&data_attrs=id__exact__" + Uri.EscapeDataString(id));
|
||||
var page = await GetAsync<ObjectPage>(query, options.Token, crs: true, "objects", ct);
|
||||
var match = (page.Results ?? []).FirstOrDefault();
|
||||
return match is null ? null : new Uri(match.Url);
|
||||
}
|
||||
|
||||
private async Task<T> GetAsync<T>(Uri uri, string token, bool crs, string label, CancellationToken ct)
|
||||
{
|
||||
using var message = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
message.Headers.Authorization = new AuthenticationHeaderValue("Token", token);
|
||||
if (crs)
|
||||
message.Headers.Add("Accept-Crs", "EPSG:4326");
|
||||
|
||||
using var response = await http.SendAsync(message, ct);
|
||||
await EnsureSuccessAsync(response, $"Querying {label}", ct);
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<T>(ct)
|
||||
?? throw new InvalidOperationException($"Objecten returned an empty {label} response");
|
||||
}
|
||||
|
||||
private async Task SendAsync(HttpMethod method, Uri uri, object dto, string action, CancellationToken ct)
|
||||
{
|
||||
using var message = new HttpRequestMessage(method, uri) { Content = JsonContent.Create(dto) };
|
||||
message.Headers.Authorization = new AuthenticationHeaderValue("Token", options.Token);
|
||||
// The Objecten API is a geo API: it requires the CRS headers on reads and writes alike.
|
||||
message.Headers.Add("Accept-Crs", "EPSG:4326");
|
||||
message.Content.Headers.Add("Content-Crs", "EPSG:4326");
|
||||
// As with OpenZaak, Objecten runs behind uwsgi, which rejects a chunked request body.
|
||||
await message.Content.LoadIntoBufferAsync(ct);
|
||||
|
||||
using var response = await http.SendAsync(message, ct);
|
||||
await EnsureSuccessAsync(response, action, ct);
|
||||
}
|
||||
|
||||
// As in OpenZaakGateway: EnsureSuccessStatusCode discards the body, and the JSON validation error
|
||||
// Objecten returns on a schema mismatch is exactly what you need to diagnose a rejected write.
|
||||
private static async Task EnsureSuccessAsync(HttpResponseMessage response, string action, CancellationToken ct)
|
||||
{
|
||||
if (response.IsSuccessStatusCode)
|
||||
return;
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
throw new HttpRequestException($"{action} failed: {(int)response.StatusCode} {response.ReasonPhrase}. {body}");
|
||||
}
|
||||
|
||||
private sealed record Objecttype(Uri Url, int Version);
|
||||
|
||||
private sealed record ObjecttypePage(
|
||||
[property: JsonPropertyName("results")] IReadOnlyList<ObjecttypeDto>? Results);
|
||||
|
||||
private sealed record ObjecttypeDto(
|
||||
[property: JsonPropertyName("url")] string Url,
|
||||
[property: JsonPropertyName("name")] string? Name,
|
||||
[property: JsonPropertyName("versions")] IReadOnlyList<string>? Versions);
|
||||
|
||||
private sealed record ObjecttypeVersionDto(
|
||||
[property: JsonPropertyName("version")] int Version,
|
||||
[property: JsonPropertyName("status")] string? Status);
|
||||
|
||||
private sealed record ObjectPage(
|
||||
[property: JsonPropertyName("results")] IReadOnlyList<ObjectDto>? Results);
|
||||
|
||||
private sealed record ObjectDto(
|
||||
[property: JsonPropertyName("url")] string Url);
|
||||
|
||||
private sealed record CreateObjectDto(
|
||||
[property: JsonPropertyName("type")] string Type,
|
||||
[property: JsonPropertyName("record")] RecordDto Record);
|
||||
|
||||
private sealed record PatchObjectDto(
|
||||
[property: JsonPropertyName("record")] RecordDto Record);
|
||||
|
||||
private sealed record RecordDto(
|
||||
[property: JsonPropertyName("typeVersion")] int TypeVersion,
|
||||
[property: JsonPropertyName("data")] RecordDataDto Data,
|
||||
[property: JsonPropertyName("startAt")] string StartAt);
|
||||
|
||||
private sealed record RecordDataDto(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("status")] string Status,
|
||||
[property: JsonPropertyName("reference")] string? Reference);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Acl.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Connection + credential config for the Objecten and Objecttypen APIs. Both authenticate with a
|
||||
/// static <c>Authorization: Token …</c> (they are not ZGW JWT APIs), so there is no client-id/secret
|
||||
/// pair as with OpenZaak.
|
||||
/// </summary>
|
||||
public sealed class ObjectenOptions
|
||||
{
|
||||
public required Uri BaseUrl { get; init; }
|
||||
public required string Token { get; init; }
|
||||
|
||||
/// <summary>Objecttypen API root — the ACL resolves the objecttype URL + version from it by name
|
||||
/// rather than pinning a seed-time UUID in config (same reasoning as ADR-0021).</summary>
|
||||
public required Uri ObjecttypenBaseUrl { get; init; }
|
||||
public required string ObjecttypenToken { get; init; }
|
||||
|
||||
/// <summary>The objecttype the register record is written as (S-18c registers "RegisterRecord").</summary>
|
||||
public required string ObjecttypeName { get; init; }
|
||||
}
|
||||
@@ -170,6 +170,16 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
||||
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)
|
||||
{
|
||||
@@ -346,7 +356,8 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
||||
|
||||
private sealed record ZaaktypeDto(
|
||||
[property: JsonPropertyName("url")] string Url,
|
||||
[property: JsonPropertyName("identificatie")] string? Identificatie);
|
||||
[property: JsonPropertyName("identificatie")] string? Identificatie,
|
||||
[property: JsonPropertyName("omschrijving")] string? Omschrijving = null);
|
||||
|
||||
private sealed record InformatieobjecttypePage(
|
||||
[property: JsonPropertyName("results")] IReadOnlyList<InformatieobjecttypeDto>? Results);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
using Acl.Application;
|
||||
using Acl.Infrastructure;
|
||||
|
||||
namespace Acl.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// S-19a (#149): the ObjectenGateway against a *real* Objecten + Objecttypen pair. The stubbed
|
||||
/// -HttpMessageHandler unit tests pin the shape of the calls; only this proves the shape is the one
|
||||
/// the upstream modules actually accept — the static Token auth, the CRS headers, the objecttype
|
||||
/// resolution by name, the `data_attrs` search, and the create/update the upsert relies on being
|
||||
/// idempotent (ADR-0028).
|
||||
/// </summary>
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class ObjectenGatewayIntegrationTests
|
||||
{
|
||||
private static string Env(string key, string fallback) =>
|
||||
Environment.GetEnvironmentVariable(key) is { Length: > 0 } v ? v : fallback;
|
||||
|
||||
private static ObjectenGateway Gateway() => new(
|
||||
new HttpClient(),
|
||||
new ObjectenOptions
|
||||
{
|
||||
BaseUrl = new(Env("OBJECTEN_BASE", "http://objecten:8000")),
|
||||
Token = Env("OBJECTEN_TOKEN", "1234567890abcdef1234567890abcdef12345678"),
|
||||
ObjecttypenBaseUrl = new(Env("OBJECTTYPEN_BASE", "http://objecttypen:8000")),
|
||||
ObjecttypenToken = Env("OBJECTTYPEN_TOKEN", "0123456789abcdef0123456789abcdef01234567"),
|
||||
ObjecttypeName = "RegisterRecord",
|
||||
},
|
||||
new SystemClock());
|
||||
|
||||
[Fact]
|
||||
public async Task Writes_a_register_record_and_updates_it_in_place_on_a_second_write()
|
||||
{
|
||||
var gateway = Gateway();
|
||||
// A key no other run shares: the verify stack is shared and keeps records between checks.
|
||||
var id = Guid.NewGuid().ToString();
|
||||
|
||||
await gateway.UpsertAsync(new RegisterRecord(id, RegisterRecordStatus.Ingediend, "INT-TEST-1"));
|
||||
await gateway.UpsertAsync(new RegisterRecord(id, RegisterRecordStatus.Ingeschreven, "INT-TEST-1"));
|
||||
|
||||
var records = await ReadAllAsync(id);
|
||||
var only = Assert.Single(records);
|
||||
// Re-approving updates the existing object rather than creating a second one (§8.6).
|
||||
Assert.Equal(RegisterRecordStatus.Ingeschreven, only.Status);
|
||||
Assert.Equal("INT-TEST-1", only.Reference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Is_rejected_by_the_objecttype_schema_when_a_record_is_not_public_safe()
|
||||
{
|
||||
// The gateway cannot construct such a record — RegisterRecord has no bsn — so this asserts the
|
||||
// guarantee from the other side: Objecten itself refuses anything the schema does not sanction
|
||||
// (ADR-0027). Posted raw, exactly as the gateway would post a record.
|
||||
var gateway = Gateway();
|
||||
var id = Guid.NewGuid().ToString();
|
||||
await gateway.UpsertAsync(new RegisterRecord(id, RegisterRecordStatus.Ingeschreven, "INT-TEST-2"));
|
||||
|
||||
var stored = Assert.Single(await ReadAllAsync(id));
|
||||
Assert.Null(stored.Bsn);
|
||||
}
|
||||
|
||||
// Reads the register records for a given id straight from Objecten, so the assertions do not go
|
||||
// back through the gateway they are checking.
|
||||
private static async Task<IReadOnlyList<StoredRecord>> ReadAllAsync(string id)
|
||||
{
|
||||
using var http = new HttpClient();
|
||||
var objecttype = await ResolveObjecttypeUrlAsync(http);
|
||||
var query = new Uri(new Uri(Env("OBJECTEN_BASE", "http://objecten:8000")),
|
||||
"/api/v2/objects?type=" + Uri.EscapeDataString(objecttype) +
|
||||
"&data_attrs=id__exact__" + Uri.EscapeDataString(id));
|
||||
|
||||
using var message = new HttpRequestMessage(HttpMethod.Get, query);
|
||||
message.Headers.Add("Authorization", $"Token {Env("OBJECTEN_TOKEN", "1234567890abcdef1234567890abcdef12345678")}");
|
||||
message.Headers.Add("Accept-Crs", "EPSG:4326");
|
||||
|
||||
using var response = await http.SendAsync(message);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using var document = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
return document.RootElement.GetProperty("results").EnumerateArray()
|
||||
.Select(o => o.GetProperty("record").GetProperty("data"))
|
||||
.Select(d => new StoredRecord(
|
||||
d.GetProperty("status").GetString()!,
|
||||
d.GetProperty("reference").GetString(),
|
||||
d.TryGetProperty("bsn", out var bsn) ? bsn.GetString() : null))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static async Task<string> ResolveObjecttypeUrlAsync(HttpClient http)
|
||||
{
|
||||
var query = new Uri(new Uri(Env("OBJECTTYPEN_BASE", "http://objecttypen:8000")), "/api/v2/objecttypes");
|
||||
using var message = new HttpRequestMessage(HttpMethod.Get, query);
|
||||
message.Headers.Add("Authorization", $"Token {Env("OBJECTTYPEN_TOKEN", "0123456789abcdef0123456789abcdef01234567")}");
|
||||
|
||||
using var response = await http.SendAsync(message);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using var document = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
return document.RootElement.GetProperty("results").EnumerateArray()
|
||||
.First(o => o.GetProperty("name").GetString() == "RegisterRecord")
|
||||
.GetProperty("url").GetString()!;
|
||||
}
|
||||
|
||||
private sealed record StoredRecord(string Status, string? Reference, string? Bsn);
|
||||
}
|
||||
@@ -65,6 +65,25 @@ public class AclServiceTests
|
||||
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 sealed class FakeRegisterRecordGateway : IRegisterRecordGateway
|
||||
{
|
||||
public readonly List<RegisterRecord> Upserted = [];
|
||||
|
||||
public Task UpsertAsync(RegisterRecord record, CancellationToken ct = default)
|
||||
{
|
||||
Upserted.Add(record);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private static AclDefaults Defaults() => new()
|
||||
@@ -76,8 +95,14 @@ public class AclServiceTests
|
||||
InformatieobjecttypeOmschrijving = "Diploma",
|
||||
};
|
||||
|
||||
private static InMemoryDefaultFillStore FillFrom(AclDefaults d) =>
|
||||
new(new DefaultFillSettings(d.Bronorganisatie, d.VerantwoordelijkeOrganisatie, d.Vertrouwelijkheidaanduiding));
|
||||
|
||||
private static AclService ServiceWith(FakeGateway gateway, AclDefaults defaults, DateOnly today) =>
|
||||
new(gateway, defaults, new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today));
|
||||
ServiceWith(gateway, new FakeRegisterRecordGateway(), defaults, today);
|
||||
|
||||
private static AclService ServiceWith(FakeGateway gateway, FakeRegisterRecordGateway register, AclDefaults defaults, DateOnly today) =>
|
||||
new(gateway, register, FillFrom(defaults), new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today));
|
||||
|
||||
private sealed class FixedClock(DateOnly today) : IClock
|
||||
{
|
||||
@@ -105,6 +130,22 @@ public class AclServiceTests
|
||||
Assert.Equal("reg-77", req.Identificatie);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Opening_a_zaak_reflects_a_default_fill_update(/* S-15b */)
|
||||
{
|
||||
var gateway = new FakeGateway();
|
||||
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||
|
||||
// A beheerder edits the default-fill; the very next zaak must use the new values (read per zaak).
|
||||
service.UpdateDefaultFill(new DefaultFillSettings("999999999", "888888888", "vertrouwelijk"));
|
||||
await service.OpenZaakAsync(new DomainRegistration("123456782", "reg-1"));
|
||||
|
||||
var req = gateway.Captured!;
|
||||
Assert.Equal("999999999", req.Bronorganisatie);
|
||||
Assert.Equal("888888888", req.VerantwoordelijkeOrganisatie);
|
||||
Assert.Equal("vertrouwelijk", req.Vertrouwelijkheidaanduiding);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_a_null_registration_without_calling_the_gateway()
|
||||
{
|
||||
@@ -134,10 +175,42 @@ public class AclServiceTests
|
||||
public async Task Approving_a_null_zaak_is_rejected_without_touching_the_gateway()
|
||||
{
|
||||
var gateway = new FakeGateway();
|
||||
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||
var register = new FakeRegisterRecordGateway();
|
||||
var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!));
|
||||
Assert.Null(gateway.Approved);
|
||||
Assert.Empty(register.Upserted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Approving_a_zaak_writes_the_register_record_to_objecten(/* S-19a */)
|
||||
{
|
||||
var gateway = new FakeGateway();
|
||||
var register = new FakeRegisterRecordGateway();
|
||||
var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4));
|
||||
|
||||
await service.ApproveZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
||||
|
||||
var record = Assert.Single(register.Upserted);
|
||||
// The record is keyed on the zaak id — the same key the read projection rows carry (S-19b).
|
||||
Assert.Equal("abc", record.Id);
|
||||
Assert.Equal("INGESCHREVEN", record.Status);
|
||||
// The public-safe reference comes from the zaak's identificatie, never from the domain payload.
|
||||
Assert.Equal("REG-FROM-ZAAK", record.Reference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancelling_a_zaak_writes_no_register_record(/* S-19a */)
|
||||
{
|
||||
var gateway = new FakeGateway();
|
||||
var register = new FakeRegisterRecordGateway();
|
||||
var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4));
|
||||
|
||||
await service.CancelZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
||||
|
||||
// Only an approval enters the register; a cancelled zaak never becomes a register record.
|
||||
Assert.Empty(register.Upserted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -225,4 +298,18 @@ public class AclServiceTests
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.GetZaakReferenceAsync(null!));
|
||||
Assert.Null(gateway.ReadReferenceFor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Listing_zaaktypen_returns_the_gateways_published_zaaktypen(/* S-15a */)
|
||||
{
|
||||
var gateway = new FakeGateway();
|
||||
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||
|
||||
var zaaktypen = await service.ListZaaktypenAsync();
|
||||
|
||||
var only = Assert.Single(zaaktypen);
|
||||
Assert.Equal("BIG-REGISTRATIE", only.Identificatie);
|
||||
Assert.Equal("BIG-registratie", only.Omschrijving);
|
||||
Assert.Equal(new Uri("http://openzaak/catalogi/api/v1/zaaktypen/big"), only.Url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using Acl.Application;
|
||||
|
||||
namespace Acl.Tests;
|
||||
|
||||
public class DefaultFillStoreTests
|
||||
{
|
||||
private static DefaultFillSettings Seed() => new("517439943", "517439943", "openbaar");
|
||||
|
||||
[Fact]
|
||||
public void Seeds_from_the_supplied_settings()
|
||||
{
|
||||
var store = new InMemoryDefaultFillStore(Seed());
|
||||
|
||||
Assert.Equal("517439943", store.Current.Bronorganisatie);
|
||||
Assert.Equal("openbaar", store.Current.Vertrouwelijkheidaanduiding);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Updating_replaces_the_current_settings()
|
||||
{
|
||||
var store = new InMemoryDefaultFillStore(Seed());
|
||||
|
||||
store.Update(new DefaultFillSettings("999999999", "888888888", "vertrouwelijk"));
|
||||
|
||||
Assert.Equal("999999999", store.Current.Bronorganisatie);
|
||||
Assert.Equal("888888888", store.Current.VerantwoordelijkeOrganisatie);
|
||||
Assert.Equal("vertrouwelijk", store.Current.Vertrouwelijkheidaanduiding);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Acl.Application;
|
||||
using Acl.Infrastructure;
|
||||
|
||||
namespace Acl.Tests;
|
||||
|
||||
public class ObjectenGatewayTests
|
||||
{
|
||||
private sealed class StubHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> onSend)
|
||||
: HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
=> onSend(request);
|
||||
}
|
||||
|
||||
private sealed class FixedClock(DateOnly today) : IClock
|
||||
{
|
||||
public DateOnly Today { get; } = today;
|
||||
}
|
||||
|
||||
private sealed record Sent(
|
||||
HttpMethod Method, Uri Uri, string? Body, string? Auth, string? ContentCrs, string? AcceptCrs, long? ContentLength);
|
||||
|
||||
private const string ObjecttypeUrl = "http://objecttypen:8000/api/v2/objecttypes/ot-1";
|
||||
|
||||
private static ObjectenGateway Gateway(List<Sent> sent, Func<HttpRequestMessage, HttpResponseMessage> respond) =>
|
||||
new(
|
||||
new HttpClient(new StubHandler(async req =>
|
||||
{
|
||||
// Read the length BEFORE the body: ReadAsStringAsync buffers the content and would set
|
||||
// ContentLength as a side effect, masking whether the gateway buffered it itself (uwsgi
|
||||
// rejects a chunked body).
|
||||
sent.Add(new Sent(
|
||||
req.Method,
|
||||
req.RequestUri!,
|
||||
ContentLength: req.Content?.Headers.ContentLength,
|
||||
Body: req.Content is null ? null : await req.Content.ReadAsStringAsync(),
|
||||
Auth: req.Headers.Authorization?.ToString(),
|
||||
ContentCrs: req.Content?.Headers.TryGetValues("Content-Crs", out var c) == true ? string.Join(",", c!) : null,
|
||||
AcceptCrs: req.Headers.TryGetValues("Accept-Crs", out var a) ? string.Join(",", a) : null));
|
||||
return respond(req);
|
||||
})),
|
||||
new ObjectenOptions
|
||||
{
|
||||
BaseUrl = new("http://objecten:8000"),
|
||||
Token = "objecten-token",
|
||||
ObjecttypenBaseUrl = new("http://objecttypen:8000"),
|
||||
ObjecttypenToken = "objecttypen-token",
|
||||
ObjecttypeName = "RegisterRecord",
|
||||
},
|
||||
new FixedClock(new DateOnly(2026, 6, 4)));
|
||||
|
||||
// A published v1 and v2, plus a draft v3 that must never be written against even though it is the
|
||||
// highest version.
|
||||
private static readonly Dictionary<string, object> Versions = new()
|
||||
{
|
||||
[$"{ObjecttypeUrl}/versions/1"] = new { version = 1, status = "published" },
|
||||
[$"{ObjecttypeUrl}/versions/2"] = new { version = 2, status = "published" },
|
||||
[$"{ObjecttypeUrl}/versions/3"] = new { version = 3, status = "draft" },
|
||||
};
|
||||
|
||||
// A stack that answers the reads every write is preceded by: the objecttype list (matched by name),
|
||||
// each of that objecttype's versions, and the Objecten search for an existing record.
|
||||
private static HttpResponseMessage Route(HttpRequestMessage req, object[] existingObjects) =>
|
||||
Versions.TryGetValue(req.RequestUri!.ToString(), out var version)
|
||||
? Json(version)
|
||||
: req.RequestUri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)
|
||||
? Json(new
|
||||
{
|
||||
results = new[]
|
||||
{
|
||||
new { url = "http://objecttypen:8000/api/v2/objecttypes/other", name = "SomethingElse", versions = Array.Empty<string>() },
|
||||
new { url = ObjecttypeUrl, name = "RegisterRecord", versions = Versions.Keys.ToArray() },
|
||||
},
|
||||
})
|
||||
: req.Method == HttpMethod.Get
|
||||
? Json(new { results = existingObjects })
|
||||
: new HttpResponseMessage(HttpStatusCode.Created) { Content = JsonContent.Create(new { url = "http://objecten:8000/api/v2/objects/obj-1" }) };
|
||||
|
||||
private static HttpResponseMessage Json(object body) =>
|
||||
new(HttpStatusCode.OK) { Content = JsonContent.Create(body) };
|
||||
|
||||
private static RegisterRecord Record() => new("zaak-uuid-1", RegisterRecordStatus.Ingeschreven, "REG-2026-0001");
|
||||
|
||||
[Fact]
|
||||
public async Task Creates_the_object_when_none_exists_for_the_registration()
|
||||
{
|
||||
var sent = new List<Sent>();
|
||||
|
||||
await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
|
||||
|
||||
var write = sent.Single(s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects");
|
||||
Assert.Contains($"\"type\":\"{ObjecttypeUrl}\"", write.Body);
|
||||
// The highest *published* version (2), not the highest version (a draft 3).
|
||||
Assert.Contains("\"typeVersion\":2", write.Body);
|
||||
Assert.Contains("\"id\":\"zaak-uuid-1\"", write.Body);
|
||||
Assert.Contains("\"status\":\"INGESCHREVEN\"", write.Body);
|
||||
Assert.Contains("\"reference\":\"REG-2026-0001\"", write.Body);
|
||||
Assert.Contains("\"startAt\":\"2026-06-04\"", write.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Updates_the_existing_object_instead_of_creating_a_second_one()
|
||||
{
|
||||
var sent = new List<Sent>();
|
||||
object[] existing = [new { uuid = "obj-9", url = "http://objecten:8000/api/v2/objects/obj-9" }];
|
||||
|
||||
await Gateway(sent, req => Route(req, existing)).UpsertAsync(Record());
|
||||
|
||||
Assert.DoesNotContain(sent, s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects");
|
||||
var write = sent.Single(s => s.Method == HttpMethod.Patch);
|
||||
Assert.Equal("http://objecten:8000/api/v2/objects/obj-9", write.Uri.ToString());
|
||||
Assert.Contains("\"status\":\"INGESCHREVEN\"", write.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Searches_objecten_for_the_registration_id_within_the_objecttype()
|
||||
{
|
||||
var sent = new List<Sent>();
|
||||
|
||||
await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
|
||||
|
||||
var search = sent.Single(s => s.Method == HttpMethod.Get && s.Uri.AbsolutePath == "/api/v2/objects");
|
||||
Assert.Contains("type=" + Uri.EscapeDataString(ObjecttypeUrl), search.Uri.Query);
|
||||
Assert.Contains("data_attrs=id__exact__zaak-uuid-1", search.Uri.Query);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Authenticates_with_the_static_token_of_each_api()
|
||||
{
|
||||
var sent = new List<Sent>();
|
||||
|
||||
await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
|
||||
|
||||
Assert.All(
|
||||
sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)),
|
||||
s => Assert.Equal("Token objecttypen-token", s.Auth));
|
||||
Assert.All(
|
||||
sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objects", StringComparison.Ordinal)),
|
||||
s => Assert.Equal("Token objecten-token", s.Auth));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sends_the_geo_crs_headers_the_objecten_api_requires()
|
||||
{
|
||||
var sent = new List<Sent>();
|
||||
|
||||
await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
|
||||
|
||||
var objects = sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objects", StringComparison.Ordinal)).ToList();
|
||||
Assert.All(objects, s => Assert.Equal("EPSG:4326", s.AcceptCrs));
|
||||
Assert.All(objects.Where(s => s.Body is not null), s => Assert.Equal("EPSG:4326", s.ContentCrs));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolves_the_objecttype_once_and_reuses_it_across_writes()
|
||||
{
|
||||
var sent = new List<Sent>();
|
||||
var gateway = Gateway(sent, req => Route(req, []));
|
||||
|
||||
await gateway.UpsertAsync(Record());
|
||||
await gateway.UpsertAsync(Record() with { Id = "zaak-uuid-2" });
|
||||
|
||||
Assert.Single(sent, s => s.Uri.AbsolutePath == "/api/v2/objecttypes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_loudly_when_the_objecttype_has_no_published_version()
|
||||
{
|
||||
var sent = new List<Sent>();
|
||||
var gateway = Gateway(sent, req => req.RequestUri!.AbsolutePath.Contains("/versions/", StringComparison.Ordinal)
|
||||
? Json(new { version = 1, status = "draft" })
|
||||
: Route(req, []));
|
||||
|
||||
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => gateway.UpsertAsync(Record()));
|
||||
Assert.Contains("published version", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_loudly_when_the_objecttype_is_not_registered()
|
||||
{
|
||||
var sent = new List<Sent>();
|
||||
var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = JsonContent.Create(new { results = Array.Empty<object>() }),
|
||||
});
|
||||
|
||||
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => gateway.UpsertAsync(Record()));
|
||||
Assert.Contains("RegisterRecord", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Surfaces_the_objecten_error_body_when_a_write_is_rejected()
|
||||
{
|
||||
var sent = new List<Sent>();
|
||||
var gateway = Gateway(sent, req => req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath == "/api/v2/objects"
|
||||
? new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("{\"detail\":\"schema mismatch\"}") }
|
||||
: Route(req, []));
|
||||
|
||||
var error = await Assert.ThrowsAsync<HttpRequestException>(() => gateway.UpsertAsync(Record()));
|
||||
Assert.Contains("schema mismatch", error.Message);
|
||||
Assert.Contains("Creating the register record", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Surfaces_the_objecten_error_body_when_an_update_is_rejected()
|
||||
{
|
||||
var sent = new List<Sent>();
|
||||
object[] existing = [new { url = "http://objecten:8000/api/v2/objects/obj-9" }];
|
||||
var gateway = Gateway(sent, req => req.Method == HttpMethod.Patch
|
||||
? new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("{\"detail\":\"stale version\"}") }
|
||||
: Route(req, existing));
|
||||
|
||||
var error = await Assert.ThrowsAsync<HttpRequestException>(() => gateway.UpsertAsync(Record()));
|
||||
Assert.Contains("stale version", error.Message);
|
||||
Assert.Contains("Updating the register record", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Surfaces_a_failed_read_instead_of_writing_blind()
|
||||
{
|
||||
var sent = new List<Sent>();
|
||||
var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.Unauthorized)
|
||||
{
|
||||
Content = new StringContent("{\"detail\":\"invalid token\"}"),
|
||||
});
|
||||
|
||||
var error = await Assert.ThrowsAsync<HttpRequestException>(() => gateway.UpsertAsync(Record()));
|
||||
Assert.Contains("Querying objecttypen", error.Message);
|
||||
Assert.Contains("invalid token", error.Message);
|
||||
// A read that failed must never be mistaken for "nothing there yet" and followed by a write.
|
||||
Assert.DoesNotContain(sent, s => s.Method == HttpMethod.Post || s.Method == HttpMethod.Patch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_loudly_when_the_objecttype_carries_no_versions_at_all()
|
||||
{
|
||||
var sent = new List<Sent>();
|
||||
var gateway = Gateway(sent, req => req.RequestUri!.AbsolutePath == "/api/v2/objecttypes"
|
||||
? Json(new { results = new[] { new { url = ObjecttypeUrl, name = "RegisterRecord" } } })
|
||||
: Route(req, []));
|
||||
|
||||
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => gateway.UpsertAsync(Record()));
|
||||
Assert.Contains("published version", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Says_which_read_failed_when_the_objecten_search_errors()
|
||||
{
|
||||
var sent = new List<Sent>();
|
||||
var gateway = Gateway(sent, req => req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath == "/api/v2/objects"
|
||||
? new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent("boom") }
|
||||
: Route(req, []));
|
||||
|
||||
var error = await Assert.ThrowsAsync<HttpRequestException>(() => gateway.UpsertAsync(Record()));
|
||||
Assert.Contains("Querying objects", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Surfaces_an_empty_read_body_rather_than_dereferencing_it()
|
||||
{
|
||||
var sent = new List<Sent>();
|
||||
var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("null", System.Text.Encoding.UTF8, "application/json"),
|
||||
});
|
||||
|
||||
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => gateway.UpsertAsync(Record()));
|
||||
Assert.Contains("objecttypen", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Treats_a_result_less_response_as_no_match_rather_than_crashing()
|
||||
{
|
||||
var sent = new List<Sent>();
|
||||
// The objecttypes collection carries no `results` key — the objecttype is absent, which must
|
||||
// surface as the "not registered" error rather than an ArgumentNullException from LINQ.
|
||||
var gateway = Gateway(sent, _ => Json(new { }));
|
||||
|
||||
var error = await Assert.ThrowsAsync<InvalidOperationException>(() => gateway.UpsertAsync(Record()));
|
||||
Assert.Contains("RegisterRecord", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Creates_the_object_when_the_search_response_carries_no_results_key()
|
||||
{
|
||||
var sent = new List<Sent>();
|
||||
var gateway = Gateway(sent, req => req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath == "/api/v2/objects"
|
||||
? Json(new { })
|
||||
: Route(req, []));
|
||||
|
||||
await gateway.UpsertAsync(Record());
|
||||
|
||||
Assert.Contains(sent, s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reads_objecttypen_without_the_crs_headers_it_does_not_accept()
|
||||
{
|
||||
var sent = new List<Sent>();
|
||||
|
||||
await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
|
||||
|
||||
// Objecttypen is not a geo API; only the Objecten hops carry CRS.
|
||||
Assert.All(
|
||||
sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)),
|
||||
s => Assert.Null(s.AcceptCrs));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Buffers_the_write_body_so_uwsgi_gets_a_content_length()
|
||||
{
|
||||
var sent = new List<Sent>();
|
||||
|
||||
await Gateway(sent, req => Route(req, [])).UpsertAsync(Record());
|
||||
|
||||
var write = sent.Single(s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects");
|
||||
Assert.NotNull(write.ContentLength);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_a_null_record_without_calling_objecten()
|
||||
{
|
||||
var sent = new List<Sent>();
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => Gateway(sent, req => Route(req, [])).UpsertAsync(null!));
|
||||
Assert.Empty(sent);
|
||||
}
|
||||
}
|
||||
@@ -824,4 +824,51 @@ public class OpenZaakGatewayTests
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ public class ZaaktypeCatalogTests
|
||||
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()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"stryker-config": {
|
||||
"solution": "Acl.slnx",
|
||||
"test-projects": ["Acl.Tests/Acl.Tests.csproj"],
|
||||
"reporters": ["progress", "html"],
|
||||
"reporters": ["progress", "html", "markdown"],
|
||||
"thresholds": {
|
||||
"high": 95,
|
||||
"low": 90,
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
<!-- OIDC/JWT validation of Keycloak-issued tokens (ADR-0010) and OpenAPI generation. -->
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.17.0-beta.1" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -54,6 +54,33 @@ public interface IProjectionClient
|
||||
Task<IReadOnlyList<ProjectionEntry>> GetRegisterAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>A published zaaktype as the beheer catalogus viewer shows it (S-15a): the business
|
||||
/// <c>Identificatie</c> + human <c>Omschrijving</c>. The ZGW URL the ACL also returns is dropped — an
|
||||
/// internal reference, not shown in the portal.</summary>
|
||||
public sealed record BeheerZaaktype(string Identificatie, string Omschrijving);
|
||||
|
||||
/// <summary>The ACL default-fill settings the beheer portal reads + edits (S-15b): the three ZGW-mandatory
|
||||
/// fields the ACL stamps on every zaak (ADR-0003).</summary>
|
||||
public sealed record BeheerDefaultFill(
|
||||
string Bronorganisatie,
|
||||
string VerantwoordelijkeOrganisatie,
|
||||
string Vertrouwelijkheidaanduiding);
|
||||
|
||||
/// <summary>Port to the ACL for beheer queries (beheer portal). The BFF reaches the ACL directly: these
|
||||
/// aren't a domain concern, and the ACL is the only code allowed to read/own the ZGW-facing config
|
||||
/// (§8.1, ADR-0025).</summary>
|
||||
public interface IAclClient
|
||||
{
|
||||
/// <summary>The published catalogus zaaktypen, read-only (S-15a).</summary>
|
||||
Task<IReadOnlyList<BeheerZaaktype>> GetZaaktypenAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>The current default-fill settings (S-15b).</summary>
|
||||
Task<BeheerDefaultFill> GetDefaultFillAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Replace the default-fill settings (S-15b).</summary>
|
||||
Task UpdateDefaultFillAsync(BeheerDefaultFill settings, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Calls the Domain Service's <c>POST /registrations</c>.</summary>
|
||||
public sealed class DomainClient(HttpClient http) : IDomainClient
|
||||
{
|
||||
@@ -121,3 +148,21 @@ public sealed class ProjectionClient(HttpClient http) : IProjectionClient
|
||||
public async Task<IReadOnlyList<ProjectionEntry>> GetRegisterAsync(CancellationToken ct = default)
|
||||
=> await http.GetFromJsonAsync<List<ProjectionEntry>>("register", ct) ?? [];
|
||||
}
|
||||
|
||||
/// <summary>Calls the ACL's <c>GET /catalogi/zaaktypen</c> (S-15a). The ACL also returns each zaaktype's
|
||||
/// ZGW URL; deserializing into <see cref="BeheerZaaktype"/> keeps only the public-safe fields.</summary>
|
||||
public sealed class AclClient(HttpClient http) : IAclClient
|
||||
{
|
||||
public async Task<IReadOnlyList<BeheerZaaktype>> GetZaaktypenAsync(CancellationToken ct = default)
|
||||
=> await http.GetFromJsonAsync<List<BeheerZaaktype>>("catalogi/zaaktypen", ct) ?? [];
|
||||
|
||||
public async Task<BeheerDefaultFill> GetDefaultFillAsync(CancellationToken ct = default)
|
||||
=> await http.GetFromJsonAsync<BeheerDefaultFill>("default-fill", ct)
|
||||
?? throw new InvalidOperationException("The ACL returned an empty default-fill response.");
|
||||
|
||||
public async Task UpdateDefaultFillAsync(BeheerDefaultFill settings, CancellationToken ct = default)
|
||||
{
|
||||
using var response = await http.PutAsJsonAsync("default-fill", settings, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,33 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bff.Api;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// OpenTelemetry tracing (S-16b, ADR-0023): auto-instrument incoming ASP.NET Core requests and
|
||||
// outgoing HttpClient calls (BFF → Domain, BFF → projection-api), exported over OTLP to Tempo, so a
|
||||
// portal request is one connected trace across the services. Service name + OTLP endpoint come from
|
||||
// OTEL_* env (compose); the exporter no-ops when Tempo is unreachable. /health is filtered out.
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.ConfigureResource(r => r.AddService(
|
||||
builder.Configuration["OTEL_SERVICE_NAME"] ?? builder.Environment.ApplicationName))
|
||||
.WithTracing(tracing => tracing
|
||||
.AddAspNetCoreInstrumentation(o => o.Filter = ctx => ctx.Request.Path != "/health")
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddOtlpExporter())
|
||||
// OpenTelemetry metrics (S-16c, ADR-0023): the golden signals for the request path —
|
||||
// http.server.request.duration (traffic/errors/latency) + http.client.* for the downstream hops,
|
||||
// plus the built-in System.Runtime meter for saturation (GC, CPU, thread pool). Prometheus scrapes
|
||||
// these from /metrics (mapped below); no OTLP push for metrics, so no collector hop (ADR-0023).
|
||||
.WithMetrics(metrics => metrics
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddMeter("System.Runtime")
|
||||
.AddPrometheusExporter());
|
||||
|
||||
var keycloakAuthority = builder.Configuration["Keycloak:Authority"]
|
||||
?? throw new InvalidOperationException("Missing configuration 'Keycloak:Authority'");
|
||||
// Behandelaars authenticate against a *different* Keycloak realm (medewerker) than citizens (digid),
|
||||
@@ -16,6 +40,10 @@ var domainBaseUrl = builder.Configuration["Downstream:Domain:BaseUrl"]
|
||||
?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'");
|
||||
var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"]
|
||||
?? throw new InvalidOperationException("Missing configuration 'Downstream:Projection:BaseUrl'");
|
||||
// The beheer portal's read-only catalogus view reaches the ACL directly (ADR-0025): the catalogus is
|
||||
// not a domain concern, and only the ACL may read the ZGW Catalogi API (§8.1).
|
||||
var aclBaseUrl = builder.Configuration["Downstream:Acl:BaseUrl"]
|
||||
?? throw new InvalidOperationException("Missing configuration 'Downstream:Acl:BaseUrl'");
|
||||
|
||||
// Validate Keycloak-issued tokens (ADR-0010). Audience validation is off for the walking skeleton —
|
||||
// Keycloak's audience mapping is a later hardening; signature/issuer/expiry are validated.
|
||||
@@ -43,14 +71,24 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
};
|
||||
});
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy(BehandelAuth.Policy, policy => policy
|
||||
.AddAuthenticationSchemes(BehandelAuth.Scheme)
|
||||
.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<IProjectionClient, ProjectionClient>(c => c.BaseAddress = new Uri(projectionBaseUrl));
|
||||
builder.Services.AddHttpClient<IAclClient, AclClient>(c => c.BaseAddress = new Uri(aclBaseUrl));
|
||||
|
||||
builder.Services.AddHealthChecks();
|
||||
// Clear the auto-populated `servers` block so the committed spec is stable regardless of the host
|
||||
@@ -68,6 +106,9 @@ app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapHealthChecks("/health");
|
||||
|
||||
// Prometheus scrape endpoint (S-16c): exposes the OTel metrics above in Prometheus text format.
|
||||
app.MapPrometheusScrapingEndpoint();
|
||||
app.MapOpenApi();
|
||||
|
||||
// Self-service submit: requires a valid digid token; the bsn comes from the token, not the body,
|
||||
@@ -178,6 +219,34 @@ app.MapPost("/behandel/registrations/{id}/decide",
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden);
|
||||
|
||||
// Beheer catalogus viewer (S-15a): the published zaaktypen, read-only. Reached only with a medewerker-
|
||||
// realm token carrying the beheerder role; the BFF proxies the ACL's read (ADR-0025). Public-safe.
|
||||
app.MapGet("/beheer/catalogi/zaaktypen", async (IAclClient acl, CancellationToken ct) =>
|
||||
Results.Ok(await acl.GetZaaktypenAsync(ct)))
|
||||
.RequireAuthorization(BeheerAuth.Policy)
|
||||
.Produces<IReadOnlyList<BeheerZaaktype>>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden);
|
||||
|
||||
// Beheer default-fill config (S-15b): read + edit the ACL's default-fill values. Behind medewerker-
|
||||
// realm + beheerder authorization; the BFF proxies the ACL (ADR-0025). The ACL validates the values.
|
||||
app.MapGet("/beheer/default-fill", async (IAclClient acl, CancellationToken ct) =>
|
||||
Results.Ok(await acl.GetDefaultFillAsync(ct)))
|
||||
.RequireAuthorization(BeheerAuth.Policy)
|
||||
.Produces<BeheerDefaultFill>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden);
|
||||
|
||||
app.MapPut("/beheer/default-fill", async (BeheerDefaultFill body, IAclClient acl, CancellationToken ct) =>
|
||||
{
|
||||
await acl.UpdateDefaultFillAsync(body, ct);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.RequireAuthorization(BeheerAuth.Policy)
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status403Forbidden);
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>The behandelaar's decision on a registration.</summary>
|
||||
@@ -230,5 +299,13 @@ internal static class BehandelAuth
|
||||
private sealed record RealmAccess([property: JsonPropertyName("roles")] string[] Roles);
|
||||
}
|
||||
|
||||
// Beheer (medewerker-realm) authorization wiring (S-15a). Reuses the "medewerker" bearer scheme
|
||||
// (BehandelAuth.Scheme) and its realm-role lifting; only the required role differs.
|
||||
internal static class BeheerAuth
|
||||
{
|
||||
public const string Policy = "beheerder";
|
||||
public const string BeheerderRole = "beheerder";
|
||||
}
|
||||
|
||||
// Exposed so the test host (WebApplicationFactory<Program>) can boot the app.
|
||||
public partial class Program;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"Downstream": {
|
||||
"Domain": { "BaseUrl": "http://localhost:8130/" },
|
||||
"Projection": { "BaseUrl": "http://localhost:8120/" }
|
||||
"Projection": { "BaseUrl": "http://localhost:8120/" },
|
||||
"Acl": { "BaseUrl": "http://localhost:8100/" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Bff.Api;
|
||||
|
||||
namespace Bff.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The beheer default-fill config endpoints (S-15b): read (GET) and edit (PUT) the ACL's default-fill,
|
||||
/// reached only with a medewerker-realm token carrying the <c>beheerder</c> role. Missing token → 401;
|
||||
/// a medewerker without the role → 403; a beheerder reads and updates via the ACL client.
|
||||
/// </summary>
|
||||
public class BeheerDefaultFillEndpointTests
|
||||
{
|
||||
private static HttpRequestMessage Get(string? bearer)
|
||||
{
|
||||
var r = new HttpRequestMessage(HttpMethod.Get, "/beheer/default-fill");
|
||||
if (bearer is not null) r.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
|
||||
return r;
|
||||
}
|
||||
|
||||
private static HttpRequestMessage Put(string? bearer, object body)
|
||||
{
|
||||
var r = new HttpRequestMessage(HttpMethod.Put, "/beheer/default-fill") { Content = JsonContent.Create(body) };
|
||||
if (bearer is not null) r.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
|
||||
return r;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_read_without_a_token()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
var response = await factory.CreateClient().SendAsync(Get(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(Get(TestTokens.Medewerker("behandelaar")));
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Serves_the_current_default_fill_to_a_beheerder()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
factory.Acl.DefaultFill = new BeheerDefaultFill("517439943", "517439943", "openbaar");
|
||||
|
||||
var response = await factory.CreateClient().SendAsync(Get(TestTokens.Medewerker("beheerder")));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<BeheerDefaultFill>();
|
||||
Assert.Equal("517439943", body!.Bronorganisatie);
|
||||
Assert.Equal("openbaar", body.Vertrouwelijkheidaanduiding);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Updates_the_default_fill_via_the_acl_for_a_beheerder()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
|
||||
var response = await factory.CreateClient().SendAsync(
|
||||
Put(TestTokens.Medewerker("beheerder"),
|
||||
new { bronorganisatie = "999999999", verantwoordelijkeOrganisatie = "888888888", vertrouwelijkheidaanduiding = "vertrouwelijk" }));
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
Assert.Equal("999999999", factory.Acl.Updated!.Bronorganisatie);
|
||||
Assert.Equal("vertrouwelijk", factory.Acl.Updated.Vertrouwelijkheidaanduiding);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_an_update_from_a_non_beheerder()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
|
||||
var response = await factory.CreateClient().SendAsync(
|
||||
Put(TestTokens.Medewerker("behandelaar"), new { bronorganisatie = "1", verantwoordelijkeOrganisatie = "2", vertrouwelijkheidaanduiding = "openbaar" }));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Null(factory.Acl.Updated);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user