Compare commits

..
Author SHA1 Message Date
not d39b5dff3c fix(test): match the catalogus zaaktype cell exactly, not case-insensitively (refs #130)
CI / build (pull_request) Successful in 1m36s
CI / lint (pull_request) Successful in 1m51s
CI / unit (pull_request) Successful in 1m49s
CI / frontend (pull_request) Successful in 4m24s
CI / mutation (pull_request) Successful in 6m58s
CI / verify-stack (pull_request) Successful in 8m14s
2026-07-24 12:09:09 +02:00
not 995b55af57 ci: re-trigger after infra flakes — mutation test-host crash + compose recreate race (refs #130)
CI / build (pull_request) Successful in 1m28s
CI / lint (pull_request) Successful in 1m41s
CI / unit (pull_request) Successful in 1m47s
CI / frontend (pull_request) Successful in 4m0s
CI / mutation (pull_request) Successful in 6m49s
CI / verify-stack (pull_request) Failing after 8m30s
2026-07-24 11:47:12 +02:00
not b812f42912 fix(test): implement ListZaaktypenAsync in the acceptance InMemoryZaakGateway (refs #130)
CI / build (pull_request) Successful in 1m38s
CI / unit (pull_request) Successful in 1m48s
CI / frontend (pull_request) Successful in 4m20s
CI / mutation (pull_request) Failing after 22m12s
CI / verify-stack (pull_request) Failing after 1m28s
CI / lint (pull_request) Successful in 1m48s
2026-07-24 11:07:40 +02:00
not 61f6f5781f test(acl): cover ListZaaktypenAsync gateway read (mutation ratchet) (refs #130)
CI / mutation (pull_request) Successful in 8m44s
CI / verify-stack (pull_request) Failing after 18m24s
CI / build (pull_request) Failing after 1m32s
CI / lint (pull_request) Successful in 1m48s
CI / unit (pull_request) Failing after 1m48s
CI / frontend (pull_request) Successful in 4m29s
2026-07-24 11:00:53 +02:00
not 5f5dfda1a0 feat(infra): beheer portal in compose + e2e + ADR-0025 + demo note (refs #130) 2026-07-24 10:59:20 +02:00
not 4c702e324a feat(portal-beheer): catalogus viewer loads published zaaktypen from the BFF (refs #130) 2026-07-24 10:56:38 +02:00
not b412721938 test(portal-beheer): beheer app scaffold + catalogus viewer specs (refs #130) 2026-07-24 10:55:58 +02:00
not df16659f94 feat(infra): add beheerder role + bram-beheerder test user to medewerker realm (refs #130) 2026-07-24 10:51:54 +02:00
not 9eb51b8b3e feat(bff): GET /beheer/catalogi/zaaktypen proxies the ACL for beheerders (refs #130) 2026-07-24 10:51:22 +02:00
not b0485a7724 test(bff): /beheer/catalogi/zaaktypen behind beheerder authorization (refs #130) 2026-07-24 10:48:40 +02:00
not 7368bf3bce feat(acl): GET /catalogi/zaaktypen lists published zaaktypen (refs #130) 2026-07-24 10:44:48 +02:00
not a62a09bdff test(acl): list published zaaktypen for the beheer catalogus viewer (refs #130) 2026-07-24 10:44:05 +02:00
not d95741385a docs(backlog): split S-15 into S-15a/b/c; mark S-16c done (refs #130) 2026-07-24 10:38:12 +02:00
88 changed files with 283 additions and 4316 deletions
+4 -117
View File
@@ -70,12 +70,6 @@ jobs:
restore-keys: |
nuget-${{ runner.os }}-
- run: make unit
# Job summary (#136): a per-service pass/fail table from the TRX `make unit` wrote.
- name: Unit test summary
if: always()
run: |
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
python3 infra/trx-summary.py TestResults >> "$GITHUB_STEP_SUMMARY"
# Frontend (Nx/Angular) lane: install with pnpm, then Nx lint + test + build.
frontend:
@@ -90,12 +84,6 @@ jobs:
node-version: '24'
cache: 'pnpm'
- run: make frontend
# Job summary (#136): a per-frontend (app) pass/fail table from the vitest JSON each app wrote.
- name: Frontend test summary
if: always()
run: |
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
python3 infra/vitest-summary.py test-output >> "$GITHUB_STEP_SUMMARY"
mutation:
runs-on: ubuntu-latest
@@ -111,29 +99,6 @@ jobs:
restore-keys: |
nuget-${{ runner.os }}-
- run: make mutation
# Job summary (#136): render each service's Stryker Markdown report on the run page (Gitea
# 1.27 $GITHUB_STEP_SUMMARY). `if: always()` so a ratchet break still reports — and because
# `make mutation` stops at the first break, the summary also shows exactly where it stopped.
# Guarded so it no-ops on a runner/server without summary support. Strips the report's UTF-8 BOM.
- name: Mutation score summary
if: always()
run: |
[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0
{
echo "## 🧬 Mutation testing"
echo
for svc in acl event-subscriber domain bff; do
echo "### $svc"
echo
report=$(ls services/"$svc"/StrykerOutput/*/reports/mutation-report.md 2>/dev/null | sort | tail -1)
if [ -n "$report" ]; then
sed '1s/^\xef\xbb\xbf//' "$report"
else
echo "_No report — \`make mutation\` stopped before \`$svc\` (earlier ratchet break)._"
fi
echo
done
} >> "$GITHUB_STEP_SUMMARY"
# Publish the Stryker HTML reports. `if: always()` uploads them even when the
# ratchet fails — that is exactly when you want to inspect the survivors.
# `continue-on-error` keeps the upload best-effort: the mutation *gate* is the
@@ -179,118 +144,40 @@ jobs:
# they never co-schedule now the runner has capacity >1. A concurrent Stryker run + full-stack
# bring-up + Playwright browser on one host is what OOMs the e2e (commit d5e5fa2, #126). The
# light .NET/frontend jobs have no `needs`, so they still parallelise up to runner capacity.
#
# No `if: ${{ !cancelled() }}` here (removed in #134): on Gitea 1.27 + act_runner 2.0.0, a job
# gated by a status-function `if` (always()/cancelled()) on top of `needs` routes through the new
# transitional "Cancelling" state + capability negotiation and never leaves `waiting` — it's never
# dispatched (gitea-actions-gotchas.md §7). Default `if: success()` dispatches normally. Cost: a
# failing mutation ratchet now skips verify-stack instead of running it anyway; the fix-and-re-push
# re-run exercises verify-stack, so we still get the signal.
# `if: !cancelled()` keeps verify-stack running even when the mutation ratchet fails (so we don't
# lose its signal) while still honouring run cancellation from the concurrency group above.
verify-stack:
needs: [mutation]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
# Bring the full stack up + wait for health — this also is the DoD "compose up
# reaches green health" smoke (it replaces the old compose-smoke job).
# Each check carries an `id` so the summary step below can report its per-check outcome (#136).
# A failed check skips the rest (no step `if:`), so the table shows exactly where it stopped.
- name: Bring up the full stack & wait for health
id: up
run: make verify-up
- name: Observability backplane (Grafana + Tempo + Prometheus datasources)
id: obs
run: OBS_TIMEOUT=180 make verify-observability
- name: Objecttypen API up + token authenticates
id: objecttypen
run: OBJECTTYPEN_TIMEOUT=120 make verify-objecttypen
- name: Objecten API up + token authenticates + trusts Objecttypen
id: objecten
run: OBJECTEN_TIMEOUT=120 make verify-objecten
- name: RegisterRecord objecttype registered + published
id: registerrecord
run: REGISTERRECORD_TIMEOUT=120 make verify-registerrecord
- name: ACL ↔ OpenZaak integration tests
id: acl
run: make verify-acl
- name: OpenZaak → NRC notification delivery
id: nrc
run: make verify-nrc
- name: OpenZaak → NRC → Event Subscriber → projection-api
id: projection
run: make verify-projection
- name: Objecten → NRC notification delivery
id: objecten_nrc
run: make verify-objecten-notifications
- name: Domain → Flowable → ACL → OpenZaak
id: domain
run: make verify-domain
- name: BFF → Keycloak + domain + projection
id: bff
run: make verify-bff
- name: Distributed traces reach Tempo (one connected trace across services)
id: tracing
run: TRACING_TIMEOUT=120 make verify-tracing
- name: Golden-signal metrics scraped by Prometheus (/metrics on every service)
id: metrics
run: METRICS_TIMEOUT=120 make verify-metrics
- name: Self-service e2e (Playwright, login → submit → success)
id: e2e
run: make verify-e2e
# Job summary (#136): a pass/fail table of every live-stack check, so a red verify-stack shows
# which check failed at a glance. `if: always()` (step-level — safe on runner 2.0.0, unlike the
# job-level status-function `if` of #134) so it renders even after a check fails.
- name: verify-stack check summary
if: always()
env:
UP: ${{ steps.up.outcome }}
OBS: ${{ steps.obs.outcome }}
OBJECTTYPEN: ${{ steps.objecttypen.outcome }}
OBJECTEN: ${{ steps.objecten.outcome }}
REGISTERRECORD: ${{ steps.registerrecord.outcome }}
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).
- name: Dump container logs on failure
if: failure()
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel beheer 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
if: always()
run: make down
-3
View File
@@ -58,6 +58,3 @@ tests/e2e/node_modules/
tests/e2e/test-results/
tests/e2e/playwright-report/
__pycache__/
TestResults/
test-output/
tests/e2e/playwright-report.json
+2 -15
View File
@@ -277,29 +277,16 @@ Split into independently deployable sub-slices (CLAUDE.md §13):
## 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.
Split into independently deployable sub-slices (CLAUDE.md §13):
- **S-18a** (#139, ✅) · Objecttypen API up in compose (own DB + seeded config + health + static token).
- **S-18b** (#140, ✅) · Objecten API up in compose, wired to Objecttypen. Depends on S-18a.
- **S-18c** (#141, ✅) · RegisterRecord objecttype defined + registered (public-safe JSON schema). Depends on S-18a/b.
### S-19 · ACL extension: write register-record to Objecten on approval *(split — #20 closed)*
### S-19 · ACL extension: write register-record to Objecten on approval
**Outcome:** Approval path writes the canonical register record to Objecten, not OpenZaak eigenschappen. Projection now sourced from Objecten events.
**ADR required:** "Why Objecten holds the register, OpenZaak holds the process."
Split into independently deployable sub-slices (CLAUDE.md §13):
- **S-19a** (#149, ✅) · ACL writes the `RegisterRecord` to Objecten on approval, idempotently, alongside the ZGW eindstatus. Carries the ADR (ADR-0028).
- **S-19b** (#150, ✅) · Read projection sourced from Objecten instead of NRC zaak events. *(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`)*
+8 -30
View File
@@ -10,7 +10,7 @@ COMPOSE := infra/docker-compose.yml
# Long-running services with a healthcheck — the smoke polls these for readiness
# (infra/wait-healthy.sh). One-shot init jobs (oz-init, nrc-init, flowable-init)
# are not polled; they only need to have run. See docs/runbooks/gitea-actions-gotchas.md.
WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api self-service openbaar behandel beheer 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
# into external named volumes via `docker cp` (infra/seed-config.sh) instead of
# bind-mounted, because bind mounts don't reach sibling containers on the
@@ -18,7 +18,7 @@ WAIT_SVCS := openzaak nrc-web acl bff domain event-subscriber projection-api se
# volumes are `external`, so compose won't remove them — CFG_VOLS lists them for
# explicit teardown. See docs/runbooks/gitea-actions-gotchas.md.
SEED := bash infra/seed-config.sh
CFG_VOLS := rr-oz-config rr-nrc-config rr-kc-realms rr-fl-bpmn 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
# plain `docker compose -f infra/docker-compose.local.yml up` works on any local
# engine. This is the no-make / Windows-friendly path. See that file's header.
@@ -43,7 +43,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
endif
endif
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-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)
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
@@ -70,9 +70,8 @@ build:
dotnet build $(SLN) -c Release
## unit: run unit tests (excludes the container-backed Integration lane)
# TRX per test project (→ TestResults/) feeds the CI per-service summary (#136); harmless locally.
unit:
dotnet test $(SLN) -c Release --filter "Category!=Integration" --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)
# 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
# (oz-init, flowable-init) aren't polled; they just need to have run.
smoke:
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
$(SEED) oz nrc kc fl
docker compose -f $(COMPOSE) up -d --build
bash -c 'WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS); rc=$$?; docker compose -f $(COMPOSE) down --volumes; docker volume rm -f $(CFG_VOLS) >/dev/null 2>&1; exit $$rc'
## up: seed config volumes and start the full stack (use instead of bare
## `docker compose up`, which can't self-seed the external config volumes)
up:
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
$(SEED) oz nrc kc fl
docker compose -f $(COMPOSE) up -d --build
## 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;
## subsumes the old compose-smoke health gate — the DoD "up reaches green" check).
verify-up:
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
$(SEED) oz nrc kc fl
docker compose -f $(COMPOSE) up -d --build
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
@@ -186,38 +185,17 @@ verify-tracing:
verify-metrics:
bash infra/run-metrics-check.sh
## verify-objecttypen: assert the Objecttypen API is up + its static token authenticates
## (S-18a), against the already-running stack.
verify-objecttypen:
bash infra/run-objecttypen-check.sh
## verify-objecten: assert the Objecten API is up + its static token authenticates and it
## trusts the Objecttypen API (S-18b), against the already-running stack.
verify-objecten:
bash infra/run-objecten-check.sh
## verify-registerrecord: assert the RegisterRecord objecttype is registered + published in the
## Objecttypen API (S-18c), against the already-running stack.
verify-registerrecord:
bash infra/run-registerrecord-check.sh
## verify-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,
## tear down (always). For fast single-concern local iteration use `integration`
## (oz-only) or `verify-notifications` (oz+nrc) instead.
verify:
$(SEED) oz nrc kc fl objecttypen objecten registerrecord
$(SEED) oz nrc kc fl
docker compose -f $(COMPOSE) up -d --build
@bash -c 'set -e; rc=0; \
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS) \
&& bash infra/run-acl-integration.sh \
&& bash infra/run-notification-check.sh \
&& bash infra/run-projection-check.sh \
&& bash infra/run-objecten-notifications-check.sh \
&& bash infra/run-domain-check.sh \
&& bash infra/run-bff-check.sh \
&& bash infra/run-e2e-check.sh || rc=$$?; \
+1 -3
View File
@@ -64,9 +64,7 @@
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false,
"reporters": ["default", "json"],
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
"watch": false
}
},
"serve-static": {
+1 -3
View File
@@ -64,9 +64,7 @@
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false,
"reporters": ["default", "json"],
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
"watch": false
}
},
"serve-static": {
-4
View File
@@ -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>
-2
View File
@@ -1,9 +1,7 @@
import { Route } from '@angular/router';
import { authenticatedGuard } from 'auth';
import { CatalogusPage } from './catalogus/catalogus-page';
import { DefaultFillPage } from './default-fill/default-fill-page';
export const appRoutes: Route[] = [
{ path: '', component: CatalogusPage, canActivate: [authenticatedGuard] },
{ path: 'default-fill', component: DefaultFillPage, canActivate: [authenticatedGuard] },
];
@@ -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);
},
});
}
}
+1 -3
View File
@@ -64,9 +64,7 @@
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false,
"reporters": ["default", "json"],
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
"watch": false
}
},
"serve-static": {
+1 -3
View File
@@ -64,9 +64,7 @@
"test": {
"executor": "@angular/build:unit-test",
"options": {
"watch": false,
"reporters": ["default", "json"],
"outputFile": "{workspaceRoot}/test-output/{projectName}.json"
"watch": false
}
},
"serve-static": {
+1 -1
View File
@@ -207,7 +207,7 @@ A slice is done when:
## 15. Out of scope for v1
- OpenMetadata data governance module (v3 slice).
- ~~Objecten as the authoritative register record store~~ — **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).
- Multi-tenancy.
- 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.
-146
View File
@@ -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)
**Outcome:** a new **beheer** portal (medewerker realm, like behandel) shows the ZTC catalogus —
-49
View File
@@ -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
**401** without the configured `Authorization`. The verify sink
(`infra/notification-sink.py`) enforces a bearer token for exactly this reason.
---
## 7. A job with `if: ${{ !cancelled() }}` (or `always()`) + `needs` sticks in "waiting"
**Symptom** — after upgrading to **Gitea 1.27** + **act_runner 2.0.0**, one job never
starts: the run sits in state `waiting` forever, the job has **no logs** (never
dispatched to a runner), and the other jobs finish normally. `main` stays pending/red.
Seen on the `verify-stack` job (#134).
**Why** — Gitea 1.27 reworked cancellation/aggregation: a job gated by a
**status-function `if`** (`always()` / `cancelled()` / `!cancelled()`) on top of
`needs` now routes through a new transitional **`Cancelling`** job state plus a
server↔runner **capability negotiation** ("Requires Gitea Runner 2.0.0"). On the
1.27 + 2.0.0 pairing that handshake doesn't resolve for such a job, so it's never
offered to a runner and never leaves `waiting`. Jobs with no `if`/`needs` are
unaffected. (Related upstream: go-gitea/gitea#31074, #27116, #35782.)
**Fix** — don't gate a `needs` job with a status-function `if`. Use the default
`if: success()` (i.e. omit the `if`). If you need "run even when an upstream job
fails", prefer serialising with a `concurrency` group over `needs` + `always()`.
**Also** — a run already stuck this way will **not** clear itself; force-cancel it
from the Actions UI (plain cancel can also stall on this version, #35782). Push the
workflow fix to produce a fresh run.
---
## 8. Job summaries (`$GITHUB_STEP_SUMMARY`) need Gitea ≥1.27 + runner ≥2.0
Markdown a step appends to the `$GITHUB_STEP_SUMMARY` file renders on the run page
(no artifact download). We use it for per-run reports (#136): mutation scores
(Stryker `markdown` reporter), per-service unit results (`infra/trx-summary.py` over
TRX), per-frontend results (`infra/vitest-summary.py` over each app's vitest JSON),
the verify-stack check table, and per-spec e2e results (`infra/playwright-summary.py`).
**Requirements / conventions:**
- Requires **Gitea ≥ 1.27** (stores/renders summaries) and **act_runner ≥ 2.0.0**
(uploads them). Older pairings silently skip the upload.
- **Guard every write:** `[ -n "${GITHUB_STEP_SUMMARY:-}" ] || exit 0` — on a runner
without support the var is unset and `>> "$GITHUB_STEP_SUMMARY"` would be an
ambiguous-redirect error. The guard makes the step a no-op locally / on old runners.
- Use `if: always()` (step-level) on summary steps so they render even when the thing
they report on failed. Step-level `always()` is fine on 2.0.0 — unlike the *job*-level
status-function `if` of §7.
- Getting a report out of the e2e container: Playwright writes `playwright-report.json`
inside the container; `infra/run-e2e-check.sh` `docker cp`s it back to the host
(capturing the test exit code first) so the summary step can read it.
-193
View File
@@ -56,10 +56,6 @@ services:
oz-init:
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
environment: &oz-env
# 1 uWSGI worker, not the image default of 4×4 (#147) — idle workers pressure the runner; the
# -init/-celery containers share this anchor and ignore it (they don't run uwsgi).
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: openzaak.conf.docker
SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: oz-db
@@ -142,9 +138,6 @@ services:
# bind-mounted here (this twin is the local/no-make path). See ADR-0007.
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: &nrc-env
# 1 uWSGI worker, not the image default of 4×4 (#147) — see the oz-env note above.
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: nrc.conf.docker
SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: nrc-db
@@ -338,15 +331,6 @@ services:
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
# Objecten holds the register, OpenZaak holds the process (S-19a, ADR-0028). Both APIs take a
# static token, not a ZGW JWT. The objecttype URL is assigned at seed time, so the ACL resolves
# it by name — lazily, on the first approval, so no depends_on is needed here.
# 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:
- "8100:8080"
volumes:
@@ -576,188 +560,11 @@ services:
condition: service_started
networks: [cg]
# ── Objecttypen API (S-18a) — bind-mounted config (local variant) ──────────
objecttypen-db:
image: docker.io/library/postgres:17-alpine
environment:
POSTGRES_USER: objecttypes
POSTGRES_PASSWORD: objecttypes
POSTGRES_DB: objecttypes
volumes:
- objecttypen-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U objecttypes"]
interval: 5s
timeout: 3s
retries: 10
networks: [cg]
objecttypen-redis:
image: docker.io/library/redis:7
networks: [cg]
objecttypen-init:
image: docker.io/maykinmedia/objecttypes-api:${OBJECTTYPES_TAG:-3.4.2}
environment: &objecttypen-env-local
# 1 uWSGI worker, not the image default of 4×4 (#144) — idle workers starve the CI runner.
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: objecttypes.conf.docker
SECRET_KEY: ${OBJECTTYPES_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: objecttypen-db
DB_NAME: objecttypes
DB_USER: objecttypes
DB_PASSWORD: objecttypes
ALLOWED_HOSTS: "*"
CACHE_DEFAULT: objecttypen-redis:6379/0
CACHE_AXES: objecttypen-redis:6379/0
DISABLE_2FA: "true"
OTEL_SDK_DISABLED: "true"
RUN_SETUP_CONFIG: "true"
command: /setup_configuration.sh
volumes:
- ./objecttypen/setup_configuration:/app/setup_configuration:ro,z
depends_on:
objecttypen-db:
condition: service_healthy
objecttypen-redis:
condition: service_started
networks: [cg]
objecttypen:
image: docker.io/maykinmedia/objecttypes-api:${OBJECTTYPES_TAG:-3.4.2}
environment: *objecttypen-env-local
healthcheck:
test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
ports:
- "8020:8000"
depends_on:
objecttypen-init:
condition: service_completed_successfully
networks: [cg]
# ── RegisterRecord objecttype (S-18c) — API-seeded one-shot (local variant) ─
registerrecord-init:
image: docker.io/library/python:3-slim
environment:
OBJECTTYPEN: http://objecttypen:8000
OBJECTTYPEN_TOKEN: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
SCHEMA: /config/registerrecord.schema.json
command: python /config/register.py
volumes:
- ./objecttypen-registerrecord:/config:ro,z
depends_on:
objecttypen:
condition: service_healthy
networks: [cg]
# ── Objecten API (S-18b) — bind-mounted config (local variant) ─────────────
objecten-db:
image: docker.io/postgis/postgis:17-3.5
environment:
POSTGRES_USER: objects
POSTGRES_PASSWORD: objects
POSTGRES_DB: objects
volumes:
- objecten-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U objects"]
interval: 5s
timeout: 3s
retries: 10
networks: [cg]
objecten-redis:
image: docker.io/library/redis:7
networks: [cg]
objecten-init:
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
environment: &objecten-env-local
# 1 uWSGI worker, not the image default of 4×4 (#144) — idle workers starve the CI runner.
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: objects.conf.docker
SECRET_KEY: ${OBJECTS_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: objecten-db
DB_NAME: objects
DB_USER: objects
DB_PASSWORD: objects
ALLOWED_HOSTS: "*"
CACHE_DEFAULT: objecten-redis:6379/0
CACHE_AXES: objecten-redis:6379/0
DISABLE_2FA: "true"
OTEL_SDK_DISABLED: "true"
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:
oz-db:
nrc-db:
flowable-db:
projection-db:
objecttypen-db:
objecten-db:
# Carries the seed-generated acl.env (server-assigned zaaktype URLs) from local-seed to the ACL.
seed-env:
-222
View File
@@ -51,12 +51,6 @@ services:
oz-init:
image: docker.io/openzaak/open-zaak:${OPENZAAK_TAG:-1.28.2}
environment: &oz-env
# 1 uWSGI worker, not the image default of 4×4 (#147, same lever as #145): OpenZaak serves
# single-request smoke checks here and is not load-tested, so 4 idle Django workers just pin
# ~800 MB and pressure the shared runner. The -init (setup_configuration) and -celery containers
# share this anchor and ignore it — they don't run uwsgi.
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: openzaak.conf.docker
SECRET_KEY: ${OZ_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: oz-db
@@ -141,9 +135,6 @@ services:
# needs no baked config.
image: docker.io/openzaak/open-notificaties:${OPENNOTIFICATIES_TAG:-1.16.1}
environment: &nrc-env
# 1 uWSGI worker, not the image default of 4×4 (#147) — see the oz-env note above.
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: nrc.conf.docker
SECRET_KEY: ${NRC_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: nrc-db
@@ -323,15 +314,6 @@ services:
# so verify-domain still points the ACL at OpenZaak's container IP.
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
# Objecten holds the register, OpenZaak holds the process (S-19a, ADR-0028). Both APIs take a
# static token, not a ZGW JWT. The objecttype URL is assigned at seed time, so the ACL resolves
# it by name — lazily, on the first approval, so no depends_on is needed here.
# 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:
- "8100:8080"
healthcheck:
@@ -587,199 +569,6 @@ services:
condition: service_started
networks: [cg]
# ── Objecttypen API (S-18a) — upstream Maykin image, verbatim ──────────────
# The register's objecttype catalogue. Same shape as the other CG modules: own DB + redis, an
# `-init` that runs setup_configuration (RUN_SETUP_CONFIG → migrate + provision a static API token)
# from the external config volume streamed in by infra/seed-config.sh, and a health-checked web
# service that depends on init completing.
objecttypen-db:
image: docker.io/library/postgres:17-alpine
environment:
POSTGRES_USER: objecttypes
POSTGRES_PASSWORD: objecttypes
POSTGRES_DB: objecttypes
volumes:
- objecttypen-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U objecttypes"]
interval: 5s
timeout: 3s
retries: 10
networks: [cg]
objecttypen-redis:
image: docker.io/library/redis:7
networks: [cg]
objecttypen-init:
image: docker.io/maykinmedia/objecttypes-api:${OBJECTTYPES_TAG:-3.4.2}
environment: &objecttypen-env
# 1 uWSGI worker, not the image default of 4×4: this API only serves single-request smoke
# checks and sits idle during the e2e step — 4 idle Django workers each pin ~200 MB and starve
# the shared CI runner (#144). Init ignores this (it runs setup_configuration, not uwsgi).
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: objecttypes.conf.docker
SECRET_KEY: ${OBJECTTYPES_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: objecttypen-db
DB_NAME: objecttypes
DB_USER: objecttypes
DB_PASSWORD: objecttypes
ALLOWED_HOSTS: "*"
CACHE_DEFAULT: objecttypen-redis:6379/0
CACHE_AXES: objecttypen-redis:6379/0
DISABLE_2FA: "true"
OTEL_SDK_DISABLED: "true"
RUN_SETUP_CONFIG: "true"
command: /setup_configuration.sh
# data.yaml is streamed into this external volume by infra/seed-config.sh before start.
volumes:
- objecttypen-config:/app/setup_configuration:ro
depends_on:
objecttypen-db:
condition: service_healthy
objecttypen-redis:
condition: service_started
networks: [cg]
objecttypen:
image: docker.io/maykinmedia/objecttypes-api:${OBJECTTYPES_TAG:-3.4.2}
environment: *objecttypen-env
healthcheck:
test: ["CMD", "python", "-c", "import requests,sys; sys.exit(0 if requests.head('http://localhost:8000/admin/').status_code in (200,302) else 1)"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
ports:
- "8020:8000"
depends_on:
objecttypen-init:
condition: service_completed_successfully
networks: [cg]
# ── RegisterRecord objecttype (S-18c) — API-seeded one-shot ────────────────
# The Objecttypen setup_configuration (3.4.2) can only provision tokens — no declarative objecttype
# step — so this one-shot creates the RegisterRecord objecttype + a published version over the API
# once Objecttypen is healthy (idempotent; ADR-0020 self-seed, ADR-0027 schema). The schema + script
# are streamed into the external config volume by infra/seed-config.sh, like the *-init volumes.
registerrecord-init:
image: docker.io/library/python:3-slim
environment:
OBJECTTYPEN: http://objecttypen:8000
OBJECTTYPEN_TOKEN: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567}
SCHEMA: /config/registerrecord.schema.json
command: python /config/register.py
volumes:
- registerrecord-config:/config:ro
depends_on:
objecttypen:
condition: service_healthy
networks: [cg]
# ── Objecten API (S-18b) — upstream Maykin image, verbatim ─────────────────
# The authoritative object store. Same shape as Objecttypen (own DB + redis, an `-init` that runs
# setup_configuration from the external config volume, a health-checked web). Two differences: the
# DB is PostGIS (objects carry geometry), and setup_configuration registers the Objecttypen API
# (S-18a) as a trusted service so an object can reference its objecttype.
objecten-db:
image: docker.io/postgis/postgis:17-3.5
environment:
POSTGRES_USER: objects
POSTGRES_PASSWORD: objects
POSTGRES_DB: objects
volumes:
- objecten-db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U objects"]
interval: 5s
timeout: 3s
retries: 10
networks: [cg]
objecten-redis:
image: docker.io/library/redis:7
networks: [cg]
objecten-init:
image: docker.io/maykinmedia/objects-api:${OBJECTS_TAG:-3.4.0}
environment: &objecten-env
# 1 uWSGI worker, not the image default of 4×4 — see the objecttypen note above (#144).
UWSGI_PROCESSES: "1"
UWSGI_THREADS: "2"
DJANGO_SETTINGS_MODULE: objects.conf.docker
SECRET_KEY: ${OBJECTS_SECRET_KEY:-dev-only-not-for-production}
DB_HOST: objecten-db
DB_NAME: objects
DB_USER: objects
DB_PASSWORD: objects
ALLOWED_HOSTS: "*"
CACHE_DEFAULT: objecten-redis:6379/0
CACHE_AXES: objecten-redis:6379/0
DISABLE_2FA: "true"
OTEL_SDK_DISABLED: "true"
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) ──────────────────────────────
# Grafana-native stack: Tempo ingests OTLP traces (the .NET services export
# straight to it — no collector hop, S-16b), Prometheus scrapes service
@@ -829,8 +618,6 @@ volumes:
nrc-db:
flowable-db:
projection-db:
objecttypen-db:
objecten-db:
# Config volumes — created and populated out-of-band by infra/seed-config.sh
# (docker cp), because bind mounts don't reach sibling containers on the CI
# runner. `external` keeps the names deterministic; the seed step manages them.
@@ -846,15 +633,6 @@ volumes:
fl-bpmn:
external: true
name: rr-fl-bpmn
objecttypen-config:
external: true
name: rr-objecttypen-config
registerrecord-config:
external: true
name: rr-registerrecord-config
objecten-config:
external: true
name: rr-objecten-config
networks:
cg:
+6 -11
View File
@@ -2,10 +2,10 @@
"""Local-stack bootstrap (S-B04, #110, ADR-0020) — register the NRC abonnement.
Runs as the `nrc-subscribe` init container of infra/docker-compose.local.yml. Registers an
abonnement on the `objecten` kanaal pointing at the event-subscriber's /notifications callback, so
the register writes the ACL makes (INGEDIEND on submit, INGESCHREVEN on approval) reach the
projection — without this the openbaar (public) register stays empty. Since S-19b-2 the projection
is sourced from the register in Objecten, not from ZGW zaak events (ADR-0030).
abonnement on the `zaken` kanaal pointing at the event-subscriber's /notifications callback, so
OpenZaak's notifications (zaak create + status set) reach the projection — without this the openbaar
(public) register stays empty. This is what infra/verify-notification-driver.py does for CI (minus
the test zaak it also creates).
The callback host is the event-subscriber's resolved **container IP**, not `event-subscriber`, because
NRC validates callbackUrl with Django's URLValidator (a single-label host is rejected — same reason the
@@ -22,8 +22,6 @@ SINK_PORT = os.environ.get("SINK_PORT", "8080")
SINK_AUTH = os.environ.get("SINK_AUTH", "Bearer big-reference-notifications")
CID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed")
SECRET = os.environ.get("OZ_SECRET", "insecure-dev-secret-change-me")
# The projection is sourced from the register in Objecten, not from ZGW zaak events (S-19b-2).
KANAAL = "objecten"
def token():
@@ -62,10 +60,7 @@ def main():
status, body = call("GET", f"{NRC}/api/v1/abonnement")
for ab in (body or []) if status == 200 else []:
if str(ab.get("callbackUrl", "")).endswith("/notifications"):
# The kanaal is part of "current": an abonnement left over from before S-19b-2 points at
# 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]:
if ab.get("callbackUrl") == callback:
print(f"abonnement already current: {ab['url']}")
return
call("DELETE", ab["url"])
@@ -73,7 +68,7 @@ def main():
status, ab = call("POST", f"{NRC}/api/v1/abonnement", {
"callbackUrl": callback, "auth": SINK_AUTH,
"kanalen": [{"naam": KANAAL, "filters": {}}]})
"kanalen": [{"naam": "zaken", "filters": {}}]})
if status != 201:
sys.exit(f"create abonnement -> {status}: {json.dumps(ab)}")
print(f"abonnement registered: {ab['url']} -> {callback}")
-49
View File
@@ -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())
-121
View File
@@ -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
-48
View File
@@ -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:
authorizations_api_service_identifier: openzaak-ac
# 4. The kanalen publishers announce on: `zaken` (OpenZaak) and `objecten` (Objecten, S-19b-1).
# 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.
# 4. The kanaal OpenZaak publishes zaak events on.
notifications_kanalen_config_enable: true
notifications_kanalen_config:
items:
@@ -41,11 +39,3 @@ notifications_kanalen_config:
- bronorganisatie
- zaaktype
- 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
-57
View File
@@ -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"))
-66
View File
@@ -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())
-1
View File
@@ -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; }
echo "OK — behandelaar claimed and completed the Beoordelen task; the registratie process finished"
# ── S-11: withdrawal. A second registration parks at Beoordelen; the citizen withdraws it via the
# domain, which delivers the RegistratieIngetrokken message to the task's execution, tripping the
# BPMN boundary event so the process ends and the Beoordelen task disappears (ADR-0014). ────────────
+1 -5
View File
@@ -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')"
trap 'docker rm -f "$cid" >/dev/null 2>&1 || true' EXIT
docker cp "$root/tests/e2e/." "$cid:/e2e" >/dev/null
rc=0
docker start -a "$cid" || rc=$?
# Copy the Playwright JSON report out — regardless of pass/fail — for the CI job summary (#136).
docker cp "$cid:/e2e/playwright-report.json" "$root/tests/e2e/playwright-report.json" 2>/dev/null || true
exit $rc
docker start -a "$cid"
-28
View File
@@ -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
-83
View File
@@ -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
-28
View File
@@ -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
+18 -50
View File
@@ -1,26 +1,18 @@
#!/usr/bin/env bash
#
# Verify the end-to-end read-projection path (S-06, re-sourced by S-19b-2) against an ALREADY-RUNNING
# full stack: ACL → Objecten → NRC → Event Subscriber → projection → projection-api. Seeds a
# published BIG zaaktype (idempotent), registers an abonnement on the `objecten` kanaal pointing at
# the real Event Subscriber's /notifications callback (with the bearer it enforces), opens a zaak
# *through the ACL*, and asserts projection-api serves a row for it 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.
# Verify the end-to-end read-projection path (S-06) against an ALREADY-RUNNING full stack:
# OpenZaak → NRC → Event Subscriber → projection → projection-api. Seeds a published BIG
# zaaktype (idempotent), registers an abonnement on the `zaken` kanaal pointing at the real
# Event Subscriber's /notifications callback (with the bearer it enforces), creates a zaak,
# and asserts projection-api serves a row for that zaak with status INGEDIEND.
#
# 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
# lifecycle (the caller brings it up and tears it down), but does recreate the `acl` service to
# repoint it — see below, and run-domain-check.sh, which does the same. Plain docker primitives only.
# See ADR-0007/0008/0030.
# the runner can't reach published ports (gitea-actions-gotchas.md §5/§6). Reuses the
# notification driver to register the abonnement + create the zaak. Does NOT manage the stack
# lifecycle (the caller owns bring-up + teardown). Plain docker primitives only. See ADR-0007/0008.
set -euo pipefail
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}"
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)"
es="$(docker ps -q --filter 'name=event-subscriber' | 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 "$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)"
oz_ip="$(ip "$oz")"; nrc_ip="$(ip "$nrc")"; es_ip="$(ip "$es")"; proj_ip="$(ip "$proj")"; acl_ip="$(ip "$acl")"
echo ">> network=$net openzaak=$oz_ip nrc=$nrc_ip event-subscriber=$es_ip projection-api=$proj_ip acl=$acl_ip"
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"
echo ">> seeding a published BIG zaaktype (idempotent)"
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 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
# The same script the local stack uses (ADR-0020), so both paths register the identical abonnement.
drv="$(docker create --network "$net" --name rr-pverify \
-e "NRC_BASE=http://$nrc_ip:8000" \
-e "SINK_HOST=$es_ip" -e "SINK_PORT=8080" -e "SINK_AUTH=$WEBHOOK_AUTH" \
python:3-slim python /subscribe.py)"
docker cp "$here/local/register-abonnement.py" "$drv:/subscribe.py" >/dev/null
-e "OZ_BASE=http://$oz_ip:8000" -e "NRC_BASE=http://$nrc_ip:8000" \
-e "SINK_CALLBACK=http://$es_ip:8080/notifications" -e "SINK_AUTH=$WEBHOOK_AUTH" \
python:3-slim python /driver.py)"
docker cp "$here/verify-notification-driver.py" "$drv:/driver.py" >/dev/null
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
# 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; }
[ -n "$zaak_url" ] || { echo "ERROR: driver did not create a zaak" >&2; exit 1; }
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)"
for _ in $(seq 1 30); do
@@ -93,8 +63,6 @@ for _ in $(seq 1 30); do
sleep 2
done
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 "--- 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
-28
View File
@@ -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
+2 -5
View File
@@ -13,7 +13,7 @@
# subcommand. Fixed-name `external` volumes keep the names deterministic across
# both runtimes. See docs/runbooks/gitea-actions-gotchas.md.
#
# Usage: seed-config.sh <key> [<key> ...] where key ∈ { oz, nrc, kc, fl, objecttypen, objecten, registerrecord }
# Usage: seed-config.sh <key> [<key> ...] where key ∈ { oz, kc, fl }
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -33,7 +33,7 @@ populate() { # volume source(file or dir/.)
echo " seeded $vol"
}
[ "$#" -gt 0 ] || { echo "usage: seed-config.sh <oz|nrc|kc|fl|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
# 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/." ;;
nrc) populate rr-nrc-config "$here/opennotificaties/setup_configuration/." ;;
kc) populate rr-kc-realms "$here/keycloak/realms/." ;;
objecttypen) populate rr-objecttypen-config "$here/objecttypen/setup_configuration/." ;;
objecten) populate rr-objecten-config "$here/objecten/setup_configuration/." ;;
registerrecord) populate rr-registerrecord-config "$here/objecttypen-registerrecord/." ;;
fl) d="$(mktemp -d)"; stage_flowable_workflows "$d"; populate rr-fl-bpmn "$d/." ;;
*) echo "unknown seed key: $key" >&2; exit 2 ;;
esac
-65
View File
@@ -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"))
-44
View File
@@ -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"))
+3 -7
View File
@@ -15,13 +15,9 @@ set -euo pipefail
timeout="${WAIT_TIMEOUT:-420}"
deadline=$(( $(date +%s) + timeout ))
# compose service name -> container id. `--filter name=` is a substring match, so it is anchored on
# the compose replica suffix — otherwise 'objecten' also matches objecten-db / objecten-redis /
# objecten-celery, and 'objecttypen' matches objecttypen-db. Whichever docker listed first won, so a
# 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; }
# compose service name -> container id. The name filter matches both docker
# compose ("infra-openzaak-1") and podman-compose ("infra_openzaak_1") naming.
cid_for() { docker ps -aq --filter "name=$1" | head -1; }
for svc in "$@"; do
echo "waiting for '$svc' to be healthy (timeout ${timeout}s)..."
@@ -24,12 +24,6 @@ import {
Observable
} from 'rxjs';
export interface BeheerDefaultFill {
bronorganisatie: string;
verantwoordelijkeOrganisatie: string;
vertrouwelijkheidaanduiding: string;
}
export interface BeheerZaaktype {
identificatie: 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',
}
);
}
};
-43
View File
@@ -33,21 +33,7 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
.GetSection("Acl:OpenZaak").Get<OpenZaakOptions>()
?? throw new InvalidOperationException("Missing configuration section 'Acl:OpenZaak'"));
// The default-fill values are held in a runtime-mutable store (S-15b, ADR-0026), seeded from the
// configured Acl:Defaults. The beheer portal edits it; the worker reads it per zaak. The S-27
// resolution keys stay on AclDefaults (static) — see DefaultFillSettings.
builder.Services.AddSingleton<IDefaultFillStore>(sp =>
{
var d = sp.GetRequiredService<AclDefaults>();
return new InMemoryDefaultFillStore(
new DefaultFillSettings(d.Bronorganisatie, d.VerantwoordelijkeOrganisatie, d.Vertrouwelijkheidaanduiding));
});
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
.GetSection("Acl:Objecten").Get<ObjectenOptions>()
?? throw new InvalidOperationException("Missing configuration section 'Acl:Objecten'"));
builder.Services.AddHttpClient<IZaakGateway, OpenZaakGateway>();
// The Objecten hop that writes the register record on approval (S-19a, ADR-0028).
builder.Services.AddHttpClient<IRegisterRecordGateway, ObjectenGateway>();
// Singleton so the resolved zaaktype/informatieobjecttype URLs are cached across requests (S-27).
builder.Services.AddSingleton<IZaaktypeCatalog, CachedZaaktypeCatalog>();
builder.Services.AddScoped<AclService>();
@@ -90,16 +76,6 @@ app.MapPost("/zaken/reference", async (ZaakReferenceRequest body, AclService acl
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
// 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) =>
@@ -115,22 +91,6 @@ app.MapPost("/documenten", async (StoreDocumentRequest body, AclService acl, Can
app.MapGet("/catalogi/zaaktypen", async (AclService acl, CancellationToken ct) =>
Results.Ok(await acl.ListZaaktypenAsync(ct)));
// Read the current default-fill settings (beheer config viewer, S-15b).
app.MapGet("/default-fill", (AclService acl) => Results.Ok(acl.GetDefaultFill()));
// Update the default-fill settings from the beheer portal (S-15b). Behind beheerder authorization at
// the BFF; the ACL validates the values are present (the three ZGW-mandatory fields).
app.MapPut("/default-fill", (DefaultFillSettings body, AclService acl) =>
{
if (string.IsNullOrWhiteSpace(body.Bronorganisatie) ||
string.IsNullOrWhiteSpace(body.VerantwoordelijkeOrganisatie) ||
string.IsNullOrWhiteSpace(body.Vertrouwelijkheidaanduiding))
return Results.BadRequest(new { error = "bronorganisatie, verantwoordelijkeOrganisatie and vertrouwelijkheidaanduiding are all required." });
acl.UpdateDefaultFill(body);
return Results.NoContent();
});
app.Run();
public sealed record OpenZaakRequest(string Bsn, string Reference);
@@ -141,9 +101,6 @@ public sealed record CancelZaakRequest(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 partial class Program;
+4 -62
View File
@@ -2,20 +2,12 @@ namespace Acl.Application;
/// <summary>The ACL's single operation: open a zaak from a domain payload,
/// default-filling the ZGW-mandatory fields (ADR-0003).</summary>
public sealed class AclService(
IZaakGateway gateway,
IRegisterRecordGateway register,
IDefaultFillStore fill,
IZaaktypeCatalog catalog,
IClock clock)
public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaaktypeCatalog catalog, IClock clock)
{
public async Task<Uri> OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(registration);
// Read the current default-fill per zaak (not at construction), so a beheerder edit (S-15b)
// takes effect on the next zaak without a restart.
var defaults = fill.Current;
var request = new ZaakRequest(
defaults.Bronorganisatie,
defaults.VerantwoordelijkeOrganisatie,
@@ -24,58 +16,20 @@ public sealed class AclService(
clock.Today,
registration.Reference);
var zaakUrl = 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;
return await gateway.OpenZaakAsync(request, ct);
}
/// <summary>
/// Approve a zaak: set it to the eindstatus of the BIG zaaktype (resolved by identificatie, S-27),
/// then write the register record to Objecten (S-19a). The domain hands over only the zaak URL; the
/// ACL owns which statustype means "approved" and what the register record looks like (§8.1).
/// Approve a zaak: set it to the eindstatus of the BIG zaaktype (resolved by identificatie, S-27).
/// The domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1).
/// </summary>
/// <remarks>
/// OpenZaak holds the process, Objecten holds the register (ADR-0028), so approval is two writes
/// across two modules and is eventually consistent by construction. Both are idempotent — a status
/// is a log entry, the record upsert is keyed on the zaak id — so a caller that retries a failed
/// approval converges rather than duplicating.
/// </remarks>
public async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(zaakUrl);
await gateway.SetZaakToEindstatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct);
await register.UpsertAsync(
new RegisterRecord(
ZaakId(zaakUrl),
RegisterRecordStatus.Ingeschreven,
await gateway.GetZaakIdentificatieAsync(zaakUrl, ct)),
ct);
}
/// <summary>
/// The 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>
/// 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
@@ -93,17 +47,6 @@ public sealed class AclService(
public Task<IReadOnlyList<ZaaktypeSummary>> ListZaaktypenAsync(CancellationToken ct = default) =>
gateway.ListZaaktypenAsync(ct);
/// <summary>The current default-fill settings, for the beheer config viewer (S-15b).</summary>
public DefaultFillSettings GetDefaultFill() => fill.Current;
/// <summary>Replace the default-fill settings from the beheer portal (S-15b). Takes effect on the
/// next zaak (the fill is read per zaak, not cached).</summary>
public void UpdateDefaultFill(DefaultFillSettings settings)
{
ArgumentNullException.ThrowIfNull(settings);
fill.Update(settings);
}
/// <summary>The zaak's reference (its ZGW identificatie), for the read projection (#78).</summary>
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
{
@@ -125,7 +68,6 @@ public sealed class AclService(
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
ArgumentException.ThrowIfNullOrWhiteSpace(contentType);
var defaults = fill.Current;
var request = new DocumentRequest(
defaults.Bronorganisatie,
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);
}
+2 -123
View File
@@ -75,27 +75,6 @@ public class AclServiceTests
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()
{
Bronorganisatie = "517439943",
@@ -105,14 +84,8 @@ public class AclServiceTests
InformatieobjecttypeOmschrijving = "Diploma",
};
private static InMemoryDefaultFillStore FillFrom(AclDefaults d) =>
new(new DefaultFillSettings(d.Bronorganisatie, d.VerantwoordelijkeOrganisatie, d.Vertrouwelijkheidaanduiding));
private static AclService ServiceWith(FakeGateway gateway, AclDefaults defaults, DateOnly today) =>
ServiceWith(gateway, new FakeRegisterRecordGateway(), defaults, today);
private static AclService ServiceWith(FakeGateway gateway, FakeRegisterRecordGateway register, AclDefaults defaults, DateOnly today) =>
new(gateway, register, FillFrom(defaults), new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today));
new(gateway, defaults, new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today));
private sealed class FixedClock(DateOnly today) : IClock
{
@@ -140,68 +113,6 @@ public class AclServiceTests
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]
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()
{
var gateway = new FakeGateway();
var register = new FakeRegisterRecordGateway();
var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4));
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!));
Assert.Null(gateway.Approved);
Assert.Empty(register.Upserted);
}
[Fact]
public async Task Approving_a_zaak_writes_the_register_record_to_objecten(/* S-19a */)
{
var gateway = new FakeGateway();
var register = new FakeRegisterRecordGateway();
var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4));
await service.ApproveZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
var record = Assert.Single(register.Upserted);
// The record is keyed on the zaak id — the same key the read projection rows carry (S-19b).
Assert.Equal("abc", record.Id);
Assert.Equal("INGESCHREVEN", record.Status);
// The public-safe reference comes from the zaak's identificatie, never from the domain payload.
Assert.Equal("REG-FROM-ZAAK", record.Reference);
}
[Fact]
public async Task Cancelling_a_zaak_writes_no_register_record(/* S-19a */)
{
var gateway = new FakeGateway();
var register = new FakeRegisterRecordGateway();
var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4));
await service.CancelZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
// Only an approval enters the register; a cancelled zaak never becomes a register record.
Assert.Empty(register.Upserted);
}
[Fact]
@@ -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);
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"stryker-config": {
"solution": "Acl.slnx",
"test-projects": ["Acl.Tests/Acl.Tests.csproj"],
"reporters": ["progress", "html", "markdown"],
"reporters": ["progress", "html"],
"thresholds": {
"high": 95,
"low": 90,
+3 -27
View File
@@ -59,26 +59,12 @@ public interface IProjectionClient
/// internal reference, not shown in the portal.</summary>
public sealed record BeheerZaaktype(string Identificatie, string Omschrijving);
/// <summary>The ACL default-fill settings the beheer portal reads + edits (S-15b): the three ZGW-mandatory
/// fields the ACL stamps on every zaak (ADR-0003).</summary>
public sealed record BeheerDefaultFill(
string Bronorganisatie,
string VerantwoordelijkeOrganisatie,
string Vertrouwelijkheidaanduiding);
/// <summary>Port to the ACL for beheer queries (beheer portal). The BFF reaches the ACL directly: these
/// aren't a domain concern, and the ACL is the only code allowed to read/own the ZGW-facing config
/// (§8.1, ADR-0025).</summary>
/// <summary>Port to the ACL for read-only catalogus queries (beheer portal, S-15a). The BFF reaches the
/// ACL directly for this read: the catalogus isn't a domain concern, and the ACL is the only code
/// allowed to read the ZGW Catalogi API (§8.1, ADR-0025).</summary>
public interface IAclClient
{
/// <summary>The published catalogus zaaktypen, read-only (S-15a).</summary>
Task<IReadOnlyList<BeheerZaaktype>> GetZaaktypenAsync(CancellationToken ct = default);
/// <summary>The current default-fill settings (S-15b).</summary>
Task<BeheerDefaultFill> GetDefaultFillAsync(CancellationToken ct = default);
/// <summary>Replace the default-fill settings (S-15b).</summary>
Task UpdateDefaultFillAsync(BeheerDefaultFill settings, CancellationToken ct = default);
}
/// <summary>Calls the Domain Service's <c>POST /registrations</c>.</summary>
@@ -155,14 +141,4 @@ public sealed class AclClient(HttpClient http) : IAclClient
{
public async Task<IReadOnlyList<BeheerZaaktype>> GetZaaktypenAsync(CancellationToken ct = default)
=> await http.GetFromJsonAsync<List<BeheerZaaktype>>("catalogi/zaaktypen", ct) ?? [];
public async Task<BeheerDefaultFill> GetDefaultFillAsync(CancellationToken ct = default)
=> await http.GetFromJsonAsync<BeheerDefaultFill>("default-fill", ct)
?? throw new InvalidOperationException("The ACL returned an empty default-fill response.");
public async Task UpdateDefaultFillAsync(BeheerDefaultFill settings, CancellationToken ct = default)
{
using var response = await http.PutAsJsonAsync("default-fill", settings, ct);
response.EnsureSuccessStatusCode();
}
}
-19
View File
@@ -228,25 +228,6 @@ app.MapGet("/beheer/catalogi/zaaktypen", async (IAclClient acl, CancellationToke
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden);
// Beheer default-fill config (S-15b): read + edit the ACL's default-fill values. Behind medewerker-
// realm + beheerder authorization; the BFF proxies the ACL (ADR-0025). The ACL validates the values.
app.MapGet("/beheer/default-fill", async (IAclClient acl, CancellationToken ct) =>
Results.Ok(await acl.GetDefaultFillAsync(ct)))
.RequireAuthorization(BeheerAuth.Policy)
.Produces<BeheerDefaultFill>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden);
app.MapPut("/beheer/default-fill", async (BeheerDefaultFill body, IAclClient acl, CancellationToken ct) =>
{
await acl.UpdateDefaultFillAsync(body, ct);
return Results.NoContent();
})
.RequireAuthorization(BeheerAuth.Policy)
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden);
app.Run();
/// <summary>The behandelaar's decision on a registration.</summary>
@@ -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);
}
}
+1 -14
View File
@@ -142,24 +142,11 @@ internal sealed class FakeProjectionClient : IProjectionClient
=> 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
{
public List<BeheerZaaktype> Zaaktypen { get; } = [];
public Task<IReadOnlyList<BeheerZaaktype>> GetZaaktypenAsync(CancellationToken ct = default)
=> 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;
}
}
-70
View File
@@ -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": {
"schemas": {
"BeheerDefaultFill": {
"required": [
"bronorganisatie",
"verantwoordelijkeOrganisatie",
"vertrouwelijkheidaanduiding"
],
"type": "object",
"properties": {
"bronorganisatie": {
"type": "string"
},
"verantwoordelijkeOrganisatie": {
"type": "string"
},
"vertrouwelijkheidaanduiding": {
"type": "string"
}
}
},
"BeheerZaaktype": {
"required": [
"identificatie",
+1 -1
View File
@@ -2,7 +2,7 @@
"stryker-config": {
"solution": "Bff.slnx",
"test-projects": ["Bff.Tests/Bff.Tests.csproj"],
"reporters": ["progress", "html", "markdown"],
"reporters": ["progress", "html"],
"mutate": [
"!**/Program.cs",
"!**/DownstreamClients.cs"
+1 -1
View File
@@ -2,7 +2,7 @@
"stryker-config": {
"solution": "Big.slnx",
"test-projects": ["Big.Tests/Big.Tests.csproj"],
"reporters": ["progress", "html", "markdown"],
"reporters": ["progress", "html"],
"mutate": [
"!**/OpenZaakJobPump.cs",
"!**/BeoordelingEscalatiePump.cs",
@@ -1,4 +1,3 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using EventSubscriber.Application;
@@ -6,28 +5,26 @@ using EventSubscriber.Application;
namespace EventSubscriber.Api;
/// <summary>
/// HTTP client to the ACL service. An Objecten notification carries only the object URL, so the
/// subscriber reads the register record back through the ACL — the only code that may talk to
/// Objecten (§8.1, ADR-0028/ADR-0030) — rather than reading Objecten itself.
/// HTTP client to the ACL service. The subscriber enriches the projection with the zaak's reference
/// (identificatie) by asking the ACL — the only code that may read ZGW (§8.1) — rather than reading
/// OpenZaak itself (adr-proposal #78).
/// </summary>
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(
new Uri(http.BaseAddress!, "register-records/read"),
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;
new Uri(http.BaseAddress!, "zaken/reference"), new ReferenceRequest(zaakUrl.ToString()), ct);
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();
/// <summary>The NRC notification body, as Open Notificaties POSTs it. Only the fields the projector
/// needs are bound; <c>aanmaakdatum</c>, <c>kenmerken</c> and <c>hoofdObject</c> are ignored for a
/// register write hoofdObject is the same object as resourceUrl (ADR-0030).</summary>
public sealed record NotificationDto(string Kanaal, string Resource, string Actie, Uri ResourceUrl)
/// <summary>The NRC notification body, as Open Notificaties POSTs it. Only the fields the
/// projection needs are bound; <c>aanmaakdatum</c>/<c>kenmerken</c> are ignored for the minimal slice.</summary>
public sealed record NotificationDto(string Kanaal, string Resource, string Actie, Uri ResourceUrl, Uri? HoofdObject = null)
{
public Notification ToNotification() => new(Kanaal, Resource, Actie, ResourceUrl);
public Notification ToNotification() => new(Kanaal, Resource, Actie, ResourceUrl, HoofdObject);
}
public partial class Program
@@ -2,39 +2,40 @@ namespace EventSubscriber.Application;
/// <summary>
/// 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>
/// <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(
string Kanaal,
string Resource,
string Actie,
Uri ResourceUrl)
Uri ResourceUrl,
Uri? HoofdObject = null)
{
/// <summary>
/// A register record written to Objecten — <c>create</c> on submit and <c>partial_update</c> on
/// approval, since the ACL upserts the same object for a registration (§8.6).
/// </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>A zaak being created — projected as INGEDIEND.</summary>
public bool IsZaakCreated =>
Kanaal == "zaken" && Resource == "zaak" && Actie == "create";
/// <summary>The object holding the register record. For a <c>resource: object</c> notification
/// Objecten sends the object as both <c>hoofdObject</c> and <c>resourceUrl</c> — the object is
/// the main resource — so the notification's own <c>hoofdObject</c> is not modelled.</summary>
public Uri ObjectUrl => ResourceUrl;
/// <summary>A status being set on a zaak — the approval, projected as INGESCHREVEN (S-09b). In the
/// walking skeleton the only status ever set after creation is the approval, and the subscriber may
/// not read OpenZaak (§8.1), so any status-create is taken as the approval.</summary>
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>
/// 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
/// 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>
public sealed class NotificationProjector(INotificationLog log, IProjectionStore store, IAclClient acl)
{
/// <summary>Handle one inbound notification. Reacts to a register record being written to
/// Objecten (S-19b-2, ADR-0030) and ignores everything else. The notification carries only the
/// object URL, so the record is read back through the ACL (§8.1) and becomes the row verbatim.</summary>
/// <summary>Handle one inbound notification. Reacts to a zaak being created (INGEDIEND) and a
/// status being set (INGESCHREVEN); ignores everything else. Enriches the row with the zaak's
/// 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)
{
ArgumentNullException.ThrowIfNull(notification);
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)
if (!notification.IsZaakCreated && !notification.IsZaakStatusSet)
return;
var reference = await acl.GetZaakReferenceAsync(notification.ZaakUrl, ct);
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
// 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);
}
/// <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>
public async Task RebuildAsync(CancellationToken ct = default)
{
@@ -55,9 +35,11 @@ public sealed class NotificationProjector(INotificationLog log, IProjectionStore
await store.UpsertAsync(ToEntry(recorded), ct);
}
/// <summary>The projection row for an accepted notification. The log already holds exactly the
/// row's fields, so a rebuild needs no mapping rules and no upstream reads. bsn/naam stay
/// deferred — the register record is public-safe by construction (ADR-0027).</summary>
/// <summary>The projection row for an accepted notification: a status-set maps to INGESCHREVEN,
/// a zaak-create to INGEDIEND. bsn/naam are deferred (ADR-0008).</summary>
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
/// 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
/// 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>
public interface INotificationLog
{
@@ -19,29 +19,22 @@ public interface INotificationLog
Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default);
}
/// <summary>
/// An accepted notification, retaining exactly the projection row it produced — so a rebuild
/// reproduces the row by replaying the log, without re-reading Objecten (S-19b-2, ADR-0030).
/// </summary>
public sealed record RecordedNotification(string Key, string RegisterId, string Status, string? Reference);
/// <summary>A notification that has been accepted, retaining what a rebuild needs to recompute its
/// projection row — the ZGW <c>resource</c> (zaak-create → INGEDIEND vs status-set → INGESCHREVEN) and
/// the zaak <c>reference</c> (identificatie), so a rebuild reproduces the row without re-reading ZGW (#78).</summary>
public sealed record RecordedNotification(string Key, string Actie, string ZaakId, string Resource, string? Reference);
/// <summary>
/// Port to the Anti-Corruption Layer. An Objecten notification carries only the object URL, so the
/// subscriber reads the register record back through the ACL — the only code that may talk to
/// Objecten (§8.1, ADR-0028) — rather than reading Objecten itself.
/// Port to the Anti-Corruption Layer. The subscriber enriches the projection with the zaak's
/// public-safe reference (its identificatie) by asking the ACL — the only code that may read ZGW
/// (§8.1) — rather than reading OpenZaak itself (adr-proposal #78).
/// </summary>
public interface IAclClient
{
/// <summary>The register record the object at <paramref name="objectUrl"/> holds, or
/// <c>null</c> if it holds none — the object may be gone by the time a redelivered
/// notification is handled, which is not an error (§8.6).</summary>
Task<RegisterRecord?> GetRegisterRecordAsync(Uri objectUrl, CancellationToken ct = default);
/// <summary>The zaak's reference (identificatie) for the read projection.</summary>
Task<string> GetZaakReferenceAsync(Uri zaakUrl, 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
/// subscriber writes to it and the projection-api reads it.</summary>
public interface IProjectionStore
@@ -5,42 +5,27 @@ using EventSubscriber.Api;
namespace EventSubscriber.Tests;
/// <summary>
/// Unit tests for the subscriber's ACL client, which reads a register record through the ACL — the
/// only code allowed to talk to Objecten (§8.1, ADR-0028/ADR-0030). Uses a scripted message handler
/// so no real ACL is required.
/// Unit tests for the subscriber's ACL client, which reads a zaak's reference (identificatie) through
/// the ACL — the only code allowed to talk to ZGW (§8.1, #78). Uses a scripted message handler so no
/// real ACL is required.
/// </summary>
public class AclHttpClientTests
{
private const string ObjectUrl = "http://objecten.local:8000/api/v2/objects/obj-9";
private static AclHttpClient Client(StubHandler handler) =>
new(new HttpClient(handler) { BaseAddress = new Uri("http://acl/") });
[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 client = Client(capture.Responds(
HttpStatusCode.OK, """{"id":"zaak-1","status":"INGESCHREVEN","reference":"REG-42"}"""));
var client = Client(capture.Responds(HttpStatusCode.OK, """{"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("INGESCHREVEN", record.Status);
Assert.Equal("REG-42", record.Reference);
Assert.Equal("REG-42", reference);
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://acl/register-records/read", capture.Seen.RequestUri!.ToString());
Assert.Contains($"\"objectUrl\":\"{ObjectUrl}\"", 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)));
Assert.Equal("http://acl/zaken/reference", capture.Seen.RequestUri!.ToString());
Assert.Contains("\"zaakUrl\":\"http://openzaak/zaken/api/v1/zaken/abc\"", capture.Body);
}
[Fact]
@@ -50,7 +35,7 @@ public class AclHttpClientTests
var client = Client(capture.Responds(HttpStatusCode.BadGateway));
await Assert.ThrowsAsync<HttpRequestException>(
() => client.GetRegisterRecordAsync(new Uri(ObjectUrl)));
() => client.GetZaakReferenceAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc")));
}
[Fact]
@@ -60,17 +45,17 @@ public class AclHttpClientTests
var client = Client(capture.Responds(HttpStatusCode.OK, "null"));
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);
}
[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 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);
}
}
@@ -5,18 +5,16 @@ namespace EventSubscriber.Tests;
/// <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
/// convention — no mocking library).</summary>
/// <summary>A fake ACL client standing in for the register records Objecten holds: a test seeds a
/// record per object URL, and the call count proves a rebuild does not re-read through the ACL.</summary>
/// <summary>A fake ACL client that returns a fixed reference derived from the zaak, and records
/// how many times it was called (to prove a rebuild does not re-read via the ACL).</summary>
internal sealed class FakeAclClient : IAclClient
{
public Dictionary<string, RegisterRecord> Records { get; } = [];
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++;
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;
/// <summary>Behaviour of the projector that turns NRC notifications into projection rows. Since
/// S-19b-2 the source is the register in Objecten (ADR-0030), not ZGW zaak events: a notification
/// carries only the object URL, so the record is read back through the ACL. Duplicate and
/// out-of-order deliveries must be tolerated (CLAUDE.md §8.6).</summary>
/// <summary>Behaviour of the projector that turns NRC notifications into projection rows.
/// The walking skeleton reacts only to a zaak being created (status INGEDIEND) and must
/// tolerate duplicate and out-of-order deliveries (CLAUDE.md §8.6).</summary>
public sealed class NotificationProjectorTests
{
private const string ObjectUrl = "http://objecten.local:8000/api/v2/objects/11111111-1111-1111-1111-111111111111";
private const string ZaakId = "99999999-9999-9999-9999-999999999999";
private const string ZaakUrl = "http://openzaak:8000/zaken/api/v1/zaken/11111111-1111-1111-1111-111111111111";
private const string StatusUrl = "http://openzaak:8000/zaken/api/v1/statussen/22222222-2222-2222-2222-222222222222";
private readonly InMemoryNotificationLog _log = new();
private readonly InMemoryProjectionStore _store = new();
@@ -17,60 +16,46 @@ public sealed class NotificationProjectorTests
private NotificationProjector Projector() => new(_log, _store, _acl);
/// <summary>A register write as Objecten publishes it: the object is both hoofdObject and
/// resourceUrl, and the record itself is only reachable by reading that object.</summary>
private Notification RecordWritten(string actie = "create", string url = ObjectUrl, string status = RegistrationStatus.Ingediend, string zaakId = ZaakId)
private static Notification ZaakCreated(string url = ZaakUrl)
=> new("zaken", "zaak", "create", new Uri(url));
// 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");
return new Notification("objecten", "object", actie, new Uri(url));
await Projector().HandleAsync(ZaakCreated());
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]
public async Task a_register_record_write_is_projected_as_a_row_keyed_on_the_registration()
{
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)
public async Task rebuild_reproduces_the_reference_without_re_reading_via_the_acl()
{
var projector = Projector();
await projector.HandleAsync(RecordWritten());
await projector.HandleAsync(RecordWritten(actie, status: RegistrationStatus.Ingeschreven));
await projector.HandleAsync(ZaakCreated());
var callsAfterProjection = _acl.CallCount;
await projector.RebuildAsync();
var entry = Assert.Single(await _store.AllAsync());
Assert.Equal(ZaakId, entry.Id);
Assert.Equal(RegistrationStatus.Ingeschreven, entry.Status);
}
[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());
Assert.Equal("REG-11111111-1111-1111-1111-111111111111", entry.Reference);
// Rebuild replays the log (which stored the reference) — no extra ACL calls (#78, ADR-0008).
Assert.Equal(callsAfterProjection, _acl.CallCount);
}
[Fact]
public async Task replaying_the_same_notification_keeps_a_single_row()
{
var projector = Projector();
await projector.HandleAsync(RecordWritten());
await projector.HandleAsync(RecordWritten());
await projector.HandleAsync(ZaakCreated());
await projector.HandleAsync(ZaakCreated());
Assert.Single(await _store.AllAsync());
}
@@ -79,8 +64,8 @@ public sealed class NotificationProjectorTests
public async Task a_replayed_notification_never_reaches_the_projection_store()
{
var projector = Projector();
await projector.HandleAsync(RecordWritten());
await projector.HandleAsync(RecordWritten());
await projector.HandleAsync(ZaakCreated());
await projector.HandleAsync(ZaakCreated());
// 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.
@@ -88,59 +73,77 @@ public sealed class NotificationProjectorTests
}
[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();
await projector.HandleAsync(RecordWritten());
await projector.HandleAsync(RecordWritten(url: ObjectUrl[..^1] + "2", zaakId: "other-zaak"));
await projector.HandleAsync(ZaakCreated());
await projector.HandleAsync(ZaakCreated(ZaakUrl[..^1] + "2")); // a distinct zaak url
Assert.Equal(2, (await _store.AllAsync()).Count);
}
[Theory]
[InlineData("zaken", "zaak", "create")] // the ZGW source S-19b-2 replaced
[InlineData("zaken", "status", "create")] // ditto
[InlineData("objecten", "object", "destroy")] // a delete we do not project
[InlineData("documenten", "object", "create")] // wrong kanaal
[InlineData("documenten", "enkelvoudiginformatieobject", "create")] // wrong kanaal + resource
[InlineData("documenten", "zaak", "create")] // wrong kanaal only
[InlineData("zaken", "zaak", "update")] // wrong actie
[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)
{
_acl.Records[ObjectUrl] = new RegisterRecord(ZaakId, RegistrationStatus.Ingediend, "REG-2026-0001");
await Projector().HandleAsync(new Notification(kanaal, resource, actie, new Uri(ObjectUrl)));
await Projector().HandleAsync(new Notification(kanaal, resource, actie, new Uri(ZaakUrl)));
Assert.Empty(await _store.AllAsync());
}
[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();
await projector.HandleAsync(RecordWritten());
await projector.HandleAsync(RecordWritten("partial_update", status: RegistrationStatus.Ingeschreven));
var callsAfterProjection = _acl.CallCount;
await projector.HandleAsync(ZaakCreated());
await projector.HandleAsync(StatusSet());
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();
var entry = Assert.Single(await _store.AllAsync());
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]
public async Task rebuild_clears_stale_rows_and_repopulates_from_the_notification_log()
{
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.
await _store.UpsertAsync(new RegisterEntry("stale-9999", RegistrationStatus.Ingediend));
await projector.RebuildAsync();
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);
}
}
@@ -2,7 +2,7 @@
"stryker-config": {
"solution": "EventSubscriber.slnx",
"test-projects": ["EventSubscriber.Tests/EventSubscriber.Tests.csproj"],
"reporters": ["progress", "html", "markdown"],
"reporters": ["progress", "html"],
"thresholds": {
"high": 95,
"low": 90,
@@ -13,8 +13,9 @@ public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog
db.ProcessedNotifications.Add(new ProcessedNotificationRow
{
Key = notification.Key,
RegisterId = notification.RegisterId,
Status = notification.Status,
Actie = notification.Actie,
ZaakId = notification.ZaakId,
Resource = notification.Resource,
Reference = notification.Reference,
ReceivedAt = DateTimeOffset.UtcNow,
});
@@ -35,6 +36,6 @@ public sealed class EfNotificationLog(ProjectionDbContext db) : INotificationLog
public async Task<IReadOnlyList<RecordedNotification>> AllAsync(CancellationToken ct = default)
=> await db.ProcessedNotifications
.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);
}
@@ -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
}
}
}
@@ -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: "");
}
}
}
@@ -28,6 +28,11 @@ namespace Projection.ReadModel.Migrations
.HasColumnType("text")
.HasColumnName("key");
b.Property<string>("Actie")
.IsRequired()
.HasColumnType("text")
.HasColumnName("actie");
b.Property<DateTimeOffset>("ReceivedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("received_at");
@@ -36,15 +41,15 @@ namespace Projection.ReadModel.Migrations
.HasColumnType("text")
.HasColumnName("reference");
b.Property<string>("RegisterId")
b.Property<string>("Resource")
.IsRequired()
.HasColumnType("text")
.HasColumnName("register_id");
.HasColumnName("resource");
b.Property<string>("Status")
b.Property<string>("ZaakId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
.HasColumnName("zaak_id");
b.HasKey("Key");
@@ -34,8 +34,9 @@ public sealed class ProjectionDbContext(DbContextOptions<ProjectionDbContext> op
e.ToTable("processed_notifications");
e.HasKey(r => r.Key);
e.Property(r => r.Key).HasColumnName("key");
e.Property(r => r.RegisterId).HasColumnName("register_id").IsRequired();
e.Property(r => r.Status).HasColumnName("status").IsRequired();
e.Property(r => r.Actie).HasColumnName("actie").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.ReceivedAt).HasColumnName("received_at");
});
@@ -55,20 +56,18 @@ public sealed class RegisterEntryRow
public string? NaamPlaceholder { get; set; }
}
/// <summary>An accepted notification, retained so the projection can be rebuilt without reading
/// 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>
/// <summary>An accepted notification, retained so the projection can be rebuilt without OpenZaak (§8.1).</summary>
public sealed class ProcessedNotificationRow
{
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>
public required string RegisterId { get; set; }
/// <summary>The ZGW resource (e.g. <c>zaak</c> or <c>status</c>) — retained so a rebuild reprojects
/// 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>
public required string Status { get; set; }
/// <summary>The citizen-facing reference the record carried — matches the submit confirmation (#78).</summary>
/// <summary>The zaak reference (identificatie), retained so a rebuild reprojects it without the ACL (#78).</summary>
public string? Reference { get; set; }
public DateTimeOffset ReceivedAt { get; set; }
@@ -1,28 +1,19 @@
# language: en
# Drives S-19b-2 (#153), re-sourcing S-06 (#7). The read projection is derived from the
# RegisterRecord in Objecten (ADR-0030), not from ZGW zaak events: the ACL records a registration
# in the register, Objecten notifies, and the Event Subscriber projects the record that
# notification points at. This scenario exercises the use case against in-memory stand-ins for the
# register, the projection store and the notification log; real Objecten → NRC → subscriber
# delivery is verified by the live-stack check (verify-projection, ADR-0007/0030).
Feature: Register-projectie bijwerken op een registerwijziging
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.
# Drives S-06 (#7). On a zaak-created notification from NRC the Event Subscriber writes a
# rebuildable read-projection row (PRD §8.4). This scenario exercises the use case against an
# in-memory stand-in for the projection store and notification log; real OpenZaak → NRC →
# subscriber delivery is verified by the live-stack check (verify-projection, ADR-0007/#58).
Feature: Register-projectie bijwerken op een zaaknotificatie
Als openbaar register wil ik dat een aangemaakte zaak in de projectie verschijnt
zodat het register de ingediende registratie kan tonen.
Scenario: Een ingediende registratie levert een rij met status INGEDIEND
Given registration "11111111-1111-1111-1111-111111111111" is recorded in the register with status "INGEDIEND"
When the register notification is delivered to the event subscriber
Scenario: Een zaaknotificatie levert een rij met status INGEDIEND
Given a zaak is created in OpenZaak with id "11111111-1111-1111-1111-111111111111"
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"
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
Given registration "22222222-2222-2222-2222-222222222222" is recorded in the register with status "INGEDIEND"
When the register notification is delivered to the event subscriber
And the same register notification is delivered again
Given a zaak is created in OpenZaak with id "22222222-2222-2222-2222-222222222222"
When the NRC notification for that zaak is delivered to the event subscriber
And the same NRC notification is delivered again
Then the register projection contains exactly one row for "22222222-2222-2222-2222-222222222222"
+1 -3
View File
@@ -44,9 +44,7 @@ public sealed class EenZaakOpenenSteps
[When("the domain asks the ACL to open a zaak")]
public async Task WhenTheDomainAsksTheAclToOpenAZaak()
{
var fill = new InMemoryDefaultFillStore(new DefaultFillSettings(
_defaults!.Bronorganisatie, _defaults.VerantwoordelijkeOrganisatie, _defaults.Vertrouwelijkheidaanduiding));
var service = new AclService(_gateway, new InMemoryRegisterRecordGateway(), fill, new CachedZaaktypeCatalog(_gateway, _defaults!), new FixedClock(_today));
var service = new AclService(_gateway, _defaults!, new CachedZaaktypeCatalog(_gateway, _defaults!), new FixedClock(_today));
_returnedUrl = await service.OpenZaakAsync(_registration!);
}
@@ -5,39 +5,31 @@ using Xunit;
namespace Acceptance.Steps;
/// <summary>Bindings for <c>RegisterProjectieBijwerken.feature</c> (S-06, re-sourced by S-19b-2).
/// Reqnroll creates one instance per scenario, so instance fields hold scenario-scoped state.</summary>
/// <summary>Bindings for <c>RegisterProjectieBijwerken.feature</c> (S-06). Reqnroll creates
/// one instance per scenario, so instance fields hold scenario-scoped state.</summary>
[Binding]
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 InMemoryProjectionStore _store = new();
private readonly InMemoryRegisterRecordClient _register = new();
private readonly NotificationProjector _projector;
private Notification? _notification;
public RegisterProjectieBijwerkenSteps()
=> _projector = new NotificationProjector(_log, _store, _register);
=> _projector = new NotificationProjector(_log, _store, new InMemoryAclReferenceClient());
[Given("registration \"(.*)\" is recorded in the register with status \"(.*)\"")]
[When("registration \"(.*)\" is recorded in the register with status \"(.*)\"")]
public void RegistrationIsRecorded(string id, string status)
{
// 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("a zaak is created in OpenZaak with id \"(.*)\"")]
public void GivenAZaakIsCreatedInOpenZaakWithId(string id)
=> _notification = new Notification("zaken", "zaak", "create", new Uri(ZaakBase + id));
[Given("the register notification is delivered to the event subscriber")]
[When("the register notification is delivered to the event subscriber")]
public Task TheNotificationIsDelivered()
[When("the NRC notification for that zaak is delivered to the event subscriber")]
public Task WhenTheNotificationIsDelivered()
=> _projector.HandleAsync(_notification!);
[When("the same register notification is delivered again")]
public Task TheSameNotificationIsDeliveredAgain()
[When("the same NRC notification is delivered again")]
public Task WhenTheSameNotificationIsDeliveredAgain()
=> _projector.HandleAsync(_notification!);
[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)];
}
/// <summary>An in-memory stand-in for the register the ACL reads back for the projector, so the
/// scenario runs without a running ACL or Objecten (S-19b-2, ADR-0030).</summary>
public sealed class InMemoryRegisterRecordClient : IAclClient
/// <summary>A fake ACL client for the projection acceptance scenario: returns a reference derived
/// from the zaak, so the projector can enrich rows without a running ACL (#78).</summary>
public sealed class InMemoryAclReferenceClient : IAclClient
{
public Dictionary<string, RegisterRecord> Records { get; } = [];
public Task<RegisterRecord?> GetRegisterRecordAsync(Uri objectUrl, CancellationToken ct = default)
=> Task.FromResult(Records.TryGetValue(objectUrl.ToString(), out var record) ? record : null);
public Task<string> GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default)
=> Task.FromResult("REG-" + zaakUrl.Segments[^1].Trim('/'));
}
@@ -53,20 +53,3 @@ public sealed class InMemoryZaakGateway : IZaakGateway
=> Task.FromResult<IReadOnlyList<ZaaktypeSummary>>(
[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]);
}
-26
View File
@@ -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();
});
+1 -3
View File
@@ -21,9 +21,7 @@ export default defineConfig({
// 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.
workers: 1,
// `list` for the live log; `json` (→ /e2e/playwright-report.json in the container) is copied out
// 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' }]],
reporter: [['list']],
use: {
baseURL,
trace: 'on-first-retry',
+7 -61
View File
@@ -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
// logs in via mock DigiD and submits through the self-service portal → BFF → domain; the entry
// appears in the openbaar register as INGEDIEND; the citizen supplies the documents the process is
// waiting for (S-10a); a behandelaar then logs in to the behandel portal, finds the registration in
// the werkbak, and approves it (goedkeuren); the decision completes the Flowable Beoordelen task and
// flows via the ACL → Objecten → NRC → event-subscriber → projection, and the openbaar register
// 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.
// Walking-skeleton happy path (S-08d + S-09 + S-09b + S-12 + S-10a): a zorgprofessional logs in via
// mock DigiD and submits through the self-service portal → BFF → domain; the entry appears in the
// openbaar register as INGEDIEND; the citizen supplies the documents the process is waiting for
// (S-10a); a behandelaar then logs in to the behandel portal, finds the registration in the werkbak,
// and approves it (goedkeuren); the decision completes the Flowable Beoordelen task and flows via the
// ACL → NRC → event-subscriber → projection, and the openbaar register shows INGESCHREVEN.
test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt → public INGESCHREVEN', async ({
page,
context,
@@ -114,53 +109,4 @@ test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt
return staff.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGESCHREVEN' }).count();
}, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] })
.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();
}
}