Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcd24d17a3 | ||
|
|
a693137c7c | ||
|
|
a940a6c9ce | ||
|
|
97ef3b9535 |
@@ -9,12 +9,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Supersede stale runs: a new push to the same branch/PR cancels the previous run, so the runner's
|
||||
# concurrency slots aren't spent on commits nobody is waiting for (refs #127).
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Self-hosted runner — see docs/runbooks/ci.md for the runner setup.
|
||||
# `uses:` are absolute, tag-pinned URLs (CLAUDE.md §8.7 / §15).
|
||||
|
||||
@@ -135,20 +129,12 @@ jobs:
|
||||
path: services/bff/StrykerOutput/**/reports/mutation-report.html
|
||||
if-no-files-found: warn
|
||||
|
||||
# One stage for every check that needs the live stack. Booting OpenZaak once (instead
|
||||
# of once per job) is the cheapest layout (issue #58). No setup-dotnet: the ACL test runs
|
||||
# in a built image and everything reaches services by container IP. Needs Docker + egress
|
||||
# One stage for every check that needs the live stack. On the single self-hosted
|
||||
# runner jobs run sequentially, so booting OpenZaak once (instead of once per job)
|
||||
# is the cheapest layout (issue #58). No setup-dotnet: the ACL test runs in a built
|
||||
# image and everything reaches services by container IP. Needs Docker + egress
|
||||
# (base images, nuget, selectielijst.openzaak.nl).
|
||||
#
|
||||
# `needs: [mutation]` is NOT a data dependency — it serialises the two memory-heavy jobs so
|
||||
# they never co-schedule now the runner has capacity >1. A concurrent Stryker run + full-stack
|
||||
# bring-up + Playwright browser on one host is what OOMs the e2e (commit d5e5fa2, #126). The
|
||||
# light .NET/frontend jobs have no `needs`, so they still parallelise up to runner capacity.
|
||||
# `if: !cancelled()` keeps verify-stack running even when the mutation ratchet fails (so we don't
|
||||
# lose its signal) while still honouring run cancellation from the concurrency group above.
|
||||
verify-stack:
|
||||
needs: [mutation]
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
@@ -156,8 +142,6 @@ jobs:
|
||||
# reaches green health" smoke (it replaces the old compose-smoke job).
|
||||
- name: Bring up the full stack & wait for health
|
||||
run: make verify-up
|
||||
- name: Observability backplane (Grafana + Tempo + Prometheus datasources)
|
||||
run: OBS_TIMEOUT=180 make verify-observability
|
||||
- name: ACL ↔ OpenZaak integration tests
|
||||
run: make verify-acl
|
||||
- name: OpenZaak → NRC notification delivery
|
||||
@@ -168,14 +152,12 @@ jobs:
|
||||
run: make verify-domain
|
||||
- name: BFF → Keycloak + domain + projection
|
||||
run: make verify-bff
|
||||
- name: Distributed traces reach Tempo (one connected trace across services)
|
||||
run: TRACING_TIMEOUT=120 make verify-tracing
|
||||
- name: Self-service e2e (Playwright, login → submit → success)
|
||||
run: make verify-e2e
|
||||
# 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 tempo prometheus grafana 2>&1 || true
|
||||
run: docker compose -f infra/docker-compose.yml logs --no-color --tail=100 oz-init openzaak nrc-init nrc-web nrc-celery nrc-beat flowable-db flowable-rest flowable-init keycloak acl bff domain projection-db event-subscriber projection-api self-service openbaar behandel 2>&1 || true
|
||||
- name: Tear down
|
||||
if: always()
|
||||
run: make down
|
||||
|
||||
+3
-9
@@ -253,19 +253,13 @@ Split (issue #11 closed) into two independently-demoable slices per §13 — the
|
||||
|
||||
**Outcome:** Beheer portal lets an admin view ZTC catalogi (read-only first), and manage the ACL's default-fill configuration via a CRUD UI. MFA on the medewerker realm enforced.
|
||||
|
||||
### S-16 · OpenTelemetry traces + Grafana dashboard *(split — #17 closed)*
|
||||
### S-16 · OpenTelemetry traces + Grafana dashboard
|
||||
|
||||
**Outcome:** Traces span portal → BFF → Domain → ACL → OpenZaak and portal → BFF → Domain → Flowable. Grafana dashboards pre-built for golden signals.
|
||||
|
||||
Split into independently deployable sub-slices (CLAUDE.md §13):
|
||||
### S-17 · Quartz.NET scheduler — herregistratie reminder sweep
|
||||
|
||||
- **S-16a** (#122) · Observability backplane — Grafana Tempo + Prometheus + Grafana in compose, datasources auto-provisioned (ADR-0023). No collector; config baked into built images.
|
||||
- **S-16b** (#123) · Distributed traces across the five .NET services (OTLP → Tempo; traceparent propagates via the typed HttpClients). Depends on S-16a. ✅
|
||||
- **S-16c** (#124) · Prometheus metrics + golden-signal Grafana dashboards. Depends on S-16a.
|
||||
|
||||
### S-17 · Quartz.NET scheduler — herregistratie reminder sweep ✅
|
||||
|
||||
**Outcome:** Daily Quartz.NET cron job finds inscriptions within 90 days of their herregistratie deadline and reminds each (flag on the aggregate + log). No outbound notification and no domain event in v1 — the reminder is the persisted flag, surfaced on the read model (ADR-0022, #120). Quartz fires time-triggered sweeps; the existing pumps stay as queue-drainers.
|
||||
**Outcome:** Nightly job that finds entries within 90 days of expiry and emits a domain event. (No outbound notification in v1 — logged.)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
|
||||
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
|
||||
|
||||
## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions)
|
||||
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
|
||||
@@ -170,16 +170,6 @@ verify-bff:
|
||||
verify-e2e:
|
||||
bash infra/run-e2e-check.sh
|
||||
|
||||
## verify-observability: assert the observability backplane (Grafana + provisioned Tempo &
|
||||
## Prometheus datasources) is live, against the already-running stack (S-16a).
|
||||
verify-observability:
|
||||
bash infra/run-observability-check.sh
|
||||
|
||||
## verify-tracing: assert one connected distributed trace spans the .NET services in Tempo
|
||||
## (S-16b), against the already-running stack.
|
||||
verify-tracing:
|
||||
bash infra/run-tracing-check.sh
|
||||
|
||||
## verify: 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.
|
||||
|
||||
@@ -21,20 +21,16 @@ function providers(
|
||||
post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||
withdraw = vi.fn().mockReturnValue(of(undefined)),
|
||||
provideDocuments = vi.fn().mockReturnValue(of(undefined)),
|
||||
// Resume lookup (S-26): default to 204/empty — no in-flight registration, so the submit form shows.
|
||||
getCurrent = vi.fn().mockReturnValue(of(undefined)),
|
||||
) {
|
||||
return {
|
||||
post,
|
||||
withdraw,
|
||||
provideDocuments,
|
||||
getCurrent,
|
||||
providers: [
|
||||
{ provide: AuthService, useClass: FakeAuth },
|
||||
{
|
||||
provide: BffApiV1Service,
|
||||
useValue: {
|
||||
getSelfServiceRegistrations: getCurrent,
|
||||
postSelfServiceRegistrations: post,
|
||||
postSelfServiceRegistrationsIdWithdraw: withdraw,
|
||||
postSelfServiceRegistrationsIdDocuments: provideDocuments,
|
||||
@@ -60,21 +56,6 @@ describe('RegistrationPage', () => {
|
||||
expect(await screen.findByText(/ontvangen/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('resumes an existing registration on load, without submitting again (S-26)', async () => {
|
||||
const { post, providers: p } = providers(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
vi.fn().mockReturnValue(of({ registrationId: 'reg-77', status: 'Ingediend' })),
|
||||
);
|
||||
await render(RegistrationPage, { providers: p });
|
||||
|
||||
// The confirmation view is restored from the in-flight registration — no submit click.
|
||||
expect(await screen.findByText(/ontvangen/i)).toBeTruthy();
|
||||
expect(screen.getByText(/reg-77/)).toBeTruthy();
|
||||
expect(post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an error and keeps the submit available when the BFF call fails', async () => {
|
||||
const { post, providers: p } = providers(vi.fn().mockReturnValue(throwError(() => new Error('BFF rejected'))));
|
||||
await render(RegistrationPage, { providers: p });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, inject, type OnInit, signal } from '@angular/core';
|
||||
import { BffApiV1Service, type CurrentRegistration, type SubmitAccepted } from 'api-client';
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { BffApiV1Service, type SubmitAccepted } from 'api-client';
|
||||
import { AuthService } from 'auth';
|
||||
import { UtrechtComponentsModule } from 'ui';
|
||||
|
||||
@@ -8,16 +8,13 @@ import { UtrechtComponentsModule } from 'ui';
|
||||
* registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and-
|
||||
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c). After
|
||||
* submitting they can withdraw it — "trek aanvraag in" — keyed by that reference (S-11c).
|
||||
*
|
||||
* On load it asks the BFF for the caller's current open registration and restores the submitted view
|
||||
* if there is one, so a page refresh no longer strands an in-flight registration (S-26).
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-registration-page',
|
||||
imports: [UtrechtComponentsModule],
|
||||
templateUrl: './registration-page.html',
|
||||
})
|
||||
export class RegistrationPage implements OnInit {
|
||||
export class RegistrationPage {
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly bff = inject(BffApiV1Service);
|
||||
|
||||
@@ -34,23 +31,6 @@ export class RegistrationPage implements OnInit {
|
||||
protected readonly provideDocumentsFailed = signal(false);
|
||||
protected readonly selectedFile = signal<File | undefined>(undefined);
|
||||
|
||||
/** Resume an existing in-flight registration after a refresh (S-26): the BFF returns the caller's
|
||||
* current open registration, or 204 (empty body) when there is none — in which case we show the
|
||||
* submit form as before. Failures are non-fatal for the same reason. */
|
||||
ngOnInit(): void {
|
||||
this.bff.getSelfServiceRegistrations().subscribe({
|
||||
next: (current: CurrentRegistration | void) => {
|
||||
if (current && current.registrationId) {
|
||||
this.reference.set(current.registrationId);
|
||||
this.submitted.set(true);
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
// No resumable registration (or the lookup failed) — fall back to the submit form.
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
this.submitting.set(true);
|
||||
this.failed.set(false);
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# ADR-0021: The ACL resolves its zaaktype by identificatie, not a pinned URL
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-22
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Relates to:** S-27 (#113), proposed in #117. The cleaner design deliberately split out of S-B04
|
||||
(#110, ADR-0020), which fixed the local stack with an infra-only bootstrap.
|
||||
|
||||
## Context
|
||||
|
||||
The ACL was handed a **pinned zaaktype URL** (`Acl__Defaults__ZaaktypeUrl`) and diploma
|
||||
informatieobjecttype URL. OpenZaak assigns those UUIDs at creation, so the URL is not knowable when
|
||||
the compose file is written — every stack had to seed the catalogus and then capture + inject the
|
||||
resulting URLs out of band: `run-domain-check.sh` for CI, and the `local-seed` → `acl.env` bootstrap
|
||||
(ADR-0020) for `make local`. Brittle, and a stale/placeholder URL failed opaquely (OpenZaak 400).
|
||||
|
||||
## Decision
|
||||
|
||||
**The ACL resolves its zaaktype (by `identificatie`) and diploma informatieobjecttype (by
|
||||
`omschrijving`) from OpenZaak's Catalogi API, instead of being handed the URLs.**
|
||||
|
||||
- **Config:** `AclDefaults.ZaaktypeUrl`/`InformatieobjecttypeUrl` → `ZaaktypeIdentificatie`
|
||||
(`BIG-REGISTRATIE`) / `InformatieobjecttypeOmschrijving` (`Diploma`).
|
||||
- **Lookup (gateway, §8.1):** `GET /catalogi/api/v1/zaaktypen?status=definitief&identificatie=…` →
|
||||
the published zaaktype URL; `GET /catalogi/api/v1/informatieobjecttypen?status=definitief` matched
|
||||
on `omschrijving`. Reuses the gateway's existing catalogus-query machinery.
|
||||
- **Timing = lazy + cached (`CachedZaaktypeCatalog`).** Resolve on first use (first zaak open /
|
||||
document store) and cache for the process lifetime. Lazy avoids a startup ordering coupling — the
|
||||
ACL never crash-loops when it boots before the catalogus is published. A **failed** resolution is
|
||||
not cached, so it is retried on the next call (e.g. once the zaaktype is published); a restart
|
||||
re-resolves.
|
||||
- **Failure mode:** no published match → a clear "No published zaaktype with identificatie '…' found
|
||||
in OpenZaak — is the BIG catalogus seeded and published?" error, replacing the opaque placeholder
|
||||
400.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- No stack captures or injects a server-assigned URL any more: `run-domain-check.sh` drops the
|
||||
`ACL_ZAAKTYPE_URL`/`ACL_INFORMATIEOBJECTTYPE_URL` capture+inject, `docker-compose.yml`/`.local.yml`
|
||||
drop the placeholder URL env, and `local-seed`/`acl.env` shrink to a single line. The ACL
|
||||
self-configures from the catalogus it already talks to.
|
||||
- The failure mode is legible (a named error instead of a 400 on a zeros-UUID).
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- The ACL still needs its OpenZaak **BaseUrl** pointed at a **URL-valid host (a container IP)**, so
|
||||
the base-URL injection from ADR-0020 stays (the local `acl.env` now carries only that; CI keeps
|
||||
`ACL_OPENZAAK_BASEURL`). This is **not** something S-27 can remove: OpenZaak validates the
|
||||
`zaaktype` field on zaak-create with Django's URLValidator and **rejects a single-label host**
|
||||
(`http://openzaak:8000/…` → `zaaktype: bad-url, "Voer een geldige URL in."`, confirmed empirically).
|
||||
So ADR-0020's `seed-env` volume + ACL entrypoint shim are **simplified, not deleted**.
|
||||
- New branching in the gateway/resolver → unit + integration test surface; the mutation ratchet
|
||||
covers it (§5).
|
||||
- A seed step still **creates + publishes** the zaaktype (this ADR changes only discovery). Reaching
|
||||
OpenZaak's Catalogi API to *seed* likewise needs the IP host (its query params hit the same
|
||||
URLValidator) — unchanged from before.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Resolve at startup** (eager). Simpler cache, but reintroduces the ordering coupling (crash-loop
|
||||
if the catalogus isn't published yet). Rejected in favour of lazy.
|
||||
- **Per-request resolution** (no cache). No stale-cache risk, but a Catalogi lookup on every ACL
|
||||
operation. Rejected; a process-lifetime cache with restart-to-refresh is enough here.
|
||||
- **Keep the pinned URL** (status quo / ADR-0020 only). Rejected — the brittleness this ADR removes is
|
||||
exactly what S-27 was carved out to fix.
|
||||
@@ -1,79 +0,0 @@
|
||||
# ADR-0022: Quartz.NET for time-triggered fleet sweeps
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-23
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-17 (#18) · **Proposal issue:** #120
|
||||
|
||||
## Context
|
||||
|
||||
A BIG inscription is valid for a fixed term; before it lapses the zorgprofessional
|
||||
must herregistreren. S-17 adds a **herregistratie reminder sweep**: once a day,
|
||||
scan the register for inscriptions whose deadline is within the reminder window and
|
||||
remind each one.
|
||||
|
||||
The Domain Service already runs periodic background work — `OpenZaakJobPump`,
|
||||
`BeoordelingEscalatiePump`, `RegistratieVerlopenPump`. Those are **continuous job
|
||||
pollers**: they drain Flowable's external-task/job queues at-least-once, picking up
|
||||
work as soon as it is parked, on a short poll interval. The reminder sweep is a
|
||||
different shape of work: **time-triggered**, once a day, over our own store — there
|
||||
is no queue to drain and no "as soon as possible" requirement.
|
||||
|
||||
The PRD already names the scheduler component: "Scheduler (Quartz.NET): fleet-wide
|
||||
sweeps (expiry, reminders)" (§39, §94). Adding Quartz.NET is nonetheless a new
|
||||
dependency, so this decision is recorded before the code lands (CLAUDE.md §14).
|
||||
|
||||
## Decision
|
||||
|
||||
**Use Quartz.NET for time-triggered fleet sweeps, starting with the herregistratie
|
||||
reminder sweep. Leave the existing pumps as `BackgroundService` job pollers.**
|
||||
|
||||
- `HerregistratieReminderJob` (a Quartz `IJob`) is fired by a cron trigger — daily
|
||||
at 03:00 by default, overridable with `Quartz__Cron`. It is a thin shell: it
|
||||
resolves the pure `HerregistratieReminderSweep` (application layer) and logs how
|
||||
many reminders went out.
|
||||
- The sweep's rule lives in the domain: `Registration.HerregistratieReminderDue(asOf)`,
|
||||
which the store query and the sweep both build on. The sweep marks each reminded
|
||||
inscription (`HerregistratieReminderVerstuurd`), so a re-fire reminds no one twice
|
||||
(§8.6).
|
||||
|
||||
Two options were rejected:
|
||||
|
||||
1. **A `BackgroundService` with a 24h `Task.Delay`.** No new dependency, but it
|
||||
drifts to process-start time, has no cron/misfire semantics, and contradicts the
|
||||
PRD's named component. A daily "run at 03:00" is exactly what cron scheduling is
|
||||
for.
|
||||
2. **Migrating the three pumps onto Quartz too, for one mechanism.** Rejected: the
|
||||
pumps are not schedulers. Forcing a "run at time T" tool onto "drain this queue
|
||||
continuously" work is churn and a boundary change for negative benefit. The
|
||||
teachable distinction is worth keeping: **pumps drain queues; Quartz fires
|
||||
sweeps.**
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- Cron scheduling with restart-stable timing and misfire handling, for free.
|
||||
- The reminder rule is one domain method, reused by the store query and the sweep;
|
||||
the scheduler owns none of the policy.
|
||||
- The reference app now demonstrates the intended Scheduler component.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- One new dependency (`Quartz`, `Quartz.Extensions.Hosting`) in the Domain Service.
|
||||
- Two periodic-work mechanisms coexist (pumps + Quartz). Deliberate — they model
|
||||
two genuinely different concerns, documented here.
|
||||
|
||||
**Follow-up**
|
||||
|
||||
- The validity term (5 years) and reminder lead time (16 weeks) are domain
|
||||
calibration knobs; promote them to beheer config (S-15) if a demo needs them
|
||||
per-catalogus.
|
||||
- The Quartz job stores its schedule in RAM (`RAMJobStore`); a persistent/clustered
|
||||
store is a later concern if the Domain Service is scaled out.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None. Quartz is internal to the Domain Service and drives an application use case
|
||||
over the store port. No ZGW or Flowable coupling is added; the sweep talks to no
|
||||
peer module.
|
||||
@@ -1,74 +0,0 @@
|
||||
# ADR-0023: Grafana-native observability stack (Tempo + Prometheus + Grafana)
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-07-23
|
||||
- **Deciders:** Respellion engineering
|
||||
- **Slice:** S-16a (#122), first of the S-16 (#17) split
|
||||
|
||||
## Context
|
||||
|
||||
The PRD calls for "OpenTelemetry traces, Prometheus metrics; a local Grafana with
|
||||
pre-built dashboards" (§80). S-16 was split (CLAUDE.md §13) into a backplane slice
|
||||
(this one), distributed tracing (#123), and metrics + dashboards (#124). The
|
||||
backplane must stand up first: a local, CI-friendly place for traces and metrics to
|
||||
land, viewable in one UI, reaching green health within the 3-minute compose budget.
|
||||
|
||||
Two shape decisions are non-obvious enough to record.
|
||||
|
||||
## Decision
|
||||
|
||||
**Run a Grafana-native stack — Grafana Tempo (traces) + Prometheus (metrics) +
|
||||
Grafana (UI) — with the services exporting OTLP straight to Tempo (no collector),
|
||||
and ship the config baked into small built images.**
|
||||
|
||||
### Trace backend: Tempo (not Jaeger)
|
||||
|
||||
Tempo keeps everything under one Grafana pane alongside metrics (and later logs),
|
||||
which is exactly the "local Grafana with dashboards" the PRD asks for. Jaeger would
|
||||
add a second UI and a second mental model for no benefit at this scale.
|
||||
|
||||
### No OTLP collector
|
||||
|
||||
Tempo ingests OTLP directly (gRPC 4317 / HTTP 4318) and Prometheus scrapes each
|
||||
service's `/metrics`, so a collector would be a hop that processes nothing. Skipped.
|
||||
If we later need fan-out, tail sampling, or log processing, a collector is an
|
||||
additive change — the services already speak OTLP.
|
||||
|
||||
### Config baked into built images, not config volumes
|
||||
|
||||
The upstream Common Ground modules (OpenZaak, NRC, Keycloak, Flowable) run as
|
||||
**verbatim** images and get their config streamed into external named volumes by
|
||||
`infra/seed-config.sh`, because bind mounts don't reach sibling containers on the
|
||||
CI runner (see `docs/runbooks/gitea-actions-gotchas.md`). The observability tools
|
||||
are **not** peer modules we must run verbatim, so we take the simpler path: a
|
||||
three-line `Dockerfile` per tool that `COPY`s its config in. This reaches sibling
|
||||
containers everywhere (docker, podman, CI) with no seed step, no `CFG_VOLS` entry,
|
||||
and no Makefile sprawl.
|
||||
|
||||
### Verified, not assumed
|
||||
|
||||
`infra/run-observability-check.sh` (the `verify-observability` step, run early in CI
|
||||
`verify-stack`) asks Grafana to reach both datasources — Prometheus via its health
|
||||
method, Tempo via the datasource proxy (Tempo's Grafana plugin implements no health
|
||||
method) — so the check proves the datasources are actually wired, not merely that
|
||||
containers started. The containers are not in `WAIT_SVCS`; the check polls Grafana
|
||||
itself, so no in-image healthcheck tool is required.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
|
||||
- One UI for traces + metrics + (future) logs. Config is versioned in
|
||||
`infra/observability/` and self-contained in the images.
|
||||
- Backplane is independent of app instrumentation — #123 and #124 build on it.
|
||||
|
||||
**Negative / costs**
|
||||
|
||||
- Three more images built each CI run (kept small; not on the health-gate list).
|
||||
- Storage is ephemeral container fs — a demo backplane, not a retention target.
|
||||
Object storage for Tempo / remote-write for Prometheus is a later concern.
|
||||
|
||||
## Coupling rules touched (CLAUDE.md §8)
|
||||
|
||||
None. The stack is passive infrastructure: services *push* OTLP and *expose*
|
||||
`/metrics`; nothing in the stack calls into a service or a peer module.
|
||||
+3
-106
@@ -5,87 +5,6 @@ copy-pasteable walkthrough against a local `make up` stack.
|
||||
|
||||
---
|
||||
|
||||
## S-16b — distributed traces across the .NET services (#123, ADR-0023)
|
||||
|
||||
**Outcome:** the five .NET services (BFF, Domain, ACL, projection-api, event-subscriber) now emit
|
||||
OpenTelemetry traces — ASP.NET Core + `HttpClient` auto-instrumentation, exported over OTLP to Tempo.
|
||||
Because every cross-service call goes through a typed `HttpClient`, the W3C `traceparent` propagates for
|
||||
free, so a request is **one connected trace** across the services (bff → domain → acl → openzaak;
|
||||
bff → projection-api). `/health` is filtered out. No browser-side instrumentation yet, so the trace
|
||||
begins at the BFF; the async Flowable-poll boundary is a separate trace (ADR-0023).
|
||||
|
||||
```bash
|
||||
# 1. Automated (a CI verify-stack step): generate BFF traffic and assert Tempo holds one trace
|
||||
# spanning multiple services.
|
||||
make verify-tracing # → OK — trace <id> spans ['bff', 'projection-api']
|
||||
|
||||
# 2. By hand: drive the stack, then explore traces in Grafana.
|
||||
make up
|
||||
curl -s localhost:8080/openbaar/register >/dev/null # BFF → projection-api
|
||||
open http://localhost:3000 # Grafana → Explore → Tempo → Search → service.name = bff → open a trace
|
||||
```
|
||||
|
||||
**The path:** each host wires `AddOpenTelemetry().WithTracing(AddAspNetCoreInstrumentation +
|
||||
AddHttpClientInstrumentation + AddOtlpExporter)`; `OTEL_SERVICE_NAME` / `OTEL_EXPORTER_OTLP_ENDPOINT`
|
||||
come from compose; spans export to **tempo:4317** and render in Grafana against the provisioned Tempo
|
||||
datasource.
|
||||
|
||||
---
|
||||
|
||||
## S-16a — observability backplane: Tempo + Prometheus + Grafana (#122, ADR-0023)
|
||||
|
||||
**Outcome:** the compose stack now includes a Grafana-native observability backplane — **Tempo** (OTLP
|
||||
trace ingest on 4317/4318), **Prometheus**, and **Grafana** with both datasources auto-provisioned.
|
||||
Nothing is instrumented yet (traces land in S-16b, metrics + dashboards in S-16c); this slice stands the
|
||||
backplane up and proves Grafana can reach both datasources. Config is baked into small built images
|
||||
(`infra/observability/`) — no collector, no config-volume seeding.
|
||||
|
||||
```bash
|
||||
# 1. Bring the stack up, then assert the backplane is live (Grafana healthy + Tempo/Prometheus
|
||||
# datasources reachable through Grafana). This is a CI verify-stack step.
|
||||
make up
|
||||
make verify-observability # → ✓ Grafana healthy ✓ Prometheus reachable ✓ Tempo reachable
|
||||
|
||||
# 2. Or just the backplane, no full stack needed (no external egress):
|
||||
docker compose -f infra/docker-compose.yml up -d --build tempo prometheus grafana
|
||||
open http://localhost:3000 # Grafana (admin/admin) → Connections → Data sources: Prometheus + Tempo
|
||||
open http://localhost:9090 # Prometheus
|
||||
```
|
||||
|
||||
**The path:** services will export OTLP → **Tempo:4317** and expose `/metrics` ← **Prometheus** scrapes;
|
||||
**Grafana** (:3000) reads both via provisioned datasources with fixed uids `tempo` / `prometheus`.
|
||||
|
||||
---
|
||||
|
||||
## S-17 — herregistratie reminder sweep on a Quartz cron (#18, ADR-0022)
|
||||
|
||||
**Outcome:** an inscription (INGESCHREVEN) now carries the moment it was entered in the register, from
|
||||
which its herregistratie deadline is derived (inscription + 5-year validity). A **Quartz.NET** cron job
|
||||
in the Domain Service sweeps once a day (03:00, overridable via `Quartz__Cron`): every inscription
|
||||
inside the 90-day window before its deadline is flagged `HerregistratieReminderVerstuurd` and logged.
|
||||
The sweep is idempotent — a re-fire reminds no one twice — and is a deliberately different mechanism
|
||||
from the queue-draining pumps (Quartz fires time-triggered sweeps; pumps drain Flowable queues,
|
||||
ADR-0022). There is no outbound notification in v1: the reminder is the flag on the aggregate plus a
|
||||
log line.
|
||||
|
||||
```bash
|
||||
# 1. The domain unit tests prove the rule and the sweep end to end (rule → store query → sweep):
|
||||
cd services/domain && dotnet test Big.Tests/Big.Tests.csproj \
|
||||
--filter "FullyQualifiedName~Herregistratie|FullyQualifiedName~ReminderSweep"
|
||||
# → the reminder is due once the 90-day window opens, not before; a reminded inscription is skipped
|
||||
# on the next sweep; the sweep flags + persists every due inscription and returns their ids.
|
||||
|
||||
# 2. The read model surfaces the deadline once a registration is approved — the field the sweep acts on:
|
||||
curl -s localhost:8000/registrations/<id> | jq '{status, herregistratieVoor, herregistratieReminderVerstuurd}'
|
||||
# → after approval: herregistratieVoor is inscription + 5 years; the flag flips true once swept.
|
||||
```
|
||||
|
||||
**The path:** `Registration.Approve(now)` stamps `IngeschrevenOp` → daily Quartz `HerregistratieReminderJob`
|
||||
→ `HerregistratieReminderSweep` → `IRegistrationStore.FindDueForHerregistratieReminderAsync` (filtered by
|
||||
the aggregate's own `HerregistratieReminderDue` rule) → `MarkHerregistratieReminderVerstuurd` + log.
|
||||
|
||||
---
|
||||
|
||||
## S-B04 — `make local` completes the whole flow with no manual seeding (#110, ADR-0020)
|
||||
|
||||
**Outcome:** the host-browser stack (`make local`) now self-seeds at bring-up — it publishes the BIG
|
||||
@@ -107,31 +26,9 @@ make verify-local # → "OK — a fresh local stack completed the flow with
|
||||
# test123); it shows as INGESCHREVEN in the openbaar register at http://localhost:8141.
|
||||
```
|
||||
|
||||
> The zaaktype is discovered by the ACL itself since S-27 (below); `local-seed`'s `acl.env` now
|
||||
> carries only OpenZaak's IP base URL, which the ACL still needs because OpenZaak rejects a
|
||||
> single-label host on zaak-create (ADR-0020 + ADR-0021).
|
||||
|
||||
---
|
||||
|
||||
## S-27 — ACL resolves its zaaktype by identificatie, not a pinned URL (#113, ADR-0021)
|
||||
|
||||
**Outcome:** the ACL discovers its BIG zaaktype (by `identificatie`) and diploma informatieobjecttype
|
||||
(by `omschrijving`) from OpenZaak's Catalogi API, instead of being handed the server-assigned URLs.
|
||||
No user-visible behaviour change — the flow runs exactly as before — but no stack captures/injects a
|
||||
zaaktype URL any more, and a missing catalogus now fails with a clear message instead of an opaque 400.
|
||||
|
||||
```bash
|
||||
# The live ACL↔OpenZaak integration test proves resolution against a real seeded OpenZaak:
|
||||
make verify-acl # → "resolves the published BIG-REGISTRATIE zaaktype + Diploma informatieobjecttype by business key"
|
||||
|
||||
# End-to-end unchanged (the ACL self-discovers the zaaktype during the flow):
|
||||
make verify-local # local stack — still green, now with no zaaktype-URL injection
|
||||
make verify-domain # CI stack — recreates the ACL pointed only at OpenZaak's IP (no URL to inject)
|
||||
```
|
||||
|
||||
> The ACL still needs its OpenZaak base URL at a URL-valid host (a container IP): OpenZaak's
|
||||
> URLValidator rejects a single-label host like `openzaak:8000` on zaak-create. So ADR-0020's base-URL
|
||||
> injection stays; only the zaaktype/informatieobjecttype **URL** injection is gone (ADR-0021).
|
||||
> The zaaktype UUID is server-assigned, so `local-seed` writes the real URL into a shared volume as
|
||||
> `acl.env` and the ACL sources it on startup (ADR-0020). The cleaner long-term fix — the ACL
|
||||
> resolving its zaaktype by `identificatie` — is tracked separately as S-27 (#113).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ All test users share the password **`test123`**.
|
||||
| Realm | Mimics | User | Identifying claim |
|
||||
|---|---|---|---|
|
||||
| `digid` | DigiD (burgers) | `jan-burger` | `bsn` = `123456782` |
|
||||
| `digid` | DigiD (burgers) | `sanne-burger` | `bsn` = `231477813` (S-26 resume e2e — its own user so it can leave an open registration) |
|
||||
| `eherkenning` | eHerkenning (bedrijven) | `acme-ondernemer` | `kvk` = `12345678` |
|
||||
| `eidas` | eIDAS (EU) | `pierre-dupont` | `eidas_id` = `FR/NL/AB-1234-5678` |
|
||||
| `medewerker` | Internal staff | `merel-behandelaar` | role `behandelaar` |
|
||||
|
||||
@@ -315,22 +315,21 @@ services:
|
||||
context: ../services/acl
|
||||
dockerfile: Dockerfile
|
||||
image: register-referentie/acl:dev
|
||||
# The ACL discovers its zaaktype + informatieobjecttype URLs from the Catalogi API by the business
|
||||
# keys below (S-27, ADR-0021), so no URL is injected. It still needs its OpenZaak BaseUrl pointed at
|
||||
# a URL-valid host (OpenZaak rejects a single-label host like `openzaak` on zaak-create), so the
|
||||
# local-seed one-shot writes that IP base into seed-env:/seed/acl.env, which the entrypoint sources
|
||||
# (set -a) before the app starts. A runtime-generated env file is why we override the entrypoint here
|
||||
# rather than use `env_file:` (which compose reads at parse time, before the seed has run).
|
||||
# The base/zaaktype/informatieobjecttype below are PLACEHOLDERS. The real, server-assigned
|
||||
# values are written by the local-seed one-shot into seed-env:/seed/acl.env, which the entrypoint
|
||||
# sources (set -a) so they override these before the app starts (S-B04, #110, ADR-0020). Sourcing
|
||||
# a runtime-generated env file is why we override the entrypoint here rather than use `env_file:`
|
||||
# (which compose reads at parse time, before the seed has run).
|
||||
entrypoint: ["/bin/sh", "-c", "set -a; . /seed/acl.env; set +a; exec dotnet Acl.Api.dll"]
|
||||
environment:
|
||||
Acl__OpenZaak__BaseUrl: http://openzaak:8000/ # placeholder; seed-env/acl.env supplies the IP base
|
||||
Acl__OpenZaak__BaseUrl: http://openzaak:8000/
|
||||
Acl__OpenZaak__ClientId: big-reference-seed
|
||||
Acl__OpenZaak__Secret: insecure-dev-secret-change-me
|
||||
Acl__Defaults__Bronorganisatie: "517439943"
|
||||
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
|
||||
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
|
||||
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
|
||||
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
|
||||
Acl__Defaults__ZaaktypeUrl: http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000
|
||||
Acl__Defaults__InformatieobjecttypeUrl: http://openzaak:8000/catalogi/api/v1/informatieobjecttypen/00000000-0000-0000-0000-000000000000
|
||||
ports:
|
||||
- "8100:8080"
|
||||
volumes:
|
||||
|
||||
+11
-76
@@ -15,12 +15,12 @@
|
||||
#
|
||||
# docker compose -f infra/docker-compose.yml up -d --build --wait
|
||||
#
|
||||
# After first boot, seed + publish the BIG catalogus:
|
||||
# OZ_PUBLISH=1 python infra/openzaak/seed_catalogus.py
|
||||
# The ACL discovers the zaaktype by identificatie (S-27, ADR-0021), so there is no URL to inject —
|
||||
# just point its BaseUrl at an OpenZaak host OpenZaak accepts on zaak-create (a container IP; a
|
||||
# single-label host is rejected):
|
||||
# ACL_OPENZAAK_BASEURL=http://<openzaak-ip>:8000/ docker compose -f infra/docker-compose.yml up -d acl
|
||||
# After first boot, seed the BIG catalogus and note the zaaktype URL:
|
||||
# python infra/openzaak/seed_catalogus.py
|
||||
# Then set ACL_ZAAKTYPE_URL in a .env file or your shell and re-up the acl
|
||||
# service:
|
||||
# export ACL_ZAAKTYPE_URL=http://openzaak:8000/catalogi/api/v1/zaaktypen/<uuid>
|
||||
# docker compose -f infra/docker-compose.yml up -d acl
|
||||
|
||||
services:
|
||||
|
||||
@@ -296,10 +296,6 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
image: register-referentie/acl:dev
|
||||
environment:
|
||||
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
|
||||
OTEL_SERVICE_NAME: acl
|
||||
# Overridable so verify-domain can point the ACL at the same OpenZaak host that
|
||||
# owns the seeded zaaktype URL (host-consistent zaak creation, ADR-0009).
|
||||
Acl__OpenZaak__BaseUrl: ${ACL_OPENZAAK_BASEURL:-http://openzaak:8000/}
|
||||
@@ -308,12 +304,11 @@ services:
|
||||
Acl__Defaults__Bronorganisatie: "517439943"
|
||||
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
|
||||
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
|
||||
# The ACL resolves the (server-assigned) zaaktype + diploma informatieobjecttype URLs from the
|
||||
# Catalogi API by these stable business keys (S-27, ADR-0021) — no URL to capture and inject.
|
||||
# BaseUrl above stays overridable because OpenZaak rejects a single-label host on zaak creation,
|
||||
# so verify-domain still points the ACL at OpenZaak's container IP.
|
||||
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
|
||||
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
|
||||
# Override with the real zaaktype URL after running seed_catalogus.py.
|
||||
Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000}
|
||||
# The informatieobjecttype a diploma is filed under (S-10b). Placeholder until seed_catalogus.py
|
||||
# (OZ_PUBLISH=1) reports the real URL, which verify-domain injects like the zaaktype URL.
|
||||
Acl__Defaults__InformatieobjecttypeUrl: ${ACL_INFORMATIEOBJECTTYPE_URL:-http://openzaak:8000/catalogi/api/v1/informatieobjecttypen/00000000-0000-0000-0000-000000000000}
|
||||
ports:
|
||||
- "8100:8080"
|
||||
healthcheck:
|
||||
@@ -338,10 +333,6 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
image: register-referentie/domain:dev
|
||||
environment:
|
||||
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
|
||||
OTEL_SERVICE_NAME: domain
|
||||
Flowable__BaseUrl: http://flowable-rest:8080/flowable-rest/
|
||||
Flowable__Username: rest-admin
|
||||
Flowable__Password: test
|
||||
@@ -368,10 +359,6 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
image: register-referentie/bff:dev
|
||||
environment:
|
||||
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
|
||||
OTEL_SERVICE_NAME: bff
|
||||
# The BFF is the portals' only backend; it validates digid tokens and fans out (ADR-0010).
|
||||
# Keycloak (start-dev) derives the issuer from the request host, so the BFF authority and the
|
||||
# verify token request both use keycloak:8080 to keep the issuer consistent.
|
||||
@@ -424,10 +411,6 @@ services:
|
||||
dockerfile: services/event-subscriber/Dockerfile
|
||||
image: register-referentie/event-subscriber:dev
|
||||
environment:
|
||||
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
|
||||
OTEL_SERVICE_NAME: event-subscriber
|
||||
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
|
||||
# The subscriber enriches the projection with each zaak's reference (identificatie) by asking
|
||||
# the ACL — the only code allowed to read ZGW (§8.1, #78).
|
||||
@@ -457,10 +440,6 @@ services:
|
||||
dockerfile: services/projection-api/Dockerfile
|
||||
image: register-referentie/projection-api:dev
|
||||
environment:
|
||||
# OpenTelemetry traces → Tempo (S-16b, ADR-0023).
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4317
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
|
||||
OTEL_SERVICE_NAME: projection-api
|
||||
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
|
||||
ports:
|
||||
- "8120:8080"
|
||||
@@ -544,50 +523,6 @@ services:
|
||||
condition: service_started
|
||||
networks: [cg]
|
||||
|
||||
# ── Observability backplane (S-16a, ADR-0023) ──────────────────────────────
|
||||
# Grafana-native stack: Tempo ingests OTLP traces (the .NET services export
|
||||
# straight to it — no collector hop, S-16b), Prometheus scrapes service
|
||||
# /metrics (S-16c), and Grafana reads both with datasources auto-provisioned.
|
||||
# Config is baked into small built images (COPY) rather than streamed into
|
||||
# external config volumes like the upstream CG modules — these aren't verbatim
|
||||
# peer images, so a built image is the simpler path that still reaches sibling
|
||||
# containers on the CI runner. Not in WAIT_SVCS: run-observability-check.sh
|
||||
# polls Grafana itself, so no in-image healthcheck tool is needed.
|
||||
tempo:
|
||||
build:
|
||||
context: ./observability/tempo
|
||||
image: register-referentie/tempo:dev
|
||||
command: ["-config.file=/etc/tempo.yaml"]
|
||||
# Cap the backplane's footprint so it can't starve the app stack + the Playwright browser on the
|
||||
# memory-tight CI runner (verify-e2e OOM history, commit d5e5fa2). Generous vs idle (~150M).
|
||||
mem_limit: 400m
|
||||
networks: [cg]
|
||||
|
||||
prometheus:
|
||||
build:
|
||||
context: ./observability/prometheus
|
||||
image: register-referentie/prometheus:dev
|
||||
mem_limit: 400m
|
||||
ports:
|
||||
- "9090:9090"
|
||||
networks: [cg]
|
||||
|
||||
grafana:
|
||||
build:
|
||||
context: ./observability/grafana
|
||||
image: register-referentie/grafana:dev
|
||||
mem_limit: 512m
|
||||
environment:
|
||||
GF_SECURITY_ADMIN_USER: admin
|
||||
GF_SECURITY_ADMIN_PASSWORD: admin
|
||||
GF_AUTH_ANONYMOUS_ENABLED: "true"
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- tempo
|
||||
- prometheus
|
||||
networks: [cg]
|
||||
|
||||
volumes:
|
||||
oz-db:
|
||||
nrc-db:
|
||||
|
||||
@@ -38,36 +38,6 @@
|
||||
"emailVerified": true,
|
||||
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
||||
"attributes": { "bsn": ["123456782"] }
|
||||
},
|
||||
{
|
||||
"username": "sanne-burger",
|
||||
"enabled": true,
|
||||
"firstName": "Sanne",
|
||||
"lastName": "Burger",
|
||||
"email": "sanne.burger@example.nl",
|
||||
"emailVerified": true,
|
||||
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
||||
"attributes": { "bsn": ["231477813"] }
|
||||
},
|
||||
{
|
||||
"username": "emma-burger",
|
||||
"enabled": true,
|
||||
"firstName": "Emma",
|
||||
"lastName": "Burger",
|
||||
"email": "emma.burger@example.nl",
|
||||
"emailVerified": true,
|
||||
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
||||
"attributes": { "bsn": ["231477805"] }
|
||||
},
|
||||
{
|
||||
"username": "lars-burger",
|
||||
"enabled": true,
|
||||
"firstName": "Lars",
|
||||
"lastName": "Burger",
|
||||
"email": "lars.burger@example.nl",
|
||||
"emailVerified": true,
|
||||
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
||||
"attributes": { "bsn": ["231477821"] }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -21,13 +21,15 @@ echo ">> seeding + publishing the BIG zaaktype at ${OZ_BASE} (idempotent)"
|
||||
out="$(python3 /work/seed_catalogus.py)"
|
||||
echo "$out"
|
||||
|
||||
# Sanity-check that the zaaktype was actually published (the ACL discovers it by identificatie, S-27).
|
||||
printf '%s\n' "$out" | grep -q '^ZAAKTYPE_URL ' || { echo "ERROR: seed did not publish the zaaktype" >&2; exit 1; }
|
||||
zt="$(printf '%s\n' "$out" | sed -n 's/^ZAAKTYPE_URL //p' | head -1)"
|
||||
iot="$(printf '%s\n' "$out" | sed -n 's/^INFORMATIEOBJECTTYPE_URL //p' | head -1)"
|
||||
[ -n "$zt" ] || { echo "ERROR: seed did not report a ZAAKTYPE_URL" >&2; exit 1; }
|
||||
[ -n "$iot" ] || { echo "ERROR: seed did not report an INFORMATIEOBJECTTYPE_URL" >&2; exit 1; }
|
||||
|
||||
# The ACL resolves the zaaktype/informatieobjecttype URLs itself (S-27, ADR-0021); the only value it
|
||||
# still needs injected is the OpenZaak base URL at a URL-valid host (the container IP), because OpenZaak
|
||||
# rejects a single-label host on zaak-create. The ACL entrypoint sources this.
|
||||
# The ACL entrypoint sources this; these keys override the placeholder defaults in the compose file.
|
||||
cat > /out/acl.env <<EOF
|
||||
Acl__OpenZaak__BaseUrl=${OZ_BASE}/
|
||||
Acl__Defaults__ZaaktypeUrl=${zt}
|
||||
Acl__Defaults__InformatieobjecttypeUrl=${iot}
|
||||
EOF
|
||||
echo ">> wrote /out/acl.env (base=${OZ_BASE}/)"
|
||||
echo ">> wrote /out/acl.env (base=${OZ_BASE}/ zaaktype=${zt})"
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# Grafana with datasources baked in via provisioning (S-16a, ADR-0023).
|
||||
# Dashboards (S-16c, #124) are added under provisioning/dashboards later.
|
||||
FROM grafana/grafana:11.3.0
|
||||
COPY provisioning/ /etc/grafana/provisioning/
|
||||
@@ -1,17 +0,0 @@
|
||||
# Auto-provisioned datasources (S-16a, ADR-0023). Fixed uids so dashboards (S-16c)
|
||||
# and the verify-observability check can reference them by a stable id.
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
uid: prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
isDefault: true
|
||||
|
||||
- name: Tempo
|
||||
uid: tempo
|
||||
type: tempo
|
||||
access: proxy
|
||||
url: http://tempo:3200
|
||||
@@ -1,2 +0,0 @@
|
||||
FROM prom/prometheus:v2.55.1
|
||||
COPY prometheus.yml /etc/prometheus/prometheus.yml
|
||||
@@ -1,10 +0,0 @@
|
||||
# Prometheus scrape config (S-16a, ADR-0023). For the backplane slice it scrapes
|
||||
# only itself; the .NET services' /metrics scrape targets are added in S-16c
|
||||
# (#124) when the services expose metrics.
|
||||
global:
|
||||
scrape_interval: 15s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: prometheus
|
||||
static_configs:
|
||||
- targets: ['localhost:9090']
|
||||
@@ -1,4 +0,0 @@
|
||||
# Tempo with our config baked in — so it reaches sibling containers on the CI
|
||||
# runner without the external-config-volume dance the upstream CG images need.
|
||||
FROM grafana/tempo:2.6.1
|
||||
COPY tempo.yaml /etc/tempo.yaml
|
||||
@@ -1,27 +0,0 @@
|
||||
# Grafana Tempo — single-binary, all-in-one, local storage (S-16a, ADR-0023).
|
||||
# Ingests OTLP directly (services export straight to Tempo; no collector hop).
|
||||
# Storage is ephemeral container fs — this is a local/CI demo backplane, not a
|
||||
# retention target. ponytail: local backend, swap for object storage if traces
|
||||
# must outlive the stack.
|
||||
server:
|
||||
http_listen_port: 3200
|
||||
|
||||
distributor:
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
grpc:
|
||||
endpoint: 0.0.0.0:4317
|
||||
http:
|
||||
endpoint: 0.0.0.0:4318
|
||||
|
||||
ingester:
|
||||
max_block_duration: 5m
|
||||
|
||||
storage:
|
||||
trace:
|
||||
backend: local
|
||||
local:
|
||||
path: /var/tempo/blocks
|
||||
wal:
|
||||
path: /var/tempo/wal
|
||||
Binary file not shown.
@@ -30,18 +30,21 @@ oz_ip="$(ip "$oz")"; dom_ip="$(ip "$dom")"
|
||||
oz_base="http://$oz_ip:8000"
|
||||
echo ">> openzaak=$oz_ip domain=$dom_ip network=$net"
|
||||
|
||||
echo ">> seeding + publishing a BIG zaaktype (idempotent)"
|
||||
echo ">> seeding a published BIG zaaktype (idempotent) and capturing its URL"
|
||||
sid="$(docker create --network "$net" -e "OZ_BASE=$oz_base" -e OZ_PUBLISH=1 python:3-slim python /seed.py)"
|
||||
docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null
|
||||
seed_out="$(docker start -a "$sid")"
|
||||
zt_url="$(printf '%s\n' "$seed_out" | sed -n 's/^ZAAKTYPE_URL //p' | head -1)"
|
||||
iot_url="$(printf '%s\n' "$seed_out" | sed -n 's/^INFORMATIEOBJECTTYPE_URL //p' | head -1)"
|
||||
docker rm -f "$sid" >/dev/null
|
||||
printf '%s\n' "$seed_out" | grep -q '^ZAAKTYPE_URL ' || { echo "ERROR: seed did not publish the zaaktype" >&2; exit 1; }
|
||||
[ -n "$zt_url" ] || { echo "ERROR: seed did not report a ZAAKTYPE_URL" >&2; exit 1; }
|
||||
[ -n "$iot_url" ] || { echo "ERROR: seed did not report an INFORMATIEOBJECTTYPE_URL" >&2; exit 1; }
|
||||
echo ">> zaaktype: $zt_url"
|
||||
echo ">> informatieobjecttype: $iot_url"
|
||||
|
||||
# The ACL resolves the zaaktype + informatieobjecttype by identificatie/omschrijving (S-27, ADR-0021),
|
||||
# so there is no URL to inject — only the OpenZaak base URL, pointed at the same host's container IP
|
||||
# (OpenZaak rejects a single-label host on zaak-create).
|
||||
echo ">> recreating the acl service pointed at OpenZaak's IP (it resolves the zaaktype itself, S-27)"
|
||||
ACL_OPENZAAK_BASEURL="$oz_base/" docker compose -f "$compose" up -d acl
|
||||
echo ">> recreating the acl service pointed at the seeded zaaktype + informatieobjecttype (host-consistent)"
|
||||
ACL_ZAAKTYPE_URL="$zt_url" ACL_INFORMATIEOBJECTTYPE_URL="$iot_url" ACL_OPENZAAK_BASEURL="$oz_base/" \
|
||||
docker compose -f "$compose" up -d acl
|
||||
WAIT_TIMEOUT="${WAIT_TIMEOUT:-120}" bash "$here/wait-healthy.sh" acl
|
||||
|
||||
echo ">> submitting a registration to the domain"
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# S-16a (#122): assert the observability backplane is live against an ALREADY-RUNNING
|
||||
# stack. Runs curl INSIDE the compose network (like the other verify checks) because
|
||||
# the stack's published ports aren't on the CI runner's localhost — the stack is a set
|
||||
# of sibling containers on the host daemon. It asks Grafana to reach its provisioned
|
||||
# datasources — Prometheus via its health method, Tempo via the datasource proxy (Tempo's
|
||||
# Grafana plugin implements no health method) — so it proves the datasources are wired,
|
||||
# not merely that the containers started. Polls, so it tolerates a cold Grafana.
|
||||
#
|
||||
# Does NOT manage the stack lifecycle (the caller owns bring-up + teardown).
|
||||
set -euo pipefail
|
||||
|
||||
TIMEOUT="${OBS_TIMEOUT:-60}"
|
||||
AUTH="${GRAFANA_AUTH:-admin:admin}"
|
||||
|
||||
gf="$(docker ps -q --filter 'name=[-_]grafana[-_]' | head -1)"
|
||||
[ -n "$gf" ] || { echo "ERROR: no running grafana container — bring the stack up first" >&2; exit 1; }
|
||||
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$gf" | head -1)"
|
||||
gf_ip="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$gf")"
|
||||
base="http://$gf_ip:3000"
|
||||
echo ">> grafana=$gf_ip network=$net"
|
||||
|
||||
# Run curl inside a throwaway container on the stack network (reaches services by IP).
|
||||
net_curl() { docker run --rm --network "$net" curlimages/curl:latest "$@"; }
|
||||
|
||||
# poll <description> <grep -E pattern> <curl args...>
|
||||
poll() {
|
||||
local desc="$1" pat="$2"; shift 2
|
||||
local deadline=$(( $(date +%s) + TIMEOUT ))
|
||||
while :; do
|
||||
if net_curl -fsS "$@" 2>/dev/null | grep -Eq "$pat"; then echo " ✓ $desc"; return 0; fi
|
||||
if [ "$(date +%s)" -ge "$deadline" ]; then echo " ✗ $desc ($*)" >&2; return 1; fi
|
||||
sleep 3
|
||||
done
|
||||
}
|
||||
|
||||
echo "Checking observability backplane at $base ..."
|
||||
poll "Grafana is healthy" \
|
||||
'"database":[[:space:]]*"ok"' "$base/api/health"
|
||||
poll "Prometheus datasource reachable" \
|
||||
'"status":[[:space:]]*"OK"' -u "$AUTH" "$base/api/datasources/uid/prometheus/health"
|
||||
poll "Tempo datasource reachable (via Grafana proxy)" \
|
||||
'"version"' -u "$AUTH" "$base/api/datasources/proxy/uid/tempo/api/status/buildinfo"
|
||||
echo "Observability backplane OK."
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# S-16b (#123): assert one connected distributed trace spans the .NET services in Tempo,
|
||||
# against an ALREADY-RUNNING full stack. Runs the driver in a python:3-slim container on the
|
||||
# stack network (services reached by container IP; the runner can't reach published ports —
|
||||
# gitea-actions-gotchas.md §5/§6). Does NOT manage the stack lifecycle.
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
ip() { docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$1"; }
|
||||
|
||||
bff="$(docker ps -q --filter 'name=[-_]bff[-_]' | head -1)"
|
||||
tempo="$(docker ps -q --filter 'name=[-_]tempo[-_]' | head -1)"
|
||||
[ -n "$bff" ] && [ -n "$tempo" ] || { echo "ERROR: bff and/or tempo not running — bring the stack up first" >&2; exit 1; }
|
||||
net="$(docker inspect -f '{{range $k,$_ := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' "$bff" | head -1)"
|
||||
bff_ip="$(ip "$bff")"; tempo_ip="$(ip "$tempo")"
|
||||
echo ">> network=$net bff=$bff_ip tempo=$tempo_ip"
|
||||
|
||||
cid="$(docker create --network "$net" \
|
||||
-e "BFF=http://$bff_ip:8080" -e "TEMPO=http://$tempo_ip:3200" \
|
||||
-e "TRACING_TIMEOUT=${TRACING_TIMEOUT:-90}" \
|
||||
python:3-slim python /tracing-check.py)"
|
||||
docker cp "$here/tracing-check.py" "$cid:/tracing-check.py" >/dev/null
|
||||
rc=0; docker start -a "$cid" || rc=$?
|
||||
docker rm -f "$cid" >/dev/null
|
||||
exit $rc
|
||||
@@ -1,81 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""S-16b (#123): prove distributed tracing works end to end.
|
||||
|
||||
Generate anonymous BFF traffic (GET /openbaar/register, which the BFF serves by
|
||||
calling projection-api — no auth, no OpenZaak egress), then query Tempo and assert
|
||||
that ONE trace contains spans from both `bff` and `projection-api`. That proves the
|
||||
services export OTLP to Tempo AND that the W3C traceparent propagates across the
|
||||
HttpClient hop, stitching the request into a single connected trace.
|
||||
|
||||
Stdlib only (urllib/json) so it runs in a bare python:3-slim container in-network.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
BFF = os.environ["BFF"] # http://<bff-ip>:8080
|
||||
TEMPO = os.environ["TEMPO"] # http://<tempo-ip>:3200
|
||||
TIMEOUT = int(os.environ.get("TRACING_TIMEOUT", "90"))
|
||||
WANT = {"bff", "projection-api"} # the two services that must share one trace
|
||||
|
||||
|
||||
def _get(url):
|
||||
with urllib.request.urlopen(url, timeout=10) as r:
|
||||
return r.read()
|
||||
|
||||
|
||||
def generate_traffic():
|
||||
# A non-2xx still produces spans; only total unreachability of the BFF is fatal.
|
||||
for _ in range(3):
|
||||
try:
|
||||
_get(f"{BFF}/openbaar/register")
|
||||
except urllib.error.HTTPError:
|
||||
pass
|
||||
|
||||
|
||||
def search_trace_ids():
|
||||
q = urllib.parse.quote('{ resource.service.name = "bff" }')
|
||||
try:
|
||||
data = json.loads(_get(f"{TEMPO}/api/search?q={q}&limit=50"))
|
||||
except Exception:
|
||||
return []
|
||||
return [t["traceID"] for t in data.get("traces", [])]
|
||||
|
||||
|
||||
def services_in_trace(trace_id):
|
||||
try:
|
||||
data = json.loads(_get(f"{TEMPO}/api/traces/{trace_id}"))
|
||||
except Exception:
|
||||
return set()
|
||||
names = set()
|
||||
for batch in data.get("batches", []):
|
||||
for attr in batch.get("resource", {}).get("attributes", []):
|
||||
if attr.get("key") == "service.name":
|
||||
names.add(attr.get("value", {}).get("stringValue"))
|
||||
return names
|
||||
|
||||
|
||||
def main():
|
||||
deadline = time.time() + TIMEOUT
|
||||
generate_traffic()
|
||||
seen = set()
|
||||
while time.time() < deadline:
|
||||
for tid in search_trace_ids():
|
||||
names = services_in_trace(tid)
|
||||
seen |= names
|
||||
if WANT.issubset(names):
|
||||
print(f"OK — trace {tid} spans {sorted(names)}")
|
||||
return 0
|
||||
time.sleep(3)
|
||||
generate_traffic()
|
||||
print(f"FAIL — no single trace spanned {sorted(WANT)}; services seen: {sorted(seen)}",
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -24,11 +24,6 @@ import {
|
||||
Observable
|
||||
} from 'rxjs';
|
||||
|
||||
export interface CurrentRegistration {
|
||||
registrationId: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface DecideRequest {
|
||||
besluit: string;
|
||||
}
|
||||
@@ -205,37 +200,6 @@ export class BffApiV1Service {
|
||||
);
|
||||
}
|
||||
|
||||
getSelfServiceRegistrations<TData = CurrentRegistration | void>( options?: HttpClientBodyOptions): Observable<TData>;
|
||||
getSelfServiceRegistrations<TData = CurrentRegistration | void>( options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||
getSelfServiceRegistrations<TData = CurrentRegistration | void>( options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||
getSelfServiceRegistrations<TData = CurrentRegistration | void>(
|
||||
options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
||||
if (options?.observe === 'events') {
|
||||
return this.http.get<TData>(
|
||||
`/self-service/registrations`,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'events',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (options?.observe === 'response') {
|
||||
return this.http.get<TData>(
|
||||
`/self-service/registrations`,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'response',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return this.http.get<TData>(
|
||||
`/self-service/registrations`,{
|
||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||
observe: 'body',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientBodyOptions): Observable<TData>;
|
||||
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||
|
||||
@@ -5,13 +5,6 @@
|
||||
<ProjectReference Include="..\Acl.Infrastructure\Acl.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
using Acl.Application;
|
||||
using Acl.Infrastructure;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// OpenTelemetry tracing (S-16b, ADR-0023): auto-instrument incoming ASP.NET Core requests and
|
||||
// outgoing HttpClient calls (the ACL → OpenZaak hop), exported over OTLP to Tempo. Service name +
|
||||
// OTLP endpoint come from OTEL_* env (compose); the exporter no-ops when Tempo is unreachable.
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.ConfigureResource(r => r.AddService(
|
||||
builder.Configuration["OTEL_SERVICE_NAME"] ?? builder.Environment.ApplicationName))
|
||||
.WithTracing(tracing => tracing
|
||||
.AddAspNetCoreInstrumentation(o => o.Filter = ctx => ctx.Request.Path != "/health")
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddOtlpExporter());
|
||||
|
||||
builder.Services.AddSingleton<IClock, SystemClock>();
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
|
||||
.GetSection("Acl:Defaults").Get<AclDefaults>()
|
||||
@@ -24,8 +11,6 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
|
||||
.GetSection("Acl:OpenZaak").Get<OpenZaakOptions>()
|
||||
?? throw new InvalidOperationException("Missing configuration section 'Acl:OpenZaak'"));
|
||||
builder.Services.AddHttpClient<IZaakGateway, OpenZaakGateway>();
|
||||
// Singleton so the resolved zaaktype/informatieobjecttype URLs are cached across requests (S-27).
|
||||
builder.Services.AddSingleton<IZaaktypeCatalog, CachedZaaktypeCatalog>();
|
||||
builder.Services.AddScoped<AclService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -6,12 +6,9 @@ public sealed class AclDefaults
|
||||
public required string Bronorganisatie { get; init; }
|
||||
public required string VerantwoordelijkeOrganisatie { get; init; }
|
||||
public required string Vertrouwelijkheidaanduiding { get; init; }
|
||||
public required Uri ZaaktypeUrl { get; init; }
|
||||
|
||||
/// <summary>The BIG zaaktype's stable business key. The ACL resolves the (server-assigned) zaaktype
|
||||
/// URL from this via the Catalogi API instead of being handed a pinned URL (S-27, ADR-0021).</summary>
|
||||
public required string ZaaktypeIdentificatie { get; init; }
|
||||
|
||||
/// <summary>The omschrijving of the informatieobjecttype an uploaded diploma is filed under (S-10b);
|
||||
/// resolved to a URL by the Catalogi API, like <see cref="ZaaktypeIdentificatie"/>.</summary>
|
||||
public required string InformatieobjecttypeOmschrijving { get; init; }
|
||||
/// <summary>The informatieobjecttype an uploaded diploma is filed under (S-10b). Seeded in the
|
||||
/// catalogus and injected like <see cref="ZaaktypeUrl"/>.</summary>
|
||||
public required Uri InformatieobjecttypeUrl { get; init; }
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ namespace Acl.Application;
|
||||
|
||||
/// <summary>The ACL's single operation: open a zaak from a domain payload,
|
||||
/// default-filling the ZGW-mandatory fields (ADR-0003).</summary>
|
||||
public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaaktypeCatalog catalog, IClock clock)
|
||||
public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IClock clock)
|
||||
{
|
||||
public async Task<Uri> OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default)
|
||||
public Task<Uri> OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registration);
|
||||
|
||||
@@ -12,34 +12,34 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaak
|
||||
defaults.Bronorganisatie,
|
||||
defaults.VerantwoordelijkeOrganisatie,
|
||||
defaults.Vertrouwelijkheidaanduiding,
|
||||
await catalog.GetZaaktypeUrlAsync(ct),
|
||||
defaults.ZaaktypeUrl,
|
||||
clock.Today,
|
||||
registration.Reference);
|
||||
|
||||
return await gateway.OpenZaakAsync(request, ct);
|
||||
return gateway.OpenZaakAsync(request, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Approve a zaak: set it to the eindstatus of the BIG zaaktype (resolved by identificatie, S-27).
|
||||
/// The domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1).
|
||||
/// Approve a zaak: set it to the eindstatus of the configured BIG zaaktype (ADR-0003 default). The
|
||||
/// domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1).
|
||||
/// </summary>
|
||||
public async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||
public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||
|
||||
await gateway.SetZaakToEindstatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct);
|
||||
return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
|
||||
}
|
||||
|
||||
/// <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
|
||||
/// Cancel a zaak on document-timeout expiry (S-10c): set it to the configured BIG zaaktype's
|
||||
/// cancellation statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which
|
||||
/// statustype/resultaat means "cancelled" (§8.1).
|
||||
/// </summary>
|
||||
public async Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||
public Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||
|
||||
await gateway.SetZaakToCancellationStatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct);
|
||||
return gateway.SetZaakToCancellationStatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
|
||||
}
|
||||
|
||||
/// <summary>The zaak's reference (its ZGW identificatie), for the read projection (#78).</summary>
|
||||
@@ -56,7 +56,7 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaak
|
||||
/// and hand the file to the gateway, which creates the informatieobject and relates it to the zaak.
|
||||
/// The domain supplies only the zaak, the bytes, and the file's name/type (§8.1).
|
||||
/// </summary>
|
||||
public async Task<Uri> StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default)
|
||||
public Task<Uri> StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
@@ -65,7 +65,7 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaak
|
||||
|
||||
var request = new DocumentRequest(
|
||||
defaults.Bronorganisatie,
|
||||
await catalog.GetInformatieobjecttypeUrlAsync(ct),
|
||||
defaults.InformatieobjecttypeUrl,
|
||||
defaults.Vertrouwelijkheidaanduiding,
|
||||
zaakUrl,
|
||||
clock.Today,
|
||||
@@ -76,6 +76,6 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaak
|
||||
Formaat: contentType,
|
||||
Inhoud: content);
|
||||
|
||||
return await gateway.StoreDocumentAsync(request, ct);
|
||||
return gateway.StoreDocumentAsync(request, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
namespace Acl.Application;
|
||||
|
||||
/// <summary>Resolves the zaaktype + diploma-informatieobjecttype URLs from the Catalogi API on first
|
||||
/// use and caches them for the process lifetime (S-27, ADR-0021). Lazy (not at startup) so the ACL
|
||||
/// never crash-loops when it boots before the catalogus is seeded/published; a <em>failed</em>
|
||||
/// resolution is not cached, so it is retried on the next call (e.g. once the zaaktype is published).
|
||||
/// A process restart re-resolves.</summary>
|
||||
public sealed class CachedZaaktypeCatalog(IZaakGateway gateway, AclDefaults defaults) : IZaaktypeCatalog
|
||||
{
|
||||
private readonly SemaphoreSlim gate = new(1, 1);
|
||||
private Uri? zaaktype;
|
||||
private Uri? informatieobjecttype;
|
||||
|
||||
public Task<Uri> GetZaaktypeUrlAsync(CancellationToken ct = default) =>
|
||||
ResolveOnceAsync(
|
||||
() => zaaktype, value => zaaktype = value,
|
||||
() => gateway.ResolveZaaktypeUrlAsync(defaults.ZaaktypeIdentificatie, ct), ct);
|
||||
|
||||
public Task<Uri> GetInformatieobjecttypeUrlAsync(CancellationToken ct = default) =>
|
||||
ResolveOnceAsync(
|
||||
() => informatieobjecttype, value => informatieobjecttype = value,
|
||||
() => gateway.ResolveInformatieobjecttypeUrlAsync(defaults.InformatieobjecttypeOmschrijving, ct), ct);
|
||||
|
||||
// Double-checked, single-flight resolution: return the cache if set; otherwise resolve under the
|
||||
// gate and cache only on success (a throw leaves the cache empty so the next call retries).
|
||||
private async Task<Uri> ResolveOnceAsync(Func<Uri?> read, Action<Uri> store, Func<Task<Uri>> resolve, CancellationToken ct)
|
||||
{
|
||||
if (read() is { } cached)
|
||||
return cached;
|
||||
|
||||
await gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
if (read() is { } existing)
|
||||
return existing;
|
||||
|
||||
var resolved = await resolve();
|
||||
store(resolved);
|
||||
return resolved;
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,12 +32,4 @@ public interface IZaakGateway
|
||||
/// the created informatieobject.
|
||||
/// </summary>
|
||||
Task<Uri> StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Resolve the URL of the published zaaktype with the given <paramref name="identificatie"/>
|
||||
/// from the Catalogi API (S-27). Throws if no published zaaktype matches.</summary>
|
||||
Task<Uri> ResolveZaaktypeUrlAsync(string identificatie, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Resolve the URL of the published informatieobjecttype with the given
|
||||
/// <paramref name="omschrijving"/> from the Catalogi API (S-27). Throws if none matches.</summary>
|
||||
Task<Uri> ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace Acl.Application;
|
||||
|
||||
/// <summary>Supplies the ACL's zaaktype + diploma-informatieobjecttype URLs, resolved from OpenZaak's
|
||||
/// Catalogi API by their stable business keys (<see cref="AclDefaults.ZaaktypeIdentificatie"/> /
|
||||
/// <see cref="AclDefaults.InformatieobjecttypeOmschrijving"/>) rather than pinned in config (S-27,
|
||||
/// ADR-0021). Implementations resolve lazily on first use and cache the result.</summary>
|
||||
public interface IZaaktypeCatalog
|
||||
{
|
||||
Task<Uri> GetZaaktypeUrlAsync(CancellationToken ct = default);
|
||||
|
||||
Task<Uri> GetInformatieobjecttypeUrlAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -142,48 +142,6 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
||||
return created;
|
||||
}
|
||||
|
||||
public async Task<Uri> ResolveZaaktypeUrlAsync(string identificatie, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(identificatie);
|
||||
|
||||
// The published zaaktype with this identificatie; status=definitief excludes concepts.
|
||||
var page = await GetAsync<ZaaktypePage>(
|
||||
"/catalogi/api/v1/zaaktypen?status=definitief&identificatie=" + Uri.EscapeDataString(identificatie),
|
||||
"zaaktypen", ct);
|
||||
var match = (page.Results ?? []).FirstOrDefault()
|
||||
?? throw new InvalidOperationException(
|
||||
$"No published zaaktype with identificatie '{identificatie}' found in OpenZaak — is the BIG catalogus seeded and published?");
|
||||
return new Uri(match.Url);
|
||||
}
|
||||
|
||||
public async Task<Uri> ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(omschrijving);
|
||||
|
||||
// The informatieobjecttypen collection has no omschrijving filter, so match client-side over the
|
||||
// published ones.
|
||||
var page = await GetAsync<InformatieobjecttypePage>(
|
||||
"/catalogi/api/v1/informatieobjecttypen?status=definitief", "informatieobjecttypen", ct);
|
||||
var match = (page.Results ?? []).FirstOrDefault(i => i.Omschrijving == omschrijving)
|
||||
?? throw new InvalidOperationException(
|
||||
$"No published informatieobjecttype '{omschrijving}' found in OpenZaak — is the BIG catalogus seeded and published?");
|
||||
return new Uri(match.Url);
|
||||
}
|
||||
|
||||
// GETs an absolute-by-path ZGW resource with auth (no CRS — catalogi is not a geo API).
|
||||
private async Task<T> GetAsync<T>(string pathAndQuery, string label, CancellationToken ct)
|
||||
{
|
||||
using var message = new HttpRequestMessage(HttpMethod.Get, new Uri(options.BaseUrl, pathAndQuery));
|
||||
message.Headers.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
|
||||
|
||||
using var response = await http.SendAsync(message, ct);
|
||||
await EnsureSuccessAsync(response, $"Querying {label}", ct);
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<T>(ct)
|
||||
?? throw new InvalidOperationException($"OpenZaak returned an empty {label} response");
|
||||
}
|
||||
|
||||
// POSTs a non-geo ZGW resource (resultaat/status — no CRS headers). Buffers the body so uwsgi gets
|
||||
// a Content-Length instead of a chunked body (as with zaak-create).
|
||||
private async Task PostAsync(string path, object dto, string action, CancellationToken ct)
|
||||
@@ -340,18 +298,4 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
||||
private sealed record ZaakInformatieobjectDto(
|
||||
[property: JsonPropertyName("zaak")] string Zaak,
|
||||
[property: JsonPropertyName("informatieobject")] string Informatieobject);
|
||||
|
||||
private sealed record ZaaktypePage(
|
||||
[property: JsonPropertyName("results")] IReadOnlyList<ZaaktypeDto>? Results);
|
||||
|
||||
private sealed record ZaaktypeDto(
|
||||
[property: JsonPropertyName("url")] string Url,
|
||||
[property: JsonPropertyName("identificatie")] string? Identificatie);
|
||||
|
||||
private sealed record InformatieobjecttypePage(
|
||||
[property: JsonPropertyName("results")] IReadOnlyList<InformatieobjecttypeDto>? Results);
|
||||
|
||||
private sealed record InformatieobjecttypeDto(
|
||||
[property: JsonPropertyName("url")] string Url,
|
||||
[property: JsonPropertyName("omschrijving")] string? Omschrijving);
|
||||
}
|
||||
|
||||
@@ -161,32 +161,4 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
|
||||
Assert.Contains(relations.EnumerateArray(),
|
||||
r => r.GetProperty("zaak").GetString() == zaakUrl.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolves_the_published_zaaktype_and_diploma_informatieobjecttype_by_business_key()
|
||||
{
|
||||
var expectedZaaktype = await stack.FindPublishedBigZaaktypeAsync();
|
||||
Assert.True(expectedZaaktype is not null,
|
||||
"No published BIG-REGISTRATIE zaaktype found — seed the stack with OZ_PUBLISH=1.");
|
||||
var expectedInformatieobjecttype = await stack.FindPublishedDiplomaInformatieobjecttypeAsync();
|
||||
Assert.True(expectedInformatieobjecttype is not null,
|
||||
"No published Diploma informatieobjecttype found — seed the stack with OZ_PUBLISH=1.");
|
||||
|
||||
var gateway = new OpenZaakGateway(stack.Http, stack.Options);
|
||||
|
||||
// The ACL discovers both URLs from the live Catalogi API by their stable business keys (S-27),
|
||||
// matching what the fixture found independently — no pinned URL needed.
|
||||
Assert.Equal(expectedZaaktype, await gateway.ResolveZaaktypeUrlAsync("BIG-REGISTRATIE"));
|
||||
Assert.Equal(expectedInformatieobjecttype, await gateway.ResolveInformatieobjecttypeUrlAsync("Diploma"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolving_an_unknown_zaaktype_identificatie_throws_a_clear_error()
|
||||
{
|
||||
var gateway = new OpenZaakGateway(stack.Http, stack.Options);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => gateway.ResolveZaaktypeUrlAsync("NO-SUCH-ZAAKTYPE"));
|
||||
Assert.Contains("NO-SUCH-ZAAKTYPE", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,6 @@ public class AclServiceTests
|
||||
{
|
||||
private sealed class FakeGateway : IZaakGateway
|
||||
{
|
||||
// The URLs the catalogus resolves the configured identificatie/omschrijving to (S-27).
|
||||
public Uri ResolvedZaaktype { get; } = new("http://openzaak/catalogi/api/v1/zaaktypen/big");
|
||||
public Uri ResolvedInformatieobjecttype { get; } = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip");
|
||||
public string? ResolvedByIdentificatie;
|
||||
public string? ResolvedByOmschrijving;
|
||||
|
||||
public ZaakRequest? Captured;
|
||||
public Uri Result { get; } = new("http://openzaak/zaken/api/v1/zaken/abc");
|
||||
|
||||
@@ -53,18 +47,6 @@ public class AclServiceTests
|
||||
StoredDocument = request;
|
||||
return Task.FromResult(DocumentResult);
|
||||
}
|
||||
|
||||
public Task<Uri> ResolveZaaktypeUrlAsync(string identificatie, CancellationToken ct = default)
|
||||
{
|
||||
ResolvedByIdentificatie = identificatie;
|
||||
return Task.FromResult(ResolvedZaaktype);
|
||||
}
|
||||
|
||||
public Task<Uri> ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default)
|
||||
{
|
||||
ResolvedByOmschrijving = omschrijving;
|
||||
return Task.FromResult(ResolvedInformatieobjecttype);
|
||||
}
|
||||
}
|
||||
|
||||
private static AclDefaults Defaults() => new()
|
||||
@@ -72,23 +54,28 @@ public class AclServiceTests
|
||||
Bronorganisatie = "517439943",
|
||||
VerantwoordelijkeOrganisatie = "517439943",
|
||||
Vertrouwelijkheidaanduiding = "openbaar",
|
||||
ZaaktypeIdentificatie = "BIG-REGISTRATIE",
|
||||
InformatieobjecttypeOmschrijving = "Diploma",
|
||||
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
|
||||
InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"),
|
||||
};
|
||||
|
||||
private static AclService ServiceWith(FakeGateway gateway, AclDefaults defaults, DateOnly today) =>
|
||||
new(gateway, defaults, new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today));
|
||||
|
||||
private sealed class FixedClock(DateOnly today) : IClock
|
||||
{
|
||||
public DateOnly Today { get; } = today;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Opening_a_zaak_default_fills_zgw_fields_and_uses_the_resolved_zaaktype()
|
||||
public async Task Opening_a_zaak_default_fills_zgw_fields_and_returns_the_zaak_url()
|
||||
{
|
||||
var gateway = new FakeGateway();
|
||||
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||
var defaults = new AclDefaults
|
||||
{
|
||||
Bronorganisatie = "517439943",
|
||||
VerantwoordelijkeOrganisatie = "517439943",
|
||||
Vertrouwelijkheidaanduiding = "openbaar",
|
||||
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
|
||||
InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"),
|
||||
};
|
||||
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
|
||||
|
||||
var url = await service.OpenZaakAsync(new DomainRegistration("123456782", "reg-77"));
|
||||
|
||||
@@ -97,9 +84,7 @@ public class AclServiceTests
|
||||
Assert.Equal("517439943", req.Bronorganisatie);
|
||||
Assert.Equal("517439943", req.VerantwoordelijkeOrganisatie);
|
||||
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding);
|
||||
// The zaaktype is resolved from the configured identificatie, not a pinned URL (S-27).
|
||||
Assert.Equal("BIG-REGISTRATIE", gateway.ResolvedByIdentificatie);
|
||||
Assert.Equal(gateway.ResolvedZaaktype, req.Zaaktype);
|
||||
Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype);
|
||||
Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum);
|
||||
// The registration reference becomes the zaak identificatie (#78).
|
||||
Assert.Equal("reg-77", req.Identificatie);
|
||||
@@ -109,24 +94,33 @@ public class AclServiceTests
|
||||
public async Task Rejects_a_null_registration_without_calling_the_gateway()
|
||||
{
|
||||
var gateway = new FakeGateway();
|
||||
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||
var defaults = new AclDefaults
|
||||
{
|
||||
Bronorganisatie = "517439943",
|
||||
VerantwoordelijkeOrganisatie = "517439943",
|
||||
Vertrouwelijkheidaanduiding = "openbaar",
|
||||
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
|
||||
InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"),
|
||||
};
|
||||
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.OpenZaakAsync(null!));
|
||||
Assert.Null(gateway.Captured);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Approving_a_zaak_sets_it_to_its_resolved_zaaktypes_eindstatus_dated_today()
|
||||
public async Task Approving_a_zaak_sets_it_to_its_zaaktypes_eindstatus_dated_today()
|
||||
{
|
||||
var gateway = new FakeGateway();
|
||||
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||
var defaults = Defaults();
|
||||
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
|
||||
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
|
||||
|
||||
await service.ApproveZaakAsync(zaak);
|
||||
|
||||
Assert.NotNull(gateway.Approved);
|
||||
Assert.Equal(zaak, gateway.Approved!.Value.Zaak);
|
||||
Assert.Equal(gateway.ResolvedZaaktype, gateway.Approved.Value.Zaaktype);
|
||||
Assert.Equal(defaults.ZaaktypeUrl, gateway.Approved.Value.Zaaktype);
|
||||
Assert.Equal(new DateOnly(2026, 6, 4), gateway.Approved.Value.Datum);
|
||||
}
|
||||
|
||||
@@ -134,7 +128,7 @@ public class AclServiceTests
|
||||
public async Task Approving_a_null_zaak_is_rejected_without_touching_the_gateway()
|
||||
{
|
||||
var gateway = new FakeGateway();
|
||||
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!));
|
||||
Assert.Null(gateway.Approved);
|
||||
@@ -144,14 +138,15 @@ public class AclServiceTests
|
||||
public async Task Cancelling_a_zaak_sets_it_to_the_cancellation_status_dated_today()
|
||||
{
|
||||
var gateway = new FakeGateway();
|
||||
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||
var defaults = Defaults();
|
||||
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
|
||||
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
|
||||
|
||||
await service.CancelZaakAsync(zaak);
|
||||
|
||||
Assert.NotNull(gateway.Cancelled);
|
||||
Assert.Equal(zaak, gateway.Cancelled!.Value.Zaak);
|
||||
Assert.Equal(gateway.ResolvedZaaktype, gateway.Cancelled.Value.Zaaktype);
|
||||
Assert.Equal(defaults.ZaaktypeUrl, gateway.Cancelled.Value.Zaaktype);
|
||||
Assert.Equal(new DateOnly(2026, 6, 4), gateway.Cancelled.Value.Datum);
|
||||
// Cancellation must not touch the approval path.
|
||||
Assert.Null(gateway.Approved);
|
||||
@@ -161,17 +156,18 @@ public class AclServiceTests
|
||||
public async Task Cancelling_a_null_zaak_is_rejected_without_touching_the_gateway()
|
||||
{
|
||||
var gateway = new FakeGateway();
|
||||
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.CancelZaakAsync(null!));
|
||||
Assert.Null(gateway.Cancelled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Storing_a_diploma_default_fills_the_document_fields_and_uses_the_resolved_informatieobjecttype()
|
||||
public async Task Storing_a_diploma_default_fills_the_document_fields_and_returns_its_url()
|
||||
{
|
||||
var gateway = new FakeGateway();
|
||||
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||
var defaults = Defaults();
|
||||
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
|
||||
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
|
||||
|
||||
var url = await service.StoreDiplomaAsync(zaak, [1, 2, 3], "diploma.pdf", "application/pdf");
|
||||
@@ -179,9 +175,7 @@ public class AclServiceTests
|
||||
Assert.Equal(gateway.DocumentResult, url);
|
||||
var req = gateway.StoredDocument!;
|
||||
Assert.Equal(zaak, req.Zaak);
|
||||
// The informatieobjecttype is resolved from the configured omschrijving (S-27).
|
||||
Assert.Equal("Diploma", gateway.ResolvedByOmschrijving);
|
||||
Assert.Equal(gateway.ResolvedInformatieobjecttype, req.Informatieobjecttype);
|
||||
Assert.Equal(defaults.InformatieobjecttypeUrl, req.Informatieobjecttype);
|
||||
Assert.Equal("517439943", req.Bronorganisatie);
|
||||
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding);
|
||||
Assert.Equal(new DateOnly(2026, 6, 4), req.Creatiedatum);
|
||||
@@ -194,7 +188,7 @@ public class AclServiceTests
|
||||
[Fact]
|
||||
public async Task Storing_a_diploma_rejects_null_or_blank_arguments()
|
||||
{
|
||||
var service = ServiceWith(new FakeGateway(), Defaults(), new DateOnly(2026, 6, 4));
|
||||
var service = new AclService(new FakeGateway(), Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
||||
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.StoreDiplomaAsync(null!, [1], "d.pdf", "application/pdf"));
|
||||
@@ -207,7 +201,7 @@ public class AclServiceTests
|
||||
public async Task Reading_a_zaak_reference_returns_the_zaaks_identificatie()
|
||||
{
|
||||
var gateway = new FakeGateway();
|
||||
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
||||
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
|
||||
|
||||
var reference = await service.GetZaakReferenceAsync(zaak);
|
||||
@@ -220,7 +214,7 @@ public class AclServiceTests
|
||||
public async Task Reading_a_null_zaak_reference_is_rejected()
|
||||
{
|
||||
var gateway = new FakeGateway();
|
||||
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.GetZaakReferenceAsync(null!));
|
||||
Assert.Null(gateway.ReadReferenceFor);
|
||||
|
||||
@@ -675,153 +675,4 @@ public class OpenZaakGatewayTests
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => Gateway(handler).StoreDocumentAsync(null!));
|
||||
}
|
||||
|
||||
// ── Catalogi resolution by business key (S-27) ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Resolves_the_published_zaaktype_url_by_identificatie()
|
||||
{
|
||||
HttpRequestMessage? seen = null;
|
||||
var handler = new StubHandler(req =>
|
||||
{
|
||||
seen = req;
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = JsonContent.Create(new
|
||||
{
|
||||
results = new[] { new { url = "http://openzaak/catalogi/api/v1/zaaktypen/big", identificatie = "BIG-REGISTRATIE" } },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
var url = await Gateway(handler).ResolveZaaktypeUrlAsync("BIG-REGISTRATIE");
|
||||
|
||||
Assert.Equal("http://openzaak/catalogi/api/v1/zaaktypen/big", url.ToString());
|
||||
Assert.Equal(HttpMethod.Get, seen!.Method);
|
||||
// Filters to the published zaaktype with that identificatie, and authenticates.
|
||||
Assert.Contains("/catalogi/api/v1/zaaktypen", seen.RequestUri!.ToString());
|
||||
Assert.Contains("status=definitief", seen.RequestUri!.Query);
|
||||
Assert.Contains("identificatie=BIG-REGISTRATIE", seen.RequestUri!.Query);
|
||||
Assert.Equal("Bearer", seen.Headers.Authorization!.Scheme);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolving_a_zaaktype_throws_a_clear_error_when_none_is_published()
|
||||
{
|
||||
var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = JsonContent.Create(new { results = Array.Empty<object>() }),
|
||||
}));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => Gateway(handler).ResolveZaaktypeUrlAsync("BIG-REGISTRATIE"));
|
||||
Assert.Contains("BIG-REGISTRATIE", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolves_the_informatieobjecttype_url_by_omschrijving()
|
||||
{
|
||||
HttpRequestMessage? seen = null;
|
||||
var handler = new StubHandler(req =>
|
||||
{
|
||||
seen = req;
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = JsonContent.Create(new
|
||||
{
|
||||
results = new[]
|
||||
{
|
||||
new { url = "http://openzaak/catalogi/api/v1/informatieobjecttypen/other", omschrijving = "Overig" },
|
||||
new { url = "http://openzaak/catalogi/api/v1/informatieobjecttypen/dip", omschrijving = "Diploma" },
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
var url = await Gateway(handler).ResolveInformatieobjecttypeUrlAsync("Diploma");
|
||||
|
||||
// Queries the published informatieobjecttypen collection, and matches on omschrijving (not position).
|
||||
Assert.Contains("/catalogi/api/v1/informatieobjecttypen", seen!.RequestUri!.ToString());
|
||||
Assert.Contains("status=definitief", seen.RequestUri!.Query);
|
||||
Assert.Equal("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip", url.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolving_a_zaaktype_throws_when_the_response_carries_no_results()
|
||||
{
|
||||
// No "results" property → the page's Results is null; the gateway must treat that as "none
|
||||
// found" (not dereference null).
|
||||
var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = JsonContent.Create(new { count = 0 }),
|
||||
}));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => Gateway(handler).ResolveZaaktypeUrlAsync("BIG-REGISTRATIE"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolving_an_informatieobjecttype_throws_when_the_response_carries_no_results()
|
||||
{
|
||||
var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = JsonContent.Create(new { count = 0 }),
|
||||
}));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => Gateway(handler).ResolveInformatieobjecttypeUrlAsync("Diploma"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolving_a_zaaktype_surfaces_a_non_success_catalogi_response()
|
||||
{
|
||||
var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
{
|
||||
Content = new StringContent("boom"),
|
||||
}));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => Gateway(handler).ResolveZaaktypeUrlAsync("BIG-REGISTRATIE"));
|
||||
// The error names the resource being queried and includes OpenZaak's body.
|
||||
Assert.Contains("zaaktypen", ex.Message);
|
||||
Assert.Contains("boom", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolving_an_informatieobjecttype_surfaces_a_non_success_catalogi_response()
|
||||
{
|
||||
var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||
{
|
||||
Content = new StringContent("boom"),
|
||||
}));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<HttpRequestException>(
|
||||
() => Gateway(handler).ResolveInformatieobjecttypeUrlAsync("Diploma"));
|
||||
Assert.Contains("informatieobjecttypen", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolving_an_informatieobjecttype_throws_when_no_omschrijving_matches()
|
||||
{
|
||||
var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = JsonContent.Create(new
|
||||
{
|
||||
results = new[] { new { url = "http://openzaak/catalogi/api/v1/informatieobjecttypen/other", omschrijving = "Overig" } },
|
||||
}),
|
||||
}));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => Gateway(handler).ResolveInformatieobjecttypeUrlAsync("Diploma"));
|
||||
Assert.Contains("Diploma", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolving_rejects_a_blank_business_key_without_calling_openzaak()
|
||||
{
|
||||
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
|
||||
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(() => Gateway(handler).ResolveZaaktypeUrlAsync(" "));
|
||||
await Assert.ThrowsAnyAsync<ArgumentException>(() => Gateway(handler).ResolveInformatieobjecttypeUrlAsync(" "));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
using Acl.Application;
|
||||
|
||||
namespace Acl.Tests;
|
||||
|
||||
public class ZaaktypeCatalogTests
|
||||
{
|
||||
// A gateway that only supports resolution; the other members are unused here.
|
||||
private sealed class ResolvingGateway : IZaakGateway
|
||||
{
|
||||
public int ZaaktypeCalls;
|
||||
public int InformatieobjecttypeCalls;
|
||||
public string? LastIdentificatie;
|
||||
public string? LastOmschrijving;
|
||||
public int ThrowZaaktypeTimes;
|
||||
public Uri ZaaktypeUrl { get; } = new("http://openzaak/catalogi/api/v1/zaaktypen/big");
|
||||
public Uri InformatieobjecttypeUrl { get; } = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip");
|
||||
|
||||
public Task<Uri> ResolveZaaktypeUrlAsync(string identificatie, CancellationToken ct = default)
|
||||
{
|
||||
ZaaktypeCalls++;
|
||||
LastIdentificatie = identificatie;
|
||||
if (ZaaktypeCalls <= ThrowZaaktypeTimes)
|
||||
throw new InvalidOperationException("no published zaaktype yet");
|
||||
return Task.FromResult(ZaaktypeUrl);
|
||||
}
|
||||
|
||||
public Task<Uri> ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default)
|
||||
{
|
||||
InformatieobjecttypeCalls++;
|
||||
LastOmschrijving = omschrijving;
|
||||
return Task.FromResult(InformatieobjecttypeUrl);
|
||||
}
|
||||
|
||||
public Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task SetZaakToEindstatusAsync(Uri z, Uri zt, DateOnly d, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task SetZaakToCancellationStatusAsync(Uri z, Uri zt, DateOnly d, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<string> GetZaakIdentificatieAsync(Uri z, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
public Task<Uri> StoreDocumentAsync(DocumentRequest r, CancellationToken ct = default) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private static AclDefaults Defaults() => new()
|
||||
{
|
||||
Bronorganisatie = "517439943",
|
||||
VerantwoordelijkeOrganisatie = "517439943",
|
||||
Vertrouwelijkheidaanduiding = "openbaar",
|
||||
ZaaktypeIdentificatie = "BIG-REGISTRATIE",
|
||||
InformatieobjecttypeOmschrijving = "Diploma",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Resolves_the_zaaktype_and_informatieobjecttype_by_their_configured_business_keys()
|
||||
{
|
||||
var gateway = new ResolvingGateway();
|
||||
var catalog = new CachedZaaktypeCatalog(gateway, Defaults());
|
||||
|
||||
Assert.Equal(gateway.ZaaktypeUrl, await catalog.GetZaaktypeUrlAsync());
|
||||
Assert.Equal(gateway.InformatieobjecttypeUrl, await catalog.GetInformatieobjecttypeUrlAsync());
|
||||
Assert.Equal("BIG-REGISTRATIE", gateway.LastIdentificatie);
|
||||
Assert.Equal("Diploma", gateway.LastOmschrijving);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Caches_the_resolved_urls_so_the_gateway_is_hit_once()
|
||||
{
|
||||
var gateway = new ResolvingGateway();
|
||||
var catalog = new CachedZaaktypeCatalog(gateway, Defaults());
|
||||
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
await catalog.GetZaaktypeUrlAsync();
|
||||
await catalog.GetInformatieobjecttypeUrlAsync();
|
||||
}
|
||||
|
||||
Assert.Equal(1, gateway.ZaaktypeCalls);
|
||||
Assert.Equal(1, gateway.InformatieobjecttypeCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Does_not_cache_a_failed_resolution_so_it_is_retried()
|
||||
{
|
||||
// The zaaktype is not published yet on the first call; the catalog must retry (not cache the
|
||||
// failure) so a later call succeeds once it is published.
|
||||
var gateway = new ResolvingGateway { ThrowZaaktypeTimes = 1 };
|
||||
var catalog = new CachedZaaktypeCatalog(gateway, Defaults());
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => catalog.GetZaaktypeUrlAsync());
|
||||
var url = await catalog.GetZaaktypeUrlAsync();
|
||||
|
||||
Assert.Equal(gateway.ZaaktypeUrl, url);
|
||||
Assert.Equal(2, gateway.ZaaktypeCalls);
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,6 @@
|
||||
<!-- OIDC/JWT validation of Keycloak-issued tokens (ADR-0010) and OpenAPI generation. -->
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.8" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -5,10 +5,6 @@ namespace Bff.Api;
|
||||
/// <summary>What the self-service submit returns to the portal (the domain's registration id + status).</summary>
|
||||
public sealed record SubmitAccepted(string RegistrationId, string Status);
|
||||
|
||||
/// <summary>The caller's current open registration, for resuming the self-service portal after a
|
||||
/// refresh (S-26): the reference (registration id) + its status.</summary>
|
||||
public sealed record CurrentRegistration(string RegistrationId, string Status);
|
||||
|
||||
/// <summary>A projection row as the projection-api serves it. <c>Bsn</c>/<c>NaamPlaceholder</c> are
|
||||
/// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09).
|
||||
/// <c>Reference</c> is the public-safe citizen reference (the zaak identificatie, #78).</summary>
|
||||
@@ -26,10 +22,6 @@ public interface IDomainClient
|
||||
{
|
||||
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The caller's current open registration (resume after refresh, S-26), or <c>null</c>
|
||||
/// when they have none in flight. Owner-scoped by <paramref name="bsn"/>.</summary>
|
||||
Task<CurrentRegistration?> GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Withdraw the caller's own registration ("trek aanvraag in"). Owner-scoped by
|
||||
/// <paramref name="bsn"/>. Returns <c>false</c> when the domain reports the registration is
|
||||
/// unknown or not the caller's (404), so the BFF can relay a 404 rather than a 500.</summary>
|
||||
@@ -66,18 +58,6 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
|
||||
return new SubmitAccepted(dto.RegistrationId, dto.Status);
|
||||
}
|
||||
|
||||
public async Task<CurrentRegistration?> GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default)
|
||||
{
|
||||
using var response = await http.GetAsync($"registrations/current?bsn={Uri.EscapeDataString(bsn)}", ct);
|
||||
// The domain 404s when the citizen has no open registration — that's "none", not an error.
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
return null;
|
||||
response.EnsureSuccessStatusCode();
|
||||
var dto = await response.Content.ReadFromJsonAsync<DomainResponse>(ct)
|
||||
?? throw new InvalidOperationException("The Domain Service returned an empty registration response.");
|
||||
return new CurrentRegistration(dto.RegistrationId, dto.Status);
|
||||
}
|
||||
|
||||
public async Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
|
||||
{
|
||||
using var response = await http.PostAsJsonAsync(
|
||||
|
||||
@@ -3,23 +3,9 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Bff.Api;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// OpenTelemetry tracing (S-16b, ADR-0023): auto-instrument incoming ASP.NET Core requests and
|
||||
// outgoing HttpClient calls (BFF → Domain, BFF → projection-api), exported over OTLP to Tempo, so a
|
||||
// portal request is one connected trace across the services. Service name + OTLP endpoint come from
|
||||
// OTEL_* env (compose); the exporter no-ops when Tempo is unreachable. /health is filtered out.
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.ConfigureResource(r => r.AddService(
|
||||
builder.Configuration["OTEL_SERVICE_NAME"] ?? builder.Environment.ApplicationName))
|
||||
.WithTracing(tracing => tracing
|
||||
.AddAspNetCoreInstrumentation(o => o.Filter = ctx => ctx.Request.Path != "/health")
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddOtlpExporter());
|
||||
|
||||
var keycloakAuthority = builder.Configuration["Keycloak:Authority"]
|
||||
?? throw new InvalidOperationException("Missing configuration 'Keycloak:Authority'");
|
||||
// Behandelaars authenticate against a *different* Keycloak realm (medewerker) than citizens (digid),
|
||||
@@ -100,24 +86,6 @@ app.MapPost("/self-service/registrations", async (ClaimsPrincipal user, IDomainC
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized);
|
||||
|
||||
// Self-service resume (S-26): the signed-in zorgprofessional's current open registration, so the
|
||||
// portal can restore its reference + actions after a page refresh. The bsn comes from the DigiD token;
|
||||
// 204 when the citizen has none in flight (so the portal shows the submit form).
|
||||
app.MapGet("/self-service/registrations", async (ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) =>
|
||||
{
|
||||
var bsn = user.FindFirstValue("bsn");
|
||||
if (string.IsNullOrWhiteSpace(bsn))
|
||||
return Results.BadRequest("The token carries no bsn claim.");
|
||||
|
||||
var current = await domain.GetCurrentRegistrationAsync(bsn, ct);
|
||||
return current is null ? Results.NoContent() : Results.Ok(current);
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.Produces<CurrentRegistration>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status400BadRequest)
|
||||
.Produces(StatusCodes.Status401Unauthorized);
|
||||
|
||||
// Self-service withdrawal (S-11): the signed-in zorgprofessional withdraws their own registration.
|
||||
// The bsn comes from the DigiD token and is forwarded to the domain, which owner-scopes the action;
|
||||
// a registration that is unknown or not the caller's comes back 404 (ownership is not revealed).
|
||||
|
||||
@@ -82,18 +82,6 @@ internal sealed class FakeDomainClient : IDomainClient
|
||||
return Task.FromResult(Result);
|
||||
}
|
||||
|
||||
public string? CurrentQueriedBsn { get; private set; }
|
||||
|
||||
/// <summary>The current open registration the fake domain returns (null → the citizen has none in
|
||||
/// flight, so the BFF replies 204). Tests set this to exercise resume.</summary>
|
||||
public CurrentRegistration? Current { get; set; }
|
||||
|
||||
public Task<CurrentRegistration?> GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default)
|
||||
{
|
||||
CurrentQueriedBsn = bsn;
|
||||
return Task.FromResult(Current);
|
||||
}
|
||||
|
||||
public (string RegistrationId, string Bsn)? Withdrawn { get; private set; }
|
||||
|
||||
/// <summary>Whether the fake domain reports the withdrawal as done (true → 204) or not-found/not-owned
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Bff.Api;
|
||||
|
||||
namespace Bff.Tests;
|
||||
|
||||
@@ -169,52 +168,5 @@ public class SelfServiceEndpointTests
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
private static HttpRequestMessage Current(string? bearer)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, "/self-service/registrations");
|
||||
if (bearer is not null)
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
|
||||
return request;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_the_current_registration_lookup_without_a_token()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
|
||||
var response = await factory.CreateClient().SendAsync(Current(bearer: null));
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Returns_no_content_when_the_caller_has_no_open_registration()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
factory.Domain.Current = null;
|
||||
|
||||
var response = await factory.CreateClient().SendAsync(Current(TestTokens.Valid("123456782")));
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
Assert.Equal("123456782", factory.Domain.CurrentQueriedBsn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Returns_the_callers_current_registration_when_one_is_open()
|
||||
{
|
||||
using var factory = new BffFactory();
|
||||
factory.Domain.Current = new CurrentRegistration("reg-77", "Ingediend");
|
||||
|
||||
var response = await factory.CreateClient().SendAsync(Current(TestTokens.Valid("123456782")));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("123456782", factory.Domain.CurrentQueriedBsn);
|
||||
var body = await response.Content.ReadFromJsonAsync<CurrentRegistrationDto>();
|
||||
Assert.Equal("reg-77", body!.RegistrationId);
|
||||
Assert.Equal("Ingediend", body.Status);
|
||||
}
|
||||
|
||||
private sealed record SubmitAcceptedDto(string RegistrationId, string Status);
|
||||
|
||||
private sealed record CurrentRegistrationDto(string RegistrationId, string Status);
|
||||
}
|
||||
|
||||
@@ -28,32 +28,6 @@
|
||||
"description": "Unauthorized"
|
||||
}
|
||||
}
|
||||
},
|
||||
"get": {
|
||||
"tags": [
|
||||
"Bff.Api"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CurrentRegistration"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/self-service/registrations/{id}/withdraw": {
|
||||
@@ -231,21 +205,6 @@
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"CurrentRegistration": {
|
||||
"required": [
|
||||
"registrationId",
|
||||
"status"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"registrationId": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"DecideRequest": {
|
||||
"required": [
|
||||
"besluit"
|
||||
|
||||
@@ -5,14 +5,6 @@
|
||||
<ProjectReference Include="..\Big.Infrastructure\Big.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
|
||||
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.18.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -1,25 +1,9 @@
|
||||
using Big.Application;
|
||||
using Big.Domain;
|
||||
using Big.Infrastructure;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using Quartz;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// OpenTelemetry tracing (S-16b, ADR-0023): auto-instrument incoming ASP.NET Core requests and
|
||||
// outgoing HttpClient calls, exported over OTLP to Tempo, so a request is one connected trace across
|
||||
// the services. Service name + OTLP endpoint come from OTEL_* env (compose); the exporter no-ops
|
||||
// harmlessly when Tempo is unreachable (e.g. a service run standalone). /health is filtered out so
|
||||
// liveness polls don't flood the traces.
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.ConfigureResource(r => r.AddService(
|
||||
builder.Configuration["OTEL_SERVICE_NAME"] ?? builder.Environment.ApplicationName))
|
||||
.WithTracing(tracing => tracing
|
||||
.AddAspNetCoreInstrumentation(o => o.Filter = ctx => ctx.Request.Path != "/health")
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddOtlpExporter());
|
||||
|
||||
// Options bound from configuration (compose sets Flowable__* and Acl__* env vars).
|
||||
builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
|
||||
.GetSection("Flowable").Get<FlowableOptions>()
|
||||
@@ -31,10 +15,6 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
|
||||
// The in-memory registration store is shared between the submit endpoint and the worker (ADR-0009).
|
||||
builder.Services.AddSingleton<IRegistrationStore, InMemoryRegistrationStore>();
|
||||
|
||||
// The system clock, injected wherever a use case needs "now" (e.g. stamping the inscription moment
|
||||
// on approval, S-17). Injected as TimeProvider so tests can substitute a fixed clock.
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
|
||||
// The Workflow Client is one type behind two ports (start side + worker side); both resolve to the
|
||||
// same HttpClient-backed implementation — the only code that talks to Flowable (§8.2).
|
||||
builder.Services.AddHttpClient<FlowableWorkflowClient>();
|
||||
@@ -56,7 +36,6 @@ builder.Services.AddScoped<OpenZaakJobProcessor>();
|
||||
builder.Services.AddScoped<BeoordelingEscalatieProcessor>();
|
||||
builder.Services.AddScoped<ExpireRegistrationWorker>();
|
||||
builder.Services.AddScoped<RegistratieVerlopenProcessor>();
|
||||
builder.Services.AddScoped<HerregistratieReminderSweep>();
|
||||
|
||||
// The hosted external-task job worker polls Flowable and drives OpenZaakAanmaken to completion.
|
||||
builder.Services.AddHostedService<OpenZaakJobPump>();
|
||||
@@ -67,19 +46,6 @@ builder.Services.AddHostedService<BeoordelingEscalatiePump>();
|
||||
// parks and expires each lapsed registration to VERLOPEN (S-10a, ADR-0017).
|
||||
builder.Services.AddHostedService<RegistratieVerlopenPump>();
|
||||
|
||||
// The herregistratie reminder sweep runs on a daily cron via Quartz.NET (S-17, ADR-0022) — a
|
||||
// time-triggered fleet sweep, deliberately a different mechanism from the queue-draining pumps above.
|
||||
// The cron is overridable with Quartz__Cron; it defaults to 03:00 daily.
|
||||
builder.Services.AddQuartz(q =>
|
||||
{
|
||||
var jobKey = new JobKey("herregistratie-reminder");
|
||||
q.AddJob<HerregistratieReminderJob>(jobKey);
|
||||
q.AddTrigger(t => t
|
||||
.ForJob(jobKey)
|
||||
.WithCronSchedule(builder.Configuration["Quartz:Cron"] ?? "0 0 3 * * ?"));
|
||||
});
|
||||
builder.Services.AddQuartzHostedService(o => o.WaitForJobsToComplete = true);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.MapGet("/health", () => "Healthy");
|
||||
@@ -175,21 +141,6 @@ app.MapGet("/behandel/werkbak", async (Werkbak werkbak, CancellationToken ct) =>
|
||||
Results.Ok(await werkbak.GetAsync(ct)));
|
||||
|
||||
// Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually).
|
||||
// The citizen's current open registration, looked up by bsn — lets the self-service portal resume
|
||||
// after a refresh (S-26). The BFF forwards the bsn from the DigiD token; the domain trusts its
|
||||
// callers (§8.3). 404 when the citizen has none in flight.
|
||||
app.MapGet("/registrations/current", async (string bsn, IRegistrationStore store, CancellationToken ct) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bsn))
|
||||
return Results.BadRequest("A bsn is required.");
|
||||
|
||||
var registration = await store.FindOpenByBsnAsync(bsn, ct);
|
||||
return registration is null
|
||||
? Results.NotFound()
|
||||
: Results.Ok(new RegistrationResponse(
|
||||
registration.Id.ToString(), registration.Status.ToString(), registration.ZaakUrl?.ToString()));
|
||||
});
|
||||
|
||||
app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) =>
|
||||
{
|
||||
if (!Guid.TryParse(id, out var guid))
|
||||
@@ -199,8 +150,7 @@ app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, Ca
|
||||
return registration is null
|
||||
? Results.NotFound()
|
||||
: Results.Ok(new RegistrationResponse(
|
||||
registration.Id.ToString(), registration.Status.ToString(), registration.ZaakUrl?.ToString(),
|
||||
registration.HerregistratieVoor?.ToString("O"), registration.HerregistratieReminderVerstuurd));
|
||||
registration.Id.ToString(), registration.Status.ToString(), registration.ZaakUrl?.ToString()));
|
||||
});
|
||||
|
||||
await app.RunAsync();
|
||||
@@ -213,11 +163,6 @@ public sealed record WithdrawRequest(string Bsn);
|
||||
|
||||
public sealed record ProvideDocumentsRequest(string Bsn, string ContentBase64, string? FileName = null, string? ContentType = null);
|
||||
|
||||
public sealed record RegistrationResponse(
|
||||
string RegistrationId,
|
||||
string Status,
|
||||
string? ZaakUrl,
|
||||
string? HerregistratieVoor = null,
|
||||
bool HerregistratieReminderVerstuurd = false);
|
||||
public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl);
|
||||
|
||||
public partial class Program;
|
||||
|
||||
@@ -12,7 +12,7 @@ public sealed record ApproveRegistrationCommand(RegistrationId RegistrationId);
|
||||
/// zaak status is the projection's source of truth (it flows back over NRC); the aggregate transition
|
||||
/// keeps the domain's own view consistent.
|
||||
/// </summary>
|
||||
public sealed class ApproveRegistration(IRegistrationStore store, IAclClient acl, TimeProvider clock)
|
||||
public sealed class ApproveRegistration(IRegistrationStore store, IAclClient acl)
|
||||
{
|
||||
public async Task HandleAsync(ApproveRegistrationCommand command, CancellationToken ct = default)
|
||||
{
|
||||
@@ -30,7 +30,7 @@ public sealed class ApproveRegistration(IRegistrationStore store, IAclClient acl
|
||||
$"Registration {command.RegistrationId} has no zaak yet; it cannot be approved.");
|
||||
|
||||
await acl.ApproveZaakAsync(registration.ZaakUrl, ct);
|
||||
registration.Approve(clock.GetUtcNow());
|
||||
registration.Approve();
|
||||
await store.SaveAsync(registration, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ public sealed record BeoordeelRegistratieCommand(RegistrationId RegistrationId,
|
||||
/// decisions are idempotent — a repeated or redelivered decision that matches the current terminal
|
||||
/// state is a no-op, so the ACL is not called and the task not completed twice.
|
||||
/// </summary>
|
||||
public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient acl, IUserTaskClient tasks, TimeProvider clock)
|
||||
public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient acl, IUserTaskClient tasks)
|
||||
{
|
||||
public async Task HandleAsync(BeoordeelRegistratieCommand command, CancellationToken ct = default)
|
||||
{
|
||||
@@ -44,7 +44,7 @@ public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient ac
|
||||
throw new InvalidOperationException(
|
||||
$"Registration {command.RegistrationId} has no zaak yet; it cannot be approved.");
|
||||
await acl.ApproveZaakAsync(registration.ZaakUrl, ct);
|
||||
registration.Approve(clock.GetUtcNow());
|
||||
registration.Approve();
|
||||
break;
|
||||
|
||||
case BeoordelingsBesluit.Afwijzen:
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
using Big.Domain;
|
||||
|
||||
namespace Big.Application;
|
||||
|
||||
/// <summary>
|
||||
/// The herregistratie reminder sweep (S-17): find the inscriptions whose herregistratie deadline is
|
||||
/// within the reminder window and have not yet been reminded, mark each reminded, and persist it. Pure
|
||||
/// application logic over ports — it knows nothing of Quartz; the scheduled job that fires it on a cron
|
||||
/// lives in Infrastructure (mirroring how the pumps' processors are pure and the pump is the shell).
|
||||
/// Idempotent: <see cref="Registration.MarkHerregistratieReminderVerstuurd"/> drops an inscription from
|
||||
/// the next sweep's candidate set, so a re-fire reminds no one twice. Returns the reminded ids so the
|
||||
/// caller can observe the sweep's effect — the reminder itself is the flag persisted on the aggregate.
|
||||
/// </summary>
|
||||
public sealed class HerregistratieReminderSweep(IRegistrationStore store, TimeProvider clock)
|
||||
{
|
||||
public async Task<IReadOnlyList<RegistrationId>> SweepAsync(CancellationToken ct = default)
|
||||
{
|
||||
var due = await store.FindDueForHerregistratieReminderAsync(clock.GetUtcNow(), ct);
|
||||
|
||||
var reminded = new List<RegistrationId>(due.Count);
|
||||
foreach (var registration in due)
|
||||
{
|
||||
registration.MarkHerregistratieReminderVerstuurd();
|
||||
await store.SaveAsync(registration, ct);
|
||||
reminded.Add(registration.Id);
|
||||
}
|
||||
|
||||
return reminded;
|
||||
}
|
||||
}
|
||||
@@ -102,18 +102,6 @@ public interface IRegistrationStore
|
||||
|
||||
/// <summary>Load a registration by id, or <c>null</c> if none exists.</summary>
|
||||
Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The citizen's current <em>open</em> (non-terminal: INGEDIEND/IN_BEHANDELING)
|
||||
/// registration, or <c>null</c> if they have none in flight. Lets the self-service portal resume
|
||||
/// an existing registration after a refresh (S-26); terminal registrations are not resumed.</summary>
|
||||
Task<Registration?> FindOpenByBsnAsync(string bsn, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The inscriptions whose herregistratie reminder is due as of <paramref name="asOf"/> and
|
||||
/// not yet sent — the herregistratie reminder sweep's candidate set (S-17). The predicate is the
|
||||
/// aggregate's own <see cref="Registration.HerregistratieReminderDue"/> rule, so the store never
|
||||
/// duplicates the herregistratie policy.</summary>
|
||||
Task<IReadOnlyList<Registration>> FindDueForHerregistratieReminderAsync(
|
||||
DateTimeOffset asOf, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -92,12 +92,11 @@ public sealed class Registration
|
||||
|
||||
/// <summary>
|
||||
/// Approve the registration — the behandelaar's decision to enter it in the register. Advances a
|
||||
/// submitted or in-behandeling registration to <see cref="RegistrationStatus.Ingeschreven"/> and
|
||||
/// records <paramref name="ingeschrevenOp"/> as the moment of inscription, which starts the
|
||||
/// herregistratie clock (S-17). Requires an opened zaak (the approval sets that zaak's status via
|
||||
/// the ACL); a registration that has already been decided cannot be approved again.
|
||||
/// submitted or in-behandeling registration to <see cref="RegistrationStatus.Ingeschreven"/>.
|
||||
/// Requires an opened zaak (the approval sets that zaak's status via the ACL); a registration that
|
||||
/// has already been decided cannot be approved again.
|
||||
/// </summary>
|
||||
public void Approve(DateTimeOffset ingeschrevenOp)
|
||||
public void Approve()
|
||||
{
|
||||
if (ZaakUrl is null)
|
||||
throw new InvalidOperationException(
|
||||
@@ -105,54 +104,6 @@ public sealed class Registration
|
||||
|
||||
RequireOpenForDecision(nameof(Approve));
|
||||
Status = RegistrationStatus.Ingeschreven;
|
||||
IngeschrevenOp = ingeschrevenOp;
|
||||
}
|
||||
|
||||
// --- Herregistratie (S-17) — RED stubs, implemented in the green commit ---------------------
|
||||
|
||||
/// <summary>How long a BIG inscription stays valid before herregistratie is required.</summary>
|
||||
// ponytail: fixed 5-year term — a calibration knob, not a config surface. If a demo needs it
|
||||
// per-catalogus, promote it to policy passed in from the beheer config (S-15).
|
||||
public static readonly TimeSpan HerregistratieGeldigheid = TimeSpan.FromDays(365 * 5);
|
||||
|
||||
/// <summary>How long before the deadline the herregistratie reminder is sent (S-17: 90 days).</summary>
|
||||
// ponytail: fixed 90-day lead time — calibration knob; same promotion path as HerregistratieGeldigheid.
|
||||
public static readonly TimeSpan Herinneringstermijn = TimeSpan.FromDays(90);
|
||||
|
||||
/// <summary>When the registration was entered in the register, once approved; the start of its
|
||||
/// herregistratie clock. Null until it is <see cref="RegistrationStatus.Ingeschreven"/>.</summary>
|
||||
public DateTimeOffset? IngeschrevenOp { get; private set; }
|
||||
|
||||
/// <summary>The date by which herregistratie must happen: inscription + validity. Null until
|
||||
/// inscribed.</summary>
|
||||
public DateTimeOffset? HerregistratieVoor =>
|
||||
IngeschrevenOp is DateTimeOffset ingeschrevenOp ? ingeschrevenOp + HerregistratieGeldigheid : null;
|
||||
|
||||
/// <summary>Whether the herregistratie reminder has been sent for this inscription (S-17).</summary>
|
||||
public bool HerregistratieReminderVerstuurd { get; private set; }
|
||||
|
||||
/// <summary>Whether, as of <paramref name="asOf"/>, this registration is due a herregistratie
|
||||
/// reminder: it is inscribed, the reminder window before its deadline has opened, and it has not
|
||||
/// already been reminded. Once inside the window it stays due until reminded (an overdue inscription
|
||||
/// is still due). This is the single rule the store query and the sweep both build on.</summary>
|
||||
public bool HerregistratieReminderDue(DateTimeOffset asOf) =>
|
||||
Status == RegistrationStatus.Ingeschreven
|
||||
&& !HerregistratieReminderVerstuurd
|
||||
&& IngeschrevenOp is DateTimeOffset ingeschrevenOp
|
||||
&& asOf >= ingeschrevenOp + HerregistratieGeldigheid - Herinneringstermijn;
|
||||
|
||||
/// <summary>Record that the herregistratie reminder has been sent. Idempotent — a re-sweep is a
|
||||
/// no-op (§8.6); only an inscribed registration can be reminded.</summary>
|
||||
public void MarkHerregistratieReminderVerstuurd()
|
||||
{
|
||||
if (HerregistratieReminderVerstuurd)
|
||||
return;
|
||||
|
||||
if (Status != RegistrationStatus.Ingeschreven)
|
||||
throw new InvalidOperationException(
|
||||
$"Registration {Id} is {Status}; only an INGESCHREVEN registration can be sent a herregistratie reminder.");
|
||||
|
||||
HerregistratieReminderVerstuurd = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Quartz" Version="3.18.2" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
using Big.Application;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Quartz;
|
||||
|
||||
namespace Big.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// The Quartz job that fires the herregistratie reminder sweep on a cron schedule (S-17, ADR-0022).
|
||||
/// A deliberately thin shell — it resolves the pure <see cref="HerregistratieReminderSweep"/> (Quartz's
|
||||
/// MS-DI job factory gives each fire its own scope) and logs how many reminders went out; all the
|
||||
/// sweep logic is unit-tested in the application layer. Quartz drives this — rather than a
|
||||
/// BackgroundService poll loop like the pumps — because it is a time-triggered fleet sweep, not a
|
||||
/// queue to drain (the distinction recorded in ADR-0022). <see cref="DisallowConcurrentExecutionAttribute"/>
|
||||
/// stops a slow sweep overlapping the next fire against the shared store.
|
||||
/// </summary>
|
||||
[DisallowConcurrentExecution]
|
||||
public sealed class HerregistratieReminderJob(
|
||||
HerregistratieReminderSweep sweep, ILogger<HerregistratieReminderJob> logger) : IJob
|
||||
{
|
||||
public async Task Execute(IJobExecutionContext context)
|
||||
{
|
||||
var reminded = await sweep.SweepAsync(context.CancellationToken);
|
||||
logger.LogInformation(
|
||||
"Herregistratie-sweep voltooid: {Count} herinnering(en) verstuurd.", reminded.Count);
|
||||
}
|
||||
}
|
||||
@@ -22,13 +22,4 @@ public sealed class InMemoryRegistrationStore : IRegistrationStore
|
||||
|
||||
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
|
||||
=> Task.FromResult(_byId.GetValueOrDefault(id));
|
||||
|
||||
public Task<Registration?> FindOpenByBsnAsync(string bsn, CancellationToken ct = default)
|
||||
=> Task.FromResult(_byId.Values.FirstOrDefault(r =>
|
||||
r.Bsn == bsn && r.Status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling));
|
||||
|
||||
public Task<IReadOnlyList<Registration>> FindDueForHerregistratieReminderAsync(
|
||||
DateTimeOffset asOf, CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<Registration>>(
|
||||
_byId.Values.Where(r => r.HerregistratieReminderDue(asOf)).ToList());
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public class ApproveRegistrationTests
|
||||
var acl = new FakeAclClient();
|
||||
var registration = WithZaak();
|
||||
store.Seed(registration);
|
||||
var handler = new ApproveRegistration(store, acl, TimeProvider.System);
|
||||
var handler = new ApproveRegistration(store, acl);
|
||||
|
||||
await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id));
|
||||
|
||||
@@ -36,7 +36,7 @@ public class ApproveRegistrationTests
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var handler = new ApproveRegistration(store, acl, TimeProvider.System);
|
||||
var handler = new ApproveRegistration(store, acl);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
|
||||
Assert.Equal(0, acl.ApproveCallCount);
|
||||
@@ -47,7 +47,7 @@ public class ApproveRegistrationTests
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var handler = new ApproveRegistration(store, acl, TimeProvider.System);
|
||||
var handler = new ApproveRegistration(store, acl);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => handler.HandleAsync(new ApproveRegistrationCommand(RegistrationId.New())));
|
||||
@@ -62,7 +62,7 @@ public class ApproveRegistrationTests
|
||||
var acl = new FakeAclClient();
|
||||
var registration = Registration.Submit("123456782"); // no zaak yet
|
||||
store.Seed(registration);
|
||||
var handler = new ApproveRegistration(store, acl, TimeProvider.System);
|
||||
var handler = new ApproveRegistration(store, acl);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => handler.HandleAsync(new ApproveRegistrationCommand(registration.Id)));
|
||||
@@ -77,7 +77,7 @@ public class ApproveRegistrationTests
|
||||
var acl = new FakeAclClient();
|
||||
var registration = WithZaak();
|
||||
store.Seed(registration);
|
||||
var handler = new ApproveRegistration(store, acl, TimeProvider.System);
|
||||
var handler = new ApproveRegistration(store, acl);
|
||||
|
||||
await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id));
|
||||
await handler.HandleAsync(new ApproveRegistrationCommand(registration.Id));
|
||||
|
||||
@@ -29,7 +29,7 @@ public class BeoordeelRegistratieTests
|
||||
var registration = WithZaak();
|
||||
store.Seed(registration);
|
||||
var tasks = TaskFor(registration);
|
||||
var handler = new BeoordeelRegistratie(store, acl, tasks, TimeProvider.System);
|
||||
var handler = new BeoordeelRegistratie(store, acl, tasks);
|
||||
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
||||
|
||||
@@ -50,7 +50,7 @@ public class BeoordeelRegistratieTests
|
||||
var registration = WithZaak();
|
||||
store.Seed(registration);
|
||||
var tasks = TaskFor(registration);
|
||||
var handler = new BeoordeelRegistratie(store, acl, tasks, TimeProvider.System);
|
||||
var handler = new BeoordeelRegistratie(store, acl, tasks);
|
||||
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
|
||||
|
||||
@@ -69,7 +69,7 @@ public class BeoordeelRegistratieTests
|
||||
var registration = WithZaak();
|
||||
registration.TakeIntoBehandeling();
|
||||
store.Seed(registration);
|
||||
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration), TimeProvider.System);
|
||||
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
|
||||
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
||||
|
||||
@@ -81,7 +81,7 @@ public class BeoordeelRegistratieTests
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]), TimeProvider.System);
|
||||
var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]));
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
|
||||
Assert.Equal(0, acl.ApproveCallCount);
|
||||
@@ -93,7 +93,7 @@ public class BeoordeelRegistratieTests
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var acl = new FakeAclClient();
|
||||
var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]), TimeProvider.System);
|
||||
var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
handler.HandleAsync(new BeoordeelRegistratieCommand(RegistrationId.New(), BeoordelingsBesluit.Goedkeuren)));
|
||||
@@ -108,7 +108,7 @@ public class BeoordeelRegistratieTests
|
||||
var acl = new FakeAclClient();
|
||||
var registration = Registration.Submit("123456782"); // no zaak yet
|
||||
store.Seed(registration);
|
||||
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration), TimeProvider.System);
|
||||
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren)));
|
||||
@@ -123,7 +123,7 @@ public class BeoordeelRegistratieTests
|
||||
var acl = new FakeAclClient();
|
||||
var registration = WithZaak();
|
||||
store.Seed(registration);
|
||||
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration), TimeProvider.System);
|
||||
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
|
||||
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
||||
@@ -139,7 +139,7 @@ public class BeoordeelRegistratieTests
|
||||
var acl = new FakeAclClient();
|
||||
var registration = WithZaak();
|
||||
store.Seed(registration);
|
||||
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration), TimeProvider.System);
|
||||
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
|
||||
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
|
||||
@@ -158,7 +158,7 @@ public class BeoordeelRegistratieTests
|
||||
var registration = WithZaak();
|
||||
store.Seed(registration);
|
||||
var tasks = new FakeUserTaskClient([]); // no open task for this registration
|
||||
var handler = new BeoordeelRegistratie(store, acl, tasks, TimeProvider.System);
|
||||
var handler = new BeoordeelRegistratie(store, acl, tasks);
|
||||
|
||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
||||
|
||||
|
||||
@@ -22,15 +22,6 @@ internal sealed class FakeRegistrationStore : IRegistrationStore
|
||||
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
|
||||
=> Task.FromResult(_byId.GetValueOrDefault(id));
|
||||
|
||||
public Task<Registration?> FindOpenByBsnAsync(string bsn, CancellationToken ct = default)
|
||||
=> Task.FromResult(_byId.Values.FirstOrDefault(r =>
|
||||
r.Bsn == bsn && r.Status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling));
|
||||
|
||||
public Task<IReadOnlyList<Registration>> FindDueForHerregistratieReminderAsync(
|
||||
DateTimeOffset asOf, CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<Registration>>(
|
||||
_byId.Values.Where(r => r.HerregistratieReminderDue(asOf)).ToList());
|
||||
|
||||
public void Seed(Registration registration) => _byId[registration.Id] = registration;
|
||||
}
|
||||
|
||||
@@ -90,13 +81,6 @@ internal sealed class FakeUserTaskClient(IReadOnlyList<BeoordelingTask> open) :
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A <see cref="TimeProvider"/> pinned to a fixed instant, so time-based use cases (the
|
||||
/// herregistratie sweep, S-17) are deterministic without the TimeProvider.Testing package.</summary>
|
||||
internal sealed class FixedClock(DateTimeOffset now) : TimeProvider
|
||||
{
|
||||
public override DateTimeOffset GetUtcNow() => now;
|
||||
}
|
||||
|
||||
/// <summary>A fake ACL client that records the bsn it was asked to open a zaak for and returns a
|
||||
/// fixed zaak URL.</summary>
|
||||
internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
using Big.Application;
|
||||
using Big.Domain;
|
||||
|
||||
namespace Big.Tests;
|
||||
|
||||
// S-17 (#18): the sweep behind the Quartz job. It reminds every inscription whose herregistratie
|
||||
// reminder is due, marks each so a re-fire is a no-op (§8.6), and returns the reminded ids. Pure over
|
||||
// the store + an injected clock — no Quartz here.
|
||||
public class HerregistratieReminderSweepTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 7, 23, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private static Registration Inscribed(string bsn, DateTimeOffset ingeschrevenOp)
|
||||
{
|
||||
var registration = Registration.Submit(bsn);
|
||||
registration.AttachZaak(FakeAclClient.DefaultZaakUrl);
|
||||
registration.Approve(ingeschrevenOp);
|
||||
return registration;
|
||||
}
|
||||
|
||||
// Inscribed exactly (geldigheid - herinneringstermijn) before Now: the reminder window is open.
|
||||
private static Registration Due(string bsn)
|
||||
=> Inscribed(bsn, Now - Registration.HerregistratieGeldigheid + Registration.Herinneringstermijn);
|
||||
|
||||
[Fact]
|
||||
public async Task Reminds_and_persists_every_due_inscription_and_returns_their_ids()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
var a = Due("123456782");
|
||||
var b = Due("111111110");
|
||||
var freshlyInscribed = Inscribed("222222222", Now); // not yet in the window
|
||||
store.Seed(a);
|
||||
store.Seed(b);
|
||||
store.Seed(freshlyInscribed);
|
||||
|
||||
var reminded = await new HerregistratieReminderSweep(store, new FixedClock(Now)).SweepAsync();
|
||||
|
||||
Assert.Equal(new HashSet<RegistrationId> { a.Id, b.Id }, reminded.ToHashSet());
|
||||
Assert.True((await store.GetAsync(a.Id))!.HerregistratieReminderVerstuurd);
|
||||
Assert.True((await store.GetAsync(b.Id))!.HerregistratieReminderVerstuurd);
|
||||
Assert.False((await store.GetAsync(freshlyInscribed.Id))!.HerregistratieReminderVerstuurd);
|
||||
Assert.Equal(2, store.SaveCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_second_sweep_reminds_no_one_again()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
store.Seed(Due("123456782"));
|
||||
var sweep = new HerregistratieReminderSweep(store, new FixedClock(Now));
|
||||
|
||||
await sweep.SweepAsync();
|
||||
var second = await sweep.SweepAsync();
|
||||
|
||||
Assert.Empty(second);
|
||||
Assert.Equal(1, store.SaveCount); // only the first sweep persisted anything
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reminds_no_one_when_nothing_is_due()
|
||||
{
|
||||
var store = new FakeRegistrationStore();
|
||||
store.Seed(Inscribed("123456782", Now)); // freshly inscribed — deadline is 5 years off
|
||||
|
||||
Assert.Empty(await new HerregistratieReminderSweep(store, new FixedClock(Now)).SweepAsync());
|
||||
}
|
||||
}
|
||||
@@ -41,82 +41,4 @@ public class InMemoryRegistrationStoreTests
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentNullException>(() => store.SaveAsync(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Finds_the_open_registration_for_a_bsn()
|
||||
{
|
||||
var store = new InMemoryRegistrationStore();
|
||||
var open = Registration.Submit("123456782");
|
||||
await store.SaveAsync(open);
|
||||
|
||||
var found = await store.FindOpenByBsnAsync("123456782");
|
||||
|
||||
Assert.NotNull(found);
|
||||
Assert.Equal(open.Id, found.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_in_behandeling_registration_is_still_open()
|
||||
{
|
||||
var store = new InMemoryRegistrationStore();
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.TakeIntoBehandeling();
|
||||
await store.SaveAsync(registration);
|
||||
|
||||
Assert.NotNull(await store.FindOpenByBsnAsync("123456782"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(Registration.Withdraw))]
|
||||
[InlineData(nameof(Registration.Approve))]
|
||||
[InlineData(nameof(Registration.Reject))]
|
||||
[InlineData(nameof(Registration.Expire))]
|
||||
public async Task A_terminal_registration_is_not_returned_as_open(string transition)
|
||||
{
|
||||
var store = new InMemoryRegistrationStore();
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); // Approve requires an opened zaak
|
||||
switch (transition)
|
||||
{
|
||||
case nameof(Registration.Withdraw): registration.Withdraw(); break;
|
||||
case nameof(Registration.Approve): registration.Approve(DateTimeOffset.UtcNow); break;
|
||||
case nameof(Registration.Reject): registration.Reject(); break;
|
||||
case nameof(Registration.Expire): registration.Expire(); break;
|
||||
}
|
||||
await store.SaveAsync(registration);
|
||||
|
||||
Assert.Null(await store.FindOpenByBsnAsync("123456782"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Does_not_return_another_bsns_registration_or_an_unknown_bsn()
|
||||
{
|
||||
var store = new InMemoryRegistrationStore();
|
||||
await store.SaveAsync(Registration.Submit("111111110"));
|
||||
|
||||
Assert.Null(await store.FindOpenByBsnAsync("123456782"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Finds_only_the_inscriptions_due_for_a_herregistratie_reminder()
|
||||
{
|
||||
var now = new DateTimeOffset(2026, 7, 23, 0, 0, 0, TimeSpan.Zero);
|
||||
var store = new InMemoryRegistrationStore();
|
||||
|
||||
var due = Registration.Submit("123456782");
|
||||
due.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
||||
due.Approve(now - Registration.HerregistratieGeldigheid + Registration.Herinneringstermijn);
|
||||
await store.SaveAsync(due);
|
||||
|
||||
var freshlyInscribed = Registration.Submit("111111110");
|
||||
freshlyInscribed.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/def"));
|
||||
freshlyInscribed.Approve(now);
|
||||
await store.SaveAsync(freshlyInscribed);
|
||||
|
||||
await store.SaveAsync(Registration.Submit("222222222")); // still INGEDIEND — never inscribed
|
||||
|
||||
var result = await store.FindDueForHerregistratieReminderAsync(now);
|
||||
|
||||
Assert.Equal([due.Id], result.Select(r => r.Id).ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
using Big.Domain;
|
||||
|
||||
namespace Big.Tests;
|
||||
|
||||
// S-17 (#18): a BIG inscription is valid for a fixed term; before it lapses the zorgprofessional must
|
||||
// herregistreren. The aggregate records when it was inscribed, derives the herregistratie deadline, and
|
||||
// answers whether a reminder is due as of a given moment — the single rule the Quartz sweep and the
|
||||
// store query both build on. All arithmetic is against an explicit "now" so it is wall-clock-free.
|
||||
public class RegistrationHerregistratieTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 7, 23, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
// The moment the reminder window opens: inscribed exactly (geldigheid - herinneringstermijn) ago.
|
||||
private static DateTimeOffset InscribedSoDueAt(DateTimeOffset asOf)
|
||||
=> asOf - Registration.HerregistratieGeldigheid + Registration.Herinneringstermijn;
|
||||
|
||||
private static Registration Inscribed(DateTimeOffset ingeschrevenOp)
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.AttachZaak(FakeAclClient.DefaultZaakUrl);
|
||||
registration.Approve(ingeschrevenOp);
|
||||
return registration;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Approving_records_the_inscription_moment_and_the_herregistratie_deadline()
|
||||
{
|
||||
var registration = Inscribed(Now);
|
||||
|
||||
Assert.Equal(Now, registration.IngeschrevenOp);
|
||||
Assert.Equal(Now + Registration.HerregistratieGeldigheid, registration.HerregistratieVoor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_reminder_is_due_the_moment_the_window_before_the_deadline_opens()
|
||||
{
|
||||
var registration = Inscribed(InscribedSoDueAt(Now));
|
||||
|
||||
Assert.True(registration.HerregistratieReminderDue(Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_reminder_is_not_yet_due_one_day_before_the_window_opens()
|
||||
{
|
||||
var registration = Inscribed(InscribedSoDueAt(Now) + TimeSpan.FromDays(1));
|
||||
|
||||
Assert.False(registration.HerregistratieReminderDue(Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_registration_that_is_not_ingeschreven_is_never_due_and_has_no_deadline()
|
||||
{
|
||||
var registration = Registration.Submit("123456782"); // INGEDIEND, never inscribed
|
||||
|
||||
Assert.Null(registration.IngeschrevenOp);
|
||||
Assert.Null(registration.HerregistratieVoor);
|
||||
Assert.False(registration.HerregistratieReminderDue(Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_reminded_registration_is_no_longer_due()
|
||||
{
|
||||
var registration = Inscribed(InscribedSoDueAt(Now));
|
||||
|
||||
registration.MarkHerregistratieReminderVerstuurd();
|
||||
|
||||
Assert.True(registration.HerregistratieReminderVerstuurd);
|
||||
Assert.False(registration.HerregistratieReminderDue(Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Marking_the_reminder_sent_twice_is_idempotent()
|
||||
{
|
||||
var registration = Inscribed(InscribedSoDueAt(Now));
|
||||
|
||||
registration.MarkHerregistratieReminderVerstuurd();
|
||||
registration.MarkHerregistratieReminderVerstuurd();
|
||||
|
||||
Assert.True(registration.HerregistratieReminderVerstuurd);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Marking_a_reminder_on_a_registration_that_is_not_ingeschreven_is_rejected()
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => registration.MarkHerregistratieReminderVerstuurd());
|
||||
Assert.Contains("INGESCHREVEN", ex.Message);
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,6 @@ namespace Big.Tests;
|
||||
|
||||
public class RegistrationTests
|
||||
{
|
||||
// A fixed inscription moment for the approval tests; its exact value is irrelevant to them.
|
||||
private static readonly DateTimeOffset Ingeschreven = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[Fact]
|
||||
public void Submitting_a_registration_starts_in_ingediend()
|
||||
{
|
||||
@@ -106,7 +103,7 @@ public class RegistrationTests
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
||||
|
||||
registration.Approve(Ingeschreven);
|
||||
registration.Approve();
|
||||
|
||||
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
|
||||
}
|
||||
@@ -116,7 +113,7 @@ public class RegistrationTests
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => registration.Approve(Ingeschreven));
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => registration.Approve());
|
||||
|
||||
Assert.Contains("no zaak", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal(RegistrationStatus.Ingediend, registration.Status);
|
||||
@@ -127,9 +124,9 @@ public class RegistrationTests
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
||||
registration.Approve(Ingeschreven);
|
||||
registration.Approve();
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => registration.Approve(Ingeschreven));
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => registration.Approve());
|
||||
Assert.Contains("only an INGEDIEND", ex.Message);
|
||||
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
|
||||
}
|
||||
@@ -160,7 +157,7 @@ public class RegistrationTests
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
||||
registration.Approve(Ingeschreven);
|
||||
registration.Approve();
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => registration.TakeIntoBehandeling());
|
||||
Assert.Contains("only an INGEDIEND", ex.Message);
|
||||
@@ -174,7 +171,7 @@ public class RegistrationTests
|
||||
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
||||
registration.TakeIntoBehandeling();
|
||||
|
||||
registration.Approve(Ingeschreven);
|
||||
registration.Approve();
|
||||
|
||||
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
|
||||
}
|
||||
@@ -221,7 +218,7 @@ public class RegistrationTests
|
||||
var approveEx = Assert.Throws<InvalidOperationException>(() =>
|
||||
{
|
||||
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
||||
registration.Approve(Ingeschreven);
|
||||
registration.Approve();
|
||||
});
|
||||
Assert.Contains("IN_BEHANDELING", approveEx.Message);
|
||||
|
||||
@@ -280,7 +277,7 @@ public class RegistrationTests
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
||||
registration.Approve(Ingeschreven);
|
||||
registration.Approve();
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => registration.Withdraw());
|
||||
Assert.Contains("only an INGEDIEND", ex.Message);
|
||||
@@ -339,7 +336,7 @@ public class RegistrationTests
|
||||
{
|
||||
var registration = Registration.Submit("123456782");
|
||||
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
||||
registration.Approve(Ingeschreven);
|
||||
registration.Approve();
|
||||
|
||||
var ex = Assert.Throws<InvalidOperationException>(() => registration.Expire());
|
||||
Assert.Contains("only an INGEDIEND", ex.Message);
|
||||
|
||||
@@ -6,8 +6,7 @@
|
||||
"mutate": [
|
||||
"!**/OpenZaakJobPump.cs",
|
||||
"!**/BeoordelingEscalatiePump.cs",
|
||||
"!**/RegistratieVerlopenPump.cs",
|
||||
"!**/HerregistratieReminderJob.cs"
|
||||
"!**/RegistratieVerlopenPump.cs"
|
||||
],
|
||||
"thresholds": {
|
||||
"high": 95,
|
||||
|
||||
@@ -5,13 +5,6 @@
|
||||
<ProjectReference Include="..\..\projection-api\Projection.ReadModel\Projection.ReadModel.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
using System.Text.Json;
|
||||
using EventSubscriber.Application;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using Projection.ReadModel;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// OpenTelemetry tracing (S-16b, ADR-0023): auto-instrument the incoming NRC notification callback and
|
||||
// the outgoing ACL enrichment call, exported over OTLP to Tempo. Service name + OTLP endpoint come
|
||||
// from OTEL_* env (compose); the exporter no-ops when Tempo is unreachable.
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.ConfigureResource(r => r.AddService(
|
||||
builder.Configuration["OTEL_SERVICE_NAME"] ?? builder.Environment.ApplicationName))
|
||||
.WithTracing(tracing => tracing
|
||||
.AddAspNetCoreInstrumentation(o => o.Filter = ctx => ctx.Request.Path != "/health")
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddOtlpExporter());
|
||||
|
||||
var connectionString = builder.Configuration.GetConnectionString("Projection")
|
||||
?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Projection'");
|
||||
// The exact Authorization header value Open Notificaties sends on each abonnement callback.
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using Projection.ReadModel;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// OpenTelemetry tracing (S-16b, ADR-0023): auto-instrument incoming ASP.NET Core requests, exported
|
||||
// over OTLP to Tempo, so a BFF → projection-api read is one connected trace. Service name + OTLP
|
||||
// endpoint come from OTEL_* env (compose); the exporter no-ops when Tempo is unreachable.
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.ConfigureResource(r => r.AddService(
|
||||
builder.Configuration["OTEL_SERVICE_NAME"] ?? builder.Environment.ApplicationName))
|
||||
.WithTracing(tracing => tracing
|
||||
.AddAspNetCoreInstrumentation(o => o.Filter = ctx => ctx.Request.Path != "/health")
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddOtlpExporter());
|
||||
|
||||
var connectionString = builder.Configuration.GetConnectionString("Projection")
|
||||
?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Projection'");
|
||||
|
||||
|
||||
@@ -4,13 +4,6 @@
|
||||
<ProjectReference Include="..\Projection.ReadModel\Projection.ReadModel.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -40,7 +40,7 @@ public sealed class EenRegistratieBeoordelenSteps
|
||||
|
||||
[When("the behandelaar decides \"(.*)\"")]
|
||||
public async Task WhenTheBehandelaarDecides(string besluit)
|
||||
=> await new BeoordeelRegistratie(_store, _acl, _tasks, TimeProvider.System).HandleAsync(
|
||||
=> await new BeoordeelRegistratie(_store, _acl, _tasks).HandleAsync(
|
||||
new BeoordeelRegistratieCommand(_id, Enum.Parse<BeoordelingsBesluit>(besluit, ignoreCase: true)));
|
||||
|
||||
[Then("the registration has status \"(.*)\"")]
|
||||
|
||||
@@ -29,12 +29,9 @@ public sealed class EenZaakOpenenSteps
|
||||
Bronorganisatie = values["bronorganisatie"],
|
||||
VerantwoordelijkeOrganisatie = values["verantwoordelijkeOrganisatie"],
|
||||
Vertrouwelijkheidaanduiding = values["vertrouwelijkheidaanduiding"],
|
||||
ZaaktypeIdentificatie = "BIG-REGISTRATIE",
|
||||
InformatieobjecttypeOmschrijving = "Diploma",
|
||||
ZaaktypeUrl = new Uri(values["zaaktype"]),
|
||||
InformatieobjecttypeUrl = new Uri("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"),
|
||||
};
|
||||
// The ACL resolves the zaaktype by identificatie (S-27); the scenario's zaaktype URL is what
|
||||
// the catalogus resolves it to, so the created zaak still carries that URL.
|
||||
_gateway.ResolvedZaaktypeUrl = new Uri(values["zaaktype"]);
|
||||
}
|
||||
|
||||
[Given("today is \"(.*)\"")]
|
||||
@@ -44,7 +41,7 @@ public sealed class EenZaakOpenenSteps
|
||||
[When("the domain asks the ACL to open a zaak")]
|
||||
public async Task WhenTheDomainAsksTheAclToOpenAZaak()
|
||||
{
|
||||
var service = new AclService(_gateway, _defaults!, new CachedZaaktypeCatalog(_gateway, _defaults!), new FixedClock(_today));
|
||||
var service = new AclService(_gateway, _defaults!, new FixedClock(_today));
|
||||
_returnedUrl = await service.OpenZaakAsync(_registration!);
|
||||
}
|
||||
|
||||
|
||||
@@ -69,9 +69,6 @@ public sealed class CapturingDomainClient : IDomainClient
|
||||
return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend"));
|
||||
}
|
||||
|
||||
public Task<CurrentRegistration?> GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default)
|
||||
=> Task.FromResult<CurrentRegistration?>(null);
|
||||
|
||||
public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
|
||||
@@ -217,13 +217,4 @@ public sealed class InMemoryRegistrationStore : IRegistrationStore
|
||||
|
||||
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
|
||||
=> Task.FromResult(_byId.GetValueOrDefault(id));
|
||||
|
||||
public Task<Registration?> FindOpenByBsnAsync(string bsn, CancellationToken ct = default)
|
||||
=> Task.FromResult(_byId.Values.FirstOrDefault(r =>
|
||||
r.Bsn == bsn && r.Status is RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling));
|
||||
|
||||
public Task<IReadOnlyList<Registration>> FindDueForHerregistratieReminderAsync(
|
||||
DateTimeOffset asOf, CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<Registration>>(
|
||||
_byId.Values.Where(r => r.HerregistratieReminderDue(asOf)).ToList());
|
||||
}
|
||||
|
||||
@@ -14,11 +14,6 @@ public sealed class InMemoryZaakGateway : IZaakGateway
|
||||
public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Approved { get; private set; }
|
||||
public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Cancelled { get; private set; }
|
||||
|
||||
// The URLs the catalogus resolves the configured identificatie/omschrijving to (S-27); settable so
|
||||
// a scenario can pin the zaaktype the ACL should default-fill.
|
||||
public Uri ResolvedZaaktypeUrl { get; set; } = new("http://openzaak/catalogi/api/v1/zaaktypen/big");
|
||||
public Uri ResolvedInformatieobjecttypeUrl { get; set; } = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip");
|
||||
|
||||
public Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Captured = request;
|
||||
@@ -42,10 +37,4 @@ public sealed class InMemoryZaakGateway : IZaakGateway
|
||||
|
||||
public Task<Uri> StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default)
|
||||
=> Task.FromResult(new Uri("http://openzaak/documenten/api/v1/enkelvoudiginformatieobjecten/acc-doc"));
|
||||
|
||||
public Task<Uri> ResolveZaaktypeUrlAsync(string identificatie, CancellationToken ct = default)
|
||||
=> Task.FromResult(ResolvedZaaktypeUrl);
|
||||
|
||||
public Task<Uri> ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default)
|
||||
=> Task.FromResult(ResolvedInformatieobjecttypeUrl);
|
||||
}
|
||||
|
||||
@@ -13,11 +13,8 @@ test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt
|
||||
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
|
||||
await page.goto('/');
|
||||
|
||||
// Keycloak's default login form (stable ids across themes). Its own DigiD user: the verify-* API
|
||||
// checks submit as jan-burger (bsn 123456782) before the e2e runs on the shared stack, and
|
||||
// resume-on-load (S-26) would otherwise restore one of those on login — so each self-service spec
|
||||
// uses a dedicated citizen no other actor touches.
|
||||
await page.locator('#username').fill('emma-burger');
|
||||
// Keycloak's default login form (stable ids across themes).
|
||||
await page.locator('#username').fill('jan-burger');
|
||||
await page.locator('#password').fill('test123');
|
||||
await page.locator('#kc-login').click();
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
// S-26: a zorgprofessional submits, then reloads the self-service portal. On load the portal asks the
|
||||
// BFF for the caller's current open registration (owner-scoped by the DigiD token's bsn) and restores
|
||||
// the submitted view — so a refresh no longer strands the in-flight registration and its actions.
|
||||
test('DigiD submit → reload → self-service restores the existing registration', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Its own DigiD user (like every self-service spec): on the shared verify stack, resume-on-load
|
||||
// (S-26) restores any open registration for the bsn, so each spec uses a dedicated citizen that no
|
||||
// other spec or verify-* check touches. This one in particular leaves an open registration.
|
||||
await page.locator('#username').fill('sanne-burger');
|
||||
await page.locator('#password').fill('test123');
|
||||
await page.locator('#kc-login').click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: /Zelfservice/i })).toBeVisible();
|
||||
await page.getByRole('button', { name: /indienen/i }).click();
|
||||
|
||||
const confirmation = page.getByText(/ontvangen/i);
|
||||
await expect(confirmation).toBeVisible();
|
||||
const reference = (await confirmation.textContent())?.match(/Referentie:\s*([0-9a-fA-F-]+)/)?.[1];
|
||||
expect(reference, 'the confirmation shows a registration reference').toBeTruthy();
|
||||
|
||||
// Reload: the component's in-memory submitted state is gone, but the DigiD session persists and the
|
||||
// portal resumes from the BFF instead of dropping back to the blank submit form.
|
||||
await page.reload();
|
||||
|
||||
await expect(page.getByText(/ontvangen/i)).toBeVisible();
|
||||
// The same reference the citizen saw before the reload is restored...
|
||||
await expect(page.getByText(new RegExp(reference!))).toBeVisible();
|
||||
// ...and its actions are reachable again (e.g. "trek aanvraag in").
|
||||
await expect(page.getByRole('button', { name: /trek aanvraag in/i })).toBeVisible();
|
||||
});
|
||||
@@ -8,9 +8,7 @@ test('DigiD submit → trek aanvraag in → self-service confirms ingetrokken',
|
||||
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
|
||||
await page.goto('/');
|
||||
|
||||
// Its own DigiD user — isolated from the verify-* checks (jan-burger/123456782) so resume-on-load
|
||||
// (S-26) can't restore someone else's registration on the shared stack.
|
||||
await page.locator('#username').fill('lars-burger');
|
||||
await page.locator('#username').fill('jan-burger');
|
||||
await page.locator('#password').fill('test123');
|
||||
await page.locator('#kc-login').click();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user