Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d39b5dff3c | ||
|
|
995b55af57 | ||
|
|
b812f42912 | ||
|
|
61f6f5781f | ||
|
|
5f5dfda1a0 | ||
|
|
4c702e324a | ||
|
|
b412721938 | ||
|
|
df16659f94 | ||
|
|
9eb51b8b3e | ||
|
|
b0485a7724 | ||
|
|
7368bf3bce | ||
|
|
a62a09bdff | ||
|
|
d95741385a |
+4
-117
@@ -70,12 +70,6 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
nuget-${{ runner.os }}-
|
nuget-${{ runner.os }}-
|
||||||
- run: make unit
|
- run: make unit
|
||||||
# Job summary (#136): a per-service pass/fail table from the TRX `make unit` wrote.
|
|
||||||
- name: Unit test summary
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
|
||||||
python3 infra/trx-summary.py TestResults >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
|
|
||||||
# Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build.
|
# Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build.
|
||||||
frontend:
|
frontend:
|
||||||
@@ -90,12 +84,6 @@ jobs:
|
|||||||
node-version: '24'
|
node-version: '24'
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
- run: make frontend
|
- run: make frontend
|
||||||
# Job summary (#136): a per-frontend (app) pass/fail table from the vitest JSON each app wrote.
|
|
||||||
- name: Frontend test summary
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
|
||||||
python3 infra/vitest-summary.py test-output >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
|
|
||||||
mutation:
|
mutation:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -111,29 +99,6 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
nuget-${{ runner.os }}-
|
nuget-${{ runner.os }}-
|
||||||
- run: make mutation
|
- run: make mutation
|
||||||
# Job summary (#136): render each service's Stryker Markdown report on the run page (Gitea
|
|
||||||
# 1.27 $GITHUB_STEP_SUMMARY). `if: always()` so a ratchet break still reports — and because
|
|
||||||
# `make mutation` stops at the first break, the summary also shows exactly where it stopped.
|
|
||||||
# Guarded so it no-ops on a runner/server without summary support. Strips the report's UTF-8 BOM.
|
|
||||||
- name: Mutation score summary
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
|
||||||
{
|
|
||||||
echo "## 🧬 Mutation testing"
|
|
||||||
echo
|
|
||||||
for svc in acl event-subscriber domain bff; do
|
|
||||||
echo "### $svc"
|
|
||||||
echo
|
|
||||||
report=$(ls services/"$svc"/StrykerOutput/*/reports/mutation-report.md 2>/dev/null | sort | tail -1)
|
|
||||||
if [ -n "$report" ]; then
|
|
||||||
sed '1s/^\xef\xbb\xbf//' "$report"
|
|
||||||
else
|
|
||||||
echo "_No report — \`make mutation\` stopped before \`$svc\` (earlier ratchet break)._"
|
|
||||||
fi
|
|
||||||
echo
|
|
||||||
done
|
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
# Publish the Stryker HTML reports. `if: always()` uploads them even when the
|
# Publish the Stryker HTML reports. `if: always()` uploads them even when the
|
||||||
# ratchet fails — that is exactly when you want to inspect the survivors.
|
# ratchet fails — that is exactly when you want to inspect the survivors.
|
||||||
# `continue-on-error` keeps the upload best-effort: the mutation *gate* is the
|
# `continue-on-error` keeps the upload best-effort: the mutation *gate* is the
|
||||||
@@ -179,118 +144,40 @@ jobs:
|
|||||||
# they never co-schedule now the runner has capacity >1. A concurrent Stryker run + full-stack
|
# 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
|
# 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.
|
# light .NET/frontend jobs have no `needs`, so they still parallelise up to runner capacity.
|
||||||
#
|
# `if: !cancelled()` keeps verify-stack running even when the mutation ratchet fails (so we don't
|
||||||
# No `if: ${{ !cancelled() }}` here (removed in #134): on Gitea 1.27 + act_runner 2.0.0, a job
|
# lose its signal) while still honouring run cancellation from the concurrency group above.
|
||||||
# gated by a status-function `if` (always()/cancelled()) on top of `needs` routes through the new
|
|
||||||
# transitional "Cancelling" state + capability negotiation and never leaves `waiting` — it's never
|
|
||||||
# dispatched (gitea-actions-gotchas.md §7). Default `if: success()` dispatches normally. Cost: a
|
|
||||||
# failing mutation ratchet now skips verify-stack instead of running it anyway; the fix-and-re-push
|
|
||||||
# re-run exercises verify-stack, so we still get the signal.
|
|
||||||
verify-stack:
|
verify-stack:
|
||||||
needs: [mutation]
|
needs: [mutation]
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
# Bring the full stack up + wait for health — this also is the DoD "compose up
|
# Bring the full stack up + wait for health — this also is the DoD "compose up
|
||||||
# reaches green health" smoke (it replaces the old compose-smoke job).
|
# reaches green health" smoke (it replaces the old compose-smoke job).
|
||||||
# Each check carries an `id` so the summary step below can report its per-check outcome (#136).
|
|
||||||
# A failed check skips the rest (no step `if:`), so the table shows exactly where it stopped.
|
|
||||||
- name: Bring up the full stack & wait for health
|
- name: Bring up the full stack & wait for health
|
||||||
id: up
|
|
||||||
run: make verify-up
|
run: make verify-up
|
||||||
- name: Observability backplane (Grafana + Tempo + Prometheus datasources)
|
- name: Observability backplane (Grafana + Tempo + Prometheus datasources)
|
||||||
id: obs
|
|
||||||
run: OBS_TIMEOUT=180 make verify-observability
|
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
|
- name: ACL ↔ OpenZaak integration tests
|
||||||
id: acl
|
|
||||||
run: make verify-acl
|
run: make verify-acl
|
||||||
- name: OpenZaak → NRC notification delivery
|
- name: OpenZaak → NRC notification delivery
|
||||||
id: nrc
|
|
||||||
run: make verify-nrc
|
run: make verify-nrc
|
||||||
- name: OpenZaak → NRC → Event Subscriber → projection-api
|
- name: OpenZaak → NRC → Event Subscriber → projection-api
|
||||||
id: projection
|
|
||||||
run: make verify-projection
|
run: make verify-projection
|
||||||
- name: Objecten → NRC notification delivery
|
|
||||||
id: objecten_nrc
|
|
||||||
run: make verify-objecten-notifications
|
|
||||||
- name: Domain → Flowable → ACL → OpenZaak
|
- name: Domain → Flowable → ACL → OpenZaak
|
||||||
id: domain
|
|
||||||
run: make verify-domain
|
run: make verify-domain
|
||||||
- name: BFF → Keycloak + domain + projection
|
- name: BFF → Keycloak + domain + projection
|
||||||
id: bff
|
|
||||||
run: make verify-bff
|
run: make verify-bff
|
||||||
- name: Distributed traces reach Tempo (one connected trace across services)
|
- name: Distributed traces reach Tempo (one connected trace across services)
|
||||||
id: tracing
|
|
||||||
run: TRACING_TIMEOUT=120 make verify-tracing
|
run: TRACING_TIMEOUT=120 make verify-tracing
|
||||||
- name: Golden-signal metrics scraped by Prometheus (/metrics on every service)
|
- name: Golden-signal metrics scraped by Prometheus (/metrics on every service)
|
||||||
id: metrics
|
|
||||||
run: METRICS_TIMEOUT=120 make verify-metrics
|
run: METRICS_TIMEOUT=120 make verify-metrics
|
||||||
- name: Self-service e2e (Playwright, login → submit → success)
|
- name: Self-service e2e (Playwright, login → submit → success)
|
||||||
id: e2e
|
|
||||||
run: make verify-e2e
|
run: make verify-e2e
|
||||||
# Job summary (#136): a pass/fail table of every live-stack check, so a red verify-stack shows
|
|
||||||
# which check failed at a glance. `if: always()` (step-level — safe on runner 2.0.0, unlike the
|
|
||||||
# job-level status-function `if` of #134) so it renders even after a check fails.
|
|
||||||
- name: verify-stack check summary
|
|
||||||
if: always()
|
|
||||||
env:
|
|
||||||
UP: ${{ steps.up.outcome }}
|
|
||||||
OBS: ${{ steps.obs.outcome }}
|
|
||||||
OBJECTTYPEN: ${{ steps.objecttypen.outcome }}
|
|
||||||
OBJECTEN: ${{ steps.objecten.outcome }}
|
|
||||||
REGISTERRECORD: ${{ steps.registerrecord.outcome }}
|
|
||||||
OBJECTEN_NOTIFICATIONS: ${{ steps.objecten_nrc.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 "| Objecten → NRC | $(icon "$OBJECTEN_NOTIFICATIONS") |"
|
|
||||||
echo "| ACL ↔ OpenZaak | $(icon "$ACL") |"
|
|
||||||
echo "| OpenZaak → NRC | $(icon "$NRC") |"
|
|
||||||
echo "| NRC → Event Subscriber → projection | $(icon "$PROJECTION") |"
|
|
||||||
echo "| Domain → Flowable → ACL → OpenZaak | $(icon "$DOMAIN") |"
|
|
||||||
echo "| BFF → Keycloak + domain + projection | $(icon "$BFF") |"
|
|
||||||
echo "| Distributed traces (Tempo) | $(icon "$TRACING") |"
|
|
||||||
echo "| Golden-signal metrics (Prometheus) | $(icon "$METRICS") |"
|
|
||||||
echo "| Self-service e2e (Playwright) | $(icon "$E2E") |"
|
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
# Job summary (#136): per-spec Playwright results, from the JSON report run-e2e-check.sh copied
|
|
||||||
# out of the e2e container. Turns a red e2e into a one-glance "which spec" instead of a log dive.
|
|
||||||
- name: e2e spec summary
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
|
|
||||||
python3 infra/playwright-summary.py tests/e2e/playwright-report.json >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
# Log dump must precede teardown (which removes the containers).
|
# Log dump must precede teardown (which removes the containers).
|
||||||
- name: Dump container logs on failure
|
- name: Dump container logs on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer objecttypen-db objecttypen-redis objecttypen-init objecttypen objecten-db objecten-redis objecten-init objecten objecten-celery registerrecord-init tempo prometheus grafana 2>&1 || true
|
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer tempo prometheus grafana 2>&1 || true
|
||||||
- name: Tear down
|
- name: Tear down
|
||||||
if: always()
|
if: always()
|
||||||
run: make down
|
run: make down
|
||||||
|
|||||||
@@ -58,6 +58,3 @@ tests/e2e/node_modules/
|
|||||||
tests/e2e/test-results/
|
tests/e2e/test-results/
|
||||||
tests/e2e/playwright-report/
|
tests/e2e/playwright-report/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
TestResults/
|
|
||||||
test-output/
|
|
||||||
tests/e2e/playwright-report.json
|
|
||||||
|
|||||||
+2
-15
@@ -277,29 +277,16 @@ Split into independently deployable sub-slices (CLAUDE.md §13):
|
|||||||
|
|
||||||
## Iteration 4 — Objecten and the authoritative register *(milestone: `Iteration 4 — Objecten`)*
|
## Iteration 4 — Objecten and the authoritative register *(milestone: `Iteration 4 — Objecten`)*
|
||||||
|
|
||||||
### S-18 · Objecten + Objecttypen up in compose; Register objecttype defined *(split — #19 closed)*
|
### S-18 · Objecten + Objecttypen up in compose; Register objecttype defined
|
||||||
|
|
||||||
**Outcome:** Objecten and Objecttypen running. A `RegisterRecord` objecttype defined with the public-safe schema.
|
**Outcome:** Objecten and Objecttypen running. A `RegisterRecord` objecttype defined with the public-safe schema.
|
||||||
|
|
||||||
Split into independently deployable sub-slices (CLAUDE.md §13):
|
### S-19 · ACL extension: write register-record to Objecten on approval
|
||||||
|
|
||||||
- **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.
|
**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."
|
**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. *(split — #150 closed)*
|
|
||||||
- **S-19b-1** (#152, ✅) · Objecten publishes to NRC — broker, celery worker, `objecten` kanaal, notifications config. Turns back on what ADR-0028 deliberately disabled.
|
|
||||||
- **S-19b-2** (#153, ✅) · Projection derived from `RegisterRecord` objects, rebuildable from the Objecten-derived log. The ACL also writes an INGEDIEND record on submit, so the register holds the whole lifecycle. Carries ADR-0030.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Iteration 5 — Data governance module *(milestone: `Iteration 5 — Data Governance`)*
|
## 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
|
# Long-running services with a healthcheck — the smoke polls these for readiness
|
||||||
# (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init)
|
# (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init)
|
||||||
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
|
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
|
||||||
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel beheer objecttypen objecten
|
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel beheer
|
||||||
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
|
# Config files (OpenZaak data.yaml, Keycloak realms, Flowable BPMN) are streamed
|
||||||
# into external named volumes via `docker cp` (infra/seed-config.sh) instead of
|
# into external named volumes via `docker cp` (infra/seed-config.sh) instead of
|
||||||
# bind-mounted, because bind mounts don't reach sibling containers on the
|
# bind-mounted, because bind mounts don't reach sibling containers on the
|
||||||
@@ -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
|
# volumes are `external`, so compose won't remove them — CFG_VOLS lists them for
|
||||||
# explicit teardown. See docs/runbooks/gitea-actions-gotchas.md.
|
# explicit teardown. See docs/runbooks/gitea-actions-gotchas.md.
|
||||||
SEED := bash infra/seed-config.sh
|
SEED := bash infra/seed-config.sh
|
||||||
CFG_VOLS := rr-oz-config rr-nrc-config rr-kc-realms rr-fl-bpmn rr-objecttypen-config rr-objecten-config rr-registerrecord-config
|
CFG_VOLS := rr-oz-config rr-nrc-config rr-kc-realms rr-fl-bpmn
|
||||||
# Local-only stack: same services but config is bind-mounted (no seed step), so a
|
# 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
|
# 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.
|
# 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
|
||||||
endif
|
endif
|
||||||
|
|
||||||
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-observability verify-tracing verify-metrics verify-objecttypen verify-objecten verify-registerrecord verify-objecten-notifications 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-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
|
||||||
|
|
||||||
## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions)
|
## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions)
|
||||||
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
|
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
|
||||||
@@ -70,9 +70,8 @@ build:
|
|||||||
dotnet build $(SLN) -c Release
|
dotnet build $(SLN) -c Release
|
||||||
|
|
||||||
## unit: run unit tests (excludes the container-backed Integration lane)
|
## unit: run unit tests (excludes the container-backed Integration lane)
|
||||||
# TRX per test project (→ TestResults/) feeds the CI per-service summary (#136); harmless locally.
|
|
||||||
unit:
|
unit:
|
||||||
dotnet test $(SLN) -c Release --filter "Category!=Integration" --logger trx --results-directory TestResults
|
dotnet test $(SLN) -c Release --filter "Category!=Integration"
|
||||||
|
|
||||||
## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline)
|
## mutation: run the Stryker.NET ratchet on each service with branching logic (fails below baseline)
|
||||||
# Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore`
|
# Stryker is pinned as a local dotnet tool (.config/dotnet-tools.json); `tool restore`
|
||||||
@@ -94,14 +93,14 @@ mutation:
|
|||||||
# podman-compose, and needing no `--wait` flag or host port access. The one-shots
|
# 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.
|
# (oz-init, flowable-init) aren't polled; they just need to have run.
|
||||||
smoke:
|
smoke:
|
||||||
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
|
$(SEED) oz nrc kc fl
|
||||||
docker compose -f $(COMPOSE) up -d --build
|
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'
|
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
|
## 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)
|
## `docker compose up`, which can't self-seed the external config volumes)
|
||||||
up:
|
up:
|
||||||
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
|
$(SEED) oz nrc kc fl
|
||||||
docker compose -f $(COMPOSE) up -d --build
|
docker compose -f $(COMPOSE) up -d --build
|
||||||
|
|
||||||
## down: stop and remove the local stack (incl. the external config volumes)
|
## down: stop and remove the local stack (incl. the external config volumes)
|
||||||
@@ -139,7 +138,7 @@ changelog:
|
|||||||
## verify-up: bring the FULL stack up and wait for health (CI verify-stack step 1;
|
## 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).
|
## subsumes the old compose-smoke health gate — the DoD "up reaches green" check).
|
||||||
verify-up:
|
verify-up:
|
||||||
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
|
$(SEED) oz nrc kc fl
|
||||||
docker compose -f $(COMPOSE) up -d --build
|
docker compose -f $(COMPOSE) up -d --build
|
||||||
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
|
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
|
||||||
|
|
||||||
@@ -186,38 +185,17 @@ verify-tracing:
|
|||||||
verify-metrics:
|
verify-metrics:
|
||||||
bash infra/run-metrics-check.sh
|
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-objecten-notifications: assert a RegisterRecord write in Objecten is DELIVERED as an
|
|
||||||
## `objecten` notification via NRC (S-19b-1), against the already-running stack.
|
|
||||||
verify-objecten-notifications:
|
|
||||||
bash infra/run-objecten-notifications-check.sh
|
|
||||||
|
|
||||||
## verify: local mirror of the CI verify-stack job — full stack up once, all checks,
|
## verify: local mirror of the CI verify-stack job — full stack up once, all checks,
|
||||||
## tear down (always). For fast single-concern local iteration use `integration`
|
## tear down (always). For fast single-concern local iteration use `integration`
|
||||||
## (oz-only) or `verify-notifications` (oz+nrc) instead.
|
## (oz-only) or `verify-notifications` (oz+nrc) instead.
|
||||||
verify:
|
verify:
|
||||||
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
|
$(SEED) oz nrc kc fl
|
||||||
docker compose -f $(COMPOSE) up -d --build
|
docker compose -f $(COMPOSE) up -d --build
|
||||||
@bash -c 'set -e; rc=0; \
|
@bash -c 'set -e; rc=0; \
|
||||||
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS) \
|
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS) \
|
||||||
&& bash infra/run-acl-integration.sh \
|
&& bash infra/run-acl-integration.sh \
|
||||||
&& bash infra/run-notification-check.sh \
|
&& bash infra/run-notification-check.sh \
|
||||||
&& bash infra/run-projection-check.sh \
|
&& bash infra/run-projection-check.sh \
|
||||||
&& bash infra/run-objecten-notifications-check.sh \
|
|
||||||
&& bash infra/run-domain-check.sh \
|
&& bash infra/run-domain-check.sh \
|
||||||
&& bash infra/run-bff-check.sh \
|
&& bash infra/run-bff-check.sh \
|
||||||
&& bash infra/run-e2e-check.sh || rc=$$?; \
|
&& bash infra/run-e2e-check.sh || rc=$$?; \
|
||||||
|
|||||||
@@ -64,9 +64,7 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"executor": "@angular/build:unit-test",
|
"executor": "@angular/build:unit-test",
|
||||||
"options": {
|
"options": {
|
||||||
"watch": false,
|
"watch": false
|
||||||
"reporters": ["default", "json"],
|
|
||||||
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"serve-static": {
|
"serve-static": {
|
||||||
|
|||||||
@@ -64,9 +64,7 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"executor": "@angular/build:unit-test",
|
"executor": "@angular/build:unit-test",
|
||||||
"options": {
|
"options": {
|
||||||
"watch": false,
|
"watch": false
|
||||||
"reporters": ["default", "json"],
|
|
||||||
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"serve-static": {
|
"serve-static": {
|
||||||
|
|||||||
@@ -1,5 +1 @@
|
|||||||
<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>
|
<router-outlet></router-outlet>
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { Route } from '@angular/router';
|
import { Route } from '@angular/router';
|
||||||
import { authenticatedGuard } from 'auth';
|
import { authenticatedGuard } from 'auth';
|
||||||
import { CatalogusPage } from './catalogus/catalogus-page';
|
import { CatalogusPage } from './catalogus/catalogus-page';
|
||||||
import { DefaultFillPage } from './default-fill/default-fill-page';
|
|
||||||
|
|
||||||
export const appRoutes: Route[] = [
|
export const appRoutes: Route[] = [
|
||||||
{ path: '', component: CatalogusPage, canActivate: [authenticatedGuard] },
|
{ path: '', component: CatalogusPage, canActivate: [authenticatedGuard] },
|
||||||
{ path: 'default-fill', component: DefaultFillPage, canActivate: [authenticatedGuard] },
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
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([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -64,9 +64,7 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"executor": "@angular/build:unit-test",
|
"executor": "@angular/build:unit-test",
|
||||||
"options": {
|
"options": {
|
||||||
"watch": false,
|
"watch": false
|
||||||
"reporters": ["default", "json"],
|
|
||||||
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"serve-static": {
|
"serve-static": {
|
||||||
|
|||||||
@@ -64,9 +64,7 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"executor": "@angular/build:unit-test",
|
"executor": "@angular/build:unit-test",
|
||||||
"options": {
|
"options": {
|
||||||
"watch": false,
|
"watch": false
|
||||||
"reporters": ["default", "json"],
|
|
||||||
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"serve-static": {
|
"serve-static": {
|
||||||
|
|||||||
+1
-1
@@ -207,7 +207,7 @@ A slice is done when:
|
|||||||
## 15. Out of scope for v1
|
## 15. Out of scope for v1
|
||||||
|
|
||||||
- OpenMetadata data governance module (v3 slice).
|
- OpenMetadata data governance module (v3 slice).
|
||||||
- ~~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.
|
- Objecten as the authoritative register record store (v2 slice — v1 uses OpenZaak zaak-eigenschappen as a placeholder).
|
||||||
- Production-grade Helm chart (sketch only).
|
- Production-grade Helm chart (sketch only).
|
||||||
- Multi-tenancy.
|
- Multi-tenancy.
|
||||||
- Real outbound notifications (email/SMS) — logged to console in v1.
|
- Real outbound notifications (email/SMS) — logged to console in v1.
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
# 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.
|
|
||||||
- **Lifted by ADR-0029** (S-19b-1, #152): broker, worker, `objecten` kanaal and
|
|
||||||
notifications config now exist, and `NOTIFICATIONS_DISABLED` is `false`.
|
|
||||||
|
|
||||||
## 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 —
|
|
||||||
done in S-19b-2 (#153), ADR-0030.
|
|
||||||
|
|
||||||
**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.~~ Closed by ADR-0030:
|
|
||||||
the projection is now derived from the register, so there is only one source to agree with.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
# ADR-0029: Objecten publishes register events to NRC
|
|
||||||
|
|
||||||
- **Status:** Accepted
|
|
||||||
- **Date:** 2026-08-14
|
|
||||||
- **Deciders:** Respellion engineering
|
|
||||||
- **Slice:** S-19b-1 (#152), first of the S-19b (#150) split
|
|
||||||
- **Supersedes in part:** ADR-0028's "Objecten's notifications are off for this slice"
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
ADR-0028 put the authoritative register record in the Objecten API and had the ACL write
|
|
||||||
it on approval. It also switched Objecten's notifications **off** — deliberately, with a
|
|
||||||
stated ceiling: there was no broker, no worker, no `objecten` kanaal and no abonnement, so
|
|
||||||
turning the client side on alone would have produced a delivery path that looks wired and
|
|
||||||
drops every message.
|
|
||||||
|
|
||||||
S-19b-2 (#153) wants the read projection sourced from register writes rather than
|
|
||||||
re-derived from ZGW zaak events. That needs the notifications to actually arrive. This ADR
|
|
||||||
builds the four missing pieces and lifts the ceiling.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**Objecten publishes to the same NRC OpenZaak already publishes to, on the `objecten`
|
|
||||||
kanaal, delivered by its own Celery worker — provisioned declaratively on both sides,
|
|
||||||
exactly as ADR-0007 did for OpenZaak.**
|
|
||||||
|
|
||||||
- **Objecten** (`infra/objecten/setup_configuration/data.yaml`): a `zgw_consumers` service
|
|
||||||
`nrc` (api_type `nrc`) plus a `notifications_config` step naming it, and
|
|
||||||
`NOTIFICATIONS_DISABLED: "false"` in both compose files.
|
|
||||||
- **NRC** (`infra/opennotificaties/setup_configuration/data.yaml`): an `objecten` kanaal
|
|
||||||
alongside `zaken`.
|
|
||||||
- **`objecten-celery`**: a worker container on the Objecten image (`/celery_worker.sh`),
|
|
||||||
mirroring `oz-celery`, with `CELERY_BROKER_URL`/`CELERY_RESULT_BACKEND` on
|
|
||||||
`objecten-redis` db 1 (db 0 is already the cache).
|
|
||||||
|
|
||||||
### One NRC, one credential, one kanaal per publisher
|
|
||||||
|
|
||||||
Objecten reuses the `big-reference-seed` client OpenZaak publishes with. NRC verifies its
|
|
||||||
JWT and authorizes it against OpenZaak's Autorisaties API (ADR-0007), which grants that
|
|
||||||
client `heeft_alle_autorisaties` — so no second credential and no publisher-specific
|
|
||||||
authorization is needed. A second NRC, or a second credential, would buy isolation this
|
|
||||||
reference application has no use for.
|
|
||||||
|
|
||||||
The kanaal name is **not ours to choose**: the Objects API sends
|
|
||||||
`NOTIFICATIONS_KANAAL = "objecten"`. NRC rejects a publish to an unregistered kanaal
|
|
||||||
(`"Kanaal met deze naam bestaat niet"`), which is precisely what the failing check for this
|
|
||||||
slice reported first. Its filter set (`object_type`) matches the kenmerken the Objects API
|
|
||||||
sends, so an abonnement can narrow to one objecttype instead of receiving every write.
|
|
||||||
|
|
||||||
### Writers address Objecten as `objecten.local` — NRC rejects single-label hosts
|
|
||||||
|
|
||||||
NRC types a notification's `hoofdObject` and `resourceUrl` as DRF `URLField`s, so Django's
|
|
||||||
`URLValidator` runs on them — and it refuses a **single-label** host. Objecten fills both
|
|
||||||
from the object `url` that DRF built with `request.build_absolute_uri`, i.e. **the Host the
|
|
||||||
caller used**. Write to `http://objecten:8000` and NRC answers every publish with
|
|
||||||
|
|
||||||
```
|
|
||||||
{"hoofdObject":["Voer een geldige URL in."],"resourceUrl":["Voer een geldige URL in."]}
|
|
||||||
```
|
|
||||||
|
|
||||||
which `objecten-celery` then retries with exponential backoff, forever, in the background —
|
|
||||||
the write itself having returned 201.
|
|
||||||
|
|
||||||
`SITE_DOMAIN` does **not** fix this; it is not what builds those URLs. The fix is on the
|
|
||||||
caller side: the `objecten` service carries an `objecten.local` network alias, and every
|
|
||||||
component whose writes must be notified — the ACL (`Acl__Objecten__BaseUrl`), the gateway
|
|
||||||
integration tests, this slice's verify check — addresses it there. An alias rather than a
|
|
||||||
plain dotted `SITE_DOMAIN` so the host still **resolves in-network**: a subscriber that
|
|
||||||
follows `resourceUrl` reaches the record it points at, which S-19b-2 will do. Readers are
|
|
||||||
unaffected and keep using the plain service name.
|
|
||||||
|
|
||||||
This is the same class of constraint as ADR-0028's "the ACL's Objecttypen base URL must
|
|
||||||
match Objecten's configured `api_root`": these modules put request-derived hosts into data
|
|
||||||
another module then validates or dereferences.
|
|
||||||
|
|
||||||
- ponytail ceiling: nothing *enforces* that a new writer uses the alias — it would get a 201
|
|
||||||
and silently no notification.
|
|
||||||
- Upgrade path: if a second writer ever appears, rename the compose service to `objecten.local`
|
|
||||||
so the plain name stops working, rather than adding a lint.
|
|
||||||
|
|
||||||
### A worker, not a synchronous send
|
|
||||||
|
|
||||||
`notifications_api_common` only schedules the send on transaction commit. Without a worker
|
|
||||||
the task sits in redis forever and every register write is silently undelivered — the exact
|
|
||||||
half-wired state ADR-0028 refused to ship. No `beat` for Objecten: it is a publisher, not a
|
|
||||||
subscriber, and `nrc-beat` already drains NRC's delivery queue.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
`make verify-objecten-notifications` (`infra/run-objecten-notifications-check.sh`, in the
|
|
||||||
CI `verify-stack` job) registers an abonnement on the `objecten` kanaal pointing at a
|
|
||||||
throwaway webhook sink, writes a `RegisterRecord` exactly as the ACL does on approval, and
|
|
||||||
asserts the notification reaches the sink. That is the whole chain in one assertion:
|
|
||||||
Objecten → `objecten-celery` → NRC → `nrc-beat` → the callback. Any missing piece — broker,
|
|
||||||
worker, kanaal, notifications config — shows up as a non-delivery rather than as a green
|
|
||||||
config.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
**Positive**
|
|
||||||
|
|
||||||
- A register write is now observable by anything that subscribes, which is what S-19b-2
|
|
||||||
(#153) needs to make the projection a cache of Objecten rather than a re-derivation of ZGW.
|
|
||||||
- ADR-0028's ceiling is lifted: the delivery path is proven end to end, not merely configured.
|
|
||||||
|
|
||||||
**Negative / costs**
|
|
||||||
|
|
||||||
- One more long-running container (`objecten-celery`) on an already memory-tight CI runner.
|
|
||||||
- A second publisher on the shared `big-reference-seed` credential — a credential rotation
|
|
||||||
now touches two modules.
|
|
||||||
- Objecten now has two in-network names, and which one a caller uses silently decides
|
|
||||||
whether its writes are notified (ceiling above).
|
|
||||||
- ponytail ceiling: notification delivery has no dead-letter or alerting — a failed publish
|
|
||||||
is visible only in the worker log.
|
|
||||||
- Upgrade path: if undelivered register events start mattering, subscribe an audit sink or
|
|
||||||
read NRC's own delivery admin rather than building a retry layer here.
|
|
||||||
|
|
||||||
## Coupling rules touched (CLAUDE.md §8)
|
|
||||||
|
|
||||||
None bent. This is infrastructure between two upstream modules, over their documented
|
|
||||||
APIs; no service reaches another's database. §8.6 (idempotency at every event boundary)
|
|
||||||
applies to whatever consumes the new kanaal — S-19b-2's problem, not this slice's.
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
# ADR-0030: The read projection is sourced from the register, not from ZGW
|
|
||||||
|
|
||||||
- **Status:** Accepted
|
|
||||||
- **Date:** 2026-08-28
|
|
||||||
- **Deciders:** Respellion engineering
|
|
||||||
- **Slice:** S-19b-2 (#153), second of the S-19b (#150) split
|
|
||||||
- **Builds on:** ADR-0008 (read projection store), ADR-0028 (Objecten holds the register), ADR-0029 (Objecten publishes to NRC)
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
ADR-0028 moved the authoritative register record into the Objecten API, and said what should
|
|
||||||
follow: "the read projection can become a cache of Objecten rather than a re-derivation of
|
|
||||||
ZGW." Until this slice it was still the latter — the Event Subscriber listened on the `zaken`
|
|
||||||
kanaal and inferred register state from case events:
|
|
||||||
|
|
||||||
- a `zaak`/`create` meant INGEDIEND;
|
|
||||||
- any `status`/`create` was taken to be the approval, so meant INGESCHREVEN — the subscriber
|
|
||||||
may not read OpenZaak (§8.1), so it could not tell one statustype from another;
|
|
||||||
- the citizen-facing reference was not in the notification at all, so every projection had a
|
|
||||||
second hop: ask the ACL for the zaak's identificatie (#78).
|
|
||||||
|
|
||||||
So the register — a fact about a person — was reconstructed by guessing at the lifecycle of the
|
|
||||||
case that happened to produce it. ADR-0029 made the register itself publish. This ADR switches
|
|
||||||
the projection over to it.
|
|
||||||
|
|
||||||
## Decision
|
|
||||||
|
|
||||||
**The Event Subscriber listens on the `objecten` kanaal and projects the `RegisterRecord` the
|
|
||||||
notification points at. The projection is a cache of the register; ZGW is no longer a source.**
|
|
||||||
|
|
||||||
- The subscriber's abonnement moves from `zaken` to `objecten` (`register-abonnement.py`, and
|
|
||||||
the CI projection check).
|
|
||||||
- An Objecten notification carries **no record data** — only the object URL and the objecttype
|
|
||||||
as a kenmerk — so the record is read back through the ACL (`POST /register-records/read`).
|
|
||||||
§8.1 applies to Objecten exactly as ADR-0028 established: the ACL is the only code that talks
|
|
||||||
to it.
|
|
||||||
- The accepted acties are `create`, `update` and `partial_update`. The last one is not
|
|
||||||
defensive breadth: the ACL upserts with PATCH, and DRF routes a PATCH through the notifying
|
|
||||||
`update()` while naming the action `partial_update` — which is what Objecten publishes. So
|
|
||||||
every approval arrives as `partial_update`, and accepting only `create`/`update` drops the
|
|
||||||
one state change this slice exists to project. `destroy` is deliberately not accepted:
|
|
||||||
removing a registration from the public register is its own decision.
|
|
||||||
- The record already carries `id`, `status` and `reference`, so the row is the record. The
|
|
||||||
zaak-shaped surface goes: `IsZaakCreated`, `IsZaakStatusSet`, `ZaakUrl`, `ZaakId`, and
|
|
||||||
`ToEntry`'s `Resource == "status"` inference are replaced by `IsRegisterRecordWritten` +
|
|
||||||
`ObjectUrl`, and the ACL enrichment hop disappears.
|
|
||||||
|
|
||||||
### The ACL writes an INGEDIEND record on submit
|
|
||||||
|
|
||||||
Before this slice only approval wrote a record, so re-sourcing alone would have silently
|
|
||||||
dropped every INGEDIEND row from the public register. `OpenZaakAsync` therefore upserts a
|
|
||||||
record with status INGEDIEND after opening the zaak, keyed on the same zaak id that approval
|
|
||||||
later upserts to INGESCHREVEN.
|
|
||||||
|
|
||||||
This is the same two-writes-converging posture ADR-0028 already accepted for approval, now on
|
|
||||||
the submit path too: both writes are idempotent, so a retried submit updates the record rather
|
|
||||||
than adding a second one (§8.6). The reference comes from the registration itself, so unlike
|
|
||||||
approval this path needs no ZGW read-back.
|
|
||||||
|
|
||||||
The alternative — a register holding only INGESCHREVEN — is arguably the more correct reading
|
|
||||||
of "public register", but it narrows what the openbaar portal shows and reads against PRD §68
|
|
||||||
("~50 register entries with diverse statuses"). Rejected as a behaviour change this slice was
|
|
||||||
not asked to make.
|
|
||||||
|
|
||||||
### The dedup key is the projected row, not the notification
|
|
||||||
|
|
||||||
NRC carries no notification id and may redeliver, so the idempotency key is derived from
|
|
||||||
content (as before). The obvious candidates both break here:
|
|
||||||
|
|
||||||
- **the object URL alone** — the ACL upserts *one object per registration*, so submit and
|
|
||||||
approval notify about the same URL, and the approval would be swallowed as a duplicate;
|
|
||||||
- **object URL + actie** — a retried approval is a second `update`, so it would be dropped
|
|
||||||
while genuinely being the same state (harmless), but a *third* distinct state would collide
|
|
||||||
with it (not harmless).
|
|
||||||
|
|
||||||
The key is therefore the object plus the state that write puts in the projection —
|
|
||||||
`objecten:object:{url}:{status}:{reference}`. A redelivery collapses; a genuine state change
|
|
||||||
does not. That is exactly the property §8.6 asks for, and it needs no version field from
|
|
||||||
Objecten's internals.
|
|
||||||
|
|
||||||
### The notification log holds the row, not the event
|
|
||||||
|
|
||||||
`processed_notifications` stops describing ZGW events (`actie`, `zaak_id`, `resource`) and
|
|
||||||
holds the projected row itself (`register_id`, `status`, `reference`). A rebuild becomes a
|
|
||||||
replay with no mapping rules and no upstream reads at all — §8.4 held before via the ACL hop;
|
|
||||||
now it holds outright.
|
|
||||||
|
|
||||||
The migration **drops** the old columns rather than renaming them. EF scaffolded renames
|
|
||||||
(`resource` → `register_id`, `zaak_id` → `status`) that would have carried ZGW values into
|
|
||||||
columns meaning something else entirely, and a rebuild would then have projected that garbage.
|
|
||||||
|
|
||||||
- ponytail ceiling: the migration empties both tables. A pre-slice row describes a zaak event
|
|
||||||
the new projector cannot reproject, and the registrations behind those rows have no
|
|
||||||
RegisterRecord in Objecten (only approvals wrote one), so they are not re-derivable from the
|
|
||||||
new source either.
|
|
||||||
- Upgrade path: fine while stacks are ephemeral. If a long-lived environment ever needs to keep
|
|
||||||
them, backfill by walking Objecten's objects rather than replaying the log.
|
|
||||||
|
|
||||||
## Consequences
|
|
||||||
|
|
||||||
**Positive**
|
|
||||||
|
|
||||||
- The register is read from the register. The projection is a derived cache of a first-class
|
|
||||||
record, not an inference over someone else's lifecycle.
|
|
||||||
- The "any status-create is the approval" guess is gone — a real source of wrongness the moment
|
|
||||||
the zaaktype grows a second statustype.
|
|
||||||
- One hop fewer per notification: the record carries its own reference, so the ACL enrichment
|
|
||||||
call disappears.
|
|
||||||
- A rebuild needs nothing but its own log (§8.4).
|
|
||||||
|
|
||||||
**Negative / costs**
|
|
||||||
|
|
||||||
- Submission is now two writes across two modules and eventually consistent. A failure between
|
|
||||||
them leaves a zaak with no register record until the submit is retried; nothing repairs that
|
|
||||||
automatically yet — the same gap ADR-0028 recorded for approval, now on a second path.
|
|
||||||
- The projection lags the register by a notification round trip, where it used to lag the zaak
|
|
||||||
by one. In practice the same order of magnitude.
|
|
||||||
- Projecting now depends on the ACL being reachable, where the reference enrichment used to be
|
|
||||||
the only ACL dependency. A failed read means the notification is not logged and not
|
|
||||||
projected — NRC retries, so it converges, but the failure mode is now on the main path.
|
|
||||||
- OpenZaak still publishes to `zaken` and nothing in the product listens. Kept because the
|
|
||||||
`verify-nrc` check asserts that path, and turning off a working publisher to save nothing
|
|
||||||
would be its own risk.
|
|
||||||
|
|
||||||
## Coupling rules touched (CLAUDE.md §8)
|
|
||||||
|
|
||||||
None bent. §8.1 holds — the subscriber reaches Objecten only through the ACL. §8.4 is
|
|
||||||
strengthened: the projection is rebuildable from its own log, with no upstream reads at all.
|
|
||||||
§8.6 is what the dedup-key discussion above is about.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
`make verify-projection` (`infra/run-projection-check.sh`, in CI's `verify-stack`) opens a zaak
|
|
||||||
**through the ACL** and asserts projection-api serves a row for it with status INGEDIEND — the
|
|
||||||
whole new chain in one assertion: ACL → Objecten → `objecten-celery` → NRC → `nrc-beat` →
|
|
||||||
Event Subscriber → projection → projection-api. A zaak created behind the ACL's back produces
|
|
||||||
no row, which is the re-source working rather than a gap.
|
|
||||||
|
|
||||||
`RegisterProjectieBijwerken.feature` covers the use case in business language, including the
|
|
||||||
approval case — the same row moving INGEDIEND → INGESCHREVEN, which is now one registration's
|
|
||||||
record being updated rather than two unrelated ZGW events.
|
|
||||||
@@ -5,152 +5,6 @@ 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)
|
## S-15a — Beheer-portal: read-only catalogus viewer (#130, ADR-0025)
|
||||||
|
|
||||||
**Outcome:** a new **beheer** portal (medewerker realm, like behandel) shows the ZTC catalogus —
|
**Outcome:** a new **beheer** portal (medewerker realm, like behandel) shows the ZTC catalogus —
|
||||||
|
|||||||
@@ -196,52 +196,3 @@ service name; the notif verify harness also registers the sink callback by IP.
|
|||||||
abonnement is registered and refuses it (`no-auth-on-callback-url`) unless it returns
|
abonnement is registered and refuses it (`no-auth-on-callback-url`) unless it returns
|
||||||
**401** without the configured `Authorization`. The verify sink
|
**401** without the configured `Authorization`. The verify sink
|
||||||
(`infra/notification-sink.py`) enforces a bearer token for exactly this reason.
|
(`infra/notification-sink.py`) enforces a bearer token for exactly this reason.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. A job with `if: ${{ !cancelled() }}` (or `always()`) + `needs` sticks in "waiting"
|
|
||||||
|
|
||||||
**Symptom** — after upgrading to **Gitea 1.27** + **act_runner 2.0.0**, one job never
|
|
||||||
starts: the run sits in state `waiting` forever, the job has **no logs** (never
|
|
||||||
dispatched to a runner), and the other jobs finish normally. `main` stays pending/red.
|
|
||||||
Seen on the `verify-stack` job (#134).
|
|
||||||
|
|
||||||
**Why** — Gitea 1.27 reworked cancellation/aggregation: a job gated by a
|
|
||||||
**status-function `if`** (`always()` / `cancelled()` / `!cancelled()`) on top of
|
|
||||||
`needs` now routes through a new transitional **`Cancelling`** job state plus a
|
|
||||||
server↔runner **capability negotiation** ("Requires Gitea Runner 2.0.0"). On the
|
|
||||||
1.27 + 2.0.0 pairing that handshake doesn't resolve for such a job, so it's never
|
|
||||||
offered to a runner and never leaves `waiting`. Jobs with no `if`/`needs` are
|
|
||||||
unaffected. (Related upstream: go-gitea/gitea#31074, #27116, #35782.)
|
|
||||||
|
|
||||||
**Fix** — don't gate a `needs` job with a status-function `if`. Use the default
|
|
||||||
`if: success()` (i.e. omit the `if`). If you need "run even when an upstream job
|
|
||||||
fails", prefer serialising with a `concurrency` group over `needs` + `always()`.
|
|
||||||
|
|
||||||
**Also** — a run already stuck this way will **not** clear itself; force-cancel it
|
|
||||||
from the Actions UI (plain cancel can also stall on this version, #35782). Push the
|
|
||||||
workflow fix to produce a fresh run.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Job summaries (`$GITHUB_STEP_SUMMARY`) need Gitea ≥1.27 + runner ≥2.0
|
|
||||||
|
|
||||||
Markdown a step appends to the `$GITHUB_STEP_SUMMARY` file renders on the run page
|
|
||||||
(no artifact download). We use it for per-run reports (#136): mutation scores
|
|
||||||
(Stryker `markdown` reporter), per-service unit results (`infra/trx-summary.py` over
|
|
||||||
TRX), per-frontend results (`infra/vitest-summary.py` over each app's vitest JSON),
|
|
||||||
the verify-stack check table, and per-spec e2e results (`infra/playwright-summary.py`).
|
|
||||||
|
|
||||||
**Requirements / conventions:**
|
|
||||||
|
|
||||||
- Requires **Gitea ≥ 1.27** (stores/renders summaries) and **act_runner ≥ 2.0.0**
|
|
||||||
(uploads them). Older pairings silently skip the upload.
|
|
||||||
- **Guard every write:** `[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0` — on a runner
|
|
||||||
without support the var is unset and `>> "$GITHUB_STEP_SUMMARY"` would be an
|
|
||||||
ambiguous-redirect error. The guard makes the step a no-op locally / on old runners.
|
|
||||||
- Use `if: always()` (step-level) on summary steps so they render even when the thing
|
|
||||||
they report on failed. Step-level `always()` is fine on 2.0.0 — unlike the *job*-level
|
|
||||||
status-function `if` of §7.
|
|
||||||
- Getting a report out of the e2e container: Playwright writes `playwright-report.json`
|
|
||||||
inside the container; `infra/run-e2e-check.sh` `docker cp`s it back to the host
|
|
||||||
(capturing the test exit code first) so the summary step can read it.
|
|
||||||
|
|||||||
@@ -56,10 +56,6 @@ services:
|
|||||||
oz-init:
|
oz-init:
|
||||||
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
|
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
|
||||||
environment: &oz-env
|
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
|
DJANGO_SETTINGS_MODULE: openzaak.conf.docker
|
||||||
SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production}
|
SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production}
|
||||||
DB_HOST: oz-db
|
DB_HOST: oz-db
|
||||||
@@ -142,9 +138,6 @@ services:
|
|||||||
# bind-mounted here (this twin is the local/no-make path). See ADR-0007.
|
# 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}
|
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
|
||||||
environment: &nrc-env
|
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
|
DJANGO_SETTINGS_MODULE: nrc.conf.docker
|
||||||
SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production}
|
SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production}
|
||||||
DB_HOST: nrc-db
|
DB_HOST: nrc-db
|
||||||
@@ -338,15 +331,6 @@ services:
|
|||||||
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
|
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
|
||||||
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
|
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
|
||||||
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
|
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.
|
|
||||||
# Dotted host on purpose — see the `objecten.local` alias below (ADR-0029).
|
|
||||||
Acl__Objecten__BaseUrl: http://objecten.local:8000/
|
|
||||||
Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}
|
|
||||||
Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/
|
|
||||||
Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
|
|
||||||
Acl__Objecten__ObjecttypeName: RegisterRecord
|
|
||||||
ports:
|
ports:
|
||||||
- "8100:8080"
|
- "8100:8080"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -576,188 +560,11 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
networks: [cg]
|
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"
|
|
||||||
CELERY_BROKER_URL: redis://objecten-redis:6379/1
|
|
||||||
CELERY_RESULT_BACKEND: redis://objecten-redis:6379/1
|
|
||||||
# Publish register-record events to NRC on the `objecten` kanaal (S-19b-1, ADR-0029). The NRC
|
|
||||||
# service + notifications_config are provisioned by setup_configuration
|
|
||||||
# (infra/objecten/setup_configuration/data.yaml), and objecten-celery below actually sends
|
|
||||||
# them — notifications_api_common only queues the task. See ADR-0028 for why S-19a left this
|
|
||||||
# off until all four pieces existed.
|
|
||||||
NOTIFICATIONS_DISABLED: "false"
|
|
||||||
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:
|
|
||||||
# Objecten reflects the *request* Host into the `url` it returns, and
|
|
||||||
# notifications_api_common publishes that url as the notification's hoofdObject /
|
|
||||||
# resourceUrl — which NRC types as a URLField, and Django's URLValidator rejects a
|
|
||||||
# single-label host ("Voer een geldige URL in."). So every caller whose writes must be
|
|
||||||
# notified addresses Objecten by this dotted alias instead of `objecten` (ADR-0029).
|
|
||||||
# Reads are unaffected and still use the plain service name.
|
|
||||||
aliases:
|
|
||||||
- objecten.local
|
|
||||||
|
|
||||||
# The celery worker that actually delivers Objecten's notifications to NRC (S-19b-1, ADR-0029).
|
|
||||||
# notifications_api_common only schedules the send on transaction commit; without a worker the
|
|
||||||
# task sits in redis forever and every register write is silently undelivered. Mirrors oz-celery.
|
|
||||||
# No beat: Objecten is a publisher, not a subscriber — nrc-beat drains the delivery queue.
|
|
||||||
objecten-celery:
|
|
||||||
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
|
|
||||||
environment: *objecten-env-local
|
|
||||||
command: /celery_worker.sh
|
|
||||||
depends_on:
|
|
||||||
objecten-init:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
networks: [cg]
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
oz-db:
|
oz-db:
|
||||||
nrc-db:
|
nrc-db:
|
||||||
flowable-db:
|
flowable-db:
|
||||||
projection-db:
|
projection-db:
|
||||||
objecttypen-db:
|
|
||||||
objecten-db:
|
|
||||||
# Carries the seed-generated acl.env (server-assigned zaaktype URLs) from local-seed to the ACL.
|
# Carries the seed-generated acl.env (server-assigned zaaktype URLs) from local-seed to the ACL.
|
||||||
seed-env:
|
seed-env:
|
||||||
|
|
||||||
|
|||||||
@@ -51,12 +51,6 @@ services:
|
|||||||
oz-init:
|
oz-init:
|
||||||
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
|
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
|
||||||
environment: &oz-env
|
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
|
DJANGO_SETTINGS_MODULE: openzaak.conf.docker
|
||||||
SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production}
|
SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production}
|
||||||
DB_HOST: oz-db
|
DB_HOST: oz-db
|
||||||
@@ -141,9 +135,6 @@ services:
|
|||||||
# needs no baked config.
|
# needs no baked config.
|
||||||
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
|
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
|
||||||
environment: &nrc-env
|
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
|
DJANGO_SETTINGS_MODULE: nrc.conf.docker
|
||||||
SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production}
|
SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production}
|
||||||
DB_HOST: nrc-db
|
DB_HOST: nrc-db
|
||||||
@@ -323,15 +314,6 @@ services:
|
|||||||
# so verify-domain still points the ACL at OpenZaak's container IP.
|
# so verify-domain still points the ACL at OpenZaak's container IP.
|
||||||
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
|
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
|
||||||
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
|
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.
|
|
||||||
# Dotted host on purpose — see the `objecten.local` alias below (ADR-0029).
|
|
||||||
Acl__Objecten__BaseUrl: http://objecten.local:8000/
|
|
||||||
Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}
|
|
||||||
Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/
|
|
||||||
Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
|
|
||||||
Acl__Objecten__ObjecttypeName: RegisterRecord
|
|
||||||
ports:
|
ports:
|
||||||
- "8100:8080"
|
- "8100:8080"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -587,199 +569,6 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
networks: [cg]
|
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"
|
|
||||||
CELERY_BROKER_URL: redis://objecten-redis:6379/1
|
|
||||||
CELERY_RESULT_BACKEND: redis://objecten-redis:6379/1
|
|
||||||
# Publish register-record events to NRC on the `objecten` kanaal (S-19b-1, ADR-0029). The NRC
|
|
||||||
# service + notifications_config are provisioned by setup_configuration
|
|
||||||
# (infra/objecten/setup_configuration/data.yaml), and objecten-celery below actually sends
|
|
||||||
# them — notifications_api_common only queues the task. See ADR-0028 for why S-19a left this
|
|
||||||
# off until all four pieces existed.
|
|
||||||
NOTIFICATIONS_DISABLED: "false"
|
|
||||||
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:
|
|
||||||
# Objecten reflects the *request* Host into the `url` it returns, and
|
|
||||||
# notifications_api_common publishes that url as the notification's hoofdObject /
|
|
||||||
# resourceUrl — which NRC types as a URLField, and Django's URLValidator rejects a
|
|
||||||
# single-label host ("Voer een geldige URL in."). So every caller whose writes must be
|
|
||||||
# notified addresses Objecten by this dotted alias instead of `objecten` (ADR-0029).
|
|
||||||
# Reads are unaffected and still use the plain service name.
|
|
||||||
aliases:
|
|
||||||
- objecten.local
|
|
||||||
|
|
||||||
# The celery worker that actually delivers Objecten's notifications to NRC (S-19b-1, ADR-0029).
|
|
||||||
# notifications_api_common only schedules the send on transaction commit; without a worker the
|
|
||||||
# task sits in redis forever and every register write is silently undelivered. Mirrors oz-celery.
|
|
||||||
# No beat: Objecten is a publisher, not a subscriber — nrc-beat drains the delivery queue.
|
|
||||||
objecten-celery:
|
|
||||||
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
|
|
||||||
environment: *objecten-env
|
|
||||||
command: /celery_worker.sh
|
|
||||||
depends_on:
|
|
||||||
objecten-init:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
networks: [cg]
|
|
||||||
|
|
||||||
# ── Observability backplane (S-16a, ADR-0023) ──────────────────────────────
|
# ── Observability backplane (S-16a, ADR-0023) ──────────────────────────────
|
||||||
# Grafana-native stack: Tempo ingests OTLP traces (the .NET services export
|
# Grafana-native stack: Tempo ingests OTLP traces (the .NET services export
|
||||||
# straight to it — no collector hop, S-16b), Prometheus scrapes service
|
# straight to it — no collector hop, S-16b), Prometheus scrapes service
|
||||||
@@ -829,8 +618,6 @@ volumes:
|
|||||||
nrc-db:
|
nrc-db:
|
||||||
flowable-db:
|
flowable-db:
|
||||||
projection-db:
|
projection-db:
|
||||||
objecttypen-db:
|
|
||||||
objecten-db:
|
|
||||||
# Config volumes — created and populated out-of-band by infra/seed-config.sh
|
# 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
|
# (docker cp), because bind mounts don't reach sibling containers on the CI
|
||||||
# runner. `external` keeps the names deterministic; the seed step manages them.
|
# runner. `external` keeps the names deterministic; the seed step manages them.
|
||||||
@@ -846,15 +633,6 @@ volumes:
|
|||||||
fl-bpmn:
|
fl-bpmn:
|
||||||
external: true
|
external: true
|
||||||
name: rr-fl-bpmn
|
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:
|
networks:
|
||||||
cg:
|
cg:
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
"""Local-stack bootstrap (S-B04, #110, ADR-0020) — register the NRC abonnement.
|
"""Local-stack bootstrap (S-B04, #110, ADR-0020) — register the NRC abonnement.
|
||||||
|
|
||||||
Runs as the `nrc-subscribe` init container of infra/docker-compose.local.yml. Registers an
|
Runs as the `nrc-subscribe` init container of infra/docker-compose.local.yml. Registers an
|
||||||
abonnement on the `objecten` kanaal pointing at the event-subscriber's /notifications callback, so
|
abonnement on the `zaken` kanaal pointing at the event-subscriber's /notifications callback, so
|
||||||
the register writes the ACL makes (INGEDIEND on submit, INGESCHREVEN on approval) reach the
|
OpenZaak's notifications (zaak create + status set) reach the projection — without this the openbaar
|
||||||
projection — without this the openbaar (public) register stays empty. Since S-19b-2 the projection
|
(public) register stays empty. This is what infra/verify-notification-driver.py does for CI (minus
|
||||||
is sourced from the register in Objecten, not from ZGW zaak events (ADR-0030).
|
the test zaak it also creates).
|
||||||
|
|
||||||
The callback host is the event-subscriber's resolved **container IP**, not `event-subscriber`, because
|
The callback host is the event-subscriber's resolved **container IP**, not `event-subscriber`, because
|
||||||
NRC validates callbackUrl with Django's URLValidator (a single-label host is rejected — same reason the
|
NRC validates callbackUrl with Django's URLValidator (a single-label host is rejected — same reason the
|
||||||
@@ -22,8 +22,6 @@ SINK_PORT = os.environ.get("SINK_PORT", "8080")
|
|||||||
SINK_AUTH = os.environ.get("SINK_AUTH", "Bearer big-reference-notifications")
|
SINK_AUTH = os.environ.get("SINK_AUTH", "Bearer big-reference-notifications")
|
||||||
CID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed")
|
CID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed")
|
||||||
SECRET = os.environ.get("OZ_SECRET", "insecure-dev-secret-change-me")
|
SECRET = os.environ.get("OZ_SECRET", "insecure-dev-secret-change-me")
|
||||||
# The projection is sourced from the register in Objecten, not from ZGW zaak events (S-19b-2).
|
|
||||||
KANAAL = "objecten"
|
|
||||||
|
|
||||||
|
|
||||||
def token():
|
def token():
|
||||||
@@ -62,10 +60,7 @@ def main():
|
|||||||
status, body = call("GET", f"{NRC}/api/v1/abonnement")
|
status, body = call("GET", f"{NRC}/api/v1/abonnement")
|
||||||
for ab in (body or []) if status == 200 else []:
|
for ab in (body or []) if status == 200 else []:
|
||||||
if str(ab.get("callbackUrl", "")).endswith("/notifications"):
|
if str(ab.get("callbackUrl", "")).endswith("/notifications"):
|
||||||
# The kanaal is part of "current": an abonnement left over from before S-19b-2 points at
|
if ab.get("callbackUrl") == callback:
|
||||||
# the right callback but listens on `zaken`, and would never be replaced on IP alone.
|
|
||||||
kanalen = [k.get("naam") for k in ab.get("kanalen", [])]
|
|
||||||
if ab.get("callbackUrl") == callback and kanalen == [KANAAL]:
|
|
||||||
print(f"abonnement already current: {ab['url']}")
|
print(f"abonnement already current: {ab['url']}")
|
||||||
return
|
return
|
||||||
call("DELETE", ab["url"])
|
call("DELETE", ab["url"])
|
||||||
@@ -73,7 +68,7 @@ def main():
|
|||||||
|
|
||||||
status, ab = call("POST", f"{NRC}/api/v1/abonnement", {
|
status, ab = call("POST", f"{NRC}/api/v1/abonnement", {
|
||||||
"callbackUrl": callback, "auth": SINK_AUTH,
|
"callbackUrl": callback, "auth": SINK_AUTH,
|
||||||
"kanalen": [{"naam": KANAAL, "filters": {}}]})
|
"kanalen": [{"naam": "zaken", "filters": {}}]})
|
||||||
if status != 201:
|
if status != 201:
|
||||||
sys.exit(f"create abonnement -> {status}: {json.dumps(ab)}")
|
sys.exit(f"create abonnement -> {status}: {json.dumps(ab)}")
|
||||||
print(f"abonnement registered: {ab['url']} -> {callback}")
|
print(f"abonnement registered: {ab['url']} -> {callback}")
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""S-19b-1 (#152): driver for the Objecten → NRC notification check.
|
|
||||||
|
|
||||||
Registers an abonnement on the `objecten` kanaal pointing at the webhook sink, then writes a
|
|
||||||
RegisterRecord object exactly as the ACL's ObjectenGateway does (S-19a). The caller
|
|
||||||
(run-objecten-notifications-check.sh) watches the sink for the delivery — this only sets it up,
|
|
||||||
and prints `OBJECT_URL <url>` for the caller to grep on.
|
|
||||||
|
|
||||||
Delivery exercises the whole chain: Objecten → its celery worker → NRC → nrc-beat → the callback.
|
|
||||||
Anything missing (broker, worker, kanaal, notifications config) shows up as a non-delivery.
|
|
||||||
|
|
||||||
Stdlib only so it runs in a bare python:3-slim container on the compose network.
|
|
||||||
"""
|
|
||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
OBJECTEN = os.environ["OBJECTEN"] # http://objecten:8000
|
|
||||||
OBJECTEN_TOKEN = os.environ["OBJECTEN_TOKEN"]
|
|
||||||
OBJECTTYPEN = os.environ["OBJECTTYPEN"] # http://objecttypen:8000
|
|
||||||
OBJECTTYPEN_TOKEN = os.environ["OBJECTTYPEN_TOKEN"]
|
|
||||||
NRC_BASE = os.environ["NRC_BASE"] # http://<nrc-ip>:8000
|
|
||||||
SINK_CALLBACK = os.environ["SINK_CALLBACK"] # http://<sink-ip>:9000/
|
|
||||||
SINK_AUTH = os.environ["SINK_AUTH"]
|
|
||||||
CLIENT_ID = os.environ.get("NRC_CLIENT_ID", "big-reference-seed")
|
|
||||||
SECRET = os.environ.get("NRC_SECRET", "insecure-dev-secret-change-me")
|
|
||||||
KANAAL = "objecten"
|
|
||||||
|
|
||||||
|
|
||||||
def mint():
|
|
||||||
"""The HS256 JWT NRC expects (same shape as infra/local/register-abonnement.py)."""
|
|
||||||
def seg(d):
|
|
||||||
return base64.urlsafe_b64encode(json.dumps(d).encode()).rstrip(b"=")
|
|
||||||
|
|
||||||
payload = seg({
|
|
||||||
"iss": CLIENT_ID, "iat": int(time.time()), "client_id": CLIENT_ID,
|
|
||||||
"user_id": CLIENT_ID, "user_representation": CLIENT_ID,
|
|
||||||
})
|
|
||||||
signing_input = seg({"typ": "JWT", "alg": "HS256"}) + b"." + payload
|
|
||||||
signature = base64.urlsafe_b64encode(
|
|
||||||
hmac.new(SECRET.encode(), signing_input, hashlib.sha256).digest()).rstrip(b"=")
|
|
||||||
return (signing_input + b"." + signature).decode()
|
|
||||||
|
|
||||||
|
|
||||||
def nrc(method, url, body=None):
|
|
||||||
"""Call NRC. `url` may be a path or an absolute URL (the list returns absolute ones)."""
|
|
||||||
data = json.dumps(body).encode() if body is not None else None
|
|
||||||
req = urllib.request.Request(
|
|
||||||
url if url.startswith("http") else f"{NRC_BASE}{url}", data=data, method=method,
|
|
||||||
headers={"Authorization": f"Bearer {mint()}", "Content-Type": "application/json"})
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=15) as r:
|
|
||||||
return json.load(r) if r.length != 0 else {}
|
|
||||||
except urllib.error.HTTPError as e:
|
|
||||||
# The body carries the reason (e.g. an unregistered kanaal); the status alone does not.
|
|
||||||
raise SystemExit(f"FAIL — NRC {method} {url} → {e.code}: {e.read().decode(errors='replace')[:400]}")
|
|
||||||
|
|
||||||
|
|
||||||
def token_api(base, token, method, path, body=None, crs=False):
|
|
||||||
data = json.dumps(body).encode() if body is not None else None
|
|
||||||
headers = {"Authorization": f"Token {token}"}
|
|
||||||
if body is not None:
|
|
||||||
headers["Content-Type"] = "application/json"
|
|
||||||
if crs:
|
|
||||||
headers["Accept-Crs"] = "EPSG:4326"
|
|
||||||
if body is not None:
|
|
||||||
headers["Content-Crs"] = "EPSG:4326"
|
|
||||||
req = urllib.request.Request(f"{base}{path}", data=data, method=method, headers=headers)
|
|
||||||
with urllib.request.urlopen(req, timeout=15) as r:
|
|
||||||
return json.load(r) if r.length != 0 else {}
|
|
||||||
|
|
||||||
|
|
||||||
def subscribe():
|
|
||||||
"""Register an abonnement on the objecten kanaal, replacing a stale one for the same callback."""
|
|
||||||
# NRC returns a bare list here, not a paginated envelope.
|
|
||||||
for existing in nrc("GET", "/api/v1/abonnement") or []:
|
|
||||||
if existing.get("callbackUrl") == SINK_CALLBACK:
|
|
||||||
nrc("DELETE", existing["url"])
|
|
||||||
nrc("POST", "/api/v1/abonnement", {
|
|
||||||
"callbackUrl": SINK_CALLBACK,
|
|
||||||
"auth": SINK_AUTH,
|
|
||||||
"kanalen": [{"naam": KANAAL, "filters": {}}],
|
|
||||||
})
|
|
||||||
print(f">> abonnement on '{KANAAL}' -> {SINK_CALLBACK}")
|
|
||||||
|
|
||||||
|
|
||||||
def objecttype_url():
|
|
||||||
results = token_api(OBJECTTYPEN, OBJECTTYPEN_TOKEN, "GET", "/api/v2/objecttypes").get("results", [])
|
|
||||||
match = next((o for o in results if o.get("name") == "RegisterRecord"), None)
|
|
||||||
if not match:
|
|
||||||
print("FAIL — no RegisterRecord objecttype in Objecttypen", file=sys.stderr)
|
|
||||||
raise SystemExit(1)
|
|
||||||
return match["url"]
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
subscribe()
|
|
||||||
reference = f"NOTIF-{int(time.time())}"
|
|
||||||
created = token_api(OBJECTEN, OBJECTEN_TOKEN, "POST", "/api/v2/objects", {
|
|
||||||
"type": objecttype_url(),
|
|
||||||
"record": {
|
|
||||||
"typeVersion": 1,
|
|
||||||
"data": {"id": f"zaak-{reference}", "status": "INGESCHREVEN", "reference": reference},
|
|
||||||
"startAt": time.strftime("%Y-%m-%d"),
|
|
||||||
},
|
|
||||||
}, crs=True)
|
|
||||||
print(f">> wrote RegisterRecord {created['url']}")
|
|
||||||
# An NRC notification carries no record data — only hoofdObject/resourceUrl — so the object
|
|
||||||
# URL, not the reference in its data, is what the caller can correlate the delivery on.
|
|
||||||
print(f"OBJECT_URL {created['url']}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
# 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
|
|
||||||
# (1b) The NRC Objecten publishes register-record events to (S-19b-1, ADR-0029). Same shape and
|
|
||||||
# same big-reference-seed credential OpenZaak publishes with — NRC verifies the JWT and
|
|
||||||
# authorizes it via OpenZaak's AC, which grants that client heeft_alle_autorisaties.
|
|
||||||
- identifier: nrc
|
|
||||||
label: Open Notificaties
|
|
||||||
api_type: nrc
|
|
||||||
api_root: http://nrc-web:8000/api/v1/
|
|
||||||
auth_type: zgw
|
|
||||||
client_id: big-reference-seed
|
|
||||||
secret: insecure-dev-secret-change-me
|
|
||||||
|
|
||||||
# (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
|
|
||||||
|
|
||||||
# (4) Point Objecten's notifications at that NRC service (S-19b-1, ADR-0029). Requires
|
|
||||||
# NOTIFICATIONS_DISABLED=false plus a celery broker + worker — without the worker the message is
|
|
||||||
# queued and never sent, which is exactly the half-wired state S-19a refused to ship (ADR-0028).
|
|
||||||
notifications_config_enable: true
|
|
||||||
notifications_config:
|
|
||||||
notifications_api_service_identifier: nrc
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
#!/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())
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"$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)."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -29,9 +29,7 @@ autorisaties_api_config_enable: true
|
|||||||
autorisaties_api:
|
autorisaties_api:
|
||||||
authorizations_api_service_identifier: openzaak-ac
|
authorizations_api_service_identifier: openzaak-ac
|
||||||
|
|
||||||
# 4. The kanalen publishers announce on: `zaken` (OpenZaak) and `objecten` (Objecten, S-19b-1).
|
# 4. The kanaal OpenZaak publishes zaak events on.
|
||||||
# Both authenticate with the big-reference-seed credential above, which OpenZaak's AC grants
|
|
||||||
# heeft_alle_autorisaties — so no separate publisher authorization is needed for Objecten.
|
|
||||||
notifications_kanalen_config_enable: true
|
notifications_kanalen_config_enable: true
|
||||||
notifications_kanalen_config:
|
notifications_kanalen_config:
|
||||||
items:
|
items:
|
||||||
@@ -41,11 +39,3 @@ notifications_kanalen_config:
|
|||||||
- bronorganisatie
|
- bronorganisatie
|
||||||
- zaaktype
|
- zaaktype
|
||||||
- vertrouwelijkheidaanduiding
|
- vertrouwelijkheidaanduiding
|
||||||
# 5. The kanaal Objecten publishes register-record events on (S-19b-1, ADR-0029). Its name is
|
|
||||||
# fixed by the Objects API itself (NOTIFICATIONS_KANAAL = "objecten"), not chosen here. The
|
|
||||||
# filter set matches what the Objects API sends as kenmerken, so an abonnement can narrow by
|
|
||||||
# objecttype rather than receiving every object write in the register.
|
|
||||||
- naam: objecten
|
|
||||||
documentatie_link: https://objects-and-objecttypes-api.readthedocs.io/
|
|
||||||
filters:
|
|
||||||
- object_type
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
#!/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"))
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
#!/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,7 +142,6 @@ still="$(printf '%s' "$resp" | task_for_reg "$reg_id")"
|
|||||||
[ -z "$still" ] || { echo "FAIL — Beoordelen task $still still active after completion" >&2; exit 1; }
|
[ -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"
|
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
|
# ── 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
|
# 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). ────────────
|
# BPMN boundary event so the process ends and the Beoordelen task disappears (ADR-0014). ────────────
|
||||||
|
|||||||
@@ -26,8 +26,4 @@ cid="$(docker create --network "$net" -w /e2e --ipc=host \
|
|||||||
mcr.microsoft.com/playwright:v1.61.1-noble sh -c 'npm install --no-audit --no-fund && npx playwright test')"
|
mcr.microsoft.com/playwright:v1.61.1-noble sh -c 'npm install --no-audit --no-fund && npx playwright test')"
|
||||||
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
|
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
|
||||||
docker cp "$root/tests/e2e/." "$cid:/e2e" >/dev/null
|
docker cp "$root/tests/e2e/." "$cid:/e2e" >/dev/null
|
||||||
rc=0
|
docker start -a "$cid"
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
#
|
|
||||||
# S-19b-1 (#152): verify the Objecten → NRC notification path against an ALREADY-RUNNING full
|
|
||||||
# stack. Registers an abonnement on the `objecten` kanaal pointing at a throwaway webhook sink,
|
|
||||||
# writes a RegisterRecord object (exactly as the ACL does on approval, S-19a), and asserts the sink
|
|
||||||
# receives the notification.
|
|
||||||
#
|
|
||||||
# This is the whole publish chain in one assertion: Objecten → its celery worker → NRC → nrc-beat →
|
|
||||||
# the subscriber callback. S-19a deliberately left it disconnected (ADR-0028); this proves it is
|
|
||||||
# connected for real, rather than merely configured.
|
|
||||||
#
|
|
||||||
# All in-network, reaching services by container IP (a single-label host isn't URL-valid for NRC's
|
|
||||||
# callbackUrl validator; the runner can't reach published ports — gitea-actions-gotchas.md §5/§6).
|
|
||||||
# EXCEPT Objecttypen, which must be reached by SERVICE NAME: it echoes the request Host into the
|
|
||||||
# objecttype `url` and Objecten only accepts the one matching its configured api_root (ADR-0028);
|
|
||||||
# and Objecten, reached by its `objecten.local` alias because it reflects the request Host into the
|
|
||||||
# notification's hoofdObject/resourceUrl, which NRC validates as a URL (ADR-0029).
|
|
||||||
#
|
|
||||||
# Does NOT manage the stack lifecycle, but cleans up the sink/driver it creates.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
SINK_AUTH="Bearer objecten-notification-sink-token"
|
|
||||||
|
|
||||||
cleanup() { docker rm -f rr-osink rr-overify >/dev/null 2>&1 || true; }
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
|
|
||||||
|
|
||||||
# Anchored on the compose replica suffix so they don't also match objecten-db / objecten-redis.
|
|
||||||
obj="$(docker ps -q --filter 'name=objecten[-_][0-9]+$' | head -1)"
|
|
||||||
nrc="$(docker ps -q --filter 'name=nrc-web' | head -1)"
|
|
||||||
[ -n "$obj" ] || { echo "ERROR: no running objecten container — bring the stack up first" >&2; exit 1; }
|
|
||||||
[ -n "$nrc" ] || { echo "ERROR: no running nrc-web container — bring the stack up first" >&2; exit 1; }
|
|
||||||
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$obj" | head -1)"
|
|
||||||
nrc_ip="$(ip "$nrc")"
|
|
||||||
echo ">> network=$net nrc=$nrc_ip"
|
|
||||||
|
|
||||||
echo ">> starting the webhook sink"
|
|
||||||
docker rm -f rr-osink >/dev/null 2>&1 || true
|
|
||||||
sink="$(docker create --network "$net" --name rr-osink -e "EXPECTED_AUTH=$SINK_AUTH" \
|
|
||||||
python:3-slim python /sink.py)"
|
|
||||||
docker cp "$here/notification-sink.py" "$sink:/sink.py" >/dev/null
|
|
||||||
docker start "$sink" >/dev/null
|
|
||||||
sleep 1
|
|
||||||
sink_ip="$(ip rr-osink)"
|
|
||||||
echo ">> sink at $sink_ip:9000"
|
|
||||||
|
|
||||||
echo ">> registering the abonnement + writing a RegisterRecord"
|
|
||||||
docker rm -f rr-overify >/dev/null 2>&1 || true
|
|
||||||
drv="$(docker create --network "$net" --name rr-overify \
|
|
||||||
-e "OBJECTEN=http://objecten.local:8000" \
|
|
||||||
-e "OBJECTEN_TOKEN=${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678}" \
|
|
||||||
-e "OBJECTTYPEN=http://objecttypen:8000" \
|
|
||||||
-e "OBJECTTYPEN_TOKEN=${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}" \
|
|
||||||
-e "NRC_BASE=http://$nrc_ip:8000" \
|
|
||||||
-e "SINK_CALLBACK=http://$sink_ip:9000/" -e "SINK_AUTH=$SINK_AUTH" \
|
|
||||||
python:3-slim python /driver.py)"
|
|
||||||
docker cp "$here/objecten-notifications-check.py" "$drv:/driver.py" >/dev/null
|
|
||||||
docker start -a "$drv"
|
|
||||||
object_url="$(docker logs rr-overify 2>/dev/null | sed -n 's/^OBJECT_URL //p' | head -1)"
|
|
||||||
docker rm -f rr-overify >/dev/null
|
|
||||||
[ -n "$object_url" ] || { echo "FAIL — the driver did not write a RegisterRecord" >&2; exit 1; }
|
|
||||||
echo ">> wrote $object_url"
|
|
||||||
|
|
||||||
# Correlate on the object URL: a notification carries hoofdObject/resourceUrl, never the record
|
|
||||||
# data, so the reference inside the record is not in the delivered message.
|
|
||||||
echo ">> waiting for the notification to reach the sink"
|
|
||||||
for _ in $(seq 1 "${NOTIFICATION_TRIES:-40}"); do
|
|
||||||
if docker logs rr-osink 2>&1 | grep -qF "$object_url"; then
|
|
||||||
echo "OK — Objecten published to NRC and the abonnement delivered it:"
|
|
||||||
docker logs rr-osink 2>&1 | grep -F "$object_url" | tail -1 | cut -c1-500
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "FAIL — no 'objecten' notification for $object_url reached the sink." >&2
|
|
||||||
echo " Objecten accepted the write, so the gap is downstream: the celery broker/worker," >&2
|
|
||||||
echo " the kanaal registration, or Objecten's notifications_config." >&2
|
|
||||||
echo "--- sink log ---" >&2; docker logs rr-osink 2>&1 | tail -8 >&2
|
|
||||||
echo "--- objecten log ---" >&2; docker logs "$obj" 2>&1 | tail -15 >&2
|
|
||||||
exit 1
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,26 +1,18 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
#
|
#
|
||||||
# Verify the end-to-end read-projection path (S-06, re-sourced by S-19b-2) against an ALREADY-RUNNING
|
# Verify the end-to-end read-projection path (S-06) against an ALREADY-RUNNING full stack:
|
||||||
# full stack: ACL → Objecten → NRC → Event Subscriber → projection → projection-api. Seeds a
|
# OpenZaak → NRC → Event Subscriber → projection → projection-api. Seeds a published BIG
|
||||||
# published BIG zaaktype (idempotent), registers an abonnement on the `objecten` kanaal pointing at
|
# zaaktype (idempotent), registers an abonnement on the `zaken` kanaal pointing at the real
|
||||||
# the real Event Subscriber's /notifications callback (with the bearer it enforces), opens a zaak
|
# Event Subscriber's /notifications callback (with the bearer it enforces), creates a zaak,
|
||||||
# *through the ACL*, and asserts projection-api serves a row for it with status INGEDIEND.
|
# and asserts projection-api serves a row for that zaak with status INGEDIEND.
|
||||||
#
|
|
||||||
# The zaak is opened through the ACL, not straight against OpenZaak: since ADR-0030 the projection is
|
|
||||||
# derived from the RegisterRecord in Objecten, and the ACL is what writes that record (INGEDIEND on
|
|
||||||
# submit). A zaak created behind the ACL's back produces no register write and so no projection row —
|
|
||||||
# which is the point of the re-source.
|
|
||||||
#
|
#
|
||||||
# All in-network, reaching services by container IP — single-label hosts aren't URL-valid and
|
# All in-network, reaching services by container IP — single-label hosts aren't URL-valid and
|
||||||
# the runner can't reach published ports (gitea-actions-gotchas.md §5/§6). Does not own the stack
|
# the runner can't reach published ports (gitea-actions-gotchas.md §5/§6). Reuses the
|
||||||
# lifecycle (the caller brings it up and tears it down), but does recreate the `acl` service to
|
# notification driver to register the abonnement + create the zaak. Does NOT manage the stack
|
||||||
# repoint it — see below, and run-domain-check.sh, which does the same. Plain docker primitives only.
|
# lifecycle (the caller owns bring-up + teardown). Plain docker primitives only. See ADR-0007/0008.
|
||||||
# See ADR-0007/0008/0030.
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
root="$(cd "$here/.." && pwd)"
|
|
||||||
compose="$root/infra/docker-compose.yml"
|
|
||||||
WEBHOOK_AUTH="${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}"
|
WEBHOOK_AUTH="${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}"
|
||||||
|
|
||||||
cleanup() { docker rm -f rr-pverify rr-pquery >/dev/null 2>&1 || true; }
|
cleanup() { docker rm -f rr-pverify rr-pquery >/dev/null 2>&1 || true; }
|
||||||
@@ -32,13 +24,11 @@ oz="$(docker ps -q --filter 'name=[-_]openzaak[-_]' | head -1)"
|
|||||||
nrc="$(docker ps -q --filter 'name=nrc-web' | head -1)"
|
nrc="$(docker ps -q --filter 'name=nrc-web' | head -1)"
|
||||||
es="$(docker ps -q --filter 'name=event-subscriber' | head -1)"
|
es="$(docker ps -q --filter 'name=event-subscriber' | head -1)"
|
||||||
proj="$(docker ps -q --filter 'name=projection-api' | head -1)"
|
proj="$(docker ps -q --filter 'name=projection-api' | head -1)"
|
||||||
acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)"
|
|
||||||
[ -n "$oz" ] && [ -n "$nrc" ] || { echo "ERROR: OpenZaak and/or NRC not running — bring the stack up first" >&2; exit 1; }
|
[ -n "$oz" ] && [ -n "$nrc" ] || { echo "ERROR: OpenZaak and/or NRC not running — bring the stack up first" >&2; exit 1; }
|
||||||
[ -n "$es" ] && [ -n "$proj" ] || { echo "ERROR: event-subscriber and/or projection-api not running — bring the stack up first" >&2; exit 1; }
|
[ -n "$es" ] && [ -n "$proj" ] || { echo "ERROR: event-subscriber and/or projection-api not running — bring the stack up first" >&2; exit 1; }
|
||||||
[ -n "$acl" ] || { echo "ERROR: acl not running — bring the stack up first" >&2; exit 1; }
|
|
||||||
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$oz" | head -1)"
|
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$oz" | head -1)"
|
||||||
oz_ip="$(ip "$oz")"; nrc_ip="$(ip "$nrc")"; es_ip="$(ip "$es")"; proj_ip="$(ip "$proj")"; acl_ip="$(ip "$acl")"
|
oz_ip="$(ip "$oz")"; nrc_ip="$(ip "$nrc")"; es_ip="$(ip "$es")"; proj_ip="$(ip "$proj")"
|
||||||
echo ">> network=$net openzaak=$oz_ip nrc=$nrc_ip event-subscriber=$es_ip projection-api=$proj_ip acl=$acl_ip"
|
echo ">> network=$net openzaak=$oz_ip nrc=$nrc_ip event-subscriber=$es_ip projection-api=$proj_ip"
|
||||||
|
|
||||||
echo ">> seeding a published BIG zaaktype (idempotent)"
|
echo ">> seeding a published BIG zaaktype (idempotent)"
|
||||||
sid="$(docker create --network "$net" -e "OZ_BASE=http://$oz_ip:8000" -e OZ_PUBLISH=1 \
|
sid="$(docker create --network "$net" -e "OZ_BASE=http://$oz_ip:8000" -e OZ_PUBLISH=1 \
|
||||||
@@ -47,39 +37,19 @@ docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null
|
|||||||
docker start -a "$sid"
|
docker start -a "$sid"
|
||||||
docker rm -f "$sid" >/dev/null
|
docker rm -f "$sid" >/dev/null
|
||||||
|
|
||||||
echo ">> registering the event-subscriber abonnement on the objecten kanaal"
|
echo ">> registering abonnement at the Event Subscriber + creating a zaak"
|
||||||
docker rm -f rr-pverify >/dev/null 2>&1 || true
|
docker rm -f rr-pverify >/dev/null 2>&1 || true
|
||||||
# The same script the local stack uses (ADR-0020), so both paths register the identical abonnement.
|
|
||||||
drv="$(docker create --network "$net" --name rr-pverify \
|
drv="$(docker create --network "$net" --name rr-pverify \
|
||||||
-e "NRC_BASE=http://$nrc_ip:8000" \
|
-e "OZ_BASE=http://$oz_ip:8000" -e "NRC_BASE=http://$nrc_ip:8000" \
|
||||||
-e "SINK_HOST=$es_ip" -e "SINK_PORT=8080" -e "SINK_AUTH=$WEBHOOK_AUTH" \
|
-e "SINK_CALLBACK=http://$es_ip:8080/notifications" -e "SINK_AUTH=$WEBHOOK_AUTH" \
|
||||||
python:3-slim python /subscribe.py)"
|
python:3-slim python /driver.py)"
|
||||||
docker cp "$here/local/register-abonnement.py" "$drv:/subscribe.py" >/dev/null
|
docker cp "$here/verify-notification-driver.py" "$drv:/driver.py" >/dev/null
|
||||||
docker start -a "$drv"
|
docker start -a "$drv"
|
||||||
|
zaak_url="$(docker logs rr-pverify 2>/dev/null | sed -n 's/^ZAAK_CREATED //p' | head -1)"
|
||||||
docker rm -f rr-pverify >/dev/null
|
docker rm -f rr-pverify >/dev/null
|
||||||
|
[ -n "$zaak_url" ] || { echo "ERROR: driver did not create a zaak" >&2; exit 1; }
|
||||||
# OpenZaak reflects the request Host into the zaaktype `url` it returns, and then rejects that same
|
|
||||||
# URL on zaak-create when the host is single-label ("Voer een geldige URL in."). The stack's ACL is
|
|
||||||
# configured with `http://openzaak:8000/`, so it must be repointed at OpenZaak's container IP before
|
|
||||||
# it can open a zaak — exactly what run-domain-check.sh does, and the same class of constraint as the
|
|
||||||
# `objecten.local` alias (ADR-0029). The ACL resolves the zaaktype itself (S-27, ADR-0021), so the
|
|
||||||
# base URL is the only thing to inject.
|
|
||||||
echo ">> recreating the acl service pointed at OpenZaak's IP"
|
|
||||||
ACL_OPENZAAK_BASEURL="http://$oz_ip:8000/" docker compose -f "$compose" up -d acl
|
|
||||||
WAIT_TIMEOUT="${WAIT_TIMEOUT:-120}" bash "$here/wait-healthy.sh" acl
|
|
||||||
# The container is replaced, so its IP may have changed.
|
|
||||||
acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)"
|
|
||||||
acl_ip="$(ip "$acl")"
|
|
||||||
|
|
||||||
echo ">> opening a zaak through the ACL (which writes the INGEDIEND register record)"
|
|
||||||
reference="PROJ-$(date +%s)"
|
|
||||||
zaak_url="$(docker run --rm --network "$net" curlimages/curl:latest \
|
|
||||||
-fsS -X POST "http://$acl_ip:8080/zaken" -H 'Content-Type: application/json' \
|
|
||||||
-d "{\"bsn\":\"123456782\",\"reference\":\"$reference\"}" \
|
|
||||||
| sed -n 's/.*"zaakUrl":"\([^"]*\)".*/\1/p')"
|
|
||||||
[ -n "$zaak_url" ] || { echo "ERROR: the ACL did not open a zaak" >&2; exit 1; }
|
|
||||||
zaak_uuid="${zaak_url##*/}"
|
zaak_uuid="${zaak_url##*/}"
|
||||||
echo ">> zaak created: $zaak_url (reference $reference)"
|
echo ">> zaak created: $zaak_url"
|
||||||
|
|
||||||
echo ">> polling projection-api for the projected row (status INGEDIEND)"
|
echo ">> polling projection-api for the projected row (status INGEDIEND)"
|
||||||
for _ in $(seq 1 30); do
|
for _ in $(seq 1 30); do
|
||||||
@@ -93,8 +63,6 @@ for _ in $(seq 1 30); do
|
|||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
echo "FAIL — projection-api never served an INGEDIEND row for zaak $zaak_uuid" >&2
|
echo "FAIL — projection-api never served an INGEDIEND row for zaak $zaak_uuid" >&2
|
||||||
echo " The chain is ACL → Objecten → NRC → event-subscriber → projection (ADR-0030)." >&2
|
|
||||||
echo "--- event-subscriber log ---" >&2; docker logs "$es" 2>&1 | tail -10 >&2
|
echo "--- event-subscriber log ---" >&2; docker logs "$es" 2>&1 | tail -10 >&2
|
||||||
echo "--- projection-api log ---" >&2; docker logs "$proj" 2>&1 | tail -10 >&2
|
echo "--- projection-api log ---" >&2; docker logs "$proj" 2>&1 | tail -10 >&2
|
||||||
echo "--- acl log ---" >&2; docker logs "$acl" 2>&1 | tail -10 >&2
|
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
# subcommand. Fixed-name `external` volumes keep the names deterministic across
|
# subcommand. Fixed-name `external` volumes keep the names deterministic across
|
||||||
# both runtimes. See docs/runbooks/gitea-actions-gotchas.md.
|
# both runtimes. See docs/runbooks/gitea-actions-gotchas.md.
|
||||||
#
|
#
|
||||||
# Usage: seed-config.sh <key> [<key> ...] where key ∈ { oz, nrc, kc, fl, objecttypen, objecten, registerrecord }
|
# Usage: seed-config.sh <key> [<key> ...] where key ∈ { oz, kc, fl }
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
@@ -33,7 +33,7 @@ populate() { # volume source(file or dir/.)
|
|||||||
echo " seeded $vol"
|
echo " seeded $vol"
|
||||||
}
|
}
|
||||||
|
|
||||||
[ "$#" -gt 0 ] || { echo "usage: seed-config.sh <oz|nrc|kc|fl|objecttypen|objecten|registerrecord> ..." >&2; exit 2; }
|
[ "$#" -gt 0 ] || { echo "usage: seed-config.sh <oz|nrc|kc|fl> ..." >&2; exit 2; }
|
||||||
|
|
||||||
# The registratie process (BPMN) and its diploma-eligibility DMN are deployed as SEPARATE Flowable
|
# 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
|
# deployments — the process engine and the DMN engine each own theirs (S-13, ADR-0016). flowable-rest
|
||||||
@@ -49,9 +49,6 @@ for key in "$@"; do
|
|||||||
oz) populate rr-oz-config "$here/openzaak/setup_configuration/." ;;
|
oz) populate rr-oz-config "$here/openzaak/setup_configuration/." ;;
|
||||||
nrc) populate rr-nrc-config "$here/opennotificaties/setup_configuration/." ;;
|
nrc) populate rr-nrc-config "$here/opennotificaties/setup_configuration/." ;;
|
||||||
kc) populate rr-kc-realms "$here/keycloak/realms/." ;;
|
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/." ;;
|
fl) d="$(mktemp -d)"; stage_flowable_workflows "$d"; populate rr-fl-bpmn "$d/." ;;
|
||||||
*) echo "unknown seed key: $key" >&2; exit 2 ;;
|
*) echo "unknown seed key: $key" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
#!/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"))
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
#!/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"))
|
|
||||||
@@ -15,13 +15,9 @@ set -euo pipefail
|
|||||||
timeout="${WAIT_TIMEOUT:-420}"
|
timeout="${WAIT_TIMEOUT:-420}"
|
||||||
deadline=$(( $(date +%s) + timeout ))
|
deadline=$(( $(date +%s) + timeout ))
|
||||||
|
|
||||||
# compose service name -> container id. `--filter name=` is a substring match, so it is anchored on
|
# compose service name -> container id. The name filter matches both docker
|
||||||
# the compose replica suffix — otherwise 'objecten' also matches objecten-db / objecten-redis /
|
# compose ("infra-openzaak-1") and podman-compose ("infra_openzaak_1") naming.
|
||||||
# objecten-celery, and 'objecttypen' matches objecttypen-db. Whichever docker listed first won, so a
|
cid_for() { docker ps -aq --filter "name=$1" | head -1; }
|
||||||
# service with a sibling that has no healthcheck timed out with status=none while it was in fact
|
|
||||||
# healthy. The pattern matches both docker compose ("infra-objecten-1") and podman-compose
|
|
||||||
# ("infra_objecten_1") naming; the same anchoring the verify check scripts use.
|
|
||||||
cid_for() { docker ps -aq --filter "name=$1[-_][0-9]+\$" | head -1; }
|
|
||||||
|
|
||||||
for svc in "$@"; do
|
for svc in "$@"; do
|
||||||
echo "waiting for '$svc' to be healthy (timeout ${timeout}s)..."
|
echo "waiting for '$svc' to be healthy (timeout ${timeout}s)..."
|
||||||
|
|||||||
@@ -24,12 +24,6 @@ import {
|
|||||||
Observable
|
Observable
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
|
|
||||||
export interface BeheerDefaultFill {
|
|
||||||
bronorganisatie: string;
|
|
||||||
verantwoordelijkeOrganisatie: string;
|
|
||||||
vertrouwelijkheidaanduiding: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BeheerZaaktype {
|
export interface BeheerZaaktype {
|
||||||
identificatie: string;
|
identificatie: string;
|
||||||
omschrijving: string;
|
omschrijving: string;
|
||||||
@@ -452,69 +446,4 @@ export class BffApiV1Service {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,21 +33,7 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
|
|||||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
|
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
|
||||||
.GetSection("Acl:OpenZaak").Get<OpenZaakOptions>()
|
.GetSection("Acl:OpenZaak").Get<OpenZaakOptions>()
|
||||||
?? throw new InvalidOperationException("Missing configuration section 'Acl:OpenZaak'"));
|
?? throw new InvalidOperationException("Missing configuration section 'Acl:OpenZaak'"));
|
||||||
// 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>();
|
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).
|
// Singleton so the resolved zaaktype/informatieobjecttype URLs are cached across requests (S-27).
|
||||||
builder.Services.AddSingleton<IZaaktypeCatalog, CachedZaaktypeCatalog>();
|
builder.Services.AddSingleton<IZaaktypeCatalog, CachedZaaktypeCatalog>();
|
||||||
builder.Services.AddScoped<AclService>();
|
builder.Services.AddScoped<AclService>();
|
||||||
@@ -90,16 +76,6 @@ app.MapPost("/zaken/reference", async (ZaakReferenceRequest body, AclService acl
|
|||||||
return Results.Ok(new { reference });
|
return Results.Ok(new { reference });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Read the register record an object in Objecten holds. The Event Subscriber projects a register
|
|
||||||
// write from the notification NRC delivers, which carries only the object URL, and may not talk to
|
|
||||||
// Objecten itself (§8.1, ADR-0028/ADR-0030). 404 when the object holds no record — the subscriber
|
|
||||||
// treats that as "nothing to project" rather than an error (§8.6).
|
|
||||||
app.MapPost("/register-records/read", async (RegisterRecordReadRequest body, AclService acl, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var record = await acl.GetRegisterRecordAsync(new Uri(body.ObjectUrl), ct);
|
|
||||||
return record is null ? Results.NotFound() : Results.Ok(record);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Store an uploaded diploma against a zaak (S-10b): the domain sends the file as base64; the ACL
|
// Store an uploaded diploma against a zaak (S-10b): the domain sends the file as base64; the ACL
|
||||||
// creates the ZGW enkelvoudiginformatieobject and relates it to the zaak (§8.1). Returns its URL.
|
// creates the ZGW enkelvoudiginformatieobject and relates it to the zaak (§8.1). Returns its URL.
|
||||||
app.MapPost("/documenten", async (StoreDocumentRequest body, AclService acl, CancellationToken ct) =>
|
app.MapPost("/documenten", async (StoreDocumentRequest body, AclService acl, CancellationToken ct) =>
|
||||||
@@ -115,22 +91,6 @@ app.MapPost("/documenten", async (StoreDocumentRequest body, AclService acl, Can
|
|||||||
app.MapGet("/catalogi/zaaktypen", async (AclService acl, CancellationToken ct) =>
|
app.MapGet("/catalogi/zaaktypen", async (AclService acl, CancellationToken ct) =>
|
||||||
Results.Ok(await acl.ListZaaktypenAsync(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();
|
app.Run();
|
||||||
|
|
||||||
public sealed record OpenZaakRequest(string Bsn, string Reference);
|
public sealed record OpenZaakRequest(string Bsn, string Reference);
|
||||||
@@ -141,9 +101,6 @@ public sealed record CancelZaakRequest(string ZaakUrl);
|
|||||||
|
|
||||||
public sealed record ZaakReferenceRequest(string ZaakUrl);
|
public sealed record ZaakReferenceRequest(string ZaakUrl);
|
||||||
|
|
||||||
/// <summary>The object whose register record the Event Subscriber wants read back (S-19b-2).</summary>
|
|
||||||
public sealed record RegisterRecordReadRequest(string ObjectUrl);
|
|
||||||
|
|
||||||
public sealed record StoreDocumentRequest(string ZaakUrl, string ContentBase64, string FileName, string ContentType);
|
public sealed record StoreDocumentRequest(string ZaakUrl, string ContentBase64, string FileName, string ContentType);
|
||||||
|
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|||||||
@@ -2,20 +2,12 @@ namespace Acl.Application;
|
|||||||
|
|
||||||
/// <summary>The ACL's single operation: open a zaak from a domain payload,
|
/// <summary>The ACL's single operation: open a zaak from a domain payload,
|
||||||
/// default-filling the ZGW-mandatory fields (ADR-0003).</summary>
|
/// default-filling the ZGW-mandatory fields (ADR-0003).</summary>
|
||||||
public sealed class AclService(
|
public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaaktypeCatalog catalog, IClock clock)
|
||||||
IZaakGateway gateway,
|
|
||||||
IRegisterRecordGateway register,
|
|
||||||
IDefaultFillStore fill,
|
|
||||||
IZaaktypeCatalog catalog,
|
|
||||||
IClock clock)
|
|
||||||
{
|
{
|
||||||
public async Task<Uri> OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default)
|
public async Task<Uri> OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(registration);
|
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(
|
var request = new ZaakRequest(
|
||||||
defaults.Bronorganisatie,
|
defaults.Bronorganisatie,
|
||||||
defaults.VerantwoordelijkeOrganisatie,
|
defaults.VerantwoordelijkeOrganisatie,
|
||||||
@@ -24,58 +16,20 @@ public sealed class AclService(
|
|||||||
clock.Today,
|
clock.Today,
|
||||||
registration.Reference);
|
registration.Reference);
|
||||||
|
|
||||||
var zaakUrl = await gateway.OpenZaakAsync(request, ct);
|
return await gateway.OpenZaakAsync(request, ct);
|
||||||
|
|
||||||
// The register — not ZGW — is what the read projection is sourced from (ADR-0028/ADR-0030),
|
|
||||||
// so the record exists from submission, not only from approval. Same two-writes-converging
|
|
||||||
// posture as ApproveZaakAsync: the upsert is keyed on the zaak id, so a retried submit
|
|
||||||
// updates the record rather than adding a second one (§8.6).
|
|
||||||
await register.UpsertAsync(
|
|
||||||
new RegisterRecord(ZaakId(zaakUrl), RegisterRecordStatus.Ingediend, registration.Reference), ct);
|
|
||||||
|
|
||||||
return zaakUrl;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Approve a zaak: set it to the eindstatus of the BIG zaaktype (resolved by identificatie, S-27),
|
/// Approve a zaak: set it to the eindstatus of the 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
|
/// The domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1).
|
||||||
/// ACL owns which statustype means "approved" and what the register record looks like (§8.1).
|
|
||||||
/// </summary>
|
/// </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)
|
public async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||||
|
|
||||||
await gateway.SetZaakToEindstatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct);
|
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 register record held by an object in Objecten, for the Event Subscriber (S-19b-2). The
|
|
||||||
/// subscriber gets only an object URL on the notification and may not read Objecten itself
|
|
||||||
/// (§8.1, ADR-0028), so the ACL reads it back.
|
|
||||||
/// </summary>
|
|
||||||
public Task<RegisterRecord?> GetRegisterRecordAsync(Uri objectUrl, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(objectUrl);
|
|
||||||
|
|
||||||
return register.GetAsync(objectUrl, 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>
|
/// <summary>
|
||||||
/// Cancel a zaak on document-timeout expiry (S-10c): set it to the BIG zaaktype's cancellation
|
/// Cancel a zaak on document-timeout expiry (S-10c): set it to the BIG zaaktype's cancellation
|
||||||
/// statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which
|
/// statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which
|
||||||
@@ -93,17 +47,6 @@ public sealed class AclService(
|
|||||||
public Task<IReadOnlyList<ZaaktypeSummary>> ListZaaktypenAsync(CancellationToken ct = default) =>
|
public Task<IReadOnlyList<ZaaktypeSummary>> ListZaaktypenAsync(CancellationToken ct = default) =>
|
||||||
gateway.ListZaaktypenAsync(ct);
|
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>
|
/// <summary>The zaak's reference (its ZGW identificatie), for the read projection (#78).</summary>
|
||||||
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
|
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -125,7 +68,6 @@ public sealed class AclService(
|
|||||||
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
|
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(contentType);
|
ArgumentException.ThrowIfNullOrWhiteSpace(contentType);
|
||||||
|
|
||||||
var defaults = fill.Current;
|
|
||||||
var request = new DocumentRequest(
|
var request = new DocumentRequest(
|
||||||
defaults.Bronorganisatie,
|
defaults.Bronorganisatie,
|
||||||
await catalog.GetInformatieobjecttypeUrlAsync(ct),
|
await catalog.GetInformatieobjecttypeUrlAsync(ct),
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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 register record held by the object at <paramref name="objectUrl"/>, or <c>null</c> if that
|
|
||||||
/// object holds none. The Event Subscriber projects a register write from the notification NRC
|
|
||||||
/// delivers, which carries only the object URL — so it reads the record back through the ACL
|
|
||||||
/// rather than talking to Objecten itself (§8.1, S-19b-2).
|
|
||||||
/// </summary>
|
|
||||||
Task<RegisterRecord?> GetAsync(Uri objectUrl, 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";
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<RegisterRecord?> GetAsync(Uri objectUrl, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(objectUrl);
|
|
||||||
|
|
||||||
// Fetched by the URL the notification carried, so no objecttype resolution and no search —
|
|
||||||
// unlike a write, which has to find the object for a registration id.
|
|
||||||
using var message = new HttpRequestMessage(HttpMethod.Get, objectUrl);
|
|
||||||
message.Headers.Authorization = new AuthenticationHeaderValue("Token", options.Token);
|
|
||||||
message.Headers.Add("Accept-Crs", "EPSG:4326");
|
|
||||||
|
|
||||||
using var response = await http.SendAsync(message, ct);
|
|
||||||
// The object may be gone by the time a (possibly redelivered) notification is handled —
|
|
||||||
// there is simply nothing to project, which is not a failure (§8.6).
|
|
||||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
await EnsureSuccessAsync(response, "Reading the register record", ct);
|
|
||||||
|
|
||||||
var body = await response.Content.ReadFromJsonAsync<ReadObjectDto>(ct)
|
|
||||||
?? throw new InvalidOperationException("Objecten returned an empty object response");
|
|
||||||
var data = body.Record?.Data;
|
|
||||||
return data is null ? null : new RegisterRecord(data.Id, data.Status, data.Reference);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 ReadObjectDto(
|
|
||||||
[property: JsonPropertyName("record")] ReadRecordDto? Record);
|
|
||||||
|
|
||||||
private sealed record ReadRecordDto(
|
|
||||||
[property: JsonPropertyName("data")] RecordDataDto? Data);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
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.local: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.local: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);
|
|
||||||
}
|
|
||||||
@@ -75,27 +75,6 @@ public class AclServiceTests
|
|||||||
Task.FromResult(Zaaktypen);
|
Task.FromResult(Zaaktypen);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class FakeRegisterRecordGateway : IRegisterRecordGateway
|
|
||||||
{
|
|
||||||
public readonly List<RegisterRecord> Upserted = [];
|
|
||||||
|
|
||||||
public RegisterRecord? Stored;
|
|
||||||
|
|
||||||
public Uri? ReadFrom;
|
|
||||||
|
|
||||||
public Task UpsertAsync(RegisterRecord record, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
Upserted.Add(record);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<RegisterRecord?> GetAsync(Uri objectUrl, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
ReadFrom = objectUrl;
|
|
||||||
return Task.FromResult(Stored);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static AclDefaults Defaults() => new()
|
private static AclDefaults Defaults() => new()
|
||||||
{
|
{
|
||||||
Bronorganisatie = "517439943",
|
Bronorganisatie = "517439943",
|
||||||
@@ -105,14 +84,8 @@ public class AclServiceTests
|
|||||||
InformatieobjecttypeOmschrijving = "Diploma",
|
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) =>
|
private static AclService ServiceWith(FakeGateway gateway, AclDefaults defaults, DateOnly today) =>
|
||||||
ServiceWith(gateway, new FakeRegisterRecordGateway(), defaults, today);
|
new(gateway, defaults, new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(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
|
private sealed class FixedClock(DateOnly today) : IClock
|
||||||
{
|
{
|
||||||
@@ -140,68 +113,6 @@ public class AclServiceTests
|
|||||||
Assert.Equal("reg-77", req.Identificatie);
|
Assert.Equal("reg-77", req.Identificatie);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Opening_a_zaak_also_writes_an_ingediend_register_record(/* S-19b-2 */)
|
|
||||||
{
|
|
||||||
var gateway = new FakeGateway();
|
|
||||||
var register = new FakeRegisterRecordGateway();
|
|
||||||
var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4));
|
|
||||||
|
|
||||||
await service.OpenZaakAsync(new DomainRegistration("123456782", "reg-77"));
|
|
||||||
|
|
||||||
// The register — not ZGW — is what the read projection is sourced from (ADR-0028), so a
|
|
||||||
// submitted registration has to exist there the moment the zaak is opened, not only on
|
|
||||||
// approval. Approval upserts this same record to INGESCHREVEN.
|
|
||||||
var record = Assert.Single(register.Upserted);
|
|
||||||
Assert.Equal("abc", record.Id);
|
|
||||||
Assert.Equal("INGEDIEND", record.Status);
|
|
||||||
// The reference comes from the registration itself — no ZGW read-back needed on this path.
|
|
||||||
Assert.Equal("reg-77", record.Reference);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Reading_a_register_record_goes_through_the_objecten_gateway(/* S-19b-2 */)
|
|
||||||
{
|
|
||||||
var gateway = new FakeGateway();
|
|
||||||
var register = new FakeRegisterRecordGateway { Stored = new RegisterRecord("abc", "INGESCHREVEN", "reg-77") };
|
|
||||||
var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4));
|
|
||||||
var objectUrl = new Uri("http://objecten.local:8000/api/v2/objects/9de4a2ca");
|
|
||||||
|
|
||||||
var record = await service.GetRegisterRecordAsync(objectUrl);
|
|
||||||
|
|
||||||
Assert.Equal(objectUrl, register.ReadFrom);
|
|
||||||
Assert.Equal("abc", record!.Id);
|
|
||||||
Assert.Equal("INGESCHREVEN", record.Status);
|
|
||||||
Assert.Equal("reg-77", record.Reference);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Reading_a_register_record_from_a_null_url_is_rejected(/* S-19b-2 */)
|
|
||||||
{
|
|
||||||
var gateway = new FakeGateway();
|
|
||||||
var register = new FakeRegisterRecordGateway();
|
|
||||||
var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4));
|
|
||||||
|
|
||||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.GetRegisterRecordAsync(null!));
|
|
||||||
Assert.Null(register.ReadFrom);
|
|
||||||
}
|
|
||||||
|
|
||||||
[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]
|
[Fact]
|
||||||
public async Task Rejects_a_null_registration_without_calling_the_gateway()
|
public async Task Rejects_a_null_registration_without_calling_the_gateway()
|
||||||
{
|
{
|
||||||
@@ -231,42 +142,10 @@ public class AclServiceTests
|
|||||||
public async Task Approving_a_null_zaak_is_rejected_without_touching_the_gateway()
|
public async Task Approving_a_null_zaak_is_rejected_without_touching_the_gateway()
|
||||||
{
|
{
|
||||||
var gateway = new FakeGateway();
|
var gateway = new FakeGateway();
|
||||||
var register = new FakeRegisterRecordGateway();
|
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||||
var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4));
|
|
||||||
|
|
||||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!));
|
await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!));
|
||||||
Assert.Null(gateway.Approved);
|
Assert.Null(gateway.Approved);
|
||||||
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]
|
[Fact]
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
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 Reads_a_register_record_back_from_its_object_url(/* S-19b-2 */)
|
|
||||||
{
|
|
||||||
var sent = new List<Sent>();
|
|
||||||
var objectUrl = new Uri("http://objecten:8000/api/v2/objects/obj-9");
|
|
||||||
var gateway = Gateway(sent, _ => Json(new
|
|
||||||
{
|
|
||||||
url = objectUrl.ToString(),
|
|
||||||
record = new { data = new { id = "zaak-uuid-1", status = "INGESCHREVEN", reference = "REG-2026-0001" } },
|
|
||||||
}));
|
|
||||||
|
|
||||||
var record = await gateway.GetAsync(objectUrl);
|
|
||||||
|
|
||||||
// The object is fetched directly by the URL the notification carried — no objecttype
|
|
||||||
// resolution and no search, unlike a write.
|
|
||||||
var read = Assert.Single(sent);
|
|
||||||
Assert.Equal(HttpMethod.Get, read.Method);
|
|
||||||
Assert.Equal(objectUrl, read.Uri);
|
|
||||||
// Objecten is a geo API: the CRS header is required on reads too.
|
|
||||||
Assert.Equal("EPSG:4326", read.AcceptCrs);
|
|
||||||
Assert.Equal("Token objecten-token", read.Auth);
|
|
||||||
Assert.Equal("zaak-uuid-1", record!.Id);
|
|
||||||
Assert.Equal("INGESCHREVEN", record.Status);
|
|
||||||
Assert.Equal("REG-2026-0001", record.Reference);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Reading_an_object_that_is_gone_yields_no_record(/* S-19b-2 */)
|
|
||||||
{
|
|
||||||
var sent = new List<Sent>();
|
|
||||||
var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.NotFound));
|
|
||||||
|
|
||||||
// A record deleted between the notification and the read is not an error — there is simply
|
|
||||||
// nothing to project (§8.6: the subscriber tolerates whatever order deliveries arrive in).
|
|
||||||
Assert.Null(await gateway.GetAsync(new Uri("http://objecten:8000/api/v2/objects/gone")));
|
|
||||||
}
|
|
||||||
|
|
||||||
[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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"stryker-config": {
|
"stryker-config": {
|
||||||
"solution": "Acl.slnx",
|
"solution": "Acl.slnx",
|
||||||
"test-projects": ["Acl.Tests/Acl.Tests.csproj"],
|
"test-projects": ["Acl.Tests/Acl.Tests.csproj"],
|
||||||
"reporters": ["progress", "html", "markdown"],
|
"reporters": ["progress", "html"],
|
||||||
"thresholds": {
|
"thresholds": {
|
||||||
"high": 95,
|
"high": 95,
|
||||||
"low": 90,
|
"low": 90,
|
||||||
|
|||||||
@@ -59,26 +59,12 @@ public interface IProjectionClient
|
|||||||
/// internal reference, not shown in the portal.</summary>
|
/// internal reference, not shown in the portal.</summary>
|
||||||
public sealed record BeheerZaaktype(string Identificatie, string Omschrijving);
|
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
|
/// <summary>Port to the ACL for read-only catalogus queries (beheer portal, S-15a). The BFF reaches the
|
||||||
/// fields the ACL stamps on every zaak (ADR-0003).</summary>
|
/// ACL directly for this read: the catalogus isn't a domain concern, and the ACL is the only code
|
||||||
public sealed record BeheerDefaultFill(
|
/// allowed to read the ZGW Catalogi API (§8.1, ADR-0025).</summary>
|
||||||
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
|
public interface IAclClient
|
||||||
{
|
{
|
||||||
/// <summary>The published catalogus zaaktypen, read-only (S-15a).</summary>
|
|
||||||
Task<IReadOnlyList<BeheerZaaktype>> GetZaaktypenAsync(CancellationToken ct = default);
|
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>
|
/// <summary>Calls the Domain Service's <c>POST /registrations</c>.</summary>
|
||||||
@@ -155,14 +141,4 @@ public sealed class AclClient(HttpClient http) : IAclClient
|
|||||||
{
|
{
|
||||||
public async Task<IReadOnlyList<BeheerZaaktype>> GetZaaktypenAsync(CancellationToken ct = default)
|
public async Task<IReadOnlyList<BeheerZaaktype>> GetZaaktypenAsync(CancellationToken ct = default)
|
||||||
=> await http.GetFromJsonAsync<List<BeheerZaaktype>>("catalogi/zaaktypen", ct) ?? [];
|
=> 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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,25 +228,6 @@ app.MapGet("/beheer/catalogi/zaaktypen", async (IAclClient acl, CancellationToke
|
|||||||
.Produces(StatusCodes.Status401Unauthorized)
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
.Produces(StatusCodes.Status403Forbidden);
|
.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();
|
app.Run();
|
||||||
|
|
||||||
/// <summary>The behandelaar's decision on a registration.</summary>
|
/// <summary>The behandelaar's decision on a registration.</summary>
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -142,24 +142,11 @@ internal sealed class FakeProjectionClient : IProjectionClient
|
|||||||
=> Task.FromResult<IReadOnlyList<ProjectionEntry>>(Entries);
|
=> Task.FromResult<IReadOnlyList<ProjectionEntry>>(Entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Serves catalogus zaaktypen (S-15a) and holds the default-fill settings (S-15b).</summary>
|
/// <summary>Serves a configurable set of catalogus zaaktypen (beheer viewer, S-15a).</summary>
|
||||||
internal sealed class FakeAclClient : IAclClient
|
internal sealed class FakeAclClient : IAclClient
|
||||||
{
|
{
|
||||||
public List<BeheerZaaktype> Zaaktypen { get; } = [];
|
public List<BeheerZaaktype> Zaaktypen { get; } = [];
|
||||||
|
|
||||||
public Task<IReadOnlyList<BeheerZaaktype>> GetZaaktypenAsync(CancellationToken ct = default)
|
public Task<IReadOnlyList<BeheerZaaktype>> GetZaaktypenAsync(CancellationToken ct = default)
|
||||||
=> Task.FromResult<IReadOnlyList<BeheerZaaktype>>(Zaaktypen);
|
=> Task.FromResult<IReadOnlyList<BeheerZaaktype>>(Zaaktypen);
|
||||||
|
|
||||||
public BeheerDefaultFill DefaultFill { get; set; } = new("517439943", "517439943", "openbaar");
|
|
||||||
public BeheerDefaultFill? Updated { get; private set; }
|
|
||||||
|
|
||||||
public Task<BeheerDefaultFill> GetDefaultFillAsync(CancellationToken ct = default)
|
|
||||||
=> Task.FromResult(DefaultFill);
|
|
||||||
|
|
||||||
public Task UpdateDefaultFillAsync(BeheerDefaultFill settings, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
Updated = settings;
|
|
||||||
DefaultFill = settings;
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,80 +255,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"/beheer/default-fill": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"Bff.Api"
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "OK",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/BeheerDefaultFill"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"description": "Unauthorized"
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"description": "Forbidden"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"put": {
|
|
||||||
"tags": [
|
|
||||||
"Bff.Api"
|
|
||||||
],
|
|
||||||
"requestBody": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/BeheerDefaultFill"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"204": {
|
|
||||||
"description": "No Content"
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"description": "Unauthorized"
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"description": "Forbidden"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
"schemas": {
|
"schemas": {
|
||||||
"BeheerDefaultFill": {
|
|
||||||
"required": [
|
|
||||||
"bronorganisatie",
|
|
||||||
"verantwoordelijkeOrganisatie",
|
|
||||||
"vertrouwelijkheidaanduiding"
|
|
||||||
],
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"bronorganisatie": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"verantwoordelijkeOrganisatie": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"vertrouwelijkheidaanduiding": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"BeheerZaaktype": {
|
"BeheerZaaktype": {
|
||||||
"required": [
|
"required": [
|
||||||
"identificatie",
|
"identificatie",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"stryker-config": {
|
"stryker-config": {
|
||||||
"solution": "Bff.slnx",
|
"solution": "Bff.slnx",
|
||||||
"test-projects": ["Bff.Tests/Bff.Tests.csproj"],
|
"test-projects": ["Bff.Tests/Bff.Tests.csproj"],
|
||||||
"reporters": ["progress", "html", "markdown"],
|
"reporters": ["progress", "html"],
|
||||||
"mutate": [
|
"mutate": [
|
||||||
"!**/Program.cs",
|
"!**/Program.cs",
|
||||||
"!**/DownstreamClients.cs"
|
"!**/DownstreamClients.cs"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"stryker-config": {
|
"stryker-config": {
|
||||||
"solution": "Big.slnx",
|
"solution": "Big.slnx",
|
||||||
"test-projects": ["Big.Tests/Big.Tests.csproj"],
|
"test-projects": ["Big.Tests/Big.Tests.csproj"],
|
||||||
"reporters": ["progress", "html", "markdown"],
|
"reporters": ["progress", "html"],
|
||||||
"mutate": [
|
"mutate": [
|
||||||
"!**/OpenZaakJobPump.cs",
|
"!**/OpenZaakJobPump.cs",
|
||||||
"!**/BeoordelingEscalatiePump.cs",
|
"!**/BeoordelingEscalatiePump.cs",
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using System.Net;
|
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using EventSubscriber.Application;
|
using EventSubscriber.Application;
|
||||||
@@ -6,28 +5,26 @@ using EventSubscriber.Application;
|
|||||||
namespace EventSubscriber.Api;
|
namespace EventSubscriber.Api;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// HTTP client to the ACL service. An Objecten notification carries only the object URL, so the
|
/// HTTP client to the ACL service. The subscriber enriches the projection with the zaak's reference
|
||||||
/// subscriber reads the register record back through the ACL — the only code that may talk to
|
/// (identificatie) by asking the ACL — the only code that may read ZGW (§8.1) — rather than reading
|
||||||
/// Objecten (§8.1, ADR-0028/ADR-0030) — rather than reading Objecten itself.
|
/// OpenZaak itself (adr-proposal #78).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AclHttpClient(HttpClient http) : IAclClient
|
public sealed class AclHttpClient(HttpClient http) : IAclClient
|
||||||
{
|
{
|
||||||
public async Task<RegisterRecord?> GetRegisterRecordAsync(Uri objectUrl, CancellationToken ct = default)
|
public async Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(objectUrl);
|
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||||
|
|
||||||
using var response = await http.PostAsJsonAsync(
|
using var response = await http.PostAsJsonAsync(
|
||||||
new Uri(http.BaseAddress!, "register-records/read"),
|
new Uri(http.BaseAddress!, "zaken/reference"), new ReferenceRequest(zaakUrl.ToString()), ct);
|
||||||
new ReadRequest(objectUrl.ToString()), ct);
|
|
||||||
|
|
||||||
// The object holds no register record (deleted, or never one) — nothing to project (§8.6).
|
|
||||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
return await response.Content.ReadFromJsonAsync<RegisterRecord>(ct)
|
|
||||||
?? throw new InvalidOperationException("The ACL returned an empty register record response.");
|
var body = await response.Content.ReadFromJsonAsync<ReferenceResponse>(ct)
|
||||||
|
?? throw new InvalidOperationException("The ACL returned an empty reference response.");
|
||||||
|
return body.Reference;
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record ReadRequest([property: JsonPropertyName("objectUrl")] string ObjectUrl);
|
private sealed record ReferenceRequest([property: JsonPropertyName("zaakUrl")] string ZaakUrl);
|
||||||
|
|
||||||
|
private sealed record ReferenceResponse([property: JsonPropertyName("reference")] string Reference);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,12 +84,11 @@ app.MapPost("/admin/rebuild", async (NotificationProjector projector, Cancellati
|
|||||||
|
|
||||||
await app.RunAsync();
|
await app.RunAsync();
|
||||||
|
|
||||||
/// <summary>The NRC notification body, as Open Notificaties POSTs it. Only the fields the projector
|
/// <summary>The NRC notification body, as Open Notificaties POSTs it. Only the fields the
|
||||||
/// needs are bound; <c>aanmaakdatum</c>, <c>kenmerken</c> and <c>hoofdObject</c> are ignored — for a
|
/// projection needs are bound; <c>aanmaakdatum</c>/<c>kenmerken</c> are ignored for the minimal slice.</summary>
|
||||||
/// register write hoofdObject is the same object as resourceUrl (ADR-0030).</summary>
|
public sealed record NotificationDto(string Kanaal, string Resource, string Actie, Uri ResourceUrl, Uri? HoofdObject = null)
|
||||||
public sealed record NotificationDto(string Kanaal, string Resource, string Actie, Uri ResourceUrl)
|
|
||||||
{
|
{
|
||||||
public Notification ToNotification() => new(Kanaal, Resource, Actie, ResourceUrl);
|
public Notification ToNotification() => new(Kanaal, Resource, Actie, ResourceUrl, HoofdObject);
|
||||||
}
|
}
|
||||||
|
|
||||||
public partial class Program
|
public partial class Program
|
||||||
|
|||||||
@@ -2,39 +2,40 @@ namespace EventSubscriber.Application;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An inbound NRC (Open Notificaties) notification, as Open Notificaties POSTs it to an
|
/// An inbound NRC (Open Notificaties) notification, as Open Notificaties POSTs it to an
|
||||||
/// abonnement callback. Only the fields the projection needs are modelled.
|
/// abonnement callback. Only the fields the projection needs are modelled; the full ZGW
|
||||||
|
/// "Notificatie" resource also carries <c>aanmaakdatum</c> and <c>kenmerken</c> which the
|
||||||
|
/// minimal projection ignores (bsn is deferred — see ADR-0008). For a <c>zaken</c>/<c>zaak</c>/<c>create</c>
|
||||||
|
/// notification <c>hoofdObject</c> and <c>resourceUrl</c> are both the created zaak's URL.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
|
||||||
/// Since S-19b-2 the subscriber listens on the <c>objecten</c> kanaal, not <c>zaken</c>: the
|
|
||||||
/// register record in Objecten is what the projection is derived from (ADR-0030), so the
|
|
||||||
/// projection is a cache of the register rather than a re-derivation of the case system. An
|
|
||||||
/// Objecten notification carries <b>no record data</b> — only the object URL (as both
|
|
||||||
/// <c>hoofdObject</c> and <c>resourceUrl</c>) and the objecttype as a kenmerk — so the record
|
|
||||||
/// itself is read back through the ACL.
|
|
||||||
/// </remarks>
|
|
||||||
public sealed record Notification(
|
public sealed record Notification(
|
||||||
string Kanaal,
|
string Kanaal,
|
||||||
string Resource,
|
string Resource,
|
||||||
string Actie,
|
string Actie,
|
||||||
Uri ResourceUrl)
|
Uri ResourceUrl,
|
||||||
|
Uri? HoofdObject = null)
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>A zaak being created — projected as INGEDIEND.</summary>
|
||||||
/// A register record written to Objecten — <c>create</c> on submit and <c>partial_update</c> on
|
public bool IsZaakCreated =>
|
||||||
/// approval, since the ACL upserts the same object for a registration (§8.6).
|
Kanaal == "zaken" && Resource == "zaak" && Actie == "create";
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <c>partial_update</c> is what a PATCH actually reports: DRF routes it through the notifying
|
|
||||||
/// <c>update()</c> but names the action <c>partial_update</c>, and that is what Objecten puts in
|
|
||||||
/// the notification. <c>update</c> is accepted too, so a PUT-shaped write would project the same
|
|
||||||
/// way. <c>destroy</c> is deliberately not: removing a registration from the public register is
|
|
||||||
/// its own decision, not a side effect of this one.
|
|
||||||
/// </remarks>
|
|
||||||
public bool IsRegisterRecordWritten =>
|
|
||||||
Kanaal == "objecten" && Resource == "object"
|
|
||||||
&& Actie is "create" or "update" or "partial_update";
|
|
||||||
|
|
||||||
/// <summary>The object holding the register record. For a <c>resource: object</c> notification
|
/// <summary>A status being set on a zaak — the approval, projected as INGESCHREVEN (S-09b). In the
|
||||||
/// Objecten sends the object as both <c>hoofdObject</c> and <c>resourceUrl</c> — the object is
|
/// walking skeleton the only status ever set after creation is the approval, and the subscriber may
|
||||||
/// the main resource — so the notification's own <c>hoofdObject</c> is not modelled.</summary>
|
/// not read OpenZaak (§8.1), so any status-create is taken as the approval.</summary>
|
||||||
public Uri ObjectUrl => ResourceUrl;
|
public bool IsZaakStatusSet =>
|
||||||
|
Kanaal == "zaken" && Resource == "status" && Actie == "create";
|
||||||
|
|
||||||
|
/// <summary>The zaak URL this notification concerns — <c>hoofdObject</c> (the zaak) for a status
|
||||||
|
/// notification, else the resource URL (which, for a zaak-create, is the zaak).</summary>
|
||||||
|
public Uri ZaakUrl => HoofdObject ?? ResourceUrl;
|
||||||
|
|
||||||
|
/// <summary>The zaak UUID used as the projection key — the trailing segment of <see cref="ZaakUrl"/>.</summary>
|
||||||
|
public string ZaakId => ZaakUrl.Segments[^1].Trim('/');
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A deterministic dedup key. Open Notificaties carries no notification id and may
|
||||||
|
/// redeliver, so the key is derived from the immutable notification content: two
|
||||||
|
/// deliveries of the same zaak-create collapse to one. (NRC may also deliver
|
||||||
|
/// out of order; the projector tolerates that — order does not change the outcome.)
|
||||||
|
/// </summary>
|
||||||
|
public string IdempotencyKey => $"{Kanaal}:{Resource}:{Actie}:{ResourceUrl}";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,27 +3,21 @@ namespace EventSubscriber.Application;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Projects inbound NRC notifications into the read projection. Tolerates duplicate and
|
/// Projects inbound NRC notifications into the read projection. Tolerates duplicate and
|
||||||
/// out-of-order deliveries (CLAUDE.md §8.6): the notification log dedups, and the projection
|
/// out-of-order deliveries (CLAUDE.md §8.6): the notification log dedups, and the projection
|
||||||
/// upsert is idempotent on the register id. Rebuilds the projection by replaying the log.
|
/// upsert is idempotent on the zaak id. Rebuilds the projection by replaying the log.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class NotificationProjector(INotificationLog log, IProjectionStore store, IAclClient acl)
|
public sealed class NotificationProjector(INotificationLog log, IProjectionStore store, IAclClient acl)
|
||||||
{
|
{
|
||||||
/// <summary>Handle one inbound notification. Reacts to a register record being written to
|
/// <summary>Handle one inbound notification. Reacts to a zaak being created (INGEDIEND) and a
|
||||||
/// Objecten (S-19b-2, ADR-0030) and ignores everything else. The notification carries only the
|
/// status being set (INGESCHREVEN); ignores everything else. Enriches the row with the zaak's
|
||||||
/// object URL, so the record is read back through the ACL (§8.1) and becomes the row verbatim.</summary>
|
/// reference via the ACL (§8.1) and records it so a rebuild needs no ZGW access (#78).</summary>
|
||||||
public async Task HandleAsync(Notification notification, CancellationToken ct = default)
|
public async Task HandleAsync(Notification notification, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(notification);
|
if (!notification.IsZaakCreated && !notification.IsZaakStatusSet)
|
||||||
|
|
||||||
if (!notification.IsRegisterRecordWritten)
|
|
||||||
return;
|
|
||||||
|
|
||||||
var record = await acl.GetRegisterRecordAsync(notification.ObjectUrl, ct);
|
|
||||||
// The object is gone, or holds no register record — nothing to project (§8.6).
|
|
||||||
if (record is null)
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
var reference = await acl.GetZaakReferenceAsync(notification.ZaakUrl, ct);
|
||||||
var recorded = new RecordedNotification(
|
var recorded = new RecordedNotification(
|
||||||
KeyFor(notification.ObjectUrl, record), record.Id, record.Status, record.Reference);
|
notification.IdempotencyKey, notification.Actie, notification.ZaakId, notification.Resource, reference);
|
||||||
|
|
||||||
// Atomic record-or-skip: a duplicate (or concurrent) delivery is recognised and dropped
|
// Atomic record-or-skip: a duplicate (or concurrent) delivery is recognised and dropped
|
||||||
// before it touches the projection, so the projection stays a faithful derived artefact.
|
// before it touches the projection, so the projection stays a faithful derived artefact.
|
||||||
@@ -33,20 +27,6 @@ public sealed class NotificationProjector(INotificationLog log, IProjectionStore
|
|||||||
await store.UpsertAsync(ToEntry(recorded), ct);
|
await store.UpsertAsync(ToEntry(recorded), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A deterministic dedup key: the object, plus the state that write puts in the projection.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Open Notificaties carries no notification id and may redeliver, so the key is derived from
|
|
||||||
/// content. It cannot be the object URL alone — the ACL upserts one object per registration, so
|
|
||||||
/// submit and approval both notify about the *same* URL and the approval would be swallowed as a
|
|
||||||
/// duplicate. Nor can it include the actie: a retried approval would be a second `update`. Keying
|
|
||||||
/// on the projected row means a redelivery collapses and a genuine state change does not, which
|
|
||||||
/// is exactly the property §8.6 asks for.
|
|
||||||
/// </remarks>
|
|
||||||
private static string KeyFor(Uri objectUrl, RegisterRecord record)
|
|
||||||
=> $"objecten:object:{objectUrl}:{record.Status}:{record.Reference}";
|
|
||||||
|
|
||||||
/// <summary>Rebuild the projection from the durable notification log (PRD §8.4).</summary>
|
/// <summary>Rebuild the projection from the durable notification log (PRD §8.4).</summary>
|
||||||
public async Task RebuildAsync(CancellationToken ct = default)
|
public async Task RebuildAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -55,9 +35,11 @@ public sealed class NotificationProjector(INotificationLog log, IProjectionStore
|
|||||||
await store.UpsertAsync(ToEntry(recorded), ct);
|
await store.UpsertAsync(ToEntry(recorded), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The projection row for an accepted notification. The log already holds exactly the
|
/// <summary>The projection row for an accepted notification: a status-set maps to INGESCHREVEN,
|
||||||
/// row's fields, so a rebuild needs no mapping rules and no upstream reads. bsn/naam stay
|
/// a zaak-create to INGEDIEND. bsn/naam are deferred (ADR-0008).</summary>
|
||||||
/// deferred — the register record is public-safe by construction (ADR-0027).</summary>
|
|
||||||
private static RegisterEntry ToEntry(RecordedNotification recorded)
|
private static RegisterEntry ToEntry(RecordedNotification recorded)
|
||||||
=> new(recorded.RegisterId, recorded.Status, recorded.Reference);
|
=> new(
|
||||||
|
recorded.ZaakId,
|
||||||
|
recorded.Resource == "status" ? RegistrationStatus.Ingeschreven : RegistrationStatus.Ingediend,
|
||||||
|
Reference: recorded.Reference);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace EventSubscriber.Application;
|
|||||||
/// The durable log of notifications the subscriber has accepted. It is both the idempotency
|
/// The durable log of notifications the subscriber has accepted. It is both the idempotency
|
||||||
/// guard (a replayed notification is recognised and dropped) and the rebuild source: the
|
/// guard (a replayed notification is recognised and dropped) and the rebuild source: the
|
||||||
/// projection is a derived artefact (PRD §8.4) regenerated by replaying this log, so a rebuild
|
/// projection is a derived artefact (PRD §8.4) regenerated by replaying this log, so a rebuild
|
||||||
/// needs no access to Objecten or ZGW (CLAUDE.md §8.1). Implemented in Infrastructure over Postgres.
|
/// needs no access to OpenZaak (CLAUDE.md §8.1). Implemented in Infrastructure over Postgres.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface INotificationLog
|
public interface INotificationLog
|
||||||
{
|
{
|
||||||
@@ -19,29 +19,22 @@ public interface INotificationLog
|
|||||||
Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default);
|
Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>A notification that has been accepted, retaining what a rebuild needs to recompute its
|
||||||
/// An accepted notification, retaining exactly the projection row it produced — so a rebuild
|
/// projection row — the ZGW <c>resource</c> (zaak-create → INGEDIEND vs status-set → INGESCHREVEN) and
|
||||||
/// reproduces the row by replaying the log, without re-reading Objecten (S-19b-2, ADR-0030).
|
/// the zaak <c>reference</c> (identificatie), so a rebuild reproduces the row without re-reading ZGW (#78).</summary>
|
||||||
/// </summary>
|
public sealed record RecordedNotification(string Key, string Actie, string ZaakId, string Resource, string? Reference);
|
||||||
public sealed record RecordedNotification(string Key, string RegisterId, string Status, string? Reference);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Port to the Anti-Corruption Layer. An Objecten notification carries only the object URL, so the
|
/// Port to the Anti-Corruption Layer. The subscriber enriches the projection with the zaak's
|
||||||
/// subscriber reads the register record back through the ACL — the only code that may talk to
|
/// public-safe reference (its identificatie) by asking the ACL — the only code that may read ZGW
|
||||||
/// Objecten (§8.1, ADR-0028) — rather than reading Objecten itself.
|
/// (§8.1) — rather than reading OpenZaak itself (adr-proposal #78).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IAclClient
|
public interface IAclClient
|
||||||
{
|
{
|
||||||
/// <summary>The register record the object at <paramref name="objectUrl"/> holds, or
|
/// <summary>The zaak's reference (identificatie) for the read projection.</summary>
|
||||||
/// <c>null</c> if it holds none — the object may be gone by the time a redelivered
|
Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default);
|
||||||
/// notification is handled, which is not an error (§8.6).</summary>
|
|
||||||
Task<RegisterRecord?> GetRegisterRecordAsync(Uri objectUrl, CancellationToken ct = default);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The public-safe register record as the ACL returns it — the RegisterRecord objecttype's
|
|
||||||
/// schema (ADR-0027). No bsn, no name: the register is world-readable.</summary>
|
|
||||||
public sealed record RegisterRecord(string Id, string Status, string? Reference);
|
|
||||||
|
|
||||||
/// <summary>The read projection store. Owned by the projection bounded context (ADR-0008); the
|
/// <summary>The read projection store. Owned by the projection bounded context (ADR-0008); the
|
||||||
/// subscriber writes to it and the projection-api reads it.</summary>
|
/// subscriber writes to it and the projection-api reads it.</summary>
|
||||||
public interface IProjectionStore
|
public interface IProjectionStore
|
||||||
|
|||||||
@@ -5,42 +5,27 @@ using EventSubscriber.Api;
|
|||||||
namespace EventSubscriber.Tests;
|
namespace EventSubscriber.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Unit tests for the subscriber's ACL client, which reads a register record through the ACL — the
|
/// Unit tests for the subscriber's ACL client, which reads a zaak's reference (identificatie) through
|
||||||
/// only code allowed to talk to Objecten (§8.1, ADR-0028/ADR-0030). Uses a scripted message handler
|
/// the ACL — the only code allowed to talk to ZGW (§8.1, #78). Uses a scripted message handler so no
|
||||||
/// so no real ACL is required.
|
/// real ACL is required.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AclHttpClientTests
|
public class AclHttpClientTests
|
||||||
{
|
{
|
||||||
private const string ObjectUrl = "http://objecten.local:8000/api/v2/objects/obj-9";
|
|
||||||
|
|
||||||
private static AclHttpClient Client(StubHandler handler) =>
|
private static AclHttpClient Client(StubHandler handler) =>
|
||||||
new(new HttpClient(handler) { BaseAddress = new Uri("http://acl/") });
|
new(new HttpClient(handler) { BaseAddress = new Uri("http://acl/") });
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Reads_a_register_record_by_posting_the_object_url()
|
public async Task Reads_a_zaak_reference_by_posting_the_zaak_url_and_returns_it()
|
||||||
{
|
{
|
||||||
var capture = new RequestCapture();
|
var capture = new RequestCapture();
|
||||||
var client = Client(capture.Responds(
|
var client = Client(capture.Responds(HttpStatusCode.OK, """{"reference":"REG-42"}"""));
|
||||||
HttpStatusCode.OK, """{"id":"zaak-1","status":"INGESCHREVEN","reference":"REG-42"}"""));
|
|
||||||
|
|
||||||
var record = await client.GetRegisterRecordAsync(new Uri(ObjectUrl));
|
var reference = await client.GetZaakReferenceAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
||||||
|
|
||||||
Assert.Equal("zaak-1", record!.Id);
|
Assert.Equal("REG-42", reference);
|
||||||
Assert.Equal("INGESCHREVEN", record.Status);
|
|
||||||
Assert.Equal("REG-42", record.Reference);
|
|
||||||
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
|
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
|
||||||
Assert.Equal("http://acl/register-records/read", capture.Seen.RequestUri!.ToString());
|
Assert.Equal("http://acl/zaken/reference", capture.Seen.RequestUri!.ToString());
|
||||||
Assert.Contains($"\"objectUrl\":\"{ObjectUrl}\"", capture.Body);
|
Assert.Contains("\"zaakUrl\":\"http://openzaak/zaken/api/v1/zaken/abc\"", capture.Body);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Reads_a_missing_record_as_nothing_to_project()
|
|
||||||
{
|
|
||||||
var capture = new RequestCapture();
|
|
||||||
var client = Client(capture.Responds(HttpStatusCode.NotFound));
|
|
||||||
|
|
||||||
// The object may be gone by the time a redelivered notification is handled (§8.6).
|
|
||||||
Assert.Null(await client.GetRegisterRecordAsync(new Uri(ObjectUrl)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -50,7 +35,7 @@ public class AclHttpClientTests
|
|||||||
var client = Client(capture.Responds(HttpStatusCode.BadGateway));
|
var client = Client(capture.Responds(HttpStatusCode.BadGateway));
|
||||||
|
|
||||||
await Assert.ThrowsAsync<HttpRequestException>(
|
await Assert.ThrowsAsync<HttpRequestException>(
|
||||||
() => client.GetRegisterRecordAsync(new Uri(ObjectUrl)));
|
() => client.GetZaakReferenceAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc")));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -60,17 +45,17 @@ public class AclHttpClientTests
|
|||||||
var client = Client(capture.Responds(HttpStatusCode.OK, "null"));
|
var client = Client(capture.Responds(HttpStatusCode.OK, "null"));
|
||||||
|
|
||||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
() => client.GetRegisterRecordAsync(new Uri(ObjectUrl)));
|
() => client.GetZaakReferenceAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc")));
|
||||||
Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("empty", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Rejects_a_null_object_url_without_sending_a_request()
|
public async Task Rejects_a_null_zaak_url_without_sending_a_request()
|
||||||
{
|
{
|
||||||
var capture = new RequestCapture();
|
var capture = new RequestCapture();
|
||||||
var client = Client(capture.Responds(HttpStatusCode.OK, "{}"));
|
var client = Client(capture.Responds(HttpStatusCode.OK, """{"reference":"REG-1"}"""));
|
||||||
|
|
||||||
await Assert.ThrowsAsync<ArgumentNullException>(() => client.GetRegisterRecordAsync(null!));
|
await Assert.ThrowsAsync<ArgumentNullException>(() => client.GetZaakReferenceAsync(null!));
|
||||||
Assert.Null(capture.Seen);
|
Assert.Null(capture.Seen);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,16 @@ namespace EventSubscriber.Tests;
|
|||||||
/// <summary>In-memory stand-ins for the projection store and notification log, so the
|
/// <summary>In-memory stand-ins for the projection store and notification log, so the
|
||||||
/// projector's behaviour is exercised without Postgres (hand-written stubs, the repo's
|
/// projector's behaviour is exercised without Postgres (hand-written stubs, the repo's
|
||||||
/// convention — no mocking library).</summary>
|
/// convention — no mocking library).</summary>
|
||||||
/// <summary>A fake ACL client standing in for the register records Objecten holds: a test seeds a
|
/// <summary>A fake ACL client that returns a fixed reference derived from the zaak, and records
|
||||||
/// record per object URL, and the call count proves a rebuild does not re-read through the ACL.</summary>
|
/// how many times it was called (to prove a rebuild does not re-read via the ACL).</summary>
|
||||||
internal sealed class FakeAclClient : IAclClient
|
internal sealed class FakeAclClient : IAclClient
|
||||||
{
|
{
|
||||||
public Dictionary<string, RegisterRecord> Records { get; } = [];
|
|
||||||
|
|
||||||
public int CallCount { get; private set; }
|
public int CallCount { get; private set; }
|
||||||
|
|
||||||
public Task<RegisterRecord?> GetRegisterRecordAsync(Uri objectUrl, CancellationToken ct = default)
|
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
CallCount++;
|
CallCount++;
|
||||||
return Task.FromResult(Records.TryGetValue(objectUrl.ToString(), out var record) ? record : null);
|
return Task.FromResult("REG-" + zaakUrl.Segments[^1].Trim('/'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,13 @@ using EventSubscriber.Application;
|
|||||||
|
|
||||||
namespace EventSubscriber.Tests;
|
namespace EventSubscriber.Tests;
|
||||||
|
|
||||||
/// <summary>Behaviour of the projector that turns NRC notifications into projection rows. Since
|
/// <summary>Behaviour of the projector that turns NRC notifications into projection rows.
|
||||||
/// S-19b-2 the source is the register in Objecten (ADR-0030), not ZGW zaak events: a notification
|
/// The walking skeleton reacts only to a zaak being created (status INGEDIEND) and must
|
||||||
/// carries only the object URL, so the record is read back through the ACL. Duplicate and
|
/// tolerate duplicate and out-of-order deliveries (CLAUDE.md §8.6).</summary>
|
||||||
/// out-of-order deliveries must be tolerated (CLAUDE.md §8.6).</summary>
|
|
||||||
public sealed class NotificationProjectorTests
|
public sealed class NotificationProjectorTests
|
||||||
{
|
{
|
||||||
private const string ObjectUrl = "http://objecten.local:8000/api/v2/objects/11111111-1111-1111-1111-111111111111";
|
private const string ZaakUrl = "http://openzaak:8000/zaken/api/v1/zaken/11111111-1111-1111-1111-111111111111";
|
||||||
private const string ZaakId = "99999999-9999-9999-9999-999999999999";
|
private const string StatusUrl = "http://openzaak:8000/zaken/api/v1/statussen/22222222-2222-2222-2222-222222222222";
|
||||||
|
|
||||||
private readonly InMemoryNotificationLog _log = new();
|
private readonly InMemoryNotificationLog _log = new();
|
||||||
private readonly InMemoryProjectionStore _store = new();
|
private readonly InMemoryProjectionStore _store = new();
|
||||||
@@ -17,60 +16,46 @@ public sealed class NotificationProjectorTests
|
|||||||
|
|
||||||
private NotificationProjector Projector() => new(_log, _store, _acl);
|
private NotificationProjector Projector() => new(_log, _store, _acl);
|
||||||
|
|
||||||
/// <summary>A register write as Objecten publishes it: the object is both hoofdObject and
|
private static Notification ZaakCreated(string url = ZaakUrl)
|
||||||
/// resourceUrl, and the record itself is only reachable by reading that object.</summary>
|
=> new("zaken", "zaak", "create", new Uri(url));
|
||||||
private Notification RecordWritten(string actie = "create", string url = ObjectUrl, string status = RegistrationStatus.Ingediend, string zaakId = ZaakId)
|
|
||||||
|
// A status-set notification: resourceUrl is the status resource, hoofdObject is the zaak it belongs to.
|
||||||
|
private static Notification StatusSet(string zaakUrl = ZaakUrl, string statusUrl = StatusUrl)
|
||||||
|
=> new("zaken", "status", "create", new Uri(statusUrl), new Uri(zaakUrl));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task creating_a_zaak_writes_one_row_with_status_ingediend()
|
||||||
{
|
{
|
||||||
_acl.Records[url] = new RegisterRecord(zaakId, status, "REG-2026-0001");
|
await Projector().HandleAsync(ZaakCreated());
|
||||||
return new Notification("objecten", "object", actie, new Uri(url));
|
|
||||||
|
var entry = Assert.Single(await _store.AllAsync());
|
||||||
|
Assert.Equal("11111111-1111-1111-1111-111111111111", entry.Id);
|
||||||
|
Assert.Equal(RegistrationStatus.Ingediend, entry.Status);
|
||||||
|
// Enriched with the zaak's reference (identificatie), fetched via the ACL (#78).
|
||||||
|
Assert.Equal("REG-11111111-1111-1111-1111-111111111111", entry.Reference);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task a_register_record_write_is_projected_as_a_row_keyed_on_the_registration()
|
public async Task rebuild_reproduces_the_reference_without_re_reading_via_the_acl()
|
||||||
{
|
|
||||||
await Projector().HandleAsync(RecordWritten());
|
|
||||||
|
|
||||||
var entry = Assert.Single(await _store.AllAsync());
|
|
||||||
// Keyed on the record's own id (the zaak id), not on the Objecten object's uuid — the
|
|
||||||
// projection row and the register record are the same registration.
|
|
||||||
Assert.Equal(ZaakId, entry.Id);
|
|
||||||
Assert.Equal(RegistrationStatus.Ingediend, entry.Status);
|
|
||||||
Assert.Equal("REG-2026-0001", entry.Reference);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The ACL PATCHes the same object on approval. DRF routes a PATCH through `update()` but reports
|
|
||||||
// the action as `partial_update`, which is what Objecten puts in the notification — so accepting
|
|
||||||
// only `create`/`update` silently drops every approval.
|
|
||||||
[Theory]
|
|
||||||
[InlineData("partial_update")]
|
|
||||||
[InlineData("update")]
|
|
||||||
public async Task approval_updates_the_same_row_from_ingediend_to_ingeschreven(string actie)
|
|
||||||
{
|
{
|
||||||
var projector = Projector();
|
var projector = Projector();
|
||||||
await projector.HandleAsync(RecordWritten());
|
await projector.HandleAsync(ZaakCreated());
|
||||||
await projector.HandleAsync(RecordWritten(actie, status: RegistrationStatus.Ingeschreven));
|
var callsAfterProjection = _acl.CallCount;
|
||||||
|
|
||||||
|
await projector.RebuildAsync();
|
||||||
|
|
||||||
var entry = Assert.Single(await _store.AllAsync());
|
var entry = Assert.Single(await _store.AllAsync());
|
||||||
Assert.Equal(ZaakId, entry.Id);
|
Assert.Equal("REG-11111111-1111-1111-1111-111111111111", entry.Reference);
|
||||||
Assert.Equal(RegistrationStatus.Ingeschreven, entry.Status);
|
// Rebuild replays the log (which stored the reference) — no extra ACL calls (#78, ADR-0008).
|
||||||
}
|
Assert.Equal(callsAfterProjection, _acl.CallCount);
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task an_object_whose_record_is_gone_is_not_projected()
|
|
||||||
{
|
|
||||||
// Nothing seeded in the fake ACL: the object was deleted before this (redelivered)
|
|
||||||
// notification was handled. Not an error — there is simply nothing to project (§8.6).
|
|
||||||
await Projector().HandleAsync(new Notification("objecten", "object", "create", new Uri(ObjectUrl)));
|
|
||||||
|
|
||||||
Assert.Empty(await _store.AllAsync());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task replaying_the_same_notification_keeps_a_single_row()
|
public async Task replaying_the_same_notification_keeps_a_single_row()
|
||||||
{
|
{
|
||||||
var projector = Projector();
|
var projector = Projector();
|
||||||
await projector.HandleAsync(RecordWritten());
|
await projector.HandleAsync(ZaakCreated());
|
||||||
await projector.HandleAsync(RecordWritten());
|
await projector.HandleAsync(ZaakCreated());
|
||||||
|
|
||||||
Assert.Single(await _store.AllAsync());
|
Assert.Single(await _store.AllAsync());
|
||||||
}
|
}
|
||||||
@@ -79,8 +64,8 @@ public sealed class NotificationProjectorTests
|
|||||||
public async Task a_replayed_notification_never_reaches_the_projection_store()
|
public async Task a_replayed_notification_never_reaches_the_projection_store()
|
||||||
{
|
{
|
||||||
var projector = Projector();
|
var projector = Projector();
|
||||||
await projector.HandleAsync(RecordWritten());
|
await projector.HandleAsync(ZaakCreated());
|
||||||
await projector.HandleAsync(RecordWritten());
|
await projector.HandleAsync(ZaakCreated());
|
||||||
|
|
||||||
// The duplicate is dropped at the log, before the (idempotent) upsert — so the store
|
// The duplicate is dropped at the log, before the (idempotent) upsert — so the store
|
||||||
// is written exactly once. Row count alone can't see this; the upsert count can.
|
// is written exactly once. Row count alone can't see this; the upsert count can.
|
||||||
@@ -88,59 +73,77 @@ public sealed class NotificationProjectorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task two_different_registrations_each_get_their_own_row()
|
public async Task two_different_zaken_each_get_their_own_row()
|
||||||
{
|
{
|
||||||
var projector = Projector();
|
var projector = Projector();
|
||||||
await projector.HandleAsync(RecordWritten());
|
await projector.HandleAsync(ZaakCreated());
|
||||||
await projector.HandleAsync(RecordWritten(url: ObjectUrl[..^1] + "2", zaakId: "other-zaak"));
|
await projector.HandleAsync(ZaakCreated(ZaakUrl[..^1] + "2")); // a distinct zaak url
|
||||||
|
|
||||||
Assert.Equal(2, (await _store.AllAsync()).Count);
|
Assert.Equal(2, (await _store.AllAsync()).Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("zaken", "zaak", "create")] // the ZGW source S-19b-2 replaced
|
[InlineData("documenten", "enkelvoudiginformatieobject", "create")] // wrong kanaal + resource
|
||||||
[InlineData("zaken", "status", "create")] // ditto
|
[InlineData("documenten", "zaak", "create")] // wrong kanaal only
|
||||||
[InlineData("objecten", "object", "destroy")] // a delete we do not project
|
[InlineData("zaken", "zaak", "update")] // wrong actie
|
||||||
[InlineData("documenten", "object", "create")] // wrong kanaal
|
[InlineData("zaken", "zaak", "destroy")] // wrong actie
|
||||||
|
[InlineData("zaken", "status", "update")] // a status change we ignore
|
||||||
|
[InlineData("zaken", "resultaat", "create")] // not a status we project
|
||||||
public async Task an_unrelated_notification_is_not_projected(string kanaal, string resource, string actie)
|
public async Task an_unrelated_notification_is_not_projected(string kanaal, string resource, string actie)
|
||||||
{
|
{
|
||||||
_acl.Records[ObjectUrl] = new RegisterRecord(ZaakId, RegistrationStatus.Ingediend, "REG-2026-0001");
|
await Projector().HandleAsync(new Notification(kanaal, resource, actie, new Uri(ZaakUrl)));
|
||||||
|
|
||||||
await Projector().HandleAsync(new Notification(kanaal, resource, actie, new Uri(ObjectUrl)));
|
|
||||||
|
|
||||||
Assert.Empty(await _store.AllAsync());
|
Assert.Empty(await _store.AllAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task rebuild_reproduces_the_row_without_re_reading_through_the_acl()
|
public async Task setting_a_status_projects_ingeschreven_keyed_on_the_zaak_not_the_status()
|
||||||
|
{
|
||||||
|
await Projector().HandleAsync(StatusSet());
|
||||||
|
|
||||||
|
var entry = Assert.Single(await _store.AllAsync());
|
||||||
|
// Keyed on the zaak (hoofdObject), not the status resource URL.
|
||||||
|
Assert.Equal("11111111-1111-1111-1111-111111111111", entry.Id);
|
||||||
|
Assert.Equal(RegistrationStatus.Ingeschreven, entry.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task approving_updates_the_existing_zaak_row_from_ingediend_to_ingeschreven()
|
||||||
{
|
{
|
||||||
var projector = Projector();
|
var projector = Projector();
|
||||||
await projector.HandleAsync(RecordWritten());
|
await projector.HandleAsync(ZaakCreated());
|
||||||
await projector.HandleAsync(RecordWritten("partial_update", status: RegistrationStatus.Ingeschreven));
|
await projector.HandleAsync(StatusSet());
|
||||||
var callsAfterProjection = _acl.CallCount;
|
|
||||||
|
var entry = Assert.Single(await _store.AllAsync());
|
||||||
|
Assert.Equal("11111111-1111-1111-1111-111111111111", entry.Id);
|
||||||
|
Assert.Equal(RegistrationStatus.Ingeschreven, entry.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task rebuild_reproduces_the_approved_status()
|
||||||
|
{
|
||||||
|
var projector = Projector();
|
||||||
|
await projector.HandleAsync(ZaakCreated());
|
||||||
|
await projector.HandleAsync(StatusSet());
|
||||||
|
|
||||||
await projector.RebuildAsync();
|
await projector.RebuildAsync();
|
||||||
|
|
||||||
var entry = Assert.Single(await _store.AllAsync());
|
var entry = Assert.Single(await _store.AllAsync());
|
||||||
Assert.Equal(RegistrationStatus.Ingeschreven, entry.Status);
|
Assert.Equal(RegistrationStatus.Ingeschreven, entry.Status);
|
||||||
Assert.Equal("REG-2026-0001", entry.Reference);
|
|
||||||
// The log holds the projected row itself, so a rebuild needs neither the ACL nor
|
|
||||||
// Objecten (§8.4, ADR-0030).
|
|
||||||
Assert.Equal(callsAfterProjection, _acl.CallCount);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task rebuild_clears_stale_rows_and_repopulates_from_the_notification_log()
|
public async Task rebuild_clears_stale_rows_and_repopulates_from_the_notification_log()
|
||||||
{
|
{
|
||||||
var projector = Projector();
|
var projector = Projector();
|
||||||
await projector.HandleAsync(RecordWritten());
|
await projector.HandleAsync(ZaakCreated());
|
||||||
// A stale row that is not backed by any logged notification must not survive a rebuild.
|
// A stale row that is not backed by any logged notification must not survive a rebuild.
|
||||||
await _store.UpsertAsync(new RegisterEntry("stale-9999", RegistrationStatus.Ingediend));
|
await _store.UpsertAsync(new RegisterEntry("stale-9999", RegistrationStatus.Ingediend));
|
||||||
|
|
||||||
await projector.RebuildAsync();
|
await projector.RebuildAsync();
|
||||||
|
|
||||||
var entry = Assert.Single(await _store.AllAsync());
|
var entry = Assert.Single(await _store.AllAsync());
|
||||||
Assert.Equal(ZaakId, entry.Id);
|
Assert.Equal("11111111-1111-1111-1111-111111111111", entry.Id);
|
||||||
Assert.Equal(RegistrationStatus.Ingediend, entry.Status);
|
Assert.Equal(RegistrationStatus.Ingediend, entry.Status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"stryker-config": {
|
"stryker-config": {
|
||||||
"solution": "EventSubscriber.slnx",
|
"solution": "EventSubscriber.slnx",
|
||||||
"test-projects": ["EventSubscriber.Tests/EventSubscriber.Tests.csproj"],
|
"test-projects": ["EventSubscriber.Tests/EventSubscriber.Tests.csproj"],
|
||||||
"reporters": ["progress", "html", "markdown"],
|
"reporters": ["progress", "html"],
|
||||||
"thresholds": {
|
"thresholds": {
|
||||||
"high": 95,
|
"high": 95,
|
||||||
"low": 90,
|
"low": 90,
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog
|
|||||||
db.ProcessedNotifications.Add(new ProcessedNotificationRow
|
db.ProcessedNotifications.Add(new ProcessedNotificationRow
|
||||||
{
|
{
|
||||||
Key = notification.Key,
|
Key = notification.Key,
|
||||||
RegisterId = notification.RegisterId,
|
Actie = notification.Actie,
|
||||||
Status = notification.Status,
|
ZaakId = notification.ZaakId,
|
||||||
|
Resource = notification.Resource,
|
||||||
Reference = notification.Reference,
|
Reference = notification.Reference,
|
||||||
ReceivedAt = DateTimeOffset.UtcNow,
|
ReceivedAt = DateTimeOffset.UtcNow,
|
||||||
});
|
});
|
||||||
@@ -35,6 +36,6 @@ public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog
|
|||||||
public async Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default)
|
public async Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default)
|
||||||
=> await db.ProcessedNotifications
|
=> await db.ProcessedNotifications
|
||||||
.OrderBy(r => r.ReceivedAt)
|
.OrderBy(r => r.ReceivedAt)
|
||||||
.Select(r => new RecordedNotification(r.Key, r.RegisterId, r.Status, r.Reference))
|
.Select(r => new RecordedNotification(r.Key, r.Actie, r.ZaakId, r.Resource, r.Reference))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
}
|
}
|
||||||
|
|||||||
-87
@@ -1,87 +0,0 @@
|
|||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
using Projection.ReadModel;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace Projection.ReadModel.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(ProjectionDbContext))]
|
|
||||||
[Migration("20260828103132_ProjectionSourcedFromObjecten")]
|
|
||||||
partial class ProjectionSourcedFromObjecten
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
#pragma warning disable 612, 618
|
|
||||||
modelBuilder
|
|
||||||
.HasAnnotation("ProductVersion", "10.0.0")
|
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
|
||||||
|
|
||||||
modelBuilder.Entity("Projection.ReadModel.ProcessedNotificationRow", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Key")
|
|
||||||
.HasColumnType("text")
|
|
||||||
.HasColumnName("key");
|
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("ReceivedAt")
|
|
||||||
.HasColumnType("timestamp with time zone")
|
|
||||||
.HasColumnName("received_at");
|
|
||||||
|
|
||||||
b.Property<string>("Reference")
|
|
||||||
.HasColumnType("text")
|
|
||||||
.HasColumnName("reference");
|
|
||||||
|
|
||||||
b.Property<string>("RegisterId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text")
|
|
||||||
.HasColumnName("register_id");
|
|
||||||
|
|
||||||
b.Property<string>("Status")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text")
|
|
||||||
.HasColumnName("status");
|
|
||||||
|
|
||||||
b.HasKey("Key");
|
|
||||||
|
|
||||||
b.ToTable("processed_notifications", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("Projection.ReadModel.RegisterEntryRow", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Id")
|
|
||||||
.HasColumnType("text")
|
|
||||||
.HasColumnName("id");
|
|
||||||
|
|
||||||
b.Property<string>("Bsn")
|
|
||||||
.HasColumnType("text")
|
|
||||||
.HasColumnName("bsn");
|
|
||||||
|
|
||||||
b.Property<string>("NaamPlaceholder")
|
|
||||||
.HasColumnType("text")
|
|
||||||
.HasColumnName("naam_placeholder");
|
|
||||||
|
|
||||||
b.Property<string>("Reference")
|
|
||||||
.HasColumnType("text")
|
|
||||||
.HasColumnName("reference");
|
|
||||||
|
|
||||||
b.Property<string>("Status")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("text")
|
|
||||||
.HasColumnName("status");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.ToTable("register_projection", (string)null);
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-69
@@ -1,69 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace Projection.ReadModel.Migrations
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// S-19b-2 (ADR-0030): the notification log stops describing ZGW zaak events and starts holding
|
|
||||||
/// the projected register row itself (register id, status, reference).
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// The old columns are dropped and the new ones added rather than renamed. EF scaffolded renames
|
|
||||||
/// (<c>resource</c> → <c>register_id</c>, <c>zaak_id</c> → <c>status</c>), which would carry ZGW
|
|
||||||
/// values into columns that mean something else entirely — "zaak"/"status" as a register id, a
|
|
||||||
/// zaak uuid as a register status — and a rebuild would then project that garbage.
|
|
||||||
///
|
|
||||||
/// Both tables are emptied instead. A pre-existing row describes a zaak event the new projector
|
|
||||||
/// cannot reproject, and the registrations behind those rows have no RegisterRecord in Objecten
|
|
||||||
/// (only approvals wrote one before this slice), so they are not re-derivable from the new source
|
|
||||||
/// either. The projection is a derived artefact (§8.4) and repopulates as register writes arrive.
|
|
||||||
/// </remarks>
|
|
||||||
public partial class ProjectionSourcedFromObjecten : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
// ponytail: drops the pre-slice register rather than backfilling it. Fine while stacks are
|
|
||||||
// ephemeral (a fresh `docker compose up` is the norm). If a long-lived environment ever
|
|
||||||
// needs to keep them, backfill by walking Objecten's objects instead of replaying the log.
|
|
||||||
migrationBuilder.Sql("DELETE FROM processed_notifications;");
|
|
||||||
migrationBuilder.Sql("DELETE FROM register_projection;");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "actie", table: "processed_notifications");
|
|
||||||
migrationBuilder.DropColumn(name: "zaak_id", table: "processed_notifications");
|
|
||||||
migrationBuilder.DropColumn(name: "resource", table: "processed_notifications");
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "register_id",
|
|
||||||
table: "processed_notifications",
|
|
||||||
type: "text",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: "");
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "status",
|
|
||||||
table: "processed_notifications",
|
|
||||||
type: "text",
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: "");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.Sql("DELETE FROM processed_notifications;");
|
|
||||||
migrationBuilder.Sql("DELETE FROM register_projection;");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "register_id", table: "processed_notifications");
|
|
||||||
migrationBuilder.DropColumn(name: "status", table: "processed_notifications");
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "actie", table: "processed_notifications", type: "text", nullable: false, defaultValue: "");
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "zaak_id", table: "processed_notifications", type: "text", nullable: false, defaultValue: "");
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "resource", table: "processed_notifications", type: "text", nullable: false, defaultValue: "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+9
-4
@@ -28,6 +28,11 @@ namespace Projection.ReadModel.Migrations
|
|||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
.HasColumnName("key");
|
.HasColumnName("key");
|
||||||
|
|
||||||
|
b.Property<string>("Actie")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("actie");
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("ReceivedAt")
|
b.Property<DateTimeOffset>("ReceivedAt")
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("received_at");
|
.HasColumnName("received_at");
|
||||||
@@ -36,15 +41,15 @@ namespace Projection.ReadModel.Migrations
|
|||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
.HasColumnName("reference");
|
.HasColumnName("reference");
|
||||||
|
|
||||||
b.Property<string>("RegisterId")
|
b.Property<string>("Resource")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
.HasColumnName("register_id");
|
.HasColumnName("resource");
|
||||||
|
|
||||||
b.Property<string>("Status")
|
b.Property<string>("ZaakId")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
.HasColumnName("status");
|
.HasColumnName("zaak_id");
|
||||||
|
|
||||||
b.HasKey("Key");
|
b.HasKey("Key");
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,9 @@ public sealed class ProjectionDbContext(DbContextOptions<ProjectionDbContext> op
|
|||||||
e.ToTable("processed_notifications");
|
e.ToTable("processed_notifications");
|
||||||
e.HasKey(r => r.Key);
|
e.HasKey(r => r.Key);
|
||||||
e.Property(r => r.Key).HasColumnName("key");
|
e.Property(r => r.Key).HasColumnName("key");
|
||||||
e.Property(r => r.RegisterId).HasColumnName("register_id").IsRequired();
|
e.Property(r => r.Actie).HasColumnName("actie").IsRequired();
|
||||||
e.Property(r => r.Status).HasColumnName("status").IsRequired();
|
e.Property(r => r.ZaakId).HasColumnName("zaak_id").IsRequired();
|
||||||
|
e.Property(r => r.Resource).HasColumnName("resource").IsRequired();
|
||||||
e.Property(r => r.Reference).HasColumnName("reference");
|
e.Property(r => r.Reference).HasColumnName("reference");
|
||||||
e.Property(r => r.ReceivedAt).HasColumnName("received_at");
|
e.Property(r => r.ReceivedAt).HasColumnName("received_at");
|
||||||
});
|
});
|
||||||
@@ -55,20 +56,18 @@ public sealed class RegisterEntryRow
|
|||||||
public string? NaamPlaceholder { get; set; }
|
public string? NaamPlaceholder { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>An accepted notification, retained so the projection can be rebuilt without reading
|
/// <summary>An accepted notification, retained so the projection can be rebuilt without OpenZaak (§8.1).</summary>
|
||||||
/// Objecten or ZGW (§8.1, §8.4). Since S-19b-2 it holds the projected row itself — the register
|
|
||||||
/// record's id, status and reference — so a rebuild is a replay with no mapping rules (ADR-0030).</summary>
|
|
||||||
public sealed class ProcessedNotificationRow
|
public sealed class ProcessedNotificationRow
|
||||||
{
|
{
|
||||||
public required string Key { get; set; }
|
public required string Key { get; set; }
|
||||||
|
public required string Actie { get; set; }
|
||||||
|
public required string ZaakId { get; set; }
|
||||||
|
|
||||||
/// <summary>The registration this record is for (the zaak id) — the projection row's key.</summary>
|
/// <summary>The ZGW resource (e.g. <c>zaak</c> or <c>status</c>) — retained so a rebuild reprojects
|
||||||
public required string RegisterId { get; set; }
|
/// the right status without reading OpenZaak (S-09b).</summary>
|
||||||
|
public required string Resource { get; set; }
|
||||||
|
|
||||||
/// <summary>The register status the record carried (INGEDIEND / INGESCHREVEN).</summary>
|
/// <summary>The zaak reference (identificatie), retained so a rebuild reprojects it without the ACL (#78).</summary>
|
||||||
public required string Status { get; set; }
|
|
||||||
|
|
||||||
/// <summary>The citizen-facing reference the record carried — matches the submit confirmation (#78).</summary>
|
|
||||||
public string? Reference { get; set; }
|
public string? Reference { get; set; }
|
||||||
|
|
||||||
public DateTimeOffset ReceivedAt { get; set; }
|
public DateTimeOffset ReceivedAt { get; set; }
|
||||||
|
|||||||
@@ -1,28 +1,19 @@
|
|||||||
# language: en
|
# language: en
|
||||||
# Drives S-19b-2 (#153), re-sourcing S-06 (#7). The read projection is derived from the
|
# Drives S-06 (#7). On a zaak-created notification from NRC the Event Subscriber writes a
|
||||||
# RegisterRecord in Objecten (ADR-0030), not from ZGW zaak events: the ACL records a registration
|
# rebuildable read-projection row (PRD §8.4). This scenario exercises the use case against an
|
||||||
# in the register, Objecten notifies, and the Event Subscriber projects the record that
|
# in-memory stand-in for the projection store and notification log; real OpenZaak → NRC →
|
||||||
# notification points at. This scenario exercises the use case against in-memory stand-ins for the
|
# subscriber delivery is verified by the live-stack check (verify-projection, ADR-0007/#58).
|
||||||
# register, the projection store and the notification log; real Objecten → NRC → subscriber
|
Feature: Register-projectie bijwerken op een zaaknotificatie
|
||||||
# delivery is verified by the live-stack check (verify-projection, ADR-0007/0030).
|
Als openbaar register wil ik dat een aangemaakte zaak in de projectie verschijnt
|
||||||
Feature: Register-projectie bijwerken op een registerwijziging
|
zodat het register de ingediende registratie kan tonen.
|
||||||
Als openbaar register wil ik dat een registratie in de projectie verschijnt zodra zij
|
|
||||||
in het register is vastgelegd, zodat het register haar actuele status kan tonen.
|
|
||||||
|
|
||||||
Scenario: Een ingediende registratie levert een rij met status INGEDIEND
|
Scenario: Een zaaknotificatie levert een rij met status INGEDIEND
|
||||||
Given registration "11111111-1111-1111-1111-111111111111" is recorded in the register with status "INGEDIEND"
|
Given a zaak is created in OpenZaak with id "11111111-1111-1111-1111-111111111111"
|
||||||
When the register notification is delivered to the event subscriber
|
When the NRC notification for that zaak is delivered to the event subscriber
|
||||||
Then the register projection contains a row for "11111111-1111-1111-1111-111111111111" with status "INGEDIEND"
|
Then the register projection contains a row for "11111111-1111-1111-1111-111111111111" with status "INGEDIEND"
|
||||||
|
|
||||||
Scenario: Een goedgekeurde registratie werkt dezelfde rij bij
|
|
||||||
Given registration "33333333-3333-3333-3333-333333333333" is recorded in the register with status "INGEDIEND"
|
|
||||||
And the register notification is delivered to the event subscriber
|
|
||||||
When registration "33333333-3333-3333-3333-333333333333" is recorded in the register with status "INGESCHREVEN"
|
|
||||||
And the register notification is delivered to the event subscriber
|
|
||||||
Then the register projection contains a row for "33333333-3333-3333-3333-333333333333" with status "INGESCHREVEN"
|
|
||||||
|
|
||||||
Scenario: Dezelfde notificatie tweemaal levert geen duplicaat
|
Scenario: Dezelfde notificatie tweemaal levert geen duplicaat
|
||||||
Given registration "22222222-2222-2222-2222-222222222222" is recorded in the register with status "INGEDIEND"
|
Given a zaak is created in OpenZaak with id "22222222-2222-2222-2222-222222222222"
|
||||||
When the register notification is delivered to the event subscriber
|
When the NRC notification for that zaak is delivered to the event subscriber
|
||||||
And the same register notification is delivered again
|
And the same NRC notification is delivered again
|
||||||
Then the register projection contains exactly one row for "22222222-2222-2222-2222-222222222222"
|
Then the register projection contains exactly one row for "22222222-2222-2222-2222-222222222222"
|
||||||
|
|||||||
@@ -44,9 +44,7 @@ public sealed class EenZaakOpenenSteps
|
|||||||
[When("the domain asks the ACL to open a zaak")]
|
[When("the domain asks the ACL to open a zaak")]
|
||||||
public async Task WhenTheDomainAsksTheAclToOpenAZaak()
|
public async Task WhenTheDomainAsksTheAclToOpenAZaak()
|
||||||
{
|
{
|
||||||
var fill = new InMemoryDefaultFillStore(new DefaultFillSettings(
|
var service = new AclService(_gateway, _defaults!, new CachedZaaktypeCatalog(_gateway, _defaults!), new FixedClock(_today));
|
||||||
_defaults!.Bronorganisatie, _defaults.VerantwoordelijkeOrganisatie, _defaults.Vertrouwelijkheidaanduiding));
|
|
||||||
var service = new AclService(_gateway, new InMemoryRegisterRecordGateway(), fill, new CachedZaaktypeCatalog(_gateway, _defaults!), new FixedClock(_today));
|
|
||||||
_returnedUrl = await service.OpenZaakAsync(_registration!);
|
_returnedUrl = await service.OpenZaakAsync(_registration!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,39 +5,31 @@ using Xunit;
|
|||||||
|
|
||||||
namespace Acceptance.Steps;
|
namespace Acceptance.Steps;
|
||||||
|
|
||||||
/// <summary>Bindings for <c>RegisterProjectieBijwerken.feature</c> (S-06, re-sourced by S-19b-2).
|
/// <summary>Bindings for <c>RegisterProjectieBijwerken.feature</c> (S-06). Reqnroll creates
|
||||||
/// Reqnroll creates one instance per scenario, so instance fields hold scenario-scoped state.</summary>
|
/// one instance per scenario, so instance fields hold scenario-scoped state.</summary>
|
||||||
[Binding]
|
[Binding]
|
||||||
public sealed class RegisterProjectieBijwerkenSteps
|
public sealed class RegisterProjectieBijwerkenSteps
|
||||||
{
|
{
|
||||||
private const string ObjectBase = "http://objecten.local:8000/api/v2/objects/";
|
private const string ZaakBase = "http://openzaak:8000/zaken/api/v1/zaken/";
|
||||||
|
|
||||||
private readonly InMemoryNotificationLog _log = new();
|
private readonly InMemoryNotificationLog _log = new();
|
||||||
private readonly InMemoryProjectionStore _store = new();
|
private readonly InMemoryProjectionStore _store = new();
|
||||||
private readonly InMemoryRegisterRecordClient _register = new();
|
|
||||||
private readonly NotificationProjector _projector;
|
private readonly NotificationProjector _projector;
|
||||||
private Notification? _notification;
|
private Notification? _notification;
|
||||||
|
|
||||||
public RegisterProjectieBijwerkenSteps()
|
public RegisterProjectieBijwerkenSteps()
|
||||||
=> _projector = new NotificationProjector(_log, _store, _register);
|
=> _projector = new NotificationProjector(_log, _store, new InMemoryAclReferenceClient());
|
||||||
|
|
||||||
[Given("registration \"(.*)\" is recorded in the register with status \"(.*)\"")]
|
[Given("a zaak is created in OpenZaak with id \"(.*)\"")]
|
||||||
[When("registration \"(.*)\" is recorded in the register with status \"(.*)\"")]
|
public void GivenAZaakIsCreatedInOpenZaakWithId(string id)
|
||||||
public void RegistrationIsRecorded(string id, string status)
|
=> _notification = new Notification("zaken", "zaak", "create", new Uri(ZaakBase + id));
|
||||||
{
|
|
||||||
// The ACL upserts one object per registration, so submit and approval share an object URL.
|
|
||||||
var objectUrl = ObjectBase + id;
|
|
||||||
_register.Records[objectUrl] = new RegisterRecord(id, status, "REG-" + id);
|
|
||||||
_notification = new Notification("objecten", "object", "create", new Uri(objectUrl));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Given("the register notification is delivered to the event subscriber")]
|
[When("the NRC notification for that zaak is delivered to the event subscriber")]
|
||||||
[When("the register notification is delivered to the event subscriber")]
|
public Task WhenTheNotificationIsDelivered()
|
||||||
public Task TheNotificationIsDelivered()
|
|
||||||
=> _projector.HandleAsync(_notification!);
|
=> _projector.HandleAsync(_notification!);
|
||||||
|
|
||||||
[When("the same register notification is delivered again")]
|
[When("the same NRC notification is delivered again")]
|
||||||
public Task TheSameNotificationIsDeliveredAgain()
|
public Task WhenTheSameNotificationIsDeliveredAgain()
|
||||||
=> _projector.HandleAsync(_notification!);
|
=> _projector.HandleAsync(_notification!);
|
||||||
|
|
||||||
[Then("the register projection contains a row for \"(.*)\" with status \"(.*)\"")]
|
[Then("the register projection contains a row for \"(.*)\" with status \"(.*)\"")]
|
||||||
|
|||||||
@@ -39,12 +39,10 @@ public sealed class InMemoryProjectionStore : IProjectionStore
|
|||||||
=> [.. _byId.Values.Where(e => e.Id == id)];
|
=> [.. _byId.Values.Where(e => e.Id == id)];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>An in-memory stand-in for the register the ACL reads back for the projector, so the
|
/// <summary>A fake ACL client for the projection acceptance scenario: returns a reference derived
|
||||||
/// scenario runs without a running ACL or Objecten (S-19b-2, ADR-0030).</summary>
|
/// from the zaak, so the projector can enrich rows without a running ACL (#78).</summary>
|
||||||
public sealed class InMemoryRegisterRecordClient : IAclClient
|
public sealed class InMemoryAclReferenceClient : IAclClient
|
||||||
{
|
{
|
||||||
public Dictionary<string, RegisterRecord> Records { get; } = [];
|
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult("REG-" + zaakUrl.Segments[^1].Trim('/'));
|
||||||
public Task<RegisterRecord?> GetRegisterRecordAsync(Uri objectUrl, CancellationToken ct = default)
|
|
||||||
=> Task.FromResult(Records.TryGetValue(objectUrl.ToString(), out var record) ? record : null);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,20 +53,3 @@ public sealed class InMemoryZaakGateway : IZaakGateway
|
|||||||
=> Task.FromResult<IReadOnlyList<ZaaktypeSummary>>(
|
=> Task.FromResult<IReadOnlyList<ZaaktypeSummary>>(
|
||||||
[new ZaaktypeSummary("BIG-REGISTRATIE", "BIG-registratie", ResolvedZaaktypeUrl)]);
|
[new ZaaktypeSummary("BIG-REGISTRATIE", "BIG-registratie", ResolvedZaaktypeUrl)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>An in-memory stand-in for the Objecten API (S-19a): records the register records the ACL
|
|
||||||
/// writes on approval, so a scenario can assert on them without a running Objecten.</summary>
|
|
||||||
public sealed class InMemoryRegisterRecordGateway : IRegisterRecordGateway
|
|
||||||
{
|
|
||||||
public List<RegisterRecord> Upserted { get; } = [];
|
|
||||||
|
|
||||||
public Task UpsertAsync(RegisterRecord record, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
Upserted.Add(record);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>The most recently written record — scenarios never read one back by object URL.</summary>
|
|
||||||
public Task<RegisterRecord?> GetAsync(Uri objectUrl, CancellationToken ct = default)
|
|
||||||
=> Task.FromResult(Upserted.Count == 0 ? null : Upserted[^1]);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
|
||||||
|
|
||||||
// S-15b: a beheerder edits the ACL default-fill in the beheer portal and gets a saved confirmation.
|
|
||||||
// Runs against the shared verify stack; it edits + saves (the ACL store is in-memory, ADR-0026) and
|
|
||||||
// asserts the confirmation, without depending on another test's state.
|
|
||||||
test('a beheerder edits and saves the default-fill', async ({ page }) => {
|
|
||||||
await page.goto('http://beheer/');
|
|
||||||
|
|
||||||
// Keycloak medewerker-realm login (same realm as behandel).
|
|
||||||
await page.locator('#username').fill('bram-beheerder');
|
|
||||||
await page.locator('#password').fill('test123');
|
|
||||||
await page.locator('#kc-login').click();
|
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: /Catalogus/i })).toBeVisible();
|
|
||||||
|
|
||||||
// Navigate to the default-fill editor and change a value.
|
|
||||||
await page.getByRole('link', { name: /Default-fill/i }).click();
|
|
||||||
await expect(page.getByRole('heading', { name: /Default-fill/i })).toBeVisible();
|
|
||||||
|
|
||||||
const bron = page.getByLabel('Bronorganisatie');
|
|
||||||
await expect(bron).toBeVisible();
|
|
||||||
await bron.fill('517439943');
|
|
||||||
await page.getByRole('button', { name: /Opslaan/i }).click();
|
|
||||||
|
|
||||||
await expect(page.getByText(/standaardwaarden zijn opgeslagen/i)).toBeVisible();
|
|
||||||
});
|
|
||||||
@@ -21,9 +21,7 @@ export default defineConfig({
|
|||||||
// OOM-killed mid-action ("Page crashed") — fixing the flakiness at its source rather than leaning
|
// OOM-killed mid-action ("Page crashed") — fixing the flakiness at its source rather than leaning
|
||||||
// on `retries` (CLAUDE.md §15). Only two long-running happy-path specs, so serial costs little.
|
// on `retries` (CLAUDE.md §15). Only two long-running happy-path specs, so serial costs little.
|
||||||
workers: 1,
|
workers: 1,
|
||||||
// `list` for the live log; `json` (→ /e2e/playwright-report.json in the container) is copied out
|
reporter: [['list']],
|
||||||
// by run-e2e-check.sh and rendered as a per-spec table in the CI job summary (#136).
|
|
||||||
reporter: [['list'], ['json', { outputFile: 'playwright-report.json' }]],
|
|
||||||
use: {
|
use: {
|
||||||
baseURL,
|
baseURL,
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
import { expect, request, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
// Walking-skeleton happy path (S-08d + S-09 + S-09b + S-12 + S-10a + S-19b-2): a zorgprofessional
|
// Walking-skeleton happy path (S-08d + S-09 + S-09b + S-12 + S-10a): a zorgprofessional logs in via
|
||||||
// logs in via mock DigiD and submits through the self-service portal → BFF → domain; the entry
|
// mock DigiD and submits through the self-service portal → BFF → domain; the entry appears in the
|
||||||
// appears in the openbaar register as INGEDIEND; the citizen supplies the documents the process is
|
// openbaar register as INGEDIEND; the citizen supplies the documents the process is waiting for
|
||||||
// waiting for (S-10a); a behandelaar then logs in to the behandel portal, finds the registration in
|
// (S-10a); a behandelaar then logs in to the behandel portal, finds the registration in the werkbak,
|
||||||
// the werkbak, and approves it (goedkeuren); the decision completes the Flowable Beoordelen task and
|
// and approves it (goedkeuren); the decision completes the Flowable Beoordelen task and flows via the
|
||||||
// flows via the ACL → Objecten → NRC → event-subscriber → projection, and the openbaar register
|
// ACL → NRC → event-subscriber → projection, and the openbaar register shows INGESCHREVEN.
|
||||||
// shows INGESCHREVEN.
|
|
||||||
//
|
|
||||||
// Since ADR-0030 both public statuses come from the register in Objecten, not from ZGW zaak events:
|
|
||||||
// the ACL writes the record on submit (INGEDIEND) and upserts it on approval (INGESCHREVEN), so the
|
|
||||||
// INGEDIEND assertion below is itself proof of the re-sourced path.
|
|
||||||
test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt → public INGESCHREVEN', async ({
|
test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt → public INGESCHREVEN', async ({
|
||||||
page,
|
page,
|
||||||
context,
|
context,
|
||||||
@@ -114,53 +109,4 @@ test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt
|
|||||||
return staff.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGESCHREVEN' }).count();
|
return staff.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGESCHREVEN' }).count();
|
||||||
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
|
||||||
.toBeGreaterThan(0);
|
.toBeGreaterThan(0);
|
||||||
|
|
||||||
// S-19a: the same approval also wrote the canonical register record to Objecten (ADR-0028).
|
|
||||||
// Asserted here rather than in verify-domain because this is the only check that drives a *real*
|
|
||||||
// approval — verify-domain completes the Beoordelen task straight through Flowable REST, which
|
|
||||||
// bypasses the domain `decide` path that calls the ACL.
|
|
||||||
const records = await registerRecordsFor(reference);
|
|
||||||
// Matched on OUR reference: the verify stack is shared and holds records from earlier checks.
|
|
||||||
expect(records, `expected exactly one RegisterRecord for ${reference}`).toHaveLength(1);
|
|
||||||
expect(records[0].status).toBe('INGESCHREVEN');
|
|
||||||
// The register is world-readable, so the record must carry nothing but the public-safe fields
|
|
||||||
// (ADR-0027) — Objecten's own schema validation enforces this, and this proves it end to end.
|
|
||||||
expect(Object.keys(records[0]).sort()).toEqual(['id', 'reference', 'status']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const OBJECTEN = process.env.OBJECTEN_URL ?? 'http://objecten:8000';
|
|
||||||
const OBJECTTYPEN = process.env.OBJECTTYPEN_URL ?? 'http://objecttypen:8000';
|
|
||||||
const OBJECTEN_TOKEN = process.env.OBJECTEN_TOKEN ?? '1234567890abcdef1234567890abcdef12345678';
|
|
||||||
const OBJECTTYPEN_TOKEN = process.env.OBJECTTYPEN_TOKEN ?? '0123456789abcdef0123456789abcdef01234567';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The RegisterRecord objects Objecten holds for a registration reference.
|
|
||||||
*
|
|
||||||
* The objecttype is resolved by name rather than pinned: Objecttypen echoes the request Host into
|
|
||||||
* the objecttype `url`, and Objecten only accepts the one matching its configured api_root — so
|
|
||||||
* both must be reached by service name, exactly as the ACL reaches them (ADR-0028).
|
|
||||||
*/
|
|
||||||
async function registerRecordsFor(reference: string): Promise<Record<string, string>[]> {
|
|
||||||
const api = await request.newContext();
|
|
||||||
try {
|
|
||||||
const types = await api.get(`${OBJECTTYPEN}/api/v2/objecttypes`, {
|
|
||||||
headers: { Authorization: `Token ${OBJECTTYPEN_TOKEN}` },
|
|
||||||
});
|
|
||||||
expect(types.ok(), `Objecttypen returned ${types.status()}`).toBeTruthy();
|
|
||||||
const objecttype = ((await types.json()).results as { url: string; name: string }[]).find(
|
|
||||||
(o) => o.name === 'RegisterRecord',
|
|
||||||
);
|
|
||||||
if (!objecttype) throw new Error('the RegisterRecord objecttype is not registered in Objecttypen');
|
|
||||||
|
|
||||||
const objects = await api.get(`${OBJECTEN}/api/v2/objects`, {
|
|
||||||
headers: { Authorization: `Token ${OBJECTEN_TOKEN}`, 'Accept-Crs': 'EPSG:4326' },
|
|
||||||
params: { type: objecttype.url, data_attrs: `reference__exact__${reference}` },
|
|
||||||
});
|
|
||||||
expect(objects.ok(), `Objecten returned ${objects.status()}: ${await objects.text()}`).toBeTruthy();
|
|
||||||
return ((await objects.json()).results as { record: { data: Record<string, string> } }[]).map(
|
|
||||||
(o) => o.record.data,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
await api.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user