Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25b593ec3e | ||
|
|
c25ec24c73 | ||
|
|
24927b1e80 | ||
|
|
5de8c1e292 | ||
|
|
183d0bce31 | ||
|
|
d5e5fa254c | ||
|
|
bf234e1322 | ||
|
|
c8fdfbb699 |
@@ -239,6 +239,12 @@ Split (issue #11 closed) into two independently-demoable slices per §13 — the
|
|||||||
|
|
||||||
**Outcome:** Boundary timer on beoordeling user task — 14 days. On timeout, reassigns to a teamlead role.
|
**Outcome:** Boundary timer on beoordeling user task — 14 days. On timeout, reassigns to a teamlead role.
|
||||||
|
|
||||||
|
### S-26 · Self-service — resume an existing registration after refresh — #111
|
||||||
|
|
||||||
|
**Outcome:** a signed-in zorgprofessional who reloads the self-service portal (or returns later) gets back to their in-flight registration and its actions (Documenten aanleveren, Trek aanvraag in), instead of a blank submit form with the reference lost. Today all post-submit state lives in in-memory signals, the reference is not in the URL, and there is no self-service read endpoint — so a reload strands the registration. Adds an owner-scoped (DigiD bsn) `GET /self-service/registrations` on the BFF/domain and a load-on-init/route restore in the portal.
|
||||||
|
|
||||||
|
**Acceptance:** BDD — resume after refresh shows the existing registration; lookup is owner-scoped (never another citizen's); a user with no in-flight registration still sees the submit form. Playwright e2e reloads mid-flow and asserts the actions remain reachable.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Iteration 3 — Maintenance portal and observability *(milestone: `Iteration 3 — Beheer & Observability`)*
|
## Iteration 3 — Maintenance portal and observability *(milestone: `Iteration 3 — Beheer & Observability`)*
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export DOCKER_HOST := unix://$(PODMAN_SOCK)
|
|||||||
endif
|
endif
|
||||||
endif
|
endif
|
||||||
|
|
||||||
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-notifications smoke up down local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
|
.PHONY: ci lint build unit mutation frontend integration verify verify-up verify-acl verify-nrc verify-projection verify-bff verify-domain verify-notifications smoke up down local verify-local local-down changelog openzaak-up openzaak-smoke openzaak-seed openzaak-down stack-up stack-smoke stack-down keycloak-up keycloak-smoke keycloak-down flowable-up flowable-smoke flowable-down help
|
||||||
|
|
||||||
## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions)
|
## ci: run the full pipeline — lint, build, unit, mutation, frontend, verify (mirrors Gitea Actions)
|
||||||
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
|
## `verify` is the live-stack stage (full stack up once → ACL + notification checks).
|
||||||
@@ -114,6 +114,11 @@ local:
|
|||||||
docker compose -f $(LOCAL_COMPOSE) up -d --build
|
docker compose -f $(LOCAL_COMPOSE) up -d --build
|
||||||
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
|
WAIT_TIMEOUT=420 bash infra/wait-healthy.sh $(WAIT_SVCS)
|
||||||
|
|
||||||
|
## verify-local: acceptance check for the local stack (S-B04) — a fresh `make local` completes the
|
||||||
|
## whole flow (zaaktype seeded + DMN deployed + NRC abonnement) with NO manual seeding.
|
||||||
|
verify-local:
|
||||||
|
bash infra/run-local-flow-check.sh
|
||||||
|
|
||||||
## local-down: stop and remove the bind-mount stack
|
## local-down: stop and remove the bind-mount stack
|
||||||
local-down:
|
local-down:
|
||||||
docker compose -f $(LOCAL_COMPOSE) down --volumes
|
docker compose -f $(LOCAL_COMPOSE) down --volumes
|
||||||
|
|||||||
@@ -21,16 +21,20 @@ function providers(
|
|||||||
post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
||||||
withdraw = vi.fn().mockReturnValue(of(undefined)),
|
withdraw = vi.fn().mockReturnValue(of(undefined)),
|
||||||
provideDocuments = vi.fn().mockReturnValue(of(undefined)),
|
provideDocuments = vi.fn().mockReturnValue(of(undefined)),
|
||||||
|
// Resume lookup (S-26): default to 204/empty — no in-flight registration, so the submit form shows.
|
||||||
|
getCurrent = vi.fn().mockReturnValue(of(undefined)),
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
post,
|
post,
|
||||||
withdraw,
|
withdraw,
|
||||||
provideDocuments,
|
provideDocuments,
|
||||||
|
getCurrent,
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: AuthService, useClass: FakeAuth },
|
{ provide: AuthService, useClass: FakeAuth },
|
||||||
{
|
{
|
||||||
provide: BffApiV1Service,
|
provide: BffApiV1Service,
|
||||||
useValue: {
|
useValue: {
|
||||||
|
getSelfServiceRegistrations: getCurrent,
|
||||||
postSelfServiceRegistrations: post,
|
postSelfServiceRegistrations: post,
|
||||||
postSelfServiceRegistrationsIdWithdraw: withdraw,
|
postSelfServiceRegistrationsIdWithdraw: withdraw,
|
||||||
postSelfServiceRegistrationsIdDocuments: provideDocuments,
|
postSelfServiceRegistrationsIdDocuments: provideDocuments,
|
||||||
@@ -56,6 +60,21 @@ describe('RegistrationPage', () => {
|
|||||||
expect(await screen.findByText(/ontvangen/i)).toBeTruthy();
|
expect(await screen.findByText(/ontvangen/i)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resumes an existing registration on load, without submitting again (S-26)', async () => {
|
||||||
|
const { post, providers: p } = providers(
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
vi.fn().mockReturnValue(of({ registrationId: 'reg-77', status: 'Ingediend' })),
|
||||||
|
);
|
||||||
|
await render(RegistrationPage, { providers: p });
|
||||||
|
|
||||||
|
// The confirmation view is restored from the in-flight registration — no submit click.
|
||||||
|
expect(await screen.findByText(/ontvangen/i)).toBeTruthy();
|
||||||
|
expect(screen.getByText(/reg-77/)).toBeTruthy();
|
||||||
|
expect(post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('shows an error and keeps the submit available when the BFF call fails', async () => {
|
it('shows an error and keeps the submit available when the BFF call fails', async () => {
|
||||||
const { post, providers: p } = providers(vi.fn().mockReturnValue(throwError(() => new Error('BFF rejected'))));
|
const { post, providers: p } = providers(vi.fn().mockReturnValue(throwError(() => new Error('BFF rejected'))));
|
||||||
await render(RegistrationPage, { providers: p });
|
await render(RegistrationPage, { providers: p });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Component, inject, signal } from '@angular/core';
|
import { Component, inject, type OnInit, signal } from '@angular/core';
|
||||||
import { BffApiV1Service, type SubmitAccepted } from 'api-client';
|
import { BffApiV1Service, type CurrentRegistration, type SubmitAccepted } from 'api-client';
|
||||||
import { AuthService } from 'auth';
|
import { AuthService } from 'auth';
|
||||||
import { UtrechtComponentsModule } from 'ui';
|
import { UtrechtComponentsModule } from 'ui';
|
||||||
|
|
||||||
@@ -8,13 +8,16 @@ import { UtrechtComponentsModule } from 'ui';
|
|||||||
* registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and-
|
* registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and-
|
||||||
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c). After
|
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c). After
|
||||||
* submitting they can withdraw it — "trek aanvraag in" — keyed by that reference (S-11c).
|
* submitting they can withdraw it — "trek aanvraag in" — keyed by that reference (S-11c).
|
||||||
|
*
|
||||||
|
* On load it asks the BFF for the caller's current open registration and restores the submitted view
|
||||||
|
* if there is one, so a page refresh no longer strands an in-flight registration (S-26).
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-registration-page',
|
selector: 'app-registration-page',
|
||||||
imports: [UtrechtComponentsModule],
|
imports: [UtrechtComponentsModule],
|
||||||
templateUrl: './registration-page.html',
|
templateUrl: './registration-page.html',
|
||||||
})
|
})
|
||||||
export class RegistrationPage {
|
export class RegistrationPage implements OnInit {
|
||||||
private readonly auth = inject(AuthService);
|
private readonly auth = inject(AuthService);
|
||||||
private readonly bff = inject(BffApiV1Service);
|
private readonly bff = inject(BffApiV1Service);
|
||||||
|
|
||||||
@@ -31,6 +34,23 @@ export class RegistrationPage {
|
|||||||
protected readonly provideDocumentsFailed = signal(false);
|
protected readonly provideDocumentsFailed = signal(false);
|
||||||
protected readonly selectedFile = signal<File | undefined>(undefined);
|
protected readonly selectedFile = signal<File | undefined>(undefined);
|
||||||
|
|
||||||
|
/** Resume an existing in-flight registration after a refresh (S-26): the BFF returns the caller's
|
||||||
|
* current open registration, or 204 (empty body) when there is none — in which case we show the
|
||||||
|
* submit form as before. Failures are non-fatal for the same reason. */
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.bff.getSelfServiceRegistrations().subscribe({
|
||||||
|
next: (current: CurrentRegistration | void) => {
|
||||||
|
if (current && current.registrationId) {
|
||||||
|
this.reference.set(current.registrationId);
|
||||||
|
this.submitted.set(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
// No resumable registration (or the lookup failed) — fall back to the submit form.
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
this.submitting.set(true);
|
this.submitting.set(true);
|
||||||
this.failed.set(false);
|
this.failed.set(false);
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# ADR-0020: The local stack self-seeds the zaaktype, DMN, and NRC abonnement at bring-up
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-07-22
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** S-B04 (#110). Local-stack twin of the seeding the verify-* scripts do for CI
|
||||||
|
(`infra/run-domain-check.sh`, `infra/verify-notification-driver.py`). Superseded in part by S-27
|
||||||
|
(#113), which would let the ACL resolve its zaaktype by identificatie and remove the URL injection.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
`infra/docker-compose.local.yml` is the host-browser-friendly stack (`make local`) — the one a
|
||||||
|
developer clicks through the portals with. It had drifted behind three slices, so a fresh bring-up
|
||||||
|
could not complete the flow:
|
||||||
|
|
||||||
|
1. The ACL pointed at a placeholder zaaktype (`…/00000000-…`), so zaak creation failed with OpenZaak
|
||||||
|
`400` and the registratie process stuck at `OpenZaakAanmaken` (S-05).
|
||||||
|
2. `flowable-init` deployed only `registratie.bpmn`, not `diploma-eligibility.dmn`, so completing
|
||||||
|
`WachtOpDocumenten` 404'd on the missing decision and never reached `Beoordelen` (S-10a/S-13).
|
||||||
|
3. No NRC abonnement was registered, so notifications reached NRC and went nowhere — the projection
|
||||||
|
and the openbaar register stayed empty (S-06).
|
||||||
|
|
||||||
|
The CI stack (`infra/docker-compose.yml`) does not hit this because its `verify-*` scripts seed the
|
||||||
|
zaaktype, deploy the DMN, and register the abonnement at *test* time. The local stack has no such
|
||||||
|
harness — a developer just runs `make local` and browses. The non-obvious wrinkle is (1): the
|
||||||
|
zaaktype **UUID is assigned by OpenZaak at creation**, so the ACL's zaaktype URL is not knowable when
|
||||||
|
the compose file is written and cannot be a static value.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Make the local stack self-seed at bring-up via one-shot init containers, and hand the ACL its
|
||||||
|
server-assigned zaaktype URL through a shared-volume env file it sources on startup.**
|
||||||
|
|
||||||
|
- **DMN (gap 2).** `flowable-init` now deploys `diploma-eligibility.dmn` to the DMN engine
|
||||||
|
(`/flowable-rest/dmn-api/dmn-repository/deployments`) as a separate deployment alongside the BPMN —
|
||||||
|
identical to the CI `flowable-init`. Idempotent.
|
||||||
|
- **Zaaktype + ACL wiring (gap 1).** A `local-seed` one-shot runs the existing
|
||||||
|
`infra/openzaak/seed_catalogus.py` (`OZ_PUBLISH=1`) against OpenZaak and writes the resulting
|
||||||
|
`Acl__Defaults__ZaaktypeUrl` / `…InformatieobjecttypeUrl` / `Acl__OpenZaak__BaseUrl` into
|
||||||
|
`seed-env:/out/acl.env`. The ACL mounts that volume read-only and overrides its entrypoint to
|
||||||
|
`sh -c 'set -a; . /seed/acl.env; set +a; exec dotnet Acl.Api.dll'`, so the real values override the
|
||||||
|
compose placeholders before the app reads config. The ACL `depends_on: local-seed
|
||||||
|
(service_completed_successfully)`.
|
||||||
|
- **Abonnement (gap 3).** A `nrc-subscribe` one-shot registers an abonnement on the `zaken` kanaal
|
||||||
|
pointing at the event-subscriber's `/notifications` callback (`infra/local/register-abonnement.py`).
|
||||||
|
It is a leaf — nothing depends on it — so it can wait for the event-subscriber without forming a
|
||||||
|
cycle with the ACL bootstrap.
|
||||||
|
- **Reach OpenZaak/NRC by container IP, not service name.** Both the seed's ZTC calls and the
|
||||||
|
abonnement's `callbackUrl` are validated by Django's URLValidator, which rejects a single-label host
|
||||||
|
like `openzaak` / `event-subscriber`. The scripts resolve the target's container IP at runtime (as
|
||||||
|
`infra/run-domain-check.sh` does), keeping the seeded URLs valid **and** host-consistent — the ACL's
|
||||||
|
base URL is set to the same OpenZaak IP that owns the zaaktype URL.
|
||||||
|
- **Acceptance.** `make verify-local` (`infra/run-local-flow-check.sh`) submits against a fresh stack
|
||||||
|
and asserts the zaak opens, the case reaches the werkbak after documents, and the reference appears
|
||||||
|
in the openbaar register — the red-to-green test for all three gaps.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- A fresh `make local` completes the full demo (submit → werkbak → openbaar) with no manual seeding —
|
||||||
|
the slice's stated outcome.
|
||||||
|
- Reuses the proven CI mechanisms (`seed_catalogus.py`, the DMN deploy, the abonnement driver) rather
|
||||||
|
than inventing new ones; the only genuinely new piece is the entrypoint-sourced env file.
|
||||||
|
- No service code changes — the fix is entirely in `infra/` (compose + two small scripts), so the ACL
|
||||||
|
image and the CI stack are untouched.
|
||||||
|
|
||||||
|
**Negative / costs**
|
||||||
|
|
||||||
|
- The two compose files diverge further: the CI stack seeds at test time, the local stack at bring-up.
|
||||||
|
Mitigated by reusing the same underlying scripts and cross-referencing them.
|
||||||
|
- The ACL entrypoint override couples the local ACL to the seed-written file path (`/seed/acl.env`);
|
||||||
|
if the seed fails, the ACL fails to start (loud, healthcheck-visible — preferred over silently
|
||||||
|
running with a placeholder).
|
||||||
|
- Container-IP-based URLs are re-derived on each bring-up; a keep-volumes restart with a changed
|
||||||
|
OpenZaak IP relies on OpenZaak rebuilding hyperlinked URLs from the request host (it does) so the
|
||||||
|
idempotent re-seed reports current-IP URLs.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **ACL resolves its zaaktype by identificatie (`BIG-REGISTRATIE`) at startup.** The cleaner,
|
||||||
|
less-brittle design — no server-assigned URL to capture — and it would help the CI stack too. But it
|
||||||
|
changes a service's runtime behaviour and its config contract, needs new ACL tests + mutation
|
||||||
|
coverage, and still needs a seed step to *create* the zaaktype. Deliberately split out as its own
|
||||||
|
slice with its own ADR (S-27 / #113) rather than folded into this infra-only fix.
|
||||||
|
- **A documented `make local-seed` step run after `make local`.** Smallest change, but it fails the
|
||||||
|
slice's "no manual seeding" outcome — the local stack is exactly the one meant to just work in a
|
||||||
|
browser. Rejected.
|
||||||
|
- **Fixed zaaktype UUID via OpenZaak `setup_configuration`/fixtures.** OpenZaak assigns UUIDs on POST;
|
||||||
|
declaratively creating a fully *published* zaaktype (statustypen + resultaattypen validated against
|
||||||
|
the Selectielijst + roltypen + iot relations) is not something `setup_configuration` supports
|
||||||
|
cleanly in 1.28.2. Rejected as more fragile than reusing `seed_catalogus.py`.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# ADR-0021: The ACL resolves its zaaktype by identificatie, not a pinned URL
|
||||||
|
|
||||||
|
- **Status:** Accepted
|
||||||
|
- **Date:** 2026-07-22
|
||||||
|
- **Deciders:** Respellion engineering
|
||||||
|
- **Relates to:** S-27 (#113), proposed in #117. The cleaner design deliberately split out of S-B04
|
||||||
|
(#110, ADR-0020), which fixed the local stack with an infra-only bootstrap.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The ACL was handed a **pinned zaaktype URL** (`Acl__Defaults__ZaaktypeUrl`) and diploma
|
||||||
|
informatieobjecttype URL. OpenZaak assigns those UUIDs at creation, so the URL is not knowable when
|
||||||
|
the compose file is written — every stack had to seed the catalogus and then capture + inject the
|
||||||
|
resulting URLs out of band: `run-domain-check.sh` for CI, and the `local-seed` → `acl.env` bootstrap
|
||||||
|
(ADR-0020) for `make local`. Brittle, and a stale/placeholder URL failed opaquely (OpenZaak 400).
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**The ACL resolves its zaaktype (by `identificatie`) and diploma informatieobjecttype (by
|
||||||
|
`omschrijving`) from OpenZaak's Catalogi API, instead of being handed the URLs.**
|
||||||
|
|
||||||
|
- **Config:** `AclDefaults.ZaaktypeUrl`/`InformatieobjecttypeUrl` → `ZaaktypeIdentificatie`
|
||||||
|
(`BIG-REGISTRATIE`) / `InformatieobjecttypeOmschrijving` (`Diploma`).
|
||||||
|
- **Lookup (gateway, §8.1):** `GET /catalogi/api/v1/zaaktypen?status=definitief&identificatie=…` →
|
||||||
|
the published zaaktype URL; `GET /catalogi/api/v1/informatieobjecttypen?status=definitief` matched
|
||||||
|
on `omschrijving`. Reuses the gateway's existing catalogus-query machinery.
|
||||||
|
- **Timing = lazy + cached (`CachedZaaktypeCatalog`).** Resolve on first use (first zaak open /
|
||||||
|
document store) and cache for the process lifetime. Lazy avoids a startup ordering coupling — the
|
||||||
|
ACL never crash-loops when it boots before the catalogus is published. A **failed** resolution is
|
||||||
|
not cached, so it is retried on the next call (e.g. once the zaaktype is published); a restart
|
||||||
|
re-resolves.
|
||||||
|
- **Failure mode:** no published match → a clear "No published zaaktype with identificatie '…' found
|
||||||
|
in OpenZaak — is the BIG catalogus seeded and published?" error, replacing the opaque placeholder
|
||||||
|
400.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive**
|
||||||
|
|
||||||
|
- No stack captures or injects a server-assigned URL any more: `run-domain-check.sh` drops the
|
||||||
|
`ACL_ZAAKTYPE_URL`/`ACL_INFORMATIEOBJECTTYPE_URL` capture+inject, `docker-compose.yml`/`.local.yml`
|
||||||
|
drop the placeholder URL env, and `local-seed`/`acl.env` shrink to a single line. The ACL
|
||||||
|
self-configures from the catalogus it already talks to.
|
||||||
|
- The failure mode is legible (a named error instead of a 400 on a zeros-UUID).
|
||||||
|
|
||||||
|
**Negative / costs**
|
||||||
|
|
||||||
|
- The ACL still needs its OpenZaak **BaseUrl** pointed at a **URL-valid host (a container IP)**, so
|
||||||
|
the base-URL injection from ADR-0020 stays (the local `acl.env` now carries only that; CI keeps
|
||||||
|
`ACL_OPENZAAK_BASEURL`). This is **not** something S-27 can remove: OpenZaak validates the
|
||||||
|
`zaaktype` field on zaak-create with Django's URLValidator and **rejects a single-label host**
|
||||||
|
(`http://openzaak:8000/…` → `zaaktype: bad-url, "Voer een geldige URL in."`, confirmed empirically).
|
||||||
|
So ADR-0020's `seed-env` volume + ACL entrypoint shim are **simplified, not deleted**.
|
||||||
|
- New branching in the gateway/resolver → unit + integration test surface; the mutation ratchet
|
||||||
|
covers it (§5).
|
||||||
|
- A seed step still **creates + publishes** the zaaktype (this ADR changes only discovery). Reaching
|
||||||
|
OpenZaak's Catalogi API to *seed* likewise needs the IP host (its query params hit the same
|
||||||
|
URLValidator) — unchanged from before.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Resolve at startup** (eager). Simpler cache, but reintroduces the ordering coupling (crash-loop
|
||||||
|
if the catalogus isn't published yet). Rejected in favour of lazy.
|
||||||
|
- **Per-request resolution** (no cache). No stale-cache risk, but a Catalogi lookup on every ACL
|
||||||
|
operation. Rejected; a process-lifetime cache with restart-to-refresh is enough here.
|
||||||
|
- **Keep the pinned URL** (status quo / ADR-0020 only). Rejected — the brittleness this ADR removes is
|
||||||
|
exactly what S-27 was carved out to fix.
|
||||||
@@ -5,6 +5,55 @@ copy-pasteable walkthrough against a local `make up` stack.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## S-B04 — `make local` completes the whole flow with no manual seeding (#110, ADR-0020)
|
||||||
|
|
||||||
|
**Outcome:** the host-browser stack (`make local`) now self-seeds at bring-up — it publishes the BIG
|
||||||
|
zaaktype and wires the ACL to it, deploys the `diploma-eligibility` DMN, and registers the NRC
|
||||||
|
abonnement — so a fresh bring-up runs submit → werkbak → openbaar without the manual seeding the
|
||||||
|
`verify-*` scripts do for CI. (Previously the process stuck at `OpenZaakAanmaken`, the werkbak stayed
|
||||||
|
empty, and the openbaar register showed nothing.)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Fresh bring-up (self-seeding init containers: local-seed, nrc-subscribe; DMN in flowable-init).
|
||||||
|
make local
|
||||||
|
|
||||||
|
# 2. Assert the whole flow works with no manual seeding — submit opens a zaak, documents route it to
|
||||||
|
# the werkbak, and the reference appears in the openbaar register:
|
||||||
|
make verify-local # → "OK — a fresh local stack completed the flow with no manual seeding ..."
|
||||||
|
|
||||||
|
# 3. Or by hand in the browser: log in at http://localhost:8140 (jan-burger / test123), submit +
|
||||||
|
# upload a PDF, then approve it in the werkbak at http://localhost:8142 (merel-behandelaar /
|
||||||
|
# test123); it shows as INGESCHREVEN in the openbaar register at http://localhost:8141.
|
||||||
|
```
|
||||||
|
|
||||||
|
> The zaaktype is discovered by the ACL itself since S-27 (below); `local-seed`'s `acl.env` now
|
||||||
|
> carries only OpenZaak's IP base URL, which the ACL still needs because OpenZaak rejects a
|
||||||
|
> single-label host on zaak-create (ADR-0020 + ADR-0021).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## S-27 — ACL resolves its zaaktype by identificatie, not a pinned URL (#113, ADR-0021)
|
||||||
|
|
||||||
|
**Outcome:** the ACL discovers its BIG zaaktype (by `identificatie`) and diploma informatieobjecttype
|
||||||
|
(by `omschrijving`) from OpenZaak's Catalogi API, instead of being handed the server-assigned URLs.
|
||||||
|
No user-visible behaviour change — the flow runs exactly as before — but no stack captures/injects a
|
||||||
|
zaaktype URL any more, and a missing catalogus now fails with a clear message instead of an opaque 400.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# The live ACL↔OpenZaak integration test proves resolution against a real seeded OpenZaak:
|
||||||
|
make verify-acl # → "resolves the published BIG-REGISTRATIE zaaktype + Diploma informatieobjecttype by business key"
|
||||||
|
|
||||||
|
# End-to-end unchanged (the ACL self-discovers the zaaktype during the flow):
|
||||||
|
make verify-local # local stack — still green, now with no zaaktype-URL injection
|
||||||
|
make verify-domain # CI stack — recreates the ACL pointed only at OpenZaak's IP (no URL to inject)
|
||||||
|
```
|
||||||
|
|
||||||
|
> The ACL still needs its OpenZaak base URL at a URL-valid host (a container IP): OpenZaak's
|
||||||
|
> URLValidator rejects a single-label host like `openzaak:8000` on zaak-create. So ADR-0020's base-URL
|
||||||
|
> injection stays; only the zaaktype/informatieobjecttype **URL** injection is gone (ADR-0021).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## S-08d — Walking skeleton complete: browser → submit, end-to-end
|
## S-08d — Walking skeleton complete: browser → submit, end-to-end
|
||||||
|
|
||||||
**Outcome:** the self-service portal is served in the stack and the full front-of-house happy path
|
**Outcome:** the self-service portal is served in the stack and the full front-of-house happy path
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ All test users share the password **`test123`**.
|
|||||||
| Realm | Mimics | User | Identifying claim |
|
| Realm | Mimics | User | Identifying claim |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `digid` | DigiD (burgers) | `jan-burger` | `bsn` = `123456782` |
|
| `digid` | DigiD (burgers) | `jan-burger` | `bsn` = `123456782` |
|
||||||
|
| `digid` | DigiD (burgers) | `sanne-burger` | `bsn` = `231477813` (S-26 resume e2e — its own user so it can leave an open registration) |
|
||||||
| `eherkenning` | eHerkenning (bedrijven) | `acme-ondernemer` | `kvk` = `12345678` |
|
| `eherkenning` | eHerkenning (bedrijven) | `acme-ondernemer` | `kvk` = `12345678` |
|
||||||
| `eidas` | eIDAS (EU) | `pierre-dupont` | `eidas_id` = `FR/NL/AB-1234-5678` |
|
| `eidas` | eIDAS (EU) | `pierre-dupont` | `eidas_id` = `FR/NL/AB-1234-5678` |
|
||||||
| `medewerker` | Internal staff | `merel-behandelaar` | role `behandelaar` |
|
| `medewerker` | Internal staff | `merel-behandelaar` | role `behandelaar` |
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
# LOCAL development stack — runs with a plain `docker compose up`, no make / no
|
# LOCAL development stack — runs with a plain `docker compose up`, no make / no
|
||||||
# seed step / no bash. Use this on a local engine (Docker Desktop on Windows or
|
# external seed step / no bash. Use this on a local engine (Docker Desktop on Windows or
|
||||||
# macOS, or rootless Podman on Linux).
|
# macOS, or rootless Podman on Linux).
|
||||||
#
|
#
|
||||||
|
# Self-seeding (S-B04, #110, ADR-0020): unlike the CI stack — where the verify-* scripts seed the
|
||||||
|
# zaaktype and register the NRC abonnement at test time — this stack does that itself, via one-shot
|
||||||
|
# init containers (local-seed, nrc-subscribe) + a DMN deploy in flowable-init, so a fresh bring-up
|
||||||
|
# completes the whole flow with no manual steps. `make verify-local` asserts it.
|
||||||
|
#
|
||||||
# docker compose -f infra/docker-compose.local.yml up -d --build # podman
|
# docker compose -f infra/docker-compose.local.yml up -d --build # podman
|
||||||
# docker compose -f infra/docker-compose.local.yml up -d --build --wait # Docker Desktop
|
# docker compose -f infra/docker-compose.local.yml up -d --build --wait # Docker Desktop
|
||||||
# docker compose -f infra/docker-compose.local.yml down --volumes
|
# docker compose -f infra/docker-compose.local.yml down --volumes
|
||||||
@@ -257,38 +262,79 @@ services:
|
|||||||
restart: "no"
|
restart: "no"
|
||||||
volumes:
|
volumes:
|
||||||
- ../workflows/registratie.bpmn:/work/registratie.bpmn:ro,z
|
- ../workflows/registratie.bpmn:/work/registratie.bpmn:ro,z
|
||||||
|
- ../workflows/diploma-eligibility.dmn:/work/diploma-eligibility.dmn:ro,z
|
||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
- |
|
- |
|
||||||
base=http://flowable-rest:8080/flowable-rest/service/repository/deployments
|
svc=http://flowable-rest:8080/flowable-rest/service/repository/deployments
|
||||||
until curl -sf -u rest-admin:test "$$base" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
|
dmn=http://flowable-rest:8080/flowable-rest/dmn-api/dmn-repository/deployments
|
||||||
if curl -s -u rest-admin:test "$$base?name=registratie" | grep -q '"name":"registratie"'; then
|
until curl -sf -u rest-admin:test "$$svc" >/dev/null 2>&1; do echo "waiting for flowable-rest..."; sleep 3; done
|
||||||
echo "registratie already deployed; skip"
|
# Deploy the DMN to the DMN engine and the BPMN to the process engine as SEPARATE deployments:
|
||||||
|
# flowable-rest does NOT cascade a .dmn bundled in a process .bar into the DMN engine, so the DMN
|
||||||
|
# must go via dmn-api. The registratie process's DMN service task then resolves the decision across
|
||||||
|
# deployments by key (S-13, ADR-0016). Without this the WachtOpDocumenten completion 404s on the
|
||||||
|
# missing decision and the case never reaches Beoordelen (S-B04). Both steps are idempotent.
|
||||||
|
if curl -s -u rest-admin:test "$$dmn" | grep -q '"name":"diploma-eligibility.dmn"'; then
|
||||||
|
echo "diploma-eligibility DMN already deployed; skip"
|
||||||
else
|
else
|
||||||
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$base" >/dev/null && echo "deployed registratie"
|
curl -sf -u rest-admin:test -F 'file=@/work/diploma-eligibility.dmn;filename=diploma-eligibility.dmn' "$$dmn" >/dev/null && echo "deployed diploma-eligibility DMN"
|
||||||
|
fi
|
||||||
|
if curl -s -u rest-admin:test "$$svc?name=registratie" | grep -q '"name":"registratie"'; then
|
||||||
|
echo "registratie BPMN already deployed; skip"
|
||||||
|
else
|
||||||
|
curl -sf -u rest-admin:test -F 'file=@/work/registratie.bpmn;filename=registratie.bpmn' "$$svc" >/dev/null && echo "deployed registratie BPMN"
|
||||||
fi
|
fi
|
||||||
depends_on:
|
depends_on:
|
||||||
flowable-rest:
|
flowable-rest:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
|
# ── Local bootstrap: seed the zaaktype + wire the ACL (S-B04, #110, ADR-0020) ─────────────────
|
||||||
|
# The zaaktype UUID is assigned by OpenZaak at creation, so it can't be a static value in this
|
||||||
|
# file. This one-shot seeds + publishes the BIG zaaktype (and the Diploma informatieobjecttype)
|
||||||
|
# and writes their server-assigned URLs into a shared volume as acl.env, which the ACL sources on
|
||||||
|
# startup (below). It is the local-stack equivalent of what infra/run-domain-check.sh does for CI.
|
||||||
|
# Reaches OpenZaak by its container IP because a single-label host fails OpenZaak's URLValidator.
|
||||||
|
local-seed:
|
||||||
|
image: docker.io/library/python:3-slim
|
||||||
|
restart: "no"
|
||||||
|
volumes:
|
||||||
|
- ./openzaak/seed_catalogus.py:/work/seed_catalogus.py:ro,z
|
||||||
|
- ./local/seed-zaaktype.sh:/work/seed-zaaktype.sh:ro,z
|
||||||
|
- seed-env:/out
|
||||||
|
command: ["sh", "/work/seed-zaaktype.sh"]
|
||||||
|
depends_on:
|
||||||
|
openzaak:
|
||||||
|
condition: service_healthy
|
||||||
|
networks: [cg]
|
||||||
|
|
||||||
# ── ACL ──────────────────────────────────────────────────────────────────
|
# ── ACL ──────────────────────────────────────────────────────────────────
|
||||||
acl:
|
acl:
|
||||||
build:
|
build:
|
||||||
context: ../services/acl
|
context: ../services/acl
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: register-referentie/acl:dev
|
image: register-referentie/acl:dev
|
||||||
|
# The ACL discovers its zaaktype + informatieobjecttype URLs from the Catalogi API by the business
|
||||||
|
# keys below (S-27, ADR-0021), so no URL is injected. It still needs its OpenZaak BaseUrl pointed at
|
||||||
|
# a URL-valid host (OpenZaak rejects a single-label host like `openzaak` on zaak-create), so the
|
||||||
|
# local-seed one-shot writes that IP base into seed-env:/seed/acl.env, which the entrypoint sources
|
||||||
|
# (set -a) before the app starts. A runtime-generated env file is why we override the entrypoint here
|
||||||
|
# rather than use `env_file:` (which compose reads at parse time, before the seed has run).
|
||||||
|
entrypoint: ["/bin/sh", "-c", "set -a; . /seed/acl.env; set +a; exec dotnet Acl.Api.dll"]
|
||||||
environment:
|
environment:
|
||||||
Acl__OpenZaak__BaseUrl: http://openzaak:8000/
|
Acl__OpenZaak__BaseUrl: http://openzaak:8000/ # placeholder; seed-env/acl.env supplies the IP base
|
||||||
Acl__OpenZaak__ClientId: big-reference-seed
|
Acl__OpenZaak__ClientId: big-reference-seed
|
||||||
Acl__OpenZaak__Secret: insecure-dev-secret-change-me
|
Acl__OpenZaak__Secret: insecure-dev-secret-change-me
|
||||||
Acl__Defaults__Bronorganisatie: "517439943"
|
Acl__Defaults__Bronorganisatie: "517439943"
|
||||||
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
|
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
|
||||||
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
|
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
|
||||||
Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000}
|
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
|
||||||
|
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
|
||||||
ports:
|
ports:
|
||||||
- "8100:8080"
|
- "8100:8080"
|
||||||
|
volumes:
|
||||||
|
- seed-env:/seed:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -298,6 +344,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
openzaak:
|
openzaak:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
local-seed:
|
||||||
|
condition: service_completed_successfully
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
# ── BFF ──────────────────────────────────────────────────────────────────
|
# ── BFF ──────────────────────────────────────────────────────────────────
|
||||||
@@ -400,6 +448,31 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
|
# ── Local bootstrap: register the NRC abonnement (S-B04, #110, ADR-0020) ──────────────────────
|
||||||
|
# Without a subscription, OpenZaak's notifications reach NRC and are delivered nowhere, so the
|
||||||
|
# projection (and the openbaar register) stay empty. This one-shot registers an abonnement on the
|
||||||
|
# `zaken` kanaal pointing at the event-subscriber's /notifications callback — the CI equivalent is
|
||||||
|
# infra/verify-notification-driver.py. The callback uses the event-subscriber's container IP (a
|
||||||
|
# single-label host fails NRC's URLValidator). It is a leaf (nothing depends on it), so it can wait
|
||||||
|
# for the event-subscriber without creating a cycle with the ACL bootstrap.
|
||||||
|
nrc-subscribe:
|
||||||
|
image: docker.io/library/python:3-slim
|
||||||
|
restart: "no"
|
||||||
|
volumes:
|
||||||
|
- ./local/register-abonnement.py:/work/register-abonnement.py:ro,z
|
||||||
|
environment:
|
||||||
|
NRC_BASE: http://nrc-web:8000
|
||||||
|
SINK_HOST: event-subscriber
|
||||||
|
SINK_PORT: "8080"
|
||||||
|
SINK_AUTH: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
|
||||||
|
command: ["python", "/work/register-abonnement.py"]
|
||||||
|
depends_on:
|
||||||
|
nrc-web:
|
||||||
|
condition: service_healthy
|
||||||
|
event-subscriber:
|
||||||
|
condition: service_started
|
||||||
|
networks: [cg]
|
||||||
|
|
||||||
projection-api:
|
projection-api:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
@@ -492,6 +565,8 @@ volumes:
|
|||||||
nrc-db:
|
nrc-db:
|
||||||
flowable-db:
|
flowable-db:
|
||||||
projection-db:
|
projection-db:
|
||||||
|
# Carries the seed-generated acl.env (server-assigned zaaktype URLs) from local-seed to the ACL.
|
||||||
|
seed-env:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
cg:
|
cg:
|
||||||
|
|||||||
+12
-11
@@ -15,12 +15,12 @@
|
|||||||
#
|
#
|
||||||
# docker compose -f infra/docker-compose.yml up -d --build --wait
|
# docker compose -f infra/docker-compose.yml up -d --build --wait
|
||||||
#
|
#
|
||||||
# After first boot, seed the BIG catalogus and note the zaaktype URL:
|
# After first boot, seed + publish the BIG catalogus:
|
||||||
# python infra/openzaak/seed_catalogus.py
|
# OZ_PUBLISH=1 python infra/openzaak/seed_catalogus.py
|
||||||
# Then set ACL_ZAAKTYPE_URL in a .env file or your shell and re-up the acl
|
# The ACL discovers the zaaktype by identificatie (S-27, ADR-0021), so there is no URL to inject —
|
||||||
# service:
|
# just point its BaseUrl at an OpenZaak host OpenZaak accepts on zaak-create (a container IP; a
|
||||||
# export ACL_ZAAKTYPE_URL=http://openzaak:8000/catalogi/api/v1/zaaktypen/<uuid>
|
# single-label host is rejected):
|
||||||
# docker compose -f infra/docker-compose.yml up -d acl
|
# ACL_OPENZAAK_BASEURL=http://<openzaak-ip>:8000/ docker compose -f infra/docker-compose.yml up -d acl
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
@@ -304,11 +304,12 @@ services:
|
|||||||
Acl__Defaults__Bronorganisatie: "517439943"
|
Acl__Defaults__Bronorganisatie: "517439943"
|
||||||
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
|
Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943"
|
||||||
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
|
Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar
|
||||||
# Override with the real zaaktype URL after running seed_catalogus.py.
|
# The ACL resolves the (server-assigned) zaaktype + diploma informatieobjecttype URLs from the
|
||||||
Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000}
|
# Catalogi API by these stable business keys (S-27, ADR-0021) — no URL to capture and inject.
|
||||||
# The informatieobjecttype a diploma is filed under (S-10b). Placeholder until seed_catalogus.py
|
# BaseUrl above stays overridable because OpenZaak rejects a single-label host on zaak creation,
|
||||||
# (OZ_PUBLISH=1) reports the real URL, which verify-domain injects like the zaaktype URL.
|
# so verify-domain still points the ACL at OpenZaak's container IP.
|
||||||
Acl__Defaults__InformatieobjecttypeUrl: ${ACL_INFORMATIEOBJECTTYPE_URL:-http://openzaak:8000/catalogi/api/v1/informatieobjecttypen/00000000-0000-0000-0000-000000000000}
|
Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE
|
||||||
|
Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma
|
||||||
ports:
|
ports:
|
||||||
- "8100:8080"
|
- "8100:8080"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -38,6 +38,36 @@
|
|||||||
"emailVerified": true,
|
"emailVerified": true,
|
||||||
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
||||||
"attributes": { "bsn": ["123456782"] }
|
"attributes": { "bsn": ["123456782"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "sanne-burger",
|
||||||
|
"enabled": true,
|
||||||
|
"firstName": "Sanne",
|
||||||
|
"lastName": "Burger",
|
||||||
|
"email": "sanne.burger@example.nl",
|
||||||
|
"emailVerified": true,
|
||||||
|
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
||||||
|
"attributes": { "bsn": ["231477813"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "emma-burger",
|
||||||
|
"enabled": true,
|
||||||
|
"firstName": "Emma",
|
||||||
|
"lastName": "Burger",
|
||||||
|
"email": "emma.burger@example.nl",
|
||||||
|
"emailVerified": true,
|
||||||
|
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
||||||
|
"attributes": { "bsn": ["231477805"] }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"username": "lars-burger",
|
||||||
|
"enabled": true,
|
||||||
|
"firstName": "Lars",
|
||||||
|
"lastName": "Burger",
|
||||||
|
"email": "lars.burger@example.nl",
|
||||||
|
"emailVerified": true,
|
||||||
|
"credentials": [{ "type": "password", "value": "test123", "temporary": false }],
|
||||||
|
"attributes": { "bsn": ["231477821"] }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+78
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Local-stack bootstrap (S-B04, #110, ADR-0020) — register the NRC abonnement.
|
||||||
|
|
||||||
|
Runs as the `nrc-subscribe` init container of infra/docker-compose.local.yml. Registers an
|
||||||
|
abonnement on the `zaken` kanaal pointing at the event-subscriber's /notifications callback, so
|
||||||
|
OpenZaak's notifications (zaak create + status set) reach the projection — without this the openbaar
|
||||||
|
(public) register stays empty. This is what infra/verify-notification-driver.py does for CI (minus
|
||||||
|
the test zaak it also creates).
|
||||||
|
|
||||||
|
The callback host is the event-subscriber's resolved **container IP**, not `event-subscriber`, because
|
||||||
|
NRC validates callbackUrl with Django's URLValidator (a single-label host is rejected — same reason the
|
||||||
|
zaaktype seed uses OpenZaak's IP). Idempotent + restart-safe: it removes any stale /notifications
|
||||||
|
abonnement first, then registers one for the current IP. Stdlib only.
|
||||||
|
|
||||||
|
Env: NRC_BASE, SINK_HOST, SINK_PORT, SINK_AUTH, OZ_CLIENT_ID, OZ_SECRET.
|
||||||
|
"""
|
||||||
|
import base64, hashlib, hmac, json, os, socket, sys, time, urllib.error, urllib.request
|
||||||
|
|
||||||
|
NRC = os.environ.get("NRC_BASE", "http://nrc-web:8000").rstrip("/")
|
||||||
|
SINK_HOST = os.environ.get("SINK_HOST", "event-subscriber")
|
||||||
|
SINK_PORT = os.environ.get("SINK_PORT", "8080")
|
||||||
|
SINK_AUTH = os.environ.get("SINK_AUTH", "Bearer big-reference-notifications")
|
||||||
|
CID = os.environ.get("OZ_CLIENT_ID", "big-reference-seed")
|
||||||
|
SECRET = os.environ.get("OZ_SECRET", "insecure-dev-secret-change-me")
|
||||||
|
|
||||||
|
|
||||||
|
def token():
|
||||||
|
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=")
|
||||||
|
seg = (
|
||||||
|
b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
|
||||||
|
+ b"."
|
||||||
|
+ b64(json.dumps(
|
||||||
|
{"iss": CID, "iat": int(time.time()), "client_id": CID,
|
||||||
|
"user_id": "local-seed", "user_representation": "local-seed"},
|
||||||
|
separators=(",", ":")).encode())
|
||||||
|
)
|
||||||
|
return (seg + b"." + b64(hmac.new(SECRET.encode(), seg, hashlib.sha256).digest())).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def call(method, url, body=None):
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
req = urllib.request.Request(url, data=data, method=method, headers={
|
||||||
|
"Authorization": "Bearer " + token(),
|
||||||
|
"Content-Type": "application/json", "Accept": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
raw = r.read()
|
||||||
|
return r.status, (json.loads(raw) if raw else None)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read()
|
||||||
|
return e.code, (json.loads(raw) if raw else None)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ip = socket.gethostbyname(SINK_HOST)
|
||||||
|
callback = f"http://{ip}:{SINK_PORT}/notifications"
|
||||||
|
|
||||||
|
# Restart-safe: drop any prior /notifications abonnement (its IP may be stale) before creating a
|
||||||
|
# fresh one for the current event-subscriber IP.
|
||||||
|
status, body = call("GET", f"{NRC}/api/v1/abonnement")
|
||||||
|
for ab in (body or []) if status == 200 else []:
|
||||||
|
if str(ab.get("callbackUrl", "")).endswith("/notifications"):
|
||||||
|
if ab.get("callbackUrl") == callback:
|
||||||
|
print(f"abonnement already current: {ab['url']}")
|
||||||
|
return
|
||||||
|
call("DELETE", ab["url"])
|
||||||
|
print(f"removed stale abonnement {ab['url']}")
|
||||||
|
|
||||||
|
status, ab = call("POST", f"{NRC}/api/v1/abonnement", {
|
||||||
|
"callbackUrl": callback, "auth": SINK_AUTH,
|
||||||
|
"kanalen": [{"naam": "zaken", "filters": {}}]})
|
||||||
|
if status != 201:
|
||||||
|
sys.exit(f"create abonnement -> {status}: {json.dumps(ab)}")
|
||||||
|
print(f"abonnement registered: {ab['url']} -> {callback}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Executable
+33
@@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Local-stack bootstrap (S-B04, #110, ADR-0020) — the "seed zaaktype + wire the ACL" step.
|
||||||
|
#
|
||||||
|
# Runs as the `local-seed` init container of infra/docker-compose.local.yml. It seeds + publishes
|
||||||
|
# the BIG zaaktype (and the Diploma informatieobjecttype) into OpenZaak, then writes the resulting
|
||||||
|
# **server-assigned** URLs into /out/acl.env, which the ACL entrypoint sources before starting. This
|
||||||
|
# is the local-stack equivalent of what infra/run-domain-check.sh does for CI: the zaaktype UUID is
|
||||||
|
# assigned by OpenZaak at creation, so it can't be a static value in the compose file.
|
||||||
|
#
|
||||||
|
# Why the container IP and not the `openzaak` service name: OpenZaak validates URL query params
|
||||||
|
# (e.g. ?catalogus=) with Django's URLValidator, which rejects a single-label host like `openzaak`.
|
||||||
|
# Seeding against the resolved IP keeps the seeded URLs valid AND host-consistent with the ACL, which
|
||||||
|
# we point at the same IP below. See docs/runbooks/gitea-actions-gotchas.md and ADR-0020.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
oz_ip="$(python3 -c "import socket;print(socket.gethostbyname('openzaak'))")"
|
||||||
|
OZ_BASE="http://${oz_ip}:8000"
|
||||||
|
export OZ_BASE OZ_PUBLISH=1
|
||||||
|
|
||||||
|
echo ">> seeding + publishing the BIG zaaktype at ${OZ_BASE} (idempotent)"
|
||||||
|
out="$(python3 /work/seed_catalogus.py)"
|
||||||
|
echo "$out"
|
||||||
|
|
||||||
|
# Sanity-check that the zaaktype was actually published (the ACL discovers it by identificatie, S-27).
|
||||||
|
printf '%s\n' "$out" | grep -q '^ZAAKTYPE_URL ' || { echo "ERROR: seed did not publish the zaaktype" >&2; exit 1; }
|
||||||
|
|
||||||
|
# The ACL resolves the zaaktype/informatieobjecttype URLs itself (S-27, ADR-0021); the only value it
|
||||||
|
# still needs injected is the OpenZaak base URL at a URL-valid host (the container IP), because OpenZaak
|
||||||
|
# rejects a single-label host on zaak-create. The ACL entrypoint sources this.
|
||||||
|
cat > /out/acl.env <<EOF
|
||||||
|
Acl__OpenZaak__BaseUrl=${OZ_BASE}/
|
||||||
|
EOF
|
||||||
|
echo ">> wrote /out/acl.env (base=${OZ_BASE}/)"
|
||||||
Binary file not shown.
@@ -30,21 +30,18 @@ oz_ip="$(ip "$oz")"; dom_ip="$(ip "$dom")"
|
|||||||
oz_base="http://$oz_ip:8000"
|
oz_base="http://$oz_ip:8000"
|
||||||
echo ">> openzaak=$oz_ip domain=$dom_ip network=$net"
|
echo ">> openzaak=$oz_ip domain=$dom_ip network=$net"
|
||||||
|
|
||||||
echo ">> seeding a published BIG zaaktype (idempotent) and capturing its URL"
|
echo ">> seeding + publishing a BIG zaaktype (idempotent)"
|
||||||
sid="$(docker create --network "$net" -e "OZ_BASE=$oz_base" -e OZ_PUBLISH=1 python:3-slim python /seed.py)"
|
sid="$(docker create --network "$net" -e "OZ_BASE=$oz_base" -e OZ_PUBLISH=1 python:3-slim python /seed.py)"
|
||||||
docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null
|
docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null
|
||||||
seed_out="$(docker start -a "$sid")"
|
seed_out="$(docker start -a "$sid")"
|
||||||
zt_url="$(printf '%s\n' "$seed_out" | sed -n 's/^ZAAKTYPE_URL //p' | head -1)"
|
|
||||||
iot_url="$(printf '%s\n' "$seed_out" | sed -n 's/^INFORMATIEOBJECTTYPE_URL //p' | head -1)"
|
|
||||||
docker rm -f "$sid" >/dev/null
|
docker rm -f "$sid" >/dev/null
|
||||||
[ -n "$zt_url" ] || { echo "ERROR: seed did not report a ZAAKTYPE_URL" >&2; exit 1; }
|
printf '%s\n' "$seed_out" | grep -q '^ZAAKTYPE_URL ' || { echo "ERROR: seed did not publish the zaaktype" >&2; exit 1; }
|
||||||
[ -n "$iot_url" ] || { echo "ERROR: seed did not report an INFORMATIEOBJECTTYPE_URL" >&2; exit 1; }
|
|
||||||
echo ">> zaaktype: $zt_url"
|
|
||||||
echo ">> informatieobjecttype: $iot_url"
|
|
||||||
|
|
||||||
echo ">> recreating the acl service pointed at the seeded zaaktype + informatieobjecttype (host-consistent)"
|
# The ACL resolves the zaaktype + informatieobjecttype by identificatie/omschrijving (S-27, ADR-0021),
|
||||||
ACL_ZAAKTYPE_URL="$zt_url" ACL_INFORMATIEOBJECTTYPE_URL="$iot_url" ACL_OPENZAAK_BASEURL="$oz_base/" \
|
# so there is no URL to inject — only the OpenZaak base URL, pointed at the same host's container IP
|
||||||
docker compose -f "$compose" up -d acl
|
# (OpenZaak rejects a single-label host on zaak-create).
|
||||||
|
echo ">> recreating the acl service pointed at OpenZaak's IP (it resolves the zaaktype itself, S-27)"
|
||||||
|
ACL_OPENZAAK_BASEURL="$oz_base/" docker compose -f "$compose" up -d acl
|
||||||
WAIT_TIMEOUT="${WAIT_TIMEOUT:-120}" bash "$here/wait-healthy.sh" acl
|
WAIT_TIMEOUT="${WAIT_TIMEOUT:-120}" bash "$here/wait-healthy.sh" acl
|
||||||
|
|
||||||
echo ">> submitting a registration to the domain"
|
echo ">> submitting a registration to the domain"
|
||||||
|
|||||||
Executable
+67
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Acceptance check for the local stack (S-B04, #110): a fresh `make local` must complete the whole
|
||||||
|
# flow with NO manual seeding. Run against an already-up local stack (infra/docker-compose.local.yml)
|
||||||
|
# via the host-published ports. It exercises, and thereby covers, the three bring-up gaps the slice
|
||||||
|
# fixes:
|
||||||
|
#
|
||||||
|
# 1. zaaktype seeded + ACL wired -> a submitted registration opens a zaak (zaakUrl gets filled).
|
||||||
|
# 2. diploma-eligibility DMN deployed -> providing documents completes WachtOpDocumenten, routes
|
||||||
|
# through the DMN, and the case lands on Beoordelen (visible in the behandel werkbak).
|
||||||
|
# 3. NRC abonnement registered -> the zaak shows up in the openbaar (public) register.
|
||||||
|
#
|
||||||
|
# Before the fix this fails at step 1 (ACL points at a placeholder zaaktype -> OpenZaak 400).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DOM=${DOM:-http://localhost:8130} # domain
|
||||||
|
BFF=${BFF:-http://localhost:8080} # bff (openbaar register)
|
||||||
|
BSN=${BSN:-123456782}
|
||||||
|
# A minimal, valid PDF, base64-encoded (the diploma upload).
|
||||||
|
PDF_B64="$(printf '%%PDF-1.4\n1 0 obj<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>\n%%%%EOF\n' | base64 | tr -d '\n')"
|
||||||
|
|
||||||
|
echo ">> 1. submit a registration (no manual seeding expected)"
|
||||||
|
loc="$(curl -fsS -D - -o /dev/null -X POST "$DOM/registrations" \
|
||||||
|
-H 'Content-Type: application/json' -d "{\"bsn\":\"$BSN\"}" \
|
||||||
|
| sed -n 's/\r$//; s/^[Ll]ocation: //p' | head -1)"
|
||||||
|
[ -n "$loc" ] || { echo "FAIL: POST /registrations returned no Location" >&2; exit 1; }
|
||||||
|
id="${loc##*/}"
|
||||||
|
echo " accepted: $id"
|
||||||
|
|
||||||
|
echo ">> 2. poll until the ACL opens the zaak (proves the zaaktype is seeded + wired)"
|
||||||
|
zaak=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
zaak="$(curl -fsS "$DOM$loc" | python3 -c 'import sys,json;print(json.load(sys.stdin).get("zaakUrl") or "")' 2>/dev/null || true)"
|
||||||
|
[ -n "$zaak" ] && break
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
[ -n "$zaak" ] || { echo "FAIL: zaak never opened — ACL zaaktype not wired (gap 1)" >&2; exit 1; }
|
||||||
|
echo " zaak opened: $zaak"
|
||||||
|
|
||||||
|
echo ">> 3. provide documents (proves the diploma-eligibility DMN is deployed)"
|
||||||
|
code="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$DOM/registrations/$id/documents" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"bsn\":\"$BSN\",\"contentBase64\":\"$PDF_B64\",\"fileName\":\"diploma.pdf\",\"contentType\":\"application/pdf\"}")"
|
||||||
|
[ "$code" = "204" ] || { echo "FAIL: provide documents -> $code (DMN missing routes WachtOpDocumenten to a 404 — gap 2)" >&2; exit 1; }
|
||||||
|
echo " documents accepted (204)"
|
||||||
|
|
||||||
|
echo ">> 4. poll the werkbak until the registration awaits beoordeling (reached Beoordelen)"
|
||||||
|
in_werkbak=""
|
||||||
|
for _ in $(seq 1 20); do
|
||||||
|
in_werkbak="$(curl -fsS "$DOM/behandel/werkbak" | python3 -c "import sys,json;print(any(r.get('registrationId')=='$id' for r in json.load(sys.stdin)))" 2>/dev/null || true)"
|
||||||
|
[ "$in_werkbak" = "True" ] && break
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
[ "$in_werkbak" = "True" ] || { echo "FAIL: registration never reached the werkbak (gap 2)" >&2; exit 1; }
|
||||||
|
echo " in the werkbak"
|
||||||
|
|
||||||
|
echo ">> 5. poll the openbaar register until the reference is publicly visible (proves NRC abonnement)"
|
||||||
|
public=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
public="$(curl -fsS "$BFF/openbaar/register" | python3 -c "import sys,json;print(any(r.get('reference')=='$id' for r in json.load(sys.stdin)))" 2>/dev/null || true)"
|
||||||
|
[ "$public" = "True" ] && break
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
[ "$public" = "True" ] || { echo "FAIL: reference never appeared in the openbaar register — NRC abonnement not registered (gap 3)" >&2; exit 1; }
|
||||||
|
echo " visible in the openbaar register"
|
||||||
|
|
||||||
|
echo "OK — a fresh local stack completed the flow with no manual seeding (zaaktype + DMN + abonnement)"
|
||||||
@@ -24,6 +24,11 @@ import {
|
|||||||
Observable
|
Observable
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
|
|
||||||
|
export interface CurrentRegistration {
|
||||||
|
registrationId: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DecideRequest {
|
export interface DecideRequest {
|
||||||
besluit: string;
|
besluit: string;
|
||||||
}
|
}
|
||||||
@@ -200,6 +205,37 @@ export class BffApiV1Service {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSelfServiceRegistrations<TData = CurrentRegistration | void>( options?: HttpClientBodyOptions): Observable<TData>;
|
||||||
|
getSelfServiceRegistrations<TData = CurrentRegistration | void>( options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||||
|
getSelfServiceRegistrations<TData = CurrentRegistration | void>( options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||||
|
getSelfServiceRegistrations<TData = CurrentRegistration | void>(
|
||||||
|
options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
||||||
|
if (options?.observe === 'events') {
|
||||||
|
return this.http.get<TData>(
|
||||||
|
`/self-service/registrations`,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'events',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.observe === 'response') {
|
||||||
|
return this.http.get<TData>(
|
||||||
|
`/self-service/registrations`,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'response',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.get<TData>(
|
||||||
|
`/self-service/registrations`,{
|
||||||
|
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
||||||
|
observe: 'body',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientBodyOptions): Observable<TData>;
|
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientBodyOptions): Observable<TData>;
|
||||||
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||||
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService<IConfiguration>()
|
|||||||
.GetSection("Acl:OpenZaak").Get<OpenZaakOptions>()
|
.GetSection("Acl:OpenZaak").Get<OpenZaakOptions>()
|
||||||
?? throw new InvalidOperationException("Missing configuration section 'Acl:OpenZaak'"));
|
?? throw new InvalidOperationException("Missing configuration section 'Acl:OpenZaak'"));
|
||||||
builder.Services.AddHttpClient<IZaakGateway, OpenZaakGateway>();
|
builder.Services.AddHttpClient<IZaakGateway, OpenZaakGateway>();
|
||||||
|
// Singleton so the resolved zaaktype/informatieobjecttype URLs are cached across requests (S-27).
|
||||||
|
builder.Services.AddSingleton<IZaaktypeCatalog, CachedZaaktypeCatalog>();
|
||||||
builder.Services.AddScoped<AclService>();
|
builder.Services.AddScoped<AclService>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ public sealed class AclDefaults
|
|||||||
public required string Bronorganisatie { get; init; }
|
public required string Bronorganisatie { get; init; }
|
||||||
public required string VerantwoordelijkeOrganisatie { get; init; }
|
public required string VerantwoordelijkeOrganisatie { get; init; }
|
||||||
public required string Vertrouwelijkheidaanduiding { get; init; }
|
public required string Vertrouwelijkheidaanduiding { get; init; }
|
||||||
public required Uri ZaaktypeUrl { get; init; }
|
|
||||||
|
|
||||||
/// <summary>The informatieobjecttype an uploaded diploma is filed under (S-10b). Seeded in the
|
/// <summary>The BIG zaaktype's stable business key. The ACL resolves the (server-assigned) zaaktype
|
||||||
/// catalogus and injected like <see cref="ZaaktypeUrl"/>.</summary>
|
/// URL from this via the Catalogi API instead of being handed a pinned URL (S-27, ADR-0021).</summary>
|
||||||
public required Uri InformatieobjecttypeUrl { get; init; }
|
public required string ZaaktypeIdentificatie { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The omschrijving of the informatieobjecttype an uploaded diploma is filed under (S-10b);
|
||||||
|
/// resolved to a URL by the Catalogi API, like <see cref="ZaaktypeIdentificatie"/>.</summary>
|
||||||
|
public required string InformatieobjecttypeOmschrijving { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ namespace Acl.Application;
|
|||||||
|
|
||||||
/// <summary>The ACL's single operation: open a zaak from a domain payload,
|
/// <summary>The ACL's single operation: open a zaak from a domain payload,
|
||||||
/// default-filling the ZGW-mandatory fields (ADR-0003).</summary>
|
/// default-filling the ZGW-mandatory fields (ADR-0003).</summary>
|
||||||
public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IClock clock)
|
public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaaktypeCatalog catalog, IClock clock)
|
||||||
{
|
{
|
||||||
public Task<Uri> OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default)
|
public async Task<Uri> OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(registration);
|
ArgumentNullException.ThrowIfNull(registration);
|
||||||
|
|
||||||
@@ -12,34 +12,34 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
|
|||||||
defaults.Bronorganisatie,
|
defaults.Bronorganisatie,
|
||||||
defaults.VerantwoordelijkeOrganisatie,
|
defaults.VerantwoordelijkeOrganisatie,
|
||||||
defaults.Vertrouwelijkheidaanduiding,
|
defaults.Vertrouwelijkheidaanduiding,
|
||||||
defaults.ZaaktypeUrl,
|
await catalog.GetZaaktypeUrlAsync(ct),
|
||||||
clock.Today,
|
clock.Today,
|
||||||
registration.Reference);
|
registration.Reference);
|
||||||
|
|
||||||
return gateway.OpenZaakAsync(request, ct);
|
return await gateway.OpenZaakAsync(request, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Approve a zaak: set it to the eindstatus of the configured BIG zaaktype (ADR-0003 default). The
|
/// Approve a zaak: set it to the eindstatus of the BIG zaaktype (resolved by identificatie, S-27).
|
||||||
/// domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1).
|
/// The domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
public async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||||
|
|
||||||
return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
|
await gateway.SetZaakToEindstatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Cancel a zaak on document-timeout expiry (S-10c): set it to the configured BIG zaaktype's
|
/// Cancel a zaak on document-timeout expiry (S-10c): set it to the BIG zaaktype's cancellation
|
||||||
/// cancellation statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which
|
/// statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which
|
||||||
/// statustype/resultaat means "cancelled" (§8.1).
|
/// statustype/resultaat means "cancelled" (§8.1).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
public async Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||||
|
|
||||||
return gateway.SetZaakToCancellationStatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct);
|
await gateway.SetZaakToCancellationStatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The zaak's reference (its ZGW identificatie), for the read projection (#78).</summary>
|
/// <summary>The zaak's reference (its ZGW identificatie), for the read projection (#78).</summary>
|
||||||
@@ -56,7 +56,7 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
|
|||||||
/// and hand the file to the gateway, which creates the informatieobject and relates it to the zaak.
|
/// and hand the file to the gateway, which creates the informatieobject and relates it to the zaak.
|
||||||
/// The domain supplies only the zaak, the bytes, and the file's name/type (§8.1).
|
/// The domain supplies only the zaak, the bytes, and the file's name/type (§8.1).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Task<Uri> StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default)
|
public async Task<Uri> StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(zaakUrl);
|
ArgumentNullException.ThrowIfNull(zaakUrl);
|
||||||
ArgumentNullException.ThrowIfNull(content);
|
ArgumentNullException.ThrowIfNull(content);
|
||||||
@@ -65,7 +65,7 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
|
|||||||
|
|
||||||
var request = new DocumentRequest(
|
var request = new DocumentRequest(
|
||||||
defaults.Bronorganisatie,
|
defaults.Bronorganisatie,
|
||||||
defaults.InformatieobjecttypeUrl,
|
await catalog.GetInformatieobjecttypeUrlAsync(ct),
|
||||||
defaults.Vertrouwelijkheidaanduiding,
|
defaults.Vertrouwelijkheidaanduiding,
|
||||||
zaakUrl,
|
zaakUrl,
|
||||||
clock.Today,
|
clock.Today,
|
||||||
@@ -76,6 +76,6 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc
|
|||||||
Formaat: contentType,
|
Formaat: contentType,
|
||||||
Inhoud: content);
|
Inhoud: content);
|
||||||
|
|
||||||
return gateway.StoreDocumentAsync(request, ct);
|
return await gateway.StoreDocumentAsync(request, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
namespace Acl.Application;
|
||||||
|
|
||||||
|
/// <summary>Resolves the zaaktype + diploma-informatieobjecttype URLs from the Catalogi API on first
|
||||||
|
/// use and caches them for the process lifetime (S-27, ADR-0021). Lazy (not at startup) so the ACL
|
||||||
|
/// never crash-loops when it boots before the catalogus is seeded/published; a <em>failed</em>
|
||||||
|
/// resolution is not cached, so it is retried on the next call (e.g. once the zaaktype is published).
|
||||||
|
/// A process restart re-resolves.</summary>
|
||||||
|
public sealed class CachedZaaktypeCatalog(IZaakGateway gateway, AclDefaults defaults) : IZaaktypeCatalog
|
||||||
|
{
|
||||||
|
private readonly SemaphoreSlim gate = new(1, 1);
|
||||||
|
private Uri? zaaktype;
|
||||||
|
private Uri? informatieobjecttype;
|
||||||
|
|
||||||
|
public Task<Uri> GetZaaktypeUrlAsync(CancellationToken ct = default) =>
|
||||||
|
ResolveOnceAsync(
|
||||||
|
() => zaaktype, value => zaaktype = value,
|
||||||
|
() => gateway.ResolveZaaktypeUrlAsync(defaults.ZaaktypeIdentificatie, ct), ct);
|
||||||
|
|
||||||
|
public Task<Uri> GetInformatieobjecttypeUrlAsync(CancellationToken ct = default) =>
|
||||||
|
ResolveOnceAsync(
|
||||||
|
() => informatieobjecttype, value => informatieobjecttype = value,
|
||||||
|
() => gateway.ResolveInformatieobjecttypeUrlAsync(defaults.InformatieobjecttypeOmschrijving, ct), ct);
|
||||||
|
|
||||||
|
// Double-checked, single-flight resolution: return the cache if set; otherwise resolve under the
|
||||||
|
// gate and cache only on success (a throw leaves the cache empty so the next call retries).
|
||||||
|
private async Task<Uri> ResolveOnceAsync(Func<Uri?> read, Action<Uri> store, Func<Task<Uri>> resolve, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (read() is { } cached)
|
||||||
|
return cached;
|
||||||
|
|
||||||
|
await gate.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (read() is { } existing)
|
||||||
|
return existing;
|
||||||
|
|
||||||
|
var resolved = await resolve();
|
||||||
|
store(resolved);
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
gate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,4 +32,12 @@ public interface IZaakGateway
|
|||||||
/// the created informatieobject.
|
/// the created informatieobject.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Uri> StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default);
|
Task<Uri> StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Resolve the URL of the published zaaktype with the given <paramref name="identificatie"/>
|
||||||
|
/// from the Catalogi API (S-27). Throws if no published zaaktype matches.</summary>
|
||||||
|
Task<Uri> ResolveZaaktypeUrlAsync(string identificatie, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>Resolve the URL of the published informatieobjecttype with the given
|
||||||
|
/// <paramref name="omschrijving"/> from the Catalogi API (S-27). Throws if none matches.</summary>
|
||||||
|
Task<Uri> ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Acl.Application;
|
||||||
|
|
||||||
|
/// <summary>Supplies the ACL's zaaktype + diploma-informatieobjecttype URLs, resolved from OpenZaak's
|
||||||
|
/// Catalogi API by their stable business keys (<see cref="AclDefaults.ZaaktypeIdentificatie"/> /
|
||||||
|
/// <see cref="AclDefaults.InformatieobjecttypeOmschrijving"/>) rather than pinned in config (S-27,
|
||||||
|
/// ADR-0021). Implementations resolve lazily on first use and cache the result.</summary>
|
||||||
|
public interface IZaaktypeCatalog
|
||||||
|
{
|
||||||
|
Task<Uri> GetZaaktypeUrlAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
Task<Uri> GetInformatieobjecttypeUrlAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -142,6 +142,48 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
|||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Uri> ResolveZaaktypeUrlAsync(string identificatie, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(identificatie);
|
||||||
|
|
||||||
|
// The published zaaktype with this identificatie; status=definitief excludes concepts.
|
||||||
|
var page = await GetAsync<ZaaktypePage>(
|
||||||
|
"/catalogi/api/v1/zaaktypen?status=definitief&identificatie=" + Uri.EscapeDataString(identificatie),
|
||||||
|
"zaaktypen", ct);
|
||||||
|
var match = (page.Results ?? []).FirstOrDefault()
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"No published zaaktype with identificatie '{identificatie}' found in OpenZaak — is the BIG catalogus seeded and published?");
|
||||||
|
return new Uri(match.Url);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Uri> ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(omschrijving);
|
||||||
|
|
||||||
|
// The informatieobjecttypen collection has no omschrijving filter, so match client-side over the
|
||||||
|
// published ones.
|
||||||
|
var page = await GetAsync<InformatieobjecttypePage>(
|
||||||
|
"/catalogi/api/v1/informatieobjecttypen?status=definitief", "informatieobjecttypen", ct);
|
||||||
|
var match = (page.Results ?? []).FirstOrDefault(i => i.Omschrijving == omschrijving)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"No published informatieobjecttype '{omschrijving}' found in OpenZaak — is the BIG catalogus seeded and published?");
|
||||||
|
return new Uri(match.Url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GETs an absolute-by-path ZGW resource with auth (no CRS — catalogi is not a geo API).
|
||||||
|
private async Task<T> GetAsync<T>(string pathAndQuery, string label, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var message = new HttpRequestMessage(HttpMethod.Get, new Uri(options.BaseUrl, pathAndQuery));
|
||||||
|
message.Headers.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", ZgwToken.Mint(options.ClientId, options.Secret));
|
||||||
|
|
||||||
|
using var response = await http.SendAsync(message, ct);
|
||||||
|
await EnsureSuccessAsync(response, $"Querying {label}", ct);
|
||||||
|
|
||||||
|
return await response.Content.ReadFromJsonAsync<T>(ct)
|
||||||
|
?? throw new InvalidOperationException($"OpenZaak returned an empty {label} response");
|
||||||
|
}
|
||||||
|
|
||||||
// POSTs a non-geo ZGW resource (resultaat/status — no CRS headers). Buffers the body so uwsgi gets
|
// POSTs a non-geo ZGW resource (resultaat/status — no CRS headers). Buffers the body so uwsgi gets
|
||||||
// a Content-Length instead of a chunked body (as with zaak-create).
|
// a Content-Length instead of a chunked body (as with zaak-create).
|
||||||
private async Task PostAsync(string path, object dto, string action, CancellationToken ct)
|
private async Task PostAsync(string path, object dto, string action, CancellationToken ct)
|
||||||
@@ -298,4 +340,18 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) :
|
|||||||
private sealed record ZaakInformatieobjectDto(
|
private sealed record ZaakInformatieobjectDto(
|
||||||
[property: JsonPropertyName("zaak")] string Zaak,
|
[property: JsonPropertyName("zaak")] string Zaak,
|
||||||
[property: JsonPropertyName("informatieobject")] string Informatieobject);
|
[property: JsonPropertyName("informatieobject")] string Informatieobject);
|
||||||
|
|
||||||
|
private sealed record ZaaktypePage(
|
||||||
|
[property: JsonPropertyName("results")] IReadOnlyList<ZaaktypeDto>? Results);
|
||||||
|
|
||||||
|
private sealed record ZaaktypeDto(
|
||||||
|
[property: JsonPropertyName("url")] string Url,
|
||||||
|
[property: JsonPropertyName("identificatie")] string? Identificatie);
|
||||||
|
|
||||||
|
private sealed record InformatieobjecttypePage(
|
||||||
|
[property: JsonPropertyName("results")] IReadOnlyList<InformatieobjecttypeDto>? Results);
|
||||||
|
|
||||||
|
private sealed record InformatieobjecttypeDto(
|
||||||
|
[property: JsonPropertyName("url")] string Url,
|
||||||
|
[property: JsonPropertyName("omschrijving")] string? Omschrijving);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,4 +161,32 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack)
|
|||||||
Assert.Contains(relations.EnumerateArray(),
|
Assert.Contains(relations.EnumerateArray(),
|
||||||
r => r.GetProperty("zaak").GetString() == zaakUrl.ToString());
|
r => r.GetProperty("zaak").GetString() == zaakUrl.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolves_the_published_zaaktype_and_diploma_informatieobjecttype_by_business_key()
|
||||||
|
{
|
||||||
|
var expectedZaaktype = await stack.FindPublishedBigZaaktypeAsync();
|
||||||
|
Assert.True(expectedZaaktype is not null,
|
||||||
|
"No published BIG-REGISTRATIE zaaktype found — seed the stack with OZ_PUBLISH=1.");
|
||||||
|
var expectedInformatieobjecttype = await stack.FindPublishedDiplomaInformatieobjecttypeAsync();
|
||||||
|
Assert.True(expectedInformatieobjecttype is not null,
|
||||||
|
"No published Diploma informatieobjecttype found — seed the stack with OZ_PUBLISH=1.");
|
||||||
|
|
||||||
|
var gateway = new OpenZaakGateway(stack.Http, stack.Options);
|
||||||
|
|
||||||
|
// The ACL discovers both URLs from the live Catalogi API by their stable business keys (S-27),
|
||||||
|
// matching what the fixture found independently — no pinned URL needed.
|
||||||
|
Assert.Equal(expectedZaaktype, await gateway.ResolveZaaktypeUrlAsync("BIG-REGISTRATIE"));
|
||||||
|
Assert.Equal(expectedInformatieobjecttype, await gateway.ResolveInformatieobjecttypeUrlAsync("Diploma"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolving_an_unknown_zaaktype_identificatie_throws_a_clear_error()
|
||||||
|
{
|
||||||
|
var gateway = new OpenZaakGateway(stack.Http, stack.Options);
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => gateway.ResolveZaaktypeUrlAsync("NO-SUCH-ZAAKTYPE"));
|
||||||
|
Assert.Contains("NO-SUCH-ZAAKTYPE", ex.Message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ public class AclServiceTests
|
|||||||
{
|
{
|
||||||
private sealed class FakeGateway : IZaakGateway
|
private sealed class FakeGateway : IZaakGateway
|
||||||
{
|
{
|
||||||
|
// The URLs the catalogus resolves the configured identificatie/omschrijving to (S-27).
|
||||||
|
public Uri ResolvedZaaktype { get; } = new("http://openzaak/catalogi/api/v1/zaaktypen/big");
|
||||||
|
public Uri ResolvedInformatieobjecttype { get; } = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip");
|
||||||
|
public string? ResolvedByIdentificatie;
|
||||||
|
public string? ResolvedByOmschrijving;
|
||||||
|
|
||||||
public ZaakRequest? Captured;
|
public ZaakRequest? Captured;
|
||||||
public Uri Result { get; } = new("http://openzaak/zaken/api/v1/zaken/abc");
|
public Uri Result { get; } = new("http://openzaak/zaken/api/v1/zaken/abc");
|
||||||
|
|
||||||
@@ -47,6 +53,18 @@ public class AclServiceTests
|
|||||||
StoredDocument = request;
|
StoredDocument = request;
|
||||||
return Task.FromResult(DocumentResult);
|
return Task.FromResult(DocumentResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<Uri> ResolveZaaktypeUrlAsync(string identificatie, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ResolvedByIdentificatie = identificatie;
|
||||||
|
return Task.FromResult(ResolvedZaaktype);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<Uri> ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
ResolvedByOmschrijving = omschrijving;
|
||||||
|
return Task.FromResult(ResolvedInformatieobjecttype);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AclDefaults Defaults() => new()
|
private static AclDefaults Defaults() => new()
|
||||||
@@ -54,28 +72,23 @@ public class AclServiceTests
|
|||||||
Bronorganisatie = "517439943",
|
Bronorganisatie = "517439943",
|
||||||
VerantwoordelijkeOrganisatie = "517439943",
|
VerantwoordelijkeOrganisatie = "517439943",
|
||||||
Vertrouwelijkheidaanduiding = "openbaar",
|
Vertrouwelijkheidaanduiding = "openbaar",
|
||||||
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
|
ZaaktypeIdentificatie = "BIG-REGISTRATIE",
|
||||||
InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"),
|
InformatieobjecttypeOmschrijving = "Diploma",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static AclService ServiceWith(FakeGateway gateway, AclDefaults defaults, DateOnly today) =>
|
||||||
|
new(gateway, defaults, new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today));
|
||||||
|
|
||||||
private sealed class FixedClock(DateOnly today) : IClock
|
private sealed class FixedClock(DateOnly today) : IClock
|
||||||
{
|
{
|
||||||
public DateOnly Today { get; } = today;
|
public DateOnly Today { get; } = today;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Opening_a_zaak_default_fills_zgw_fields_and_returns_the_zaak_url()
|
public async Task Opening_a_zaak_default_fills_zgw_fields_and_uses_the_resolved_zaaktype()
|
||||||
{
|
{
|
||||||
var gateway = new FakeGateway();
|
var gateway = new FakeGateway();
|
||||||
var defaults = new AclDefaults
|
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||||
{
|
|
||||||
Bronorganisatie = "517439943",
|
|
||||||
VerantwoordelijkeOrganisatie = "517439943",
|
|
||||||
Vertrouwelijkheidaanduiding = "openbaar",
|
|
||||||
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
|
|
||||||
InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"),
|
|
||||||
};
|
|
||||||
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
|
|
||||||
|
|
||||||
var url = await service.OpenZaakAsync(new DomainRegistration("123456782", "reg-77"));
|
var url = await service.OpenZaakAsync(new DomainRegistration("123456782", "reg-77"));
|
||||||
|
|
||||||
@@ -84,7 +97,9 @@ public class AclServiceTests
|
|||||||
Assert.Equal("517439943", req.Bronorganisatie);
|
Assert.Equal("517439943", req.Bronorganisatie);
|
||||||
Assert.Equal("517439943", req.VerantwoordelijkeOrganisatie);
|
Assert.Equal("517439943", req.VerantwoordelijkeOrganisatie);
|
||||||
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding);
|
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding);
|
||||||
Assert.Equal(defaults.ZaaktypeUrl, req.Zaaktype);
|
// The zaaktype is resolved from the configured identificatie, not a pinned URL (S-27).
|
||||||
|
Assert.Equal("BIG-REGISTRATIE", gateway.ResolvedByIdentificatie);
|
||||||
|
Assert.Equal(gateway.ResolvedZaaktype, req.Zaaktype);
|
||||||
Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum);
|
Assert.Equal(new DateOnly(2026, 6, 4), req.Startdatum);
|
||||||
// The registration reference becomes the zaak identificatie (#78).
|
// The registration reference becomes the zaak identificatie (#78).
|
||||||
Assert.Equal("reg-77", req.Identificatie);
|
Assert.Equal("reg-77", req.Identificatie);
|
||||||
@@ -94,33 +109,24 @@ public class AclServiceTests
|
|||||||
public async Task Rejects_a_null_registration_without_calling_the_gateway()
|
public async Task Rejects_a_null_registration_without_calling_the_gateway()
|
||||||
{
|
{
|
||||||
var gateway = new FakeGateway();
|
var gateway = new FakeGateway();
|
||||||
var defaults = new AclDefaults
|
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||||
{
|
|
||||||
Bronorganisatie = "517439943",
|
|
||||||
VerantwoordelijkeOrganisatie = "517439943",
|
|
||||||
Vertrouwelijkheidaanduiding = "openbaar",
|
|
||||||
ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"),
|
|
||||||
InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"),
|
|
||||||
};
|
|
||||||
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
|
|
||||||
|
|
||||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.OpenZaakAsync(null!));
|
await Assert.ThrowsAsync<ArgumentNullException>(() => service.OpenZaakAsync(null!));
|
||||||
Assert.Null(gateway.Captured);
|
Assert.Null(gateway.Captured);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Approving_a_zaak_sets_it_to_its_zaaktypes_eindstatus_dated_today()
|
public async Task Approving_a_zaak_sets_it_to_its_resolved_zaaktypes_eindstatus_dated_today()
|
||||||
{
|
{
|
||||||
var gateway = new FakeGateway();
|
var gateway = new FakeGateway();
|
||||||
var defaults = Defaults();
|
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||||
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
|
|
||||||
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
|
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
|
||||||
|
|
||||||
await service.ApproveZaakAsync(zaak);
|
await service.ApproveZaakAsync(zaak);
|
||||||
|
|
||||||
Assert.NotNull(gateway.Approved);
|
Assert.NotNull(gateway.Approved);
|
||||||
Assert.Equal(zaak, gateway.Approved!.Value.Zaak);
|
Assert.Equal(zaak, gateway.Approved!.Value.Zaak);
|
||||||
Assert.Equal(defaults.ZaaktypeUrl, gateway.Approved.Value.Zaaktype);
|
Assert.Equal(gateway.ResolvedZaaktype, gateway.Approved.Value.Zaaktype);
|
||||||
Assert.Equal(new DateOnly(2026, 6, 4), gateway.Approved.Value.Datum);
|
Assert.Equal(new DateOnly(2026, 6, 4), gateway.Approved.Value.Datum);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +134,7 @@ public class AclServiceTests
|
|||||||
public async Task Approving_a_null_zaak_is_rejected_without_touching_the_gateway()
|
public async Task Approving_a_null_zaak_is_rejected_without_touching_the_gateway()
|
||||||
{
|
{
|
||||||
var gateway = new FakeGateway();
|
var gateway = new FakeGateway();
|
||||||
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||||
|
|
||||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!));
|
await Assert.ThrowsAsync<ArgumentNullException>(() => service.ApproveZaakAsync(null!));
|
||||||
Assert.Null(gateway.Approved);
|
Assert.Null(gateway.Approved);
|
||||||
@@ -138,15 +144,14 @@ public class AclServiceTests
|
|||||||
public async Task Cancelling_a_zaak_sets_it_to_the_cancellation_status_dated_today()
|
public async Task Cancelling_a_zaak_sets_it_to_the_cancellation_status_dated_today()
|
||||||
{
|
{
|
||||||
var gateway = new FakeGateway();
|
var gateway = new FakeGateway();
|
||||||
var defaults = Defaults();
|
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||||
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
|
|
||||||
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
|
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
|
||||||
|
|
||||||
await service.CancelZaakAsync(zaak);
|
await service.CancelZaakAsync(zaak);
|
||||||
|
|
||||||
Assert.NotNull(gateway.Cancelled);
|
Assert.NotNull(gateway.Cancelled);
|
||||||
Assert.Equal(zaak, gateway.Cancelled!.Value.Zaak);
|
Assert.Equal(zaak, gateway.Cancelled!.Value.Zaak);
|
||||||
Assert.Equal(defaults.ZaaktypeUrl, gateway.Cancelled.Value.Zaaktype);
|
Assert.Equal(gateway.ResolvedZaaktype, gateway.Cancelled.Value.Zaaktype);
|
||||||
Assert.Equal(new DateOnly(2026, 6, 4), gateway.Cancelled.Value.Datum);
|
Assert.Equal(new DateOnly(2026, 6, 4), gateway.Cancelled.Value.Datum);
|
||||||
// Cancellation must not touch the approval path.
|
// Cancellation must not touch the approval path.
|
||||||
Assert.Null(gateway.Approved);
|
Assert.Null(gateway.Approved);
|
||||||
@@ -156,18 +161,17 @@ public class AclServiceTests
|
|||||||
public async Task Cancelling_a_null_zaak_is_rejected_without_touching_the_gateway()
|
public async Task Cancelling_a_null_zaak_is_rejected_without_touching_the_gateway()
|
||||||
{
|
{
|
||||||
var gateway = new FakeGateway();
|
var gateway = new FakeGateway();
|
||||||
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||||
|
|
||||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.CancelZaakAsync(null!));
|
await Assert.ThrowsAsync<ArgumentNullException>(() => service.CancelZaakAsync(null!));
|
||||||
Assert.Null(gateway.Cancelled);
|
Assert.Null(gateway.Cancelled);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Storing_a_diploma_default_fills_the_document_fields_and_returns_its_url()
|
public async Task Storing_a_diploma_default_fills_the_document_fields_and_uses_the_resolved_informatieobjecttype()
|
||||||
{
|
{
|
||||||
var gateway = new FakeGateway();
|
var gateway = new FakeGateway();
|
||||||
var defaults = Defaults();
|
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||||
var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4)));
|
|
||||||
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
|
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
|
||||||
|
|
||||||
var url = await service.StoreDiplomaAsync(zaak, [1, 2, 3], "diploma.pdf", "application/pdf");
|
var url = await service.StoreDiplomaAsync(zaak, [1, 2, 3], "diploma.pdf", "application/pdf");
|
||||||
@@ -175,7 +179,9 @@ public class AclServiceTests
|
|||||||
Assert.Equal(gateway.DocumentResult, url);
|
Assert.Equal(gateway.DocumentResult, url);
|
||||||
var req = gateway.StoredDocument!;
|
var req = gateway.StoredDocument!;
|
||||||
Assert.Equal(zaak, req.Zaak);
|
Assert.Equal(zaak, req.Zaak);
|
||||||
Assert.Equal(defaults.InformatieobjecttypeUrl, req.Informatieobjecttype);
|
// The informatieobjecttype is resolved from the configured omschrijving (S-27).
|
||||||
|
Assert.Equal("Diploma", gateway.ResolvedByOmschrijving);
|
||||||
|
Assert.Equal(gateway.ResolvedInformatieobjecttype, req.Informatieobjecttype);
|
||||||
Assert.Equal("517439943", req.Bronorganisatie);
|
Assert.Equal("517439943", req.Bronorganisatie);
|
||||||
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding);
|
Assert.Equal("openbaar", req.Vertrouwelijkheidaanduiding);
|
||||||
Assert.Equal(new DateOnly(2026, 6, 4), req.Creatiedatum);
|
Assert.Equal(new DateOnly(2026, 6, 4), req.Creatiedatum);
|
||||||
@@ -188,7 +194,7 @@ public class AclServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Storing_a_diploma_rejects_null_or_blank_arguments()
|
public async Task Storing_a_diploma_rejects_null_or_blank_arguments()
|
||||||
{
|
{
|
||||||
var service = new AclService(new FakeGateway(), Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
var service = ServiceWith(new FakeGateway(), Defaults(), new DateOnly(2026, 6, 4));
|
||||||
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
|
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
|
||||||
|
|
||||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.StoreDiplomaAsync(null!, [1], "d.pdf", "application/pdf"));
|
await Assert.ThrowsAsync<ArgumentNullException>(() => service.StoreDiplomaAsync(null!, [1], "d.pdf", "application/pdf"));
|
||||||
@@ -201,7 +207,7 @@ public class AclServiceTests
|
|||||||
public async Task Reading_a_zaak_reference_returns_the_zaaks_identificatie()
|
public async Task Reading_a_zaak_reference_returns_the_zaaks_identificatie()
|
||||||
{
|
{
|
||||||
var gateway = new FakeGateway();
|
var gateway = new FakeGateway();
|
||||||
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||||
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
|
var zaak = new Uri("http://openzaak/zaken/api/v1/zaken/abc");
|
||||||
|
|
||||||
var reference = await service.GetZaakReferenceAsync(zaak);
|
var reference = await service.GetZaakReferenceAsync(zaak);
|
||||||
@@ -214,7 +220,7 @@ public class AclServiceTests
|
|||||||
public async Task Reading_a_null_zaak_reference_is_rejected()
|
public async Task Reading_a_null_zaak_reference_is_rejected()
|
||||||
{
|
{
|
||||||
var gateway = new FakeGateway();
|
var gateway = new FakeGateway();
|
||||||
var service = new AclService(gateway, Defaults(), new FixedClock(new DateOnly(2026, 6, 4)));
|
var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4));
|
||||||
|
|
||||||
await Assert.ThrowsAsync<ArgumentNullException>(() => service.GetZaakReferenceAsync(null!));
|
await Assert.ThrowsAsync<ArgumentNullException>(() => service.GetZaakReferenceAsync(null!));
|
||||||
Assert.Null(gateway.ReadReferenceFor);
|
Assert.Null(gateway.ReadReferenceFor);
|
||||||
|
|||||||
@@ -675,4 +675,153 @@ public class OpenZaakGatewayTests
|
|||||||
|
|
||||||
await Assert.ThrowsAsync<ArgumentNullException>(() => Gateway(handler).StoreDocumentAsync(null!));
|
await Assert.ThrowsAsync<ArgumentNullException>(() => Gateway(handler).StoreDocumentAsync(null!));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Catalogi resolution by business key (S-27) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolves_the_published_zaaktype_url_by_identificatie()
|
||||||
|
{
|
||||||
|
HttpRequestMessage? seen = null;
|
||||||
|
var handler = new StubHandler(req =>
|
||||||
|
{
|
||||||
|
seen = req;
|
||||||
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new
|
||||||
|
{
|
||||||
|
results = new[] { new { url = "http://openzaak/catalogi/api/v1/zaaktypen/big", identificatie = "BIG-REGISTRATIE" } },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var url = await Gateway(handler).ResolveZaaktypeUrlAsync("BIG-REGISTRATIE");
|
||||||
|
|
||||||
|
Assert.Equal("http://openzaak/catalogi/api/v1/zaaktypen/big", url.ToString());
|
||||||
|
Assert.Equal(HttpMethod.Get, seen!.Method);
|
||||||
|
// Filters to the published zaaktype with that identificatie, and authenticates.
|
||||||
|
Assert.Contains("/catalogi/api/v1/zaaktypen", seen.RequestUri!.ToString());
|
||||||
|
Assert.Contains("status=definitief", seen.RequestUri!.Query);
|
||||||
|
Assert.Contains("identificatie=BIG-REGISTRATIE", seen.RequestUri!.Query);
|
||||||
|
Assert.Equal("Bearer", seen.Headers.Authorization!.Scheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolving_a_zaaktype_throws_a_clear_error_when_none_is_published()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new { results = Array.Empty<object>() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => Gateway(handler).ResolveZaaktypeUrlAsync("BIG-REGISTRATIE"));
|
||||||
|
Assert.Contains("BIG-REGISTRATIE", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolves_the_informatieobjecttype_url_by_omschrijving()
|
||||||
|
{
|
||||||
|
HttpRequestMessage? seen = null;
|
||||||
|
var handler = new StubHandler(req =>
|
||||||
|
{
|
||||||
|
seen = req;
|
||||||
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new
|
||||||
|
{
|
||||||
|
results = new[]
|
||||||
|
{
|
||||||
|
new { url = "http://openzaak/catalogi/api/v1/informatieobjecttypen/other", omschrijving = "Overig" },
|
||||||
|
new { url = "http://openzaak/catalogi/api/v1/informatieobjecttypen/dip", omschrijving = "Diploma" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var url = await Gateway(handler).ResolveInformatieobjecttypeUrlAsync("Diploma");
|
||||||
|
|
||||||
|
// Queries the published informatieobjecttypen collection, and matches on omschrijving (not position).
|
||||||
|
Assert.Contains("/catalogi/api/v1/informatieobjecttypen", seen!.RequestUri!.ToString());
|
||||||
|
Assert.Contains("status=definitief", seen.RequestUri!.Query);
|
||||||
|
Assert.Equal("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip", url.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolving_a_zaaktype_throws_when_the_response_carries_no_results()
|
||||||
|
{
|
||||||
|
// No "results" property → the page's Results is null; the gateway must treat that as "none
|
||||||
|
// found" (not dereference null).
|
||||||
|
var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new { count = 0 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => Gateway(handler).ResolveZaaktypeUrlAsync("BIG-REGISTRATIE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolving_an_informatieobjecttype_throws_when_the_response_carries_no_results()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new { count = 0 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => Gateway(handler).ResolveInformatieobjecttypeUrlAsync("Diploma"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolving_a_zaaktype_surfaces_a_non_success_catalogi_response()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||||
|
{
|
||||||
|
Content = new StringContent("boom"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<HttpRequestException>(
|
||||||
|
() => Gateway(handler).ResolveZaaktypeUrlAsync("BIG-REGISTRATIE"));
|
||||||
|
// The error names the resource being queried and includes OpenZaak's body.
|
||||||
|
Assert.Contains("zaaktypen", ex.Message);
|
||||||
|
Assert.Contains("boom", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolving_an_informatieobjecttype_surfaces_a_non_success_catalogi_response()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)
|
||||||
|
{
|
||||||
|
Content = new StringContent("boom"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<HttpRequestException>(
|
||||||
|
() => Gateway(handler).ResolveInformatieobjecttypeUrlAsync("Diploma"));
|
||||||
|
Assert.Contains("informatieobjecttypen", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolving_an_informatieobjecttype_throws_when_no_omschrijving_matches()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(new
|
||||||
|
{
|
||||||
|
results = new[] { new { url = "http://openzaak/catalogi/api/v1/informatieobjecttypen/other", omschrijving = "Overig" } },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => Gateway(handler).ResolveInformatieobjecttypeUrlAsync("Diploma"));
|
||||||
|
Assert.Contains("Diploma", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Resolving_rejects_a_blank_business_key_without_calling_openzaak()
|
||||||
|
{
|
||||||
|
var handler = new StubHandler(_ => throw new InvalidOperationException("should not be sent"));
|
||||||
|
|
||||||
|
await Assert.ThrowsAnyAsync<ArgumentException>(() => Gateway(handler).ResolveZaaktypeUrlAsync(" "));
|
||||||
|
await Assert.ThrowsAnyAsync<ArgumentException>(() => Gateway(handler).ResolveInformatieobjecttypeUrlAsync(" "));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,10 @@ namespace Bff.Api;
|
|||||||
/// <summary>What the self-service submit returns to the portal (the domain's registration id + status).</summary>
|
/// <summary>What the self-service submit returns to the portal (the domain's registration id + status).</summary>
|
||||||
public sealed record SubmitAccepted(string RegistrationId, string Status);
|
public sealed record SubmitAccepted(string RegistrationId, string Status);
|
||||||
|
|
||||||
|
/// <summary>The caller's current open registration, for resuming the self-service portal after a
|
||||||
|
/// refresh (S-26): the reference (registration id) + its status.</summary>
|
||||||
|
public sealed record CurrentRegistration(string RegistrationId, string Status);
|
||||||
|
|
||||||
/// <summary>A projection row as the projection-api serves it. <c>Bsn</c>/<c>NaamPlaceholder</c> are
|
/// <summary>A projection row as the projection-api serves it. <c>Bsn</c>/<c>NaamPlaceholder</c> are
|
||||||
/// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09).
|
/// read but never surfaced by the openbaar endpoint (public-safe filtering, ADR-0010/S-09).
|
||||||
/// <c>Reference</c> is the public-safe citizen reference (the zaak identificatie, #78).</summary>
|
/// <c>Reference</c> is the public-safe citizen reference (the zaak identificatie, #78).</summary>
|
||||||
@@ -22,6 +26,10 @@ public interface IDomainClient
|
|||||||
{
|
{
|
||||||
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
|
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>The caller's current open registration (resume after refresh, S-26), or <c>null</c>
|
||||||
|
/// when they have none in flight. Owner-scoped by <paramref name="bsn"/>.</summary>
|
||||||
|
Task<CurrentRegistration?> GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>Withdraw the caller's own registration ("trek aanvraag in"). Owner-scoped by
|
/// <summary>Withdraw the caller's own registration ("trek aanvraag in"). Owner-scoped by
|
||||||
/// <paramref name="bsn"/>. Returns <c>false</c> when the domain reports the registration is
|
/// <paramref name="bsn"/>. Returns <c>false</c> when the domain reports the registration is
|
||||||
/// unknown or not the caller's (404), so the BFF can relay a 404 rather than a 500.</summary>
|
/// unknown or not the caller's (404), so the BFF can relay a 404 rather than a 500.</summary>
|
||||||
@@ -58,6 +66,18 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
|
|||||||
return new SubmitAccepted(dto.RegistrationId, dto.Status);
|
return new SubmitAccepted(dto.RegistrationId, dto.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<CurrentRegistration?> GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var response = await http.GetAsync($"registrations/current?bsn={Uri.EscapeDataString(bsn)}", ct);
|
||||||
|
// The domain 404s when the citizen has no open registration — that's "none", not an error.
|
||||||
|
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||||
|
return null;
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
var dto = await response.Content.ReadFromJsonAsync<DomainResponse>(ct)
|
||||||
|
?? throw new InvalidOperationException("The Domain Service returned an empty registration response.");
|
||||||
|
return new CurrentRegistration(dto.RegistrationId, dto.Status);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
|
public async Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
using var response = await http.PostAsJsonAsync(
|
using var response = await http.PostAsJsonAsync(
|
||||||
|
|||||||
@@ -86,6 +86,24 @@ app.MapPost("/self-service/registrations", async (ClaimsPrincipal user, IDomainC
|
|||||||
.Produces(StatusCodes.Status400BadRequest)
|
.Produces(StatusCodes.Status400BadRequest)
|
||||||
.Produces(StatusCodes.Status401Unauthorized);
|
.Produces(StatusCodes.Status401Unauthorized);
|
||||||
|
|
||||||
|
// Self-service resume (S-26): the signed-in zorgprofessional's current open registration, so the
|
||||||
|
// portal can restore its reference + actions after a page refresh. The bsn comes from the DigiD token;
|
||||||
|
// 204 when the citizen has none in flight (so the portal shows the submit form).
|
||||||
|
app.MapGet("/self-service/registrations", async (ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var bsn = user.FindFirstValue("bsn");
|
||||||
|
if (string.IsNullOrWhiteSpace(bsn))
|
||||||
|
return Results.BadRequest("The token carries no bsn claim.");
|
||||||
|
|
||||||
|
var current = await domain.GetCurrentRegistrationAsync(bsn, ct);
|
||||||
|
return current is null ? Results.NoContent() : Results.Ok(current);
|
||||||
|
})
|
||||||
|
.RequireAuthorization()
|
||||||
|
.Produces<CurrentRegistration>(StatusCodes.Status200OK)
|
||||||
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
|
.Produces(StatusCodes.Status400BadRequest)
|
||||||
|
.Produces(StatusCodes.Status401Unauthorized);
|
||||||
|
|
||||||
// Self-service withdrawal (S-11): the signed-in zorgprofessional withdraws their own registration.
|
// Self-service withdrawal (S-11): the signed-in zorgprofessional withdraws their own registration.
|
||||||
// The bsn comes from the DigiD token and is forwarded to the domain, which owner-scopes the action;
|
// The bsn comes from the DigiD token and is forwarded to the domain, which owner-scopes the action;
|
||||||
// a registration that is unknown or not the caller's comes back 404 (ownership is not revealed).
|
// a registration that is unknown or not the caller's comes back 404 (ownership is not revealed).
|
||||||
|
|||||||
@@ -82,6 +82,18 @@ internal sealed class FakeDomainClient : IDomainClient
|
|||||||
return Task.FromResult(Result);
|
return Task.FromResult(Result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string? CurrentQueriedBsn { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>The current open registration the fake domain returns (null → the citizen has none in
|
||||||
|
/// flight, so the BFF replies 204). Tests set this to exercise resume.</summary>
|
||||||
|
public CurrentRegistration? Current { get; set; }
|
||||||
|
|
||||||
|
public Task<CurrentRegistration?> GetCurrentRegistrationAsync(string bsn, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
CurrentQueriedBsn = bsn;
|
||||||
|
return Task.FromResult(Current);
|
||||||
|
}
|
||||||
|
|
||||||
public (string RegistrationId, string Bsn)? Withdrawn { get; private set; }
|
public (string RegistrationId, string Bsn)? Withdrawn { get; private set; }
|
||||||
|
|
||||||
/// <summary>Whether the fake domain reports the withdrawal as done (true → 204) or not-found/not-owned
|
/// <summary>Whether the fake domain reports the withdrawal as done (true → 204) or not-found/not-owned
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
using Bff.Api;
|
||||||
|
|
||||||
namespace Bff.Tests;
|
namespace Bff.Tests;
|
||||||
|
|
||||||
@@ -168,5 +169,52 @@ public class SelfServiceEndpointTests
|
|||||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static HttpRequestMessage Current(string? bearer)
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, "/self-service/registrations");
|
||||||
|
if (bearer is not null)
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Rejects_the_current_registration_lookup_without_a_token()
|
||||||
|
{
|
||||||
|
using var factory = new BffFactory();
|
||||||
|
|
||||||
|
var response = await factory.CreateClient().SendAsync(Current(bearer: null));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Returns_no_content_when_the_caller_has_no_open_registration()
|
||||||
|
{
|
||||||
|
using var factory = new BffFactory();
|
||||||
|
factory.Domain.Current = null;
|
||||||
|
|
||||||
|
var response = await factory.CreateClient().SendAsync(Current(TestTokens.Valid("123456782")));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||||
|
Assert.Equal("123456782", factory.Domain.CurrentQueriedBsn);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Returns_the_callers_current_registration_when_one_is_open()
|
||||||
|
{
|
||||||
|
using var factory = new BffFactory();
|
||||||
|
factory.Domain.Current = new CurrentRegistration("reg-77", "Ingediend");
|
||||||
|
|
||||||
|
var response = await factory.CreateClient().SendAsync(Current(TestTokens.Valid("123456782")));
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||||
|
Assert.Equal("123456782", factory.Domain.CurrentQueriedBsn);
|
||||||
|
var body = await response.Content.ReadFromJsonAsync<CurrentRegistrationDto>();
|
||||||
|
Assert.Equal("reg-77", body!.RegistrationId);
|
||||||
|
Assert.Equal("Ingediend", body.Status);
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record SubmitAcceptedDto(string RegistrationId, string Status);
|
private sealed record SubmitAcceptedDto(string RegistrationId, string Status);
|
||||||
|
|
||||||
|
private sealed record CurrentRegistrationDto(string RegistrationId, string Status);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,32 @@
|
|||||||
"description": "Unauthorized"
|
"description": "Unauthorized"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Bff.Api"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CurrentRegistration"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"204": {
|
||||||
|
"description": "No Content"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/self-service/registrations/{id}/withdraw": {
|
"/self-service/registrations/{id}/withdraw": {
|
||||||
@@ -205,6 +231,21 @@
|
|||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
"schemas": {
|
"schemas": {
|
||||||
|
"CurrentRegistration": {
|
||||||
|
"required": [
|
||||||
|
"registrationId",
|
||||||
|
"status"
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"registrationId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"DecideRequest": {
|
"DecideRequest": {
|
||||||
"required": [
|
"required": [
|
||||||
"besluit"
|
"besluit"
|
||||||
|
|||||||
@@ -141,6 +141,21 @@ app.MapGet("/behandel/werkbak", async (Werkbak werkbak, CancellationToken ct) =>
|
|||||||
Results.Ok(await werkbak.GetAsync(ct)));
|
Results.Ok(await werkbak.GetAsync(ct)));
|
||||||
|
|
||||||
// Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually).
|
// Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually).
|
||||||
|
// The citizen's current open registration, looked up by bsn — lets the self-service portal resume
|
||||||
|
// after a refresh (S-26). The BFF forwards the bsn from the DigiD token; the domain trusts its
|
||||||
|
// callers (§8.3). 404 when the citizen has none in flight.
|
||||||
|
app.MapGet("/registrations/current", async (string bsn, IRegistrationStore store, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(bsn))
|
||||||
|
return Results.BadRequest("A bsn is required.");
|
||||||
|
|
||||||
|
var registration = await store.FindOpenByBsnAsync(bsn, ct);
|
||||||
|
return registration is null
|
||||||
|
? Results.NotFound()
|
||||||
|
: Results.Ok(new RegistrationResponse(
|
||||||
|
registration.Id.ToString(), registration.Status.ToString(), registration.ZaakUrl?.ToString()));
|
||||||
|
});
|
||||||
|
|
||||||
app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) =>
|
app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
if (!Guid.TryParse(id, out var guid))
|
if (!Guid.TryParse(id, out var guid))
|
||||||
|
|||||||
@@ -102,6 +102,11 @@ public interface IRegistrationStore
|
|||||||
|
|
||||||
/// <summary>Load a registration by id, or <c>null</c> if none exists.</summary>
|
/// <summary>Load a registration by id, or <c>null</c> if none exists.</summary>
|
||||||
Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default);
|
Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>The citizen's current <em>open</em> (non-terminal: INGEDIEND/IN_BEHANDELING)
|
||||||
|
/// registration, or <c>null</c> if they have none in flight. Lets the self-service portal resume
|
||||||
|
/// an existing registration after a refresh (S-26); terminal registrations are not resumed.</summary>
|
||||||
|
Task<Registration?> FindOpenByBsnAsync(string bsn, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -22,4 +22,8 @@ public sealed class InMemoryRegistrationStore : IRegistrationStore
|
|||||||
|
|
||||||
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
|
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
|
||||||
=> Task.FromResult(_byId.GetValueOrDefault(id));
|
=> 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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ internal sealed class FakeRegistrationStore : IRegistrationStore
|
|||||||
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
|
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
|
||||||
=> Task.FromResult(_byId.GetValueOrDefault(id));
|
=> 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 void Seed(Registration registration) => _byId[registration.Id] = registration;
|
public void Seed(Registration registration) => _byId[registration.Id] = registration;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,4 +41,59 @@ public class InMemoryRegistrationStoreTests
|
|||||||
|
|
||||||
await Assert.ThrowsAsync<ArgumentNullException>(() => store.SaveAsync(null!));
|
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(); 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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,9 +29,12 @@ public sealed class EenZaakOpenenSteps
|
|||||||
Bronorganisatie = values["bronorganisatie"],
|
Bronorganisatie = values["bronorganisatie"],
|
||||||
VerantwoordelijkeOrganisatie = values["verantwoordelijkeOrganisatie"],
|
VerantwoordelijkeOrganisatie = values["verantwoordelijkeOrganisatie"],
|
||||||
Vertrouwelijkheidaanduiding = values["vertrouwelijkheidaanduiding"],
|
Vertrouwelijkheidaanduiding = values["vertrouwelijkheidaanduiding"],
|
||||||
ZaaktypeUrl = new Uri(values["zaaktype"]),
|
ZaaktypeIdentificatie = "BIG-REGISTRATIE",
|
||||||
InformatieobjecttypeUrl = new Uri("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"),
|
InformatieobjecttypeOmschrijving = "Diploma",
|
||||||
};
|
};
|
||||||
|
// 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 \"(.*)\"")]
|
[Given("today is \"(.*)\"")]
|
||||||
@@ -41,7 +44,7 @@ public sealed class EenZaakOpenenSteps
|
|||||||
[When("the domain asks the ACL to open a zaak")]
|
[When("the domain asks the ACL to open a zaak")]
|
||||||
public async Task WhenTheDomainAsksTheAclToOpenAZaak()
|
public async Task WhenTheDomainAsksTheAclToOpenAZaak()
|
||||||
{
|
{
|
||||||
var service = new AclService(_gateway, _defaults!, new FixedClock(_today));
|
var service = new AclService(_gateway, _defaults!, new CachedZaaktypeCatalog(_gateway, _defaults!), new FixedClock(_today));
|
||||||
_returnedUrl = await service.OpenZaakAsync(_registration!);
|
_returnedUrl = await service.OpenZaakAsync(_registration!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ public sealed class CapturingDomainClient : IDomainClient
|
|||||||
return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend"));
|
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)
|
public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
|
||||||
=> Task.FromResult(true);
|
=> Task.FromResult(true);
|
||||||
|
|
||||||
|
|||||||
@@ -217,4 +217,8 @@ public sealed class InMemoryRegistrationStore : IRegistrationStore
|
|||||||
|
|
||||||
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
|
public Task<Registration?> GetAsync(RegistrationId id, CancellationToken ct = default)
|
||||||
=> Task.FromResult(_byId.GetValueOrDefault(id));
|
=> 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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ public sealed class InMemoryZaakGateway : IZaakGateway
|
|||||||
public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Approved { get; private set; }
|
public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Approved { get; private set; }
|
||||||
public (Uri Zaak, Uri Zaaktype, DateOnly Datum)? Cancelled { 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)
|
public Task<Uri> OpenZaakAsync(ZaakRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Captured = request;
|
Captured = request;
|
||||||
@@ -37,4 +42,10 @@ public sealed class InMemoryZaakGateway : IZaakGateway
|
|||||||
|
|
||||||
public Task<Uri> StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default)
|
public Task<Uri> StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default)
|
||||||
=> Task.FromResult(new Uri("http://openzaak/documenten/api/v1/enkelvoudiginformatieobjecten/acc-doc"));
|
=> 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ export default defineConfig({
|
|||||||
timeout: 90_000,
|
timeout: 90_000,
|
||||||
expect: { timeout: 15_000 },
|
expect: { timeout: 15_000 },
|
||||||
retries: 1,
|
retries: 1,
|
||||||
|
// Run the specs serially. Each spec drives a full `channel: 'chromium'` browser, and the e2e
|
||||||
|
// shares an 8 GB runner with the entire compose stack (OpenZaak, NRC, Keycloak, Flowable, 4×
|
||||||
|
// Postgres, every service + 3 portals). Two parallel browsers exhaust memory and the renderer is
|
||||||
|
// OOM-killed mid-action ("Page crashed") — fixing the flakiness at its source rather than leaning
|
||||||
|
// on `retries` (CLAUDE.md §15). Only two long-running happy-path specs, so serial costs little.
|
||||||
|
workers: 1,
|
||||||
reporter: [['list']],
|
reporter: [['list']],
|
||||||
use: {
|
use: {
|
||||||
baseURL,
|
baseURL,
|
||||||
@@ -26,7 +32,13 @@ export default defineConfig({
|
|||||||
// headless), not Playwright's default headless-shell, so pin `channel: 'chromium'`.
|
// headless), not Playwright's default headless-shell, so pin `channel: 'chromium'`.
|
||||||
channel: 'chromium',
|
channel: 'chromium',
|
||||||
launchOptions: {
|
launchOptions: {
|
||||||
args: [`--unsafely-treat-insecure-origin-as-secure=${baseURL},${behandelURL}`],
|
args: [
|
||||||
|
`--unsafely-treat-insecure-origin-as-secure=${baseURL},${behandelURL}`,
|
||||||
|
// Write Chromium's shared memory to /tmp instead of the container's small /dev/shm, so a
|
||||||
|
// large DOM/heap can't crash the renderer on the memory-constrained runner (belt-and-braces
|
||||||
|
// alongside the single worker above).
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt
|
|||||||
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
|
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
// Keycloak's default login form (stable ids across themes).
|
// Keycloak's default login form (stable ids across themes). Its own DigiD user: the verify-* API
|
||||||
await page.locator('#username').fill('jan-burger');
|
// 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');
|
||||||
await page.locator('#password').fill('test123');
|
await page.locator('#password').fill('test123');
|
||||||
await page.locator('#kc-login').click();
|
await page.locator('#kc-login').click();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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,7 +8,9 @@ test('DigiD submit → trek aanvraag in → self-service confirms ingetrokken',
|
|||||||
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
|
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
await page.locator('#username').fill('jan-burger');
|
// 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('#password').fill('test123');
|
await page.locator('#password').fill('test123');
|
||||||
await page.locator('#kc-login').click();
|
await page.locator('#kc-login').click();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user