diff --git a/docs/architecture/adr-0021-acl-resolves-zaaktype-by-identificatie.md b/docs/architecture/adr-0021-acl-resolves-zaaktype-by-identificatie.md new file mode 100644 index 0000000..15a296f --- /dev/null +++ b/docs/architecture/adr-0021-acl-resolves-zaaktype-by-identificatie.md @@ -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. diff --git a/docs/demo-script.md b/docs/demo-script.md index 9191f05..c94a4e0 100644 --- a/docs/demo-script.md +++ b/docs/demo-script.md @@ -26,9 +26,31 @@ make verify-local # → "OK — a fresh local stack completed the flow with # test123); it shows as INGESCHREVEN in the openbaar register at http://localhost:8141. ``` -> The zaaktype UUID is server-assigned, so `local-seed` writes the real URL into a shared volume as -> `acl.env` and the ACL sources it on startup (ADR-0020). The cleaner long-term fix — the ACL -> resolving its zaaktype by `identificatie` — is tracked separately as S-27 (#113). +> 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). --- diff --git a/infra/docker-compose.local.yml b/infra/docker-compose.local.yml index beafbb8..0bb7aad 100644 --- a/infra/docker-compose.local.yml +++ b/infra/docker-compose.local.yml @@ -315,21 +315,22 @@ services: context: ../services/acl dockerfile: Dockerfile image: register-referentie/acl:dev - # The base/zaaktype/informatieobjecttype below are PLACEHOLDERS. The real, server-assigned - # values are written by the local-seed one-shot into seed-env:/seed/acl.env, which the entrypoint - # sources (set -a) so they override these before the app starts (S-B04, #110, ADR-0020). Sourcing - # a runtime-generated env file is why we override the entrypoint here rather than use `env_file:` - # (which compose reads at parse time, before the seed has run). + # 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: - 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__Secret: insecure-dev-secret-change-me Acl__Defaults__Bronorganisatie: "517439943" Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943" Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar - Acl__Defaults__ZaaktypeUrl: http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000 - Acl__Defaults__InformatieobjecttypeUrl: http://openzaak:8000/catalogi/api/v1/informatieobjecttypen/00000000-0000-0000-0000-000000000000 + Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE + Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma ports: - "8100:8080" volumes: diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index ce76cdf..99f18fe 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -15,12 +15,12 @@ # # docker compose -f infra/docker-compose.yml up -d --build --wait # -# After first boot, seed the BIG catalogus and note the zaaktype URL: -# python infra/openzaak/seed_catalogus.py -# Then set ACL_ZAAKTYPE_URL in a .env file or your shell and re-up the acl -# service: -# export ACL_ZAAKTYPE_URL=http://openzaak:8000/catalogi/api/v1/zaaktypen/ -# docker compose -f infra/docker-compose.yml up -d acl +# After first boot, seed + publish the BIG catalogus: +# OZ_PUBLISH=1 python infra/openzaak/seed_catalogus.py +# The ACL discovers the zaaktype by identificatie (S-27, ADR-0021), so there is no URL to inject — +# just point its BaseUrl at an OpenZaak host OpenZaak accepts on zaak-create (a container IP; a +# single-label host is rejected): +# ACL_OPENZAAK_BASEURL=http://:8000/ docker compose -f infra/docker-compose.yml up -d acl services: @@ -304,11 +304,12 @@ services: Acl__Defaults__Bronorganisatie: "517439943" Acl__Defaults__VerantwoordelijkeOrganisatie: "517439943" Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar - # Override with the real zaaktype URL after running seed_catalogus.py. - Acl__Defaults__ZaaktypeUrl: ${ACL_ZAAKTYPE_URL:-http://openzaak:8000/catalogi/api/v1/zaaktypen/00000000-0000-0000-0000-000000000000} - # The informatieobjecttype a diploma is filed under (S-10b). Placeholder until seed_catalogus.py - # (OZ_PUBLISH=1) reports the real URL, which verify-domain injects like the zaaktype URL. - Acl__Defaults__InformatieobjecttypeUrl: ${ACL_INFORMATIEOBJECTTYPE_URL:-http://openzaak:8000/catalogi/api/v1/informatieobjecttypen/00000000-0000-0000-0000-000000000000} + # The ACL resolves the (server-assigned) zaaktype + diploma informatieobjecttype URLs from the + # Catalogi API by these stable business keys (S-27, ADR-0021) — no URL to capture and inject. + # BaseUrl above stays overridable because OpenZaak rejects a single-label host on zaak creation, + # so verify-domain still points the ACL at OpenZaak's container IP. + Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE + Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma ports: - "8100:8080" healthcheck: diff --git a/infra/local/seed-zaaktype.sh b/infra/local/seed-zaaktype.sh index 5773ad4..80906ed 100755 --- a/infra/local/seed-zaaktype.sh +++ b/infra/local/seed-zaaktype.sh @@ -21,15 +21,13 @@ echo ">> seeding + publishing the BIG zaaktype at ${OZ_BASE} (idempotent)" out="$(python3 /work/seed_catalogus.py)" echo "$out" -zt="$(printf '%s\n' "$out" | sed -n 's/^ZAAKTYPE_URL //p' | head -1)" -iot="$(printf '%s\n' "$out" | sed -n 's/^INFORMATIEOBJECTTYPE_URL //p' | head -1)" -[ -n "$zt" ] || { echo "ERROR: seed did not report a ZAAKTYPE_URL" >&2; exit 1; } -[ -n "$iot" ] || { echo "ERROR: seed did not report an INFORMATIEOBJECTTYPE_URL" >&2; exit 1; } +# 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 entrypoint sources this; these keys override the placeholder defaults in the compose file. +# 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 <> wrote /out/acl.env (base=${OZ_BASE}/ zaaktype=${zt})" +echo ">> wrote /out/acl.env (base=${OZ_BASE}/)" diff --git a/infra/openzaak/__pycache__/seed_catalogus.cpython-314.pyc b/infra/openzaak/__pycache__/seed_catalogus.cpython-314.pyc new file mode 100644 index 0000000..8f2c9ea Binary files /dev/null and b/infra/openzaak/__pycache__/seed_catalogus.cpython-314.pyc differ diff --git a/infra/run-domain-check.sh b/infra/run-domain-check.sh index 3c360d6..833032a 100755 --- a/infra/run-domain-check.sh +++ b/infra/run-domain-check.sh @@ -30,21 +30,18 @@ oz_ip="$(ip "$oz")"; dom_ip="$(ip "$dom")" oz_base="http://$oz_ip:8000" echo ">> openzaak=$oz_ip domain=$dom_ip network=$net" -echo ">> seeding 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)" docker cp "$here/openzaak/seed_catalogus.py" "$sid:/seed.py" >/dev/null seed_out="$(docker start -a "$sid")" -zt_url="$(printf '%s\n' "$seed_out" | sed -n 's/^ZAAKTYPE_URL //p' | head -1)" -iot_url="$(printf '%s\n' "$seed_out" | sed -n 's/^INFORMATIEOBJECTTYPE_URL //p' | head -1)" docker rm -f "$sid" >/dev/null -[ -n "$zt_url" ] || { echo "ERROR: seed did not report a ZAAKTYPE_URL" >&2; exit 1; } -[ -n "$iot_url" ] || { echo "ERROR: seed did not report an INFORMATIEOBJECTTYPE_URL" >&2; exit 1; } -echo ">> zaaktype: $zt_url" -echo ">> informatieobjecttype: $iot_url" +printf '%s\n' "$seed_out" | grep -q '^ZAAKTYPE_URL ' || { echo "ERROR: seed did not publish the zaaktype" >&2; exit 1; } -echo ">> recreating the acl service pointed at the seeded zaaktype + informatieobjecttype (host-consistent)" -ACL_ZAAKTYPE_URL="$zt_url" ACL_INFORMATIEOBJECTTYPE_URL="$iot_url" ACL_OPENZAAK_BASEURL="$oz_base/" \ - docker compose -f "$compose" up -d acl +# The ACL resolves the zaaktype + informatieobjecttype by identificatie/omschrijving (S-27, ADR-0021), +# so there is no URL to inject — only the OpenZaak base URL, pointed at the same host's container IP +# (OpenZaak rejects a single-label host on zaak-create). +echo ">> recreating the acl service pointed at OpenZaak's IP (it resolves the zaaktype itself, S-27)" +ACL_OPENZAAK_BASEURL="$oz_base/" docker compose -f "$compose" up -d acl WAIT_TIMEOUT="${WAIT_TIMEOUT:-120}" bash "$here/wait-healthy.sh" acl echo ">> submitting a registration to the domain" diff --git a/services/acl/Acl.Api/Program.cs b/services/acl/Acl.Api/Program.cs index 1b1bf7f..22cceee 100644 --- a/services/acl/Acl.Api/Program.cs +++ b/services/acl/Acl.Api/Program.cs @@ -11,6 +11,8 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService() .GetSection("Acl:OpenZaak").Get() ?? throw new InvalidOperationException("Missing configuration section 'Acl:OpenZaak'")); builder.Services.AddHttpClient(); +// Singleton so the resolved zaaktype/informatieobjecttype URLs are cached across requests (S-27). +builder.Services.AddSingleton(); builder.Services.AddScoped(); var app = builder.Build(); diff --git a/services/acl/Acl.Application/AclDefaults.cs b/services/acl/Acl.Application/AclDefaults.cs index d378c8c..7874d03 100644 --- a/services/acl/Acl.Application/AclDefaults.cs +++ b/services/acl/Acl.Application/AclDefaults.cs @@ -6,9 +6,12 @@ public sealed class AclDefaults public required string Bronorganisatie { get; init; } public required string VerantwoordelijkeOrganisatie { get; init; } public required string Vertrouwelijkheidaanduiding { get; init; } - public required Uri ZaaktypeUrl { get; init; } - /// The informatieobjecttype an uploaded diploma is filed under (S-10b). Seeded in the - /// catalogus and injected like . - public required Uri InformatieobjecttypeUrl { get; init; } + /// The BIG zaaktype's stable business key. The ACL resolves the (server-assigned) zaaktype + /// URL from this via the Catalogi API instead of being handed a pinned URL (S-27, ADR-0021). + public required string ZaaktypeIdentificatie { get; init; } + + /// The omschrijving of the informatieobjecttype an uploaded diploma is filed under (S-10b); + /// resolved to a URL by the Catalogi API, like . + public required string InformatieobjecttypeOmschrijving { get; init; } } diff --git a/services/acl/Acl.Application/AclService.cs b/services/acl/Acl.Application/AclService.cs index 6f00fef..4b23964 100644 --- a/services/acl/Acl.Application/AclService.cs +++ b/services/acl/Acl.Application/AclService.cs @@ -2,9 +2,9 @@ namespace Acl.Application; /// The ACL's single operation: open a zaak from a domain payload, /// default-filling the ZGW-mandatory fields (ADR-0003). -public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IClock clock) +public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaaktypeCatalog catalog, IClock clock) { - public Task OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default) + public async Task OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(registration); @@ -12,34 +12,34 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc defaults.Bronorganisatie, defaults.VerantwoordelijkeOrganisatie, defaults.Vertrouwelijkheidaanduiding, - defaults.ZaaktypeUrl, + await catalog.GetZaaktypeUrlAsync(ct), clock.Today, registration.Reference); - return gateway.OpenZaakAsync(request, ct); + return await gateway.OpenZaakAsync(request, ct); } /// - /// Approve a zaak: set it to the eindstatus of the configured BIG zaaktype (ADR-0003 default). The - /// domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1). + /// Approve a zaak: set it to the eindstatus of the BIG zaaktype (resolved by identificatie, S-27). + /// The domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1). /// - public Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default) + public async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(zaakUrl); - return gateway.SetZaakToEindstatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct); + await gateway.SetZaakToEindstatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct); } /// - /// Cancel a zaak on document-timeout expiry (S-10c): set it to the configured BIG zaaktype's - /// cancellation statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which + /// Cancel a zaak on document-timeout expiry (S-10c): set it to the BIG zaaktype's cancellation + /// statustype + resultaat. The domain hands over only the zaak URL; the ACL owns which /// statustype/resultaat means "cancelled" (§8.1). /// - public Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default) + public async Task CancelZaakAsync(Uri zaakUrl, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(zaakUrl); - return gateway.SetZaakToCancellationStatusAsync(zaakUrl, defaults.ZaaktypeUrl, clock.Today, ct); + await gateway.SetZaakToCancellationStatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct); } /// The zaak's reference (its ZGW identificatie), for the read projection (#78). @@ -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. /// The domain supplies only the zaak, the bytes, and the file's name/type (§8.1). /// - public Task StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default) + public async Task StoreDiplomaAsync(Uri zaakUrl, byte[] content, string fileName, string contentType, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(zaakUrl); ArgumentNullException.ThrowIfNull(content); @@ -65,7 +65,7 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc var request = new DocumentRequest( defaults.Bronorganisatie, - defaults.InformatieobjecttypeUrl, + await catalog.GetInformatieobjecttypeUrlAsync(ct), defaults.Vertrouwelijkheidaanduiding, zaakUrl, clock.Today, @@ -76,6 +76,6 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, ICloc Formaat: contentType, Inhoud: content); - return gateway.StoreDocumentAsync(request, ct); + return await gateway.StoreDocumentAsync(request, ct); } } diff --git a/services/acl/Acl.Application/CachedZaaktypeCatalog.cs b/services/acl/Acl.Application/CachedZaaktypeCatalog.cs new file mode 100644 index 0000000..bc9b293 --- /dev/null +++ b/services/acl/Acl.Application/CachedZaaktypeCatalog.cs @@ -0,0 +1,46 @@ +namespace Acl.Application; + +/// 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 failed +/// resolution is not cached, so it is retried on the next call (e.g. once the zaaktype is published). +/// A process restart re-resolves. +public sealed class CachedZaaktypeCatalog(IZaakGateway gateway, AclDefaults defaults) : IZaaktypeCatalog +{ + private readonly SemaphoreSlim gate = new(1, 1); + private Uri? zaaktype; + private Uri? informatieobjecttype; + + public Task GetZaaktypeUrlAsync(CancellationToken ct = default) => + ResolveOnceAsync( + () => zaaktype, value => zaaktype = value, + () => gateway.ResolveZaaktypeUrlAsync(defaults.ZaaktypeIdentificatie, ct), ct); + + public Task 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 ResolveOnceAsync(Func read, Action store, Func> 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(); + } + } +} diff --git a/services/acl/Acl.Application/IZaakGateway.cs b/services/acl/Acl.Application/IZaakGateway.cs index 1e71337..3848d8b 100644 --- a/services/acl/Acl.Application/IZaakGateway.cs +++ b/services/acl/Acl.Application/IZaakGateway.cs @@ -32,4 +32,12 @@ public interface IZaakGateway /// the created informatieobject. /// Task StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default); + + /// Resolve the URL of the published zaaktype with the given + /// from the Catalogi API (S-27). Throws if no published zaaktype matches. + Task ResolveZaaktypeUrlAsync(string identificatie, CancellationToken ct = default); + + /// Resolve the URL of the published informatieobjecttype with the given + /// from the Catalogi API (S-27). Throws if none matches. + Task ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default); } diff --git a/services/acl/Acl.Application/IZaaktypeCatalog.cs b/services/acl/Acl.Application/IZaaktypeCatalog.cs new file mode 100644 index 0000000..2394c2b --- /dev/null +++ b/services/acl/Acl.Application/IZaaktypeCatalog.cs @@ -0,0 +1,12 @@ +namespace Acl.Application; + +/// Supplies the ACL's zaaktype + diploma-informatieobjecttype URLs, resolved from OpenZaak's +/// Catalogi API by their stable business keys ( / +/// ) rather than pinned in config (S-27, +/// ADR-0021). Implementations resolve lazily on first use and cache the result. +public interface IZaaktypeCatalog +{ + Task GetZaaktypeUrlAsync(CancellationToken ct = default); + + Task GetInformatieobjecttypeUrlAsync(CancellationToken ct = default); +} diff --git a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs index 5f33ba8..156b810 100644 --- a/services/acl/Acl.Infrastructure/OpenZaakGateway.cs +++ b/services/acl/Acl.Infrastructure/OpenZaakGateway.cs @@ -142,6 +142,48 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : return created; } + public async Task ResolveZaaktypeUrlAsync(string identificatie, CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(identificatie); + + // The published zaaktype with this identificatie; status=definitief excludes concepts. + var page = await GetAsync( + "/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 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( + "/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 GetAsync(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(ct) + ?? throw new InvalidOperationException($"OpenZaak returned an empty {label} response"); + } + // POSTs a non-geo ZGW resource (resultaat/status — no CRS headers). Buffers the body so uwsgi gets // a Content-Length instead of a chunked body (as with zaak-create). private async Task PostAsync(string path, object dto, string action, CancellationToken ct) @@ -298,4 +340,18 @@ public sealed class OpenZaakGateway(HttpClient http, OpenZaakOptions options) : private sealed record ZaakInformatieobjectDto( [property: JsonPropertyName("zaak")] string Zaak, [property: JsonPropertyName("informatieobject")] string Informatieobject); + + private sealed record ZaaktypePage( + [property: JsonPropertyName("results")] IReadOnlyList? Results); + + private sealed record ZaaktypeDto( + [property: JsonPropertyName("url")] string Url, + [property: JsonPropertyName("identificatie")] string? Identificatie); + + private sealed record InformatieobjecttypePage( + [property: JsonPropertyName("results")] IReadOnlyList? Results); + + private sealed record InformatieobjecttypeDto( + [property: JsonPropertyName("url")] string Url, + [property: JsonPropertyName("omschrijving")] string? Omschrijving); } diff --git a/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs b/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs index d59d0d9..70c84bf 100644 --- a/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs +++ b/services/acl/Acl.IntegrationTests/OpenZaakGatewayIntegrationTests.cs @@ -161,4 +161,32 @@ public sealed class OpenZaakGatewayIntegrationTests(OpenZaakFixture stack) Assert.Contains(relations.EnumerateArray(), r => r.GetProperty("zaak").GetString() == zaakUrl.ToString()); } + + [Fact] + public async Task Resolves_the_published_zaaktype_and_diploma_informatieobjecttype_by_business_key() + { + var expectedZaaktype = await stack.FindPublishedBigZaaktypeAsync(); + Assert.True(expectedZaaktype is not null, + "No published BIG-REGISTRATIE zaaktype found — seed the stack with OZ_PUBLISH=1."); + var expectedInformatieobjecttype = await stack.FindPublishedDiplomaInformatieobjecttypeAsync(); + Assert.True(expectedInformatieobjecttype is not null, + "No published Diploma informatieobjecttype found — seed the stack with OZ_PUBLISH=1."); + + var gateway = new OpenZaakGateway(stack.Http, stack.Options); + + // The ACL discovers both URLs from the live Catalogi API by their stable business keys (S-27), + // matching what the fixture found independently — no pinned URL needed. + Assert.Equal(expectedZaaktype, await gateway.ResolveZaaktypeUrlAsync("BIG-REGISTRATIE")); + Assert.Equal(expectedInformatieobjecttype, await gateway.ResolveInformatieobjecttypeUrlAsync("Diploma")); + } + + [Fact] + public async Task Resolving_an_unknown_zaaktype_identificatie_throws_a_clear_error() + { + var gateway = new OpenZaakGateway(stack.Http, stack.Options); + + var ex = await Assert.ThrowsAsync( + () => gateway.ResolveZaaktypeUrlAsync("NO-SUCH-ZAAKTYPE")); + Assert.Contains("NO-SUCH-ZAAKTYPE", ex.Message); + } } diff --git a/services/acl/Acl.Tests/AclServiceTests.cs b/services/acl/Acl.Tests/AclServiceTests.cs index 01cbcef..930d380 100644 --- a/services/acl/Acl.Tests/AclServiceTests.cs +++ b/services/acl/Acl.Tests/AclServiceTests.cs @@ -6,6 +6,12 @@ public class AclServiceTests { private sealed class FakeGateway : IZaakGateway { + // The URLs the catalogus resolves the configured identificatie/omschrijving to (S-27). + public Uri ResolvedZaaktype { get; } = new("http://openzaak/catalogi/api/v1/zaaktypen/big"); + public Uri ResolvedInformatieobjecttype { get; } = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"); + public string? ResolvedByIdentificatie; + public string? ResolvedByOmschrijving; + public ZaakRequest? Captured; public Uri Result { get; } = new("http://openzaak/zaken/api/v1/zaken/abc"); @@ -47,6 +53,18 @@ public class AclServiceTests StoredDocument = request; return Task.FromResult(DocumentResult); } + + public Task ResolveZaaktypeUrlAsync(string identificatie, CancellationToken ct = default) + { + ResolvedByIdentificatie = identificatie; + return Task.FromResult(ResolvedZaaktype); + } + + public Task ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default) + { + ResolvedByOmschrijving = omschrijving; + return Task.FromResult(ResolvedInformatieobjecttype); + } } private static AclDefaults Defaults() => new() @@ -54,28 +72,23 @@ public class AclServiceTests Bronorganisatie = "517439943", VerantwoordelijkeOrganisatie = "517439943", Vertrouwelijkheidaanduiding = "openbaar", - ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"), - InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"), + ZaaktypeIdentificatie = "BIG-REGISTRATIE", + 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 { public DateOnly Today { get; } = today; } [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 defaults = new AclDefaults - { - Bronorganisatie = "517439943", - VerantwoordelijkeOrganisatie = "517439943", - Vertrouwelijkheidaanduiding = "openbaar", - ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"), - InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"), - }; - var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4))); + var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4)); 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.VerantwoordelijkeOrganisatie); 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); // The registration reference becomes the zaak identificatie (#78). Assert.Equal("reg-77", req.Identificatie); @@ -94,33 +109,24 @@ public class AclServiceTests public async Task Rejects_a_null_registration_without_calling_the_gateway() { var gateway = new FakeGateway(); - var defaults = new AclDefaults - { - Bronorganisatie = "517439943", - VerantwoordelijkeOrganisatie = "517439943", - Vertrouwelijkheidaanduiding = "openbaar", - ZaaktypeUrl = new("http://openzaak/catalogi/api/v1/zaaktypen/big"), - InformatieobjecttypeUrl = new("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"), - }; - var service = new AclService(gateway, defaults, new FixedClock(new DateOnly(2026, 6, 4))); + var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4)); await Assert.ThrowsAsync(() => service.OpenZaakAsync(null!)); Assert.Null(gateway.Captured); } [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 defaults = Defaults(); - 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"); await service.ApproveZaakAsync(zaak); Assert.NotNull(gateway.Approved); 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); } @@ -128,7 +134,7 @@ public class AclServiceTests public async Task Approving_a_null_zaak_is_rejected_without_touching_the_gateway() { 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(() => service.ApproveZaakAsync(null!)); 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() { var gateway = new FakeGateway(); - var defaults = Defaults(); - 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"); await service.CancelZaakAsync(zaak); Assert.NotNull(gateway.Cancelled); 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); // Cancellation must not touch the approval path. Assert.Null(gateway.Approved); @@ -156,18 +161,17 @@ public class AclServiceTests public async Task Cancelling_a_null_zaak_is_rejected_without_touching_the_gateway() { 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(() => service.CancelZaakAsync(null!)); Assert.Null(gateway.Cancelled); } [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 defaults = Defaults(); - 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 url = await service.StoreDiplomaAsync(zaak, [1, 2, 3], "diploma.pdf", "application/pdf"); @@ -175,7 +179,9 @@ public class AclServiceTests Assert.Equal(gateway.DocumentResult, url); var req = gateway.StoredDocument!; 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("openbaar", req.Vertrouwelijkheidaanduiding); Assert.Equal(new DateOnly(2026, 6, 4), req.Creatiedatum); @@ -188,7 +194,7 @@ public class AclServiceTests [Fact] 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"); await Assert.ThrowsAsync(() => 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() { 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 reference = await service.GetZaakReferenceAsync(zaak); @@ -214,7 +220,7 @@ public class AclServiceTests public async Task Reading_a_null_zaak_reference_is_rejected() { 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(() => service.GetZaakReferenceAsync(null!)); Assert.Null(gateway.ReadReferenceFor); diff --git a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs index f167f75..081701c 100644 --- a/services/acl/Acl.Tests/OpenZaakGatewayTests.cs +++ b/services/acl/Acl.Tests/OpenZaakGatewayTests.cs @@ -675,4 +675,153 @@ public class OpenZaakGatewayTests await Assert.ThrowsAsync(() => 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() }), + })); + + var ex = await Assert.ThrowsAsync( + () => 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( + () => 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( + () => 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( + () => 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( + () => 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( + () => 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(() => Gateway(handler).ResolveZaaktypeUrlAsync(" ")); + await Assert.ThrowsAnyAsync(() => Gateway(handler).ResolveInformatieobjecttypeUrlAsync(" ")); + } } diff --git a/services/acl/Acl.Tests/ZaaktypeCatalogTests.cs b/services/acl/Acl.Tests/ZaaktypeCatalogTests.cs new file mode 100644 index 0000000..8f3f7f6 --- /dev/null +++ b/services/acl/Acl.Tests/ZaaktypeCatalogTests.cs @@ -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 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 ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default) + { + InformatieobjecttypeCalls++; + LastOmschrijving = omschrijving; + return Task.FromResult(InformatieobjecttypeUrl); + } + + public Task 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 GetZaakIdentificatieAsync(Uri z, CancellationToken ct = default) => throw new NotSupportedException(); + public Task 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(() => catalog.GetZaaktypeUrlAsync()); + var url = await catalog.GetZaaktypeUrlAsync(); + + Assert.Equal(gateway.ZaaktypeUrl, url); + Assert.Equal(2, gateway.ZaaktypeCalls); + } +} diff --git a/tests/acceptance/Steps/EenZaakOpenenSteps.cs b/tests/acceptance/Steps/EenZaakOpenenSteps.cs index 6dd2fc4..493fb55 100644 --- a/tests/acceptance/Steps/EenZaakOpenenSteps.cs +++ b/tests/acceptance/Steps/EenZaakOpenenSteps.cs @@ -29,9 +29,12 @@ public sealed class EenZaakOpenenSteps Bronorganisatie = values["bronorganisatie"], VerantwoordelijkeOrganisatie = values["verantwoordelijkeOrganisatie"], Vertrouwelijkheidaanduiding = values["vertrouwelijkheidaanduiding"], - ZaaktypeUrl = new Uri(values["zaaktype"]), - InformatieobjecttypeUrl = new Uri("http://openzaak/catalogi/api/v1/informatieobjecttypen/dip"), + ZaaktypeIdentificatie = "BIG-REGISTRATIE", + 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 \"(.*)\"")] @@ -41,7 +44,7 @@ public sealed class EenZaakOpenenSteps [When("the domain asks the ACL to open a zaak")] public async Task WhenTheDomainAsksTheAclToOpenAZaak() { - var service = new AclService(_gateway, _defaults!, new FixedClock(_today)); + var service = new AclService(_gateway, _defaults!, new CachedZaaktypeCatalog(_gateway, _defaults!), new FixedClock(_today)); _returnedUrl = await service.OpenZaakAsync(_registration!); } diff --git a/tests/acceptance/Support/InMemoryZaakGateway.cs b/tests/acceptance/Support/InMemoryZaakGateway.cs index d16d353..f8fc104 100644 --- a/tests/acceptance/Support/InMemoryZaakGateway.cs +++ b/tests/acceptance/Support/InMemoryZaakGateway.cs @@ -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)? 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 OpenZaakAsync(ZaakRequest request, CancellationToken ct = default) { Captured = request; @@ -37,4 +42,10 @@ public sealed class InMemoryZaakGateway : IZaakGateway public Task StoreDocumentAsync(DocumentRequest request, CancellationToken ct = default) => Task.FromResult(new Uri("http://openzaak/documenten/api/v1/enkelvoudiginformatieobjecten/acc-doc")); + + public Task ResolveZaaktypeUrlAsync(string identificatie, CancellationToken ct = default) + => Task.FromResult(ResolvedZaaktypeUrl); + + public Task ResolveInformatieobjecttypeUrlAsync(string omschrijving, CancellationToken ct = default) + => Task.FromResult(ResolvedInformatieobjecttypeUrl); }