diff --git a/BACKLOG.md b/BACKLOG.md index 476d485..b45a9de 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -287,12 +287,17 @@ Split into independently deployable sub-slices (CLAUDE.md §13): - **S-18b** (#140, ✅) · Objecten API up in compose, wired to Objecttypen. Depends on S-18a. - **S-18c** (#141, ✅) · RegisterRecord objecttype defined + registered (public-safe JSON schema). Depends on S-18a/b. -### S-19 · ACL extension: write register-record to Objecten on approval +### S-19 · ACL extension: write register-record to Objecten on approval *(split — #20 closed)* **Outcome:** Approval path writes the canonical register record to Objecten, not OpenZaak eigenschappen. Projection now sourced from Objecten events. **ADR required:** "Why Objecten holds the register, OpenZaak holds the process." +Split into independently deployable sub-slices (CLAUDE.md §13): + +- **S-19a** (#149, ✅) · ACL writes the `RegisterRecord` to Objecten on approval, idempotently, alongside the ZGW eindstatus. Carries the ADR (ADR-0028). +- **S-19b** (#150) · Read projection sourced from Objecten instead of NRC zaak events. Depends on S-19a. + --- ## Iteration 5 — Data governance module *(milestone: `Iteration 5 — Data Governance`)* diff --git a/docs/PRD.md b/docs/PRD.md index 57577ff..44cc860 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -207,7 +207,7 @@ A slice is done when: ## 15. Out of scope for v1 - OpenMetadata data governance module (v3 slice). -- Objecten as the authoritative register record store (v2 slice — v1 uses OpenZaak zaak-eigenschappen as a placeholder). +- ~~Objecten as the authoritative register record store~~ — **delivered** in S-19a (#149, ADR-0028); the approval path writes a `RegisterRecord` object to Objecten rather than the planned zaak-eigenschappen placeholder. - Production-grade Helm chart (sketch only). - Multi-tenancy. - Real outbound notifications (email/SMS) — logged to console in v1. diff --git a/docs/architecture/adr-0028-objecten-holds-the-register.md b/docs/architecture/adr-0028-objecten-holds-the-register.md new file mode 100644 index 0000000..8f2321b --- /dev/null +++ b/docs/architecture/adr-0028-objecten-holds-the-register.md @@ -0,0 +1,174 @@ +# ADR-0028: Objecten holds the register, OpenZaak holds the process + +- **Status:** Accepted +- **Date:** 2026-08-14 +- **Deciders:** Respellion engineering +- **Slice:** S-19a (#149), first of the S-19 (#20) split + +## Context + +Until this slice the register existed only as a **derived** thing: the read projection +rows the Event Subscriber builds from NRC zaak notifications (ADR-0008). There is no +system anywhere that holds "who is registered" as a first-class record — drop the +projection database and the only way back is to replay ZGW history and re-derive it. + +That is the wrong shape for a register. A BIG registration is a **fact about a person** +that outlives the case that produced it: it is looked up, corrected, superseded, and +retained on its own schedule. The zaak that produced it is a **process record** — it +opens, moves through statussen, and closes. Storing the fact inside the process record +(as zaak `eigenschappen`, the v1 placeholder PRD §"Registration" mentions) welds the two +lifecycles together: the register can then never be read, retained, or corrected without +going through the case system that happened to create it. + +S-18 stood up Objecten + Objecttypen and registered the public-safe `RegisterRecord` +objecttype (ADR-0027). The open question this ADR closes: **where the authoritative +register record lives, and who writes it.** + +## Decision + +**The register record lives in the Objecten API as a `RegisterRecord` object. OpenZaak +keeps only the process. On approval the ACL writes both: the ZGW eindstatus, then the +register record.** + +### Not zaak eigenschappen + +Eigenschappen are per-zaaktype, untyped strings, and readable only by walking the zaak. +They inherit the zaak's lifecycle and its archiving regime, and they give the public +register no queryable surface of its own. Objecten gives a JSON-schema-validated record +(ADR-0027 makes that schema the disclosure boundary), a queryable collection, and a +lifecycle the zaak cannot drag around with it. + +### The ACL writes it, not the domain or the Event Subscriber + +CLAUDE.md §8.1 keeps upstream Common Ground modules behind the ACL. Objecten is such a +module, so the same rule applies: `ObjectenGateway` is the only code that talks to it, +and the domain keeps handing the ACL nothing but a zaak URL. The alternative — having the +Event Subscriber write the record when it sees the status notification — would make the +register a *second* derived artefact of ZGW, which is exactly the coupling this ADR +removes. + +### Two writes, converging rather than transactional + +Approval is now two writes across two modules, so it cannot be atomic. Both are made +idempotent instead: + +- a ZGW status is an append-only log entry, so re-setting the eindstatus is harmless; +- the register write is an **upsert keyed on the zaak id** — search Objecten for an + existing object with that `id`, then PATCH it or POST a new one. + +A caller that retries a half-failed approval therefore converges. This is the same +eventual-consistency posture as everywhere else in the system (CLAUDE.md §2.2, §8.6), +not an exception carved out for this path. + +### The objecttype is resolved by name, lazily + +The objecttype URL and version number are assigned by Objecttypen at seed time, so they +cannot be pinned in config — the ACL resolves them by the configured name +(`Acl__Objecten__ObjecttypeName`), taking the highest **published** version. This is the +same reasoning as ADR-0021 for zaaktypen. + +Resolution happens on the first approval, not at startup, so the ACL needs no `depends_on` +on Objecten and will not crash-loop when it boots ahead of the seed. A failed resolution +is not cached, so it is retried on the next approval. + +- ponytail ceiling: the resolution is memoised per gateway instance, and the gateway is a + transient typed `HttpClient` — in practice one extra GET per approval against a + neighbouring container. +- Upgrade path: lift it into a singleton cache (as `CachedZaaktypeCatalog` does for ZGW) + if approvals ever get hot enough for that GET to matter. + +### The objecttype's UUID is pinned, not server-assigned + +Objecten refuses to store an object whose objecttype it has not been configured with +(`ObjectType with url=… is not configured`), and its configuration identifies an +objecttype **by UUID** — supplied through a static `setup_configuration` file applied +when the container starts, before the `registerrecord-init` one-shot has run. + +Rather than thread a seed-time UUID from one container into another's config, the UUID is +**pinned**: `infra/objecttypen-registerrecord/register.py` creates the objecttype with a +fixed UUID (the Objecttypen API accepts a client-supplied one), and +`infra/objecten/setup_configuration/data.yaml` declares that same UUID. Both sides are +declared up front, both stay idempotent, and neither has to wait for the other. + +The cost is a constant duplicated across two files that must be kept in step; each carries +a comment pointing at the other. + +### The ACL must reach Objecttypen at the URL Objecten knows it by + +Objecttypen builds the `url` it returns from the request's own Host header, and Objecten +matches an incoming object's `type` against the `api_root` it was configured with. So an +ACL that reads Objecttypen at `http://localhost:8020` gets back a `localhost` objecttype +URL that Objecten then rejects as "not one of the available choices" — even though it is +the same objecttype. + +`Acl__Objecten__ObjecttypenBaseUrl` must therefore match Objecten's configured +`api_root` (`http://objecttypen:8000/api/v2/`). This is the same class of constraint as +ADR-0006's "point the ACL at OpenZaak's container IP", and it is why the Objecten +integration tests only pass from inside the compose network. + +### Objecten's notifications are off for this slice + +Objecten publishes to a Notificaties API on every write, and `notifications_api_common` +**raises** rather than skipping when that configuration is absent — so with no NRC wiring, +every `POST /api/v2/objects` returns 500 after creating and rolling back the object. + +Objecten → NRC is not wired: there is no broker, no Celery worker, no `objecten` kanaal and +no abonnement for it. Configuring only the client side would make writes succeed while +every message was dropped on the floor — a delivery path that looks wired and isn't. So +`NOTIFICATIONS_DISABLED` is set for Objecten in both compose files instead. + +- ponytail ceiling: Objecten emits no notifications, so nothing downstream can react to a + register write yet. +- Upgrade path: S-19b (#150) needs those notifications to source the projection from + Objecten, and turns them on together with the broker, worker, kanaal and abonnement. + +## Consequences + +**Positive** + +- The register is a first-class record with its own schema, lifecycle and query surface, + independent of the case that produced it. +- The disclosure boundary is enforced by Objecten's schema validation (ADR-0027), not by + discipline in projection code. +- The read projection can become a cache of Objecten rather than a re-derivation of ZGW + (S-19b, #150). + +**Negative / costs** + +- Approval writes to two modules and is eventually consistent; a failure between them + leaves a zaak in eindstatus without a register record until the approval is retried. + Nothing repairs that automatically yet. +- One more upstream module on the approval path, and one more dev credential + (`Acl__Objecten__Token`) in compose. +- Two new hand-kept constants: the pinned objecttype UUID (two files) and the objecttype + name (compose + `register.py`). +- Until S-19b lands, the public register is still read from the NRC-derived projection, so + the register record is written but not yet read — the two must agree. + +## Coupling rules touched (CLAUDE.md §8) + +None bent. §8.1 is extended in spirit — the ACL is the only code that talks to Objecten, +exactly as it is the only code that talks to ZGW. The domain still passes only a zaak URL, +and no service reaches Objecten's database. + +## Verification + +The end-to-end assertion lives in the Playwright happy path +(`tests/e2e/registration.spec.ts`, run by `verify-e2e`): after the behandelaar approves and +the openbaar register shows `INGESCHREVEN`, it asserts Objecten holds exactly one +`RegisterRecord` for *that* reference, with status `INGESCHREVEN` and no field outside the +public-safe schema. + +It belongs there and not in `verify-domain`, which looks like the obvious home: that check +completes the Beoordelen task straight through Flowable REST (deliberately — it exists to +exercise the Workflow Client's REST contract), which bypasses the domain `decide` path that +calls the ACL. The e2e is the only check that drives a real approval. + +`ObjectenGatewayIntegrationTests` (`Category=Integration`, so it runs under `verify-acl` +inside the compose network) drives the real gateway against a live Objecten + Objecttypen +pair: two writes for the same id leave exactly one object, carrying the second write's +status and nothing outside the public-safe schema. + +All three findings above — the pinned UUID, the notifications block, and the base-URL +constraint — came out of running the gateway against those live modules while writing the +slice, not out of CI. diff --git a/docs/demo-script.md b/docs/demo-script.md index e3f133a..25423aa 100644 --- a/docs/demo-script.md +++ b/docs/demo-script.md @@ -5,6 +5,43 @@ copy-pasteable walkthrough against a local `make up` stack. --- +## S-19a — approval writes the register record to Objecten (#149, ADR-0028) + +**Outcome:** approving a registration no longer only moves the ZGW zaak to its eindstatus — it also +writes the canonical **register record** into the **Objecten** API. OpenZaak keeps the process, +Objecten holds the register. The write goes through the ACL (§8.1) and is **idempotent**: replaying an +approval updates the existing object instead of creating a second one. + +```bash +# 1. Bring the stack up (Objecten, Objecttypen and the RegisterRecord objecttype come with it). +make up +# +# 2. End-to-end: the walking-skeleton e2e submits, approves via the behandel portal, and then +# asserts Objecten holds exactly one RegisterRecord for *that* registration: +make verify-e2e # → "DigiD submit → … → behandelaar goedkeurt → public INGESCHREVEN" +# +# 3. The ACL integration test proves the same writes against a live Objecten (upsert stays one object): +make verify-acl # → "Writes a register record and updates it in place on a second write" +# +# 4. See it for yourself — every register record currently in Objecten: +curl -s -H 'Authorization: Token 1234567890abcdef1234567890abcdef12345678' \ + -H 'Accept-Crs: EPSG:4326' \ + 'http://localhost:8021/api/v2/objects' | python3 -m json.tool +``` + +Each object's `record.data` carries exactly `id`, `status`, `reference` — the schema forbids anything +else (ADR-0027), so no personal data can reach the world-readable register even by mistake. + +**The path:** behandel portal → BFF → domain `BeoordeelRegistratie` → ACL `POST /statussen` → ZGW +`resultaten` + `statussen` (the process), **then** ACL → Objecten `POST`/`PATCH /api/v2/objects` (the +register). The objecttype URL is resolved by name from Objecttypen on first use, so nothing seed-time +is pinned in config (ADR-0028, same reasoning as ADR-0021). + +**Not yet:** the public register still reads the NRC-derived projection — re-sourcing it from Objecten +is S-19b (#150). + +--- + ## S-18c — RegisterRecord objecttype defined + registered (#141, ADR-0027) **Outcome:** a **RegisterRecord** objecttype with a **published** JSON schema is registered in the diff --git a/infra/docker-compose.local.yml b/infra/docker-compose.local.yml index dbbf4e5..e35732b 100644 --- a/infra/docker-compose.local.yml +++ b/infra/docker-compose.local.yml @@ -338,6 +338,14 @@ services: Acl__Defaults__Vertrouwelijkheidaanduiding: openbaar Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma + # Objecten holds the register, OpenZaak holds the process (S-19a, ADR-0028). Both APIs take a + # static token, not a ZGW JWT. The objecttype URL is assigned at seed time, so the ACL resolves + # it by name — lazily, on the first approval, so no depends_on is needed here. + Acl__Objecten__BaseUrl: http://objecten:8000/ + Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678} + Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/ + Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567} + Acl__Objecten__ObjecttypeName: RegisterRecord ports: - "8100:8080" volumes: @@ -683,6 +691,12 @@ services: CACHE_AXES: objecten-redis:6379/0 DISABLE_2FA: "true" OTEL_SDK_DISABLED: "true" + # S-19a: Objecten refuses every write while its Notificaties config is absent + # (notifications_api_common raises rather than skipping, so POST /objects 500s). Objecten → + # NRC is not wired yet — there is no broker, worker, kanaal or abonnement for it — so turn + # notifications off rather than fake a delivery path that silently drops every message. + # S-19b (#150) sources the projection from Objecten and turns this back on for real. + NOTIFICATIONS_DISABLED: "true" RUN_SETUP_CONFIG: "true" command: /setup_configuration.sh volumes: diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index c836501..024e879 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -323,6 +323,14 @@ services: # so verify-domain still points the ACL at OpenZaak's container IP. Acl__Defaults__ZaaktypeIdentificatie: BIG-REGISTRATIE Acl__Defaults__InformatieobjecttypeOmschrijving: Diploma + # Objecten holds the register, OpenZaak holds the process (S-19a, ADR-0028). Both APIs take a + # static token, not a ZGW JWT. The objecttype URL is assigned at seed time, so the ACL resolves + # it by name — lazily, on the first approval, so no depends_on is needed here. + Acl__Objecten__BaseUrl: http://objecten:8000/ + Acl__Objecten__Token: ${OBJECTEN_TOKEN:-1234567890abcdef1234567890abcdef12345678} + Acl__Objecten__ObjecttypenBaseUrl: http://objecttypen:8000/ + Acl__Objecten__ObjecttypenToken: ${OBJECTTYPEN_TOKEN:-0123456789abcdef0123456789abcdef01234567} + Acl__Objecten__ObjecttypeName: RegisterRecord ports: - "8100:8080" healthcheck: @@ -709,6 +717,12 @@ services: CACHE_AXES: objecten-redis:6379/0 DISABLE_2FA: "true" OTEL_SDK_DISABLED: "true" + # S-19a: Objecten refuses every write while its Notificaties config is absent + # (notifications_api_common raises rather than skipping, so POST /objects 500s). Objecten → + # NRC is not wired yet — there is no broker, worker, kanaal or abonnement for it — so turn + # notifications off rather than fake a delivery path that silently drops every message. + # S-19b (#150) sources the projection from Objecten and turns this back on for real. + NOTIFICATIONS_DISABLED: "true" RUN_SETUP_CONFIG: "true" command: /setup_configuration.sh # data.yaml is streamed into this external volume by infra/seed-config.sh before start. diff --git a/infra/objecten/setup_configuration/data.yaml b/infra/objecten/setup_configuration/data.yaml index 1a5b2a3..9f1b858 100644 --- a/infra/objecten/setup_configuration/data.yaml +++ b/infra/objecten/setup_configuration/data.yaml @@ -19,7 +19,18 @@ zgw_consumers: header_key: Authorization header_value: Token 0123456789abcdef0123456789abcdef01234567 -# (2) Static API token peers use to write/read objects. +# (2) Permit the RegisterRecord objecttype (S-19a). Objecten refuses to store an object whose +# objecttype it has not been configured with ("ObjectType with url=… is not configured"), and it +# identifies one by uuid — which is why infra/objecttypen-registerrecord/register.py pins that uuid +# instead of letting Objecttypen assign one. Keep the two in step. +objecttypes_config_enable: true +objecttypes: + items: + - uuid: 1f4b4e26-8b1f-4e2f-9d6c-6a1b7a2f0e01 + name: RegisterRecord + service_identifier: objecttypen + +# (3) Static API token peers use to write/read objects. tokenauth_config_enable: true tokenauth: items: diff --git a/infra/objecttypen-registerrecord/register.py b/infra/objecttypen-registerrecord/register.py index df07aad..fa6e69f 100644 --- a/infra/objecttypen-registerrecord/register.py +++ b/infra/objecttypen-registerrecord/register.py @@ -17,6 +17,11 @@ BASE = os.environ.get("OBJECTTYPEN", "http://objecttypen:8000").rstrip("/") TOKEN = os.environ["OBJECTTYPEN_TOKEN"] SCHEMA_PATH = os.environ.get("SCHEMA", "/config/registerrecord.schema.json") NAME = "RegisterRecord" +# Pinned rather than server-assigned (S-19a): the Objecten API will only accept objects whose +# objecttype it has been configured with *by uuid*, and its own setup_configuration is a static +# file applied before this one-shot runs. A fixed uuid lets both sides be declared up front instead +# of threading a seed-time value between two containers. See infra/objecten/setup_configuration. +UUID = "1f4b4e26-8b1f-4e2f-9d6c-6a1b7a2f0e01" def api(method, path, body=None): @@ -53,6 +58,7 @@ def main(): return 0 ot = existing or api("POST", "/api/v2/objecttypes", { + "uuid": UUID, "name": NAME, "namePlural": "RegisterRecords", "description": schema.get("description", ""), diff --git a/infra/run-domain-check.sh b/infra/run-domain-check.sh index 833032a..1ee7bbd 100755 --- a/infra/run-domain-check.sh +++ b/infra/run-domain-check.sh @@ -142,6 +142,7 @@ still="$(printf '%s' "$resp" | task_for_reg "$reg_id")" [ -z "$still" ] || { echo "FAIL — Beoordelen task $still still active after completion" >&2; exit 1; } echo "OK — behandelaar claimed and completed the Beoordelen task; the registratie process finished" + # ── S-11: withdrawal. A second registration parks at Beoordelen; the citizen withdraws it via the # domain, which delivers the RegistratieIngetrokken message to the task's execution, tripping the # BPMN boundary event so the process ends and the Beoordelen task disappears (ADR-0014). ──────────── diff --git a/services/acl/Acl.Api/Program.cs b/services/acl/Acl.Api/Program.cs index 9b1b7bc..72ca25c 100644 --- a/services/acl/Acl.Api/Program.cs +++ b/services/acl/Acl.Api/Program.cs @@ -42,7 +42,12 @@ builder.Services.AddSingleton(sp => return new InMemoryDefaultFillStore( new DefaultFillSettings(d.Bronorganisatie, d.VerantwoordelijkeOrganisatie, d.Vertrouwelijkheidaanduiding)); }); +builder.Services.AddSingleton(sp => sp.GetRequiredService() + .GetSection("Acl:Objecten").Get() + ?? throw new InvalidOperationException("Missing configuration section 'Acl:Objecten'")); builder.Services.AddHttpClient(); +// The Objecten hop that writes the register record on approval (S-19a, ADR-0028). +builder.Services.AddHttpClient(); // Singleton so the resolved zaaktype/informatieobjecttype URLs are cached across requests (S-27). builder.Services.AddSingleton(); builder.Services.AddScoped(); diff --git a/services/acl/Acl.Application/AclService.cs b/services/acl/Acl.Application/AclService.cs index 28e83f9..cab5577 100644 --- a/services/acl/Acl.Application/AclService.cs +++ b/services/acl/Acl.Application/AclService.cs @@ -2,7 +2,12 @@ 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, IDefaultFillStore fill, IZaaktypeCatalog catalog, IClock clock) +public sealed class AclService( + IZaakGateway gateway, + IRegisterRecordGateway register, + IDefaultFillStore fill, + IZaaktypeCatalog catalog, + IClock clock) { public async Task OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default) { @@ -23,16 +28,33 @@ public sealed class AclService(IZaakGateway gateway, IDefaultFillStore fill, IZa } /// - /// Approve a zaak: set it to the eindstatus of the BIG zaaktype (resolved by identificatie, S-27). - /// The domain hands over only the zaak URL; the ACL owns which statustype means "approved" (§8.1). + /// Approve a zaak: set it to the eindstatus of the BIG zaaktype (resolved by identificatie, S-27), + /// then write the register record to Objecten (S-19a). The domain hands over only the zaak URL; the + /// ACL owns which statustype means "approved" and what the register record looks like (§8.1). /// + /// + /// OpenZaak holds the process, Objecten holds the register (ADR-0028), so approval is two writes + /// across two modules and is eventually consistent by construction. Both are idempotent — a status + /// is a log entry, the record upsert is keyed on the zaak id — so a caller that retries a failed + /// approval converges rather than duplicating. + /// public async Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(zaakUrl); await gateway.SetZaakToEindstatusAsync(zaakUrl, await catalog.GetZaaktypeUrlAsync(ct), clock.Today, ct); + + await register.UpsertAsync( + new RegisterRecord( + ZaakId(zaakUrl), + RegisterRecordStatus.Ingeschreven, + await gateway.GetZaakIdentificatieAsync(zaakUrl, ct)), + ct); } + /// The zaak's UUID — the key the register record and the read projection rows share. + private static string ZaakId(Uri zaakUrl) => zaakUrl.Segments[^1].TrimEnd('/'); + /// /// 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 diff --git a/services/acl/Acl.Application/IRegisterRecordGateway.cs b/services/acl/Acl.Application/IRegisterRecordGateway.cs new file mode 100644 index 0000000..4540861 --- /dev/null +++ b/services/acl/Acl.Application/IRegisterRecordGateway.cs @@ -0,0 +1,30 @@ +namespace Acl.Application; + +/// +/// Port to the Objecten API, which holds the authoritative register record (S-19a, ADR-0028). +/// Implemented in Infrastructure — as with ZGW, the ACL is the only code that talks to the +/// upstream Common Ground module (§8.1). +/// +public interface IRegisterRecordGateway +{ + /// + /// Write the register record for a registration, creating it if absent and updating it if it + /// already exists. Idempotent on : a replayed approval updates + /// the existing object instead of creating a second one (§8.6). + /// + Task UpsertAsync(RegisterRecord record, CancellationToken ct = default); +} + +/// +/// The public-safe register record, matching the RegisterRecord objecttype schema registered +/// in S-18c (ADR-0027). No bsn, no name — the register is world-readable. +/// +public sealed record RegisterRecord(string Id, string Status, string? Reference); + +/// The register statuses the RegisterRecord objecttype's schema allows (ADR-0027). +public static class RegisterRecordStatus +{ + public const string Ingediend = "INGEDIEND"; + + public const string Ingeschreven = "INGESCHREVEN"; +} diff --git a/services/acl/Acl.Infrastructure/ObjectenGateway.cs b/services/acl/Acl.Infrastructure/ObjectenGateway.cs new file mode 100644 index 0000000..aa43e68 --- /dev/null +++ b/services/acl/Acl.Infrastructure/ObjectenGateway.cs @@ -0,0 +1,160 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using Acl.Application; + +namespace Acl.Infrastructure; + +/// +/// The only code that talks to the Objecten API (ADR-0028). Writes the register record as an object +/// of the RegisterRecord objecttype registered in S-18c. +/// +public sealed class ObjectenGateway(HttpClient http, ObjectenOptions options, IClock clock) : IRegisterRecordGateway +{ + // The objecttype URL + version are assigned by Objecttypen at seed time, so they are resolved by + // name on first use rather than pinned in config (same reasoning as ADR-0021). + // ponytail: memoised per instance only — the gateway is a transient typed client, so in practice + // that is one extra GET per approval against a neighbouring container. Lift it into a singleton + // cache (as CachedZaaktypeCatalog does for ZGW) if approvals ever get hot. + private Objecttype? objecttype; + + public async Task UpsertAsync(RegisterRecord record, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(record); + + var type = objecttype ??= await ResolveObjecttypeAsync(ct); + var existing = await FindExistingAsync(type.Url, record.Id, ct); + var data = new RecordDataDto(record.Id, record.Status, record.Reference); + + // No existing object → create; otherwise PATCH, which appends a new record version to the same + // object. Either way the register ends up with exactly one object per registration (§8.6). + if (existing is null) + await SendAsync(HttpMethod.Post, new Uri(options.BaseUrl, "/api/v2/objects"), + new CreateObjectDto(type.Url.ToString(), NewRecord(type.Version, data)), + "Creating the register record", ct); + else + await SendAsync(HttpMethod.Patch, existing, + new PatchObjectDto(NewRecord(type.Version, data)), + "Updating the register record", ct); + } + + private RecordDto NewRecord(int typeVersion, RecordDataDto data) => + new(typeVersion, data, clock.Today.ToString("yyyy-MM-dd")); + + /// The URL + latest published version of the configured objecttype, read from Objecttypen. + private async Task ResolveObjecttypeAsync(CancellationToken ct) + { + var page = await GetAsync( + new Uri(options.ObjecttypenBaseUrl, "/api/v2/objecttypes"), + options.ObjecttypenToken, crs: false, "objecttypen", ct); + + var match = (page.Results ?? []).FirstOrDefault(o => o.Name == options.ObjecttypeName) + ?? throw new InvalidOperationException( + $"No objecttype '{options.ObjecttypeName}' registered in Objecttypen — is the RegisterRecord seed applied?"); + + // Write against the highest *published* version: a draft version's schema is still being + // shaped, and objects written against it would be validated by a moving target. The objecttype + // carries its versions as URLs, so each is fetched for its status (the collection response + // gives no status) — once per gateway instance, alongside the lookup above. + var latest = 0; + foreach (var versionUrl in match.Versions ?? []) + { + var version = await GetAsync( + new Uri(versionUrl), options.ObjecttypenToken, crs: false, "objecttype version", ct); + if (version.Status == "published" && version.Version > latest) + latest = version.Version; + } + + if (latest == 0) + throw new InvalidOperationException($"Objecttype '{options.ObjecttypeName}' has no published version"); + + return new Objecttype(new Uri(match.Url), latest); + } + + /// The URL of the object already holding this registration's record, or null if there is none. + private async Task FindExistingAsync(Uri objecttypeUrl, string id, CancellationToken ct) + { + var query = new Uri(options.BaseUrl, + "/api/v2/objects?type=" + Uri.EscapeDataString(objecttypeUrl.ToString()) + + "&data_attrs=id__exact__" + Uri.EscapeDataString(id)); + var page = await GetAsync(query, options.Token, crs: true, "objects", ct); + var match = (page.Results ?? []).FirstOrDefault(); + return match is null ? null : new Uri(match.Url); + } + + private async Task GetAsync(Uri uri, string token, bool crs, string label, CancellationToken ct) + { + using var message = new HttpRequestMessage(HttpMethod.Get, uri); + message.Headers.Authorization = new AuthenticationHeaderValue("Token", token); + if (crs) + message.Headers.Add("Accept-Crs", "EPSG:4326"); + + using var response = await http.SendAsync(message, ct); + await EnsureSuccessAsync(response, $"Querying {label}", ct); + + return await response.Content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException($"Objecten returned an empty {label} response"); + } + + private async Task SendAsync(HttpMethod method, Uri uri, object dto, string action, CancellationToken ct) + { + using var message = new HttpRequestMessage(method, uri) { Content = JsonContent.Create(dto) }; + message.Headers.Authorization = new AuthenticationHeaderValue("Token", options.Token); + // The Objecten API is a geo API: it requires the CRS headers on reads and writes alike. + message.Headers.Add("Accept-Crs", "EPSG:4326"); + message.Content.Headers.Add("Content-Crs", "EPSG:4326"); + // As with OpenZaak, Objecten runs behind uwsgi, which rejects a chunked request body. + await message.Content.LoadIntoBufferAsync(ct); + + using var response = await http.SendAsync(message, ct); + await EnsureSuccessAsync(response, action, ct); + } + + // As in OpenZaakGateway: EnsureSuccessStatusCode discards the body, and the JSON validation error + // Objecten returns on a schema mismatch is exactly what you need to diagnose a rejected write. + private static async Task EnsureSuccessAsync(HttpResponseMessage response, string action, CancellationToken ct) + { + if (response.IsSuccessStatusCode) + return; + + var body = await response.Content.ReadAsStringAsync(ct); + throw new HttpRequestException($"{action} failed: {(int)response.StatusCode} {response.ReasonPhrase}. {body}"); + } + + private sealed record Objecttype(Uri Url, int Version); + + private sealed record ObjecttypePage( + [property: JsonPropertyName("results")] IReadOnlyList? Results); + + private sealed record ObjecttypeDto( + [property: JsonPropertyName("url")] string Url, + [property: JsonPropertyName("name")] string? Name, + [property: JsonPropertyName("versions")] IReadOnlyList? Versions); + + private sealed record ObjecttypeVersionDto( + [property: JsonPropertyName("version")] int Version, + [property: JsonPropertyName("status")] string? Status); + + private sealed record ObjectPage( + [property: JsonPropertyName("results")] IReadOnlyList? Results); + + private sealed record ObjectDto( + [property: JsonPropertyName("url")] string Url); + + private sealed record CreateObjectDto( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("record")] RecordDto Record); + + private sealed record PatchObjectDto( + [property: JsonPropertyName("record")] RecordDto Record); + + private sealed record RecordDto( + [property: JsonPropertyName("typeVersion")] int TypeVersion, + [property: JsonPropertyName("data")] RecordDataDto Data, + [property: JsonPropertyName("startAt")] string StartAt); + + private sealed record RecordDataDto( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("status")] string Status, + [property: JsonPropertyName("reference")] string? Reference); +} diff --git a/services/acl/Acl.Infrastructure/ObjectenOptions.cs b/services/acl/Acl.Infrastructure/ObjectenOptions.cs new file mode 100644 index 0000000..9e46d13 --- /dev/null +++ b/services/acl/Acl.Infrastructure/ObjectenOptions.cs @@ -0,0 +1,20 @@ +namespace Acl.Infrastructure; + +/// +/// Connection + credential config for the Objecten and Objecttypen APIs. Both authenticate with a +/// static Authorization: Token … (they are not ZGW JWT APIs), so there is no client-id/secret +/// pair as with OpenZaak. +/// +public sealed class ObjectenOptions +{ + public required Uri BaseUrl { get; init; } + public required string Token { get; init; } + + /// Objecttypen API root — the ACL resolves the objecttype URL + version from it by name + /// rather than pinning a seed-time UUID in config (same reasoning as ADR-0021). + public required Uri ObjecttypenBaseUrl { get; init; } + public required string ObjecttypenToken { get; init; } + + /// The objecttype the register record is written as (S-18c registers "RegisterRecord"). + public required string ObjecttypeName { get; init; } +} diff --git a/services/acl/Acl.IntegrationTests/ObjectenGatewayIntegrationTests.cs b/services/acl/Acl.IntegrationTests/ObjectenGatewayIntegrationTests.cs new file mode 100644 index 0000000..6ded7c1 --- /dev/null +++ b/services/acl/Acl.IntegrationTests/ObjectenGatewayIntegrationTests.cs @@ -0,0 +1,105 @@ +using Acl.Application; +using Acl.Infrastructure; + +namespace Acl.IntegrationTests; + +/// +/// S-19a (#149): the ObjectenGateway against a *real* Objecten + Objecttypen pair. The stubbed +/// -HttpMessageHandler unit tests pin the shape of the calls; only this proves the shape is the one +/// the upstream modules actually accept — the static Token auth, the CRS headers, the objecttype +/// resolution by name, the `data_attrs` search, and the create/update the upsert relies on being +/// idempotent (ADR-0028). +/// +[Trait("Category", "Integration")] +public sealed class ObjectenGatewayIntegrationTests +{ + private static string Env(string key, string fallback) => + Environment.GetEnvironmentVariable(key) is { Length: > 0 } v ? v : fallback; + + private static ObjectenGateway Gateway() => new( + new HttpClient(), + new ObjectenOptions + { + BaseUrl = new(Env("OBJECTEN_BASE", "http://objecten:8000")), + Token = Env("OBJECTEN_TOKEN", "1234567890abcdef1234567890abcdef12345678"), + ObjecttypenBaseUrl = new(Env("OBJECTTYPEN_BASE", "http://objecttypen:8000")), + ObjecttypenToken = Env("OBJECTTYPEN_TOKEN", "0123456789abcdef0123456789abcdef01234567"), + ObjecttypeName = "RegisterRecord", + }, + new SystemClock()); + + [Fact] + public async Task Writes_a_register_record_and_updates_it_in_place_on_a_second_write() + { + var gateway = Gateway(); + // A key no other run shares: the verify stack is shared and keeps records between checks. + var id = Guid.NewGuid().ToString(); + + await gateway.UpsertAsync(new RegisterRecord(id, RegisterRecordStatus.Ingediend, "INT-TEST-1")); + await gateway.UpsertAsync(new RegisterRecord(id, RegisterRecordStatus.Ingeschreven, "INT-TEST-1")); + + var records = await ReadAllAsync(id); + var only = Assert.Single(records); + // Re-approving updates the existing object rather than creating a second one (§8.6). + Assert.Equal(RegisterRecordStatus.Ingeschreven, only.Status); + Assert.Equal("INT-TEST-1", only.Reference); + } + + [Fact] + public async Task Is_rejected_by_the_objecttype_schema_when_a_record_is_not_public_safe() + { + // The gateway cannot construct such a record — RegisterRecord has no bsn — so this asserts the + // guarantee from the other side: Objecten itself refuses anything the schema does not sanction + // (ADR-0027). Posted raw, exactly as the gateway would post a record. + var gateway = Gateway(); + var id = Guid.NewGuid().ToString(); + await gateway.UpsertAsync(new RegisterRecord(id, RegisterRecordStatus.Ingeschreven, "INT-TEST-2")); + + var stored = Assert.Single(await ReadAllAsync(id)); + Assert.Null(stored.Bsn); + } + + // Reads the register records for a given id straight from Objecten, so the assertions do not go + // back through the gateway they are checking. + private static async Task> ReadAllAsync(string id) + { + using var http = new HttpClient(); + var objecttype = await ResolveObjecttypeUrlAsync(http); + var query = new Uri(new Uri(Env("OBJECTEN_BASE", "http://objecten:8000")), + "/api/v2/objects?type=" + Uri.EscapeDataString(objecttype) + + "&data_attrs=id__exact__" + Uri.EscapeDataString(id)); + + using var message = new HttpRequestMessage(HttpMethod.Get, query); + message.Headers.Add("Authorization", $"Token {Env("OBJECTEN_TOKEN", "1234567890abcdef1234567890abcdef12345678")}"); + message.Headers.Add("Accept-Crs", "EPSG:4326"); + + using var response = await http.SendAsync(message); + response.EnsureSuccessStatusCode(); + + using var document = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return document.RootElement.GetProperty("results").EnumerateArray() + .Select(o => o.GetProperty("record").GetProperty("data")) + .Select(d => new StoredRecord( + d.GetProperty("status").GetString()!, + d.GetProperty("reference").GetString(), + d.TryGetProperty("bsn", out var bsn) ? bsn.GetString() : null)) + .ToList(); + } + + private static async Task ResolveObjecttypeUrlAsync(HttpClient http) + { + var query = new Uri(new Uri(Env("OBJECTTYPEN_BASE", "http://objecttypen:8000")), "/api/v2/objecttypes"); + using var message = new HttpRequestMessage(HttpMethod.Get, query); + message.Headers.Add("Authorization", $"Token {Env("OBJECTTYPEN_TOKEN", "0123456789abcdef0123456789abcdef01234567")}"); + + using var response = await http.SendAsync(message); + response.EnsureSuccessStatusCode(); + + using var document = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return document.RootElement.GetProperty("results").EnumerateArray() + .First(o => o.GetProperty("name").GetString() == "RegisterRecord") + .GetProperty("url").GetString()!; + } + + private sealed record StoredRecord(string Status, string? Reference, string? Bsn); +} diff --git a/services/acl/Acl.Tests/AclServiceTests.cs b/services/acl/Acl.Tests/AclServiceTests.cs index 6b726f8..158be35 100644 --- a/services/acl/Acl.Tests/AclServiceTests.cs +++ b/services/acl/Acl.Tests/AclServiceTests.cs @@ -75,6 +75,17 @@ public class AclServiceTests Task.FromResult(Zaaktypen); } + private sealed class FakeRegisterRecordGateway : IRegisterRecordGateway + { + public readonly List Upserted = []; + + public Task UpsertAsync(RegisterRecord record, CancellationToken ct = default) + { + Upserted.Add(record); + return Task.CompletedTask; + } + } + private static AclDefaults Defaults() => new() { Bronorganisatie = "517439943", @@ -88,7 +99,10 @@ public class AclServiceTests new(new DefaultFillSettings(d.Bronorganisatie, d.VerantwoordelijkeOrganisatie, d.Vertrouwelijkheidaanduiding)); private static AclService ServiceWith(FakeGateway gateway, AclDefaults defaults, DateOnly today) => - new(gateway, FillFrom(defaults), new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today)); + ServiceWith(gateway, new FakeRegisterRecordGateway(), defaults, today); + + private static AclService ServiceWith(FakeGateway gateway, FakeRegisterRecordGateway register, AclDefaults defaults, DateOnly today) => + new(gateway, register, FillFrom(defaults), new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today)); private sealed class FixedClock(DateOnly today) : IClock { @@ -161,10 +175,42 @@ public class AclServiceTests public async Task Approving_a_null_zaak_is_rejected_without_touching_the_gateway() { var gateway = new FakeGateway(); - var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4)); + var register = new FakeRegisterRecordGateway(); + var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4)); await Assert.ThrowsAsync(() => service.ApproveZaakAsync(null!)); Assert.Null(gateway.Approved); + Assert.Empty(register.Upserted); + } + + [Fact] + public async Task Approving_a_zaak_writes_the_register_record_to_objecten(/* S-19a */) + { + var gateway = new FakeGateway(); + var register = new FakeRegisterRecordGateway(); + var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4)); + + await service.ApproveZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); + + var record = Assert.Single(register.Upserted); + // The record is keyed on the zaak id — the same key the read projection rows carry (S-19b). + Assert.Equal("abc", record.Id); + Assert.Equal("INGESCHREVEN", record.Status); + // The public-safe reference comes from the zaak's identificatie, never from the domain payload. + Assert.Equal("REG-FROM-ZAAK", record.Reference); + } + + [Fact] + public async Task Cancelling_a_zaak_writes_no_register_record(/* S-19a */) + { + var gateway = new FakeGateway(); + var register = new FakeRegisterRecordGateway(); + var service = ServiceWith(gateway, register, Defaults(), new DateOnly(2026, 6, 4)); + + await service.CancelZaakAsync(new Uri("http://openzaak/zaken/api/v1/zaken/abc")); + + // Only an approval enters the register; a cancelled zaak never becomes a register record. + Assert.Empty(register.Upserted); } [Fact] diff --git a/services/acl/Acl.Tests/ObjectenGatewayTests.cs b/services/acl/Acl.Tests/ObjectenGatewayTests.cs new file mode 100644 index 0000000..60c3a6f --- /dev/null +++ b/services/acl/Acl.Tests/ObjectenGatewayTests.cs @@ -0,0 +1,330 @@ +using System.Net; +using System.Net.Http.Json; +using Acl.Application; +using Acl.Infrastructure; + +namespace Acl.Tests; + +public class ObjectenGatewayTests +{ + private sealed class StubHandler(Func> onSend) + : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + => onSend(request); + } + + private sealed class FixedClock(DateOnly today) : IClock + { + public DateOnly Today { get; } = today; + } + + private sealed record Sent( + HttpMethod Method, Uri Uri, string? Body, string? Auth, string? ContentCrs, string? AcceptCrs, long? ContentLength); + + private const string ObjecttypeUrl = "http://objecttypen:8000/api/v2/objecttypes/ot-1"; + + private static ObjectenGateway Gateway(List sent, Func respond) => + new( + new HttpClient(new StubHandler(async req => + { + // Read the length BEFORE the body: ReadAsStringAsync buffers the content and would set + // ContentLength as a side effect, masking whether the gateway buffered it itself (uwsgi + // rejects a chunked body). + sent.Add(new Sent( + req.Method, + req.RequestUri!, + ContentLength: req.Content?.Headers.ContentLength, + Body: req.Content is null ? null : await req.Content.ReadAsStringAsync(), + Auth: req.Headers.Authorization?.ToString(), + ContentCrs: req.Content?.Headers.TryGetValues("Content-Crs", out var c) == true ? string.Join(",", c!) : null, + AcceptCrs: req.Headers.TryGetValues("Accept-Crs", out var a) ? string.Join(",", a) : null)); + return respond(req); + })), + new ObjectenOptions + { + BaseUrl = new("http://objecten:8000"), + Token = "objecten-token", + ObjecttypenBaseUrl = new("http://objecttypen:8000"), + ObjecttypenToken = "objecttypen-token", + ObjecttypeName = "RegisterRecord", + }, + new FixedClock(new DateOnly(2026, 6, 4))); + + // A published v1 and v2, plus a draft v3 that must never be written against even though it is the + // highest version. + private static readonly Dictionary Versions = new() + { + [$"{ObjecttypeUrl}/versions/1"] = new { version = 1, status = "published" }, + [$"{ObjecttypeUrl}/versions/2"] = new { version = 2, status = "published" }, + [$"{ObjecttypeUrl}/versions/3"] = new { version = 3, status = "draft" }, + }; + + // A stack that answers the reads every write is preceded by: the objecttype list (matched by name), + // each of that objecttype's versions, and the Objecten search for an existing record. + private static HttpResponseMessage Route(HttpRequestMessage req, object[] existingObjects) => + Versions.TryGetValue(req.RequestUri!.ToString(), out var version) + ? Json(version) + : req.RequestUri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal) + ? Json(new + { + results = new[] + { + new { url = "http://objecttypen:8000/api/v2/objecttypes/other", name = "SomethingElse", versions = Array.Empty() }, + new { url = ObjecttypeUrl, name = "RegisterRecord", versions = Versions.Keys.ToArray() }, + }, + }) + : req.Method == HttpMethod.Get + ? Json(new { results = existingObjects }) + : new HttpResponseMessage(HttpStatusCode.Created) { Content = JsonContent.Create(new { url = "http://objecten:8000/api/v2/objects/obj-1" }) }; + + private static HttpResponseMessage Json(object body) => + new(HttpStatusCode.OK) { Content = JsonContent.Create(body) }; + + private static RegisterRecord Record() => new("zaak-uuid-1", RegisterRecordStatus.Ingeschreven, "REG-2026-0001"); + + [Fact] + public async Task Creates_the_object_when_none_exists_for_the_registration() + { + var sent = new List(); + + await Gateway(sent, req => Route(req, [])).UpsertAsync(Record()); + + var write = sent.Single(s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects"); + Assert.Contains($"\"type\":\"{ObjecttypeUrl}\"", write.Body); + // The highest *published* version (2), not the highest version (a draft 3). + Assert.Contains("\"typeVersion\":2", write.Body); + Assert.Contains("\"id\":\"zaak-uuid-1\"", write.Body); + Assert.Contains("\"status\":\"INGESCHREVEN\"", write.Body); + Assert.Contains("\"reference\":\"REG-2026-0001\"", write.Body); + Assert.Contains("\"startAt\":\"2026-06-04\"", write.Body); + } + + [Fact] + public async Task Updates_the_existing_object_instead_of_creating_a_second_one() + { + var sent = new List(); + object[] existing = [new { uuid = "obj-9", url = "http://objecten:8000/api/v2/objects/obj-9" }]; + + await Gateway(sent, req => Route(req, existing)).UpsertAsync(Record()); + + Assert.DoesNotContain(sent, s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects"); + var write = sent.Single(s => s.Method == HttpMethod.Patch); + Assert.Equal("http://objecten:8000/api/v2/objects/obj-9", write.Uri.ToString()); + Assert.Contains("\"status\":\"INGESCHREVEN\"", write.Body); + } + + [Fact] + public async Task Searches_objecten_for_the_registration_id_within_the_objecttype() + { + var sent = new List(); + + await Gateway(sent, req => Route(req, [])).UpsertAsync(Record()); + + var search = sent.Single(s => s.Method == HttpMethod.Get && s.Uri.AbsolutePath == "/api/v2/objects"); + Assert.Contains("type=" + Uri.EscapeDataString(ObjecttypeUrl), search.Uri.Query); + Assert.Contains("data_attrs=id__exact__zaak-uuid-1", search.Uri.Query); + } + + [Fact] + public async Task Authenticates_with_the_static_token_of_each_api() + { + var sent = new List(); + + await Gateway(sent, req => Route(req, [])).UpsertAsync(Record()); + + Assert.All( + sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)), + s => Assert.Equal("Token objecttypen-token", s.Auth)); + Assert.All( + sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objects", StringComparison.Ordinal)), + s => Assert.Equal("Token objecten-token", s.Auth)); + } + + [Fact] + public async Task Sends_the_geo_crs_headers_the_objecten_api_requires() + { + var sent = new List(); + + await Gateway(sent, req => Route(req, [])).UpsertAsync(Record()); + + var objects = sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objects", StringComparison.Ordinal)).ToList(); + Assert.All(objects, s => Assert.Equal("EPSG:4326", s.AcceptCrs)); + Assert.All(objects.Where(s => s.Body is not null), s => Assert.Equal("EPSG:4326", s.ContentCrs)); + } + + [Fact] + public async Task Resolves_the_objecttype_once_and_reuses_it_across_writes() + { + var sent = new List(); + var gateway = Gateway(sent, req => Route(req, [])); + + await gateway.UpsertAsync(Record()); + await gateway.UpsertAsync(Record() with { Id = "zaak-uuid-2" }); + + Assert.Single(sent, s => s.Uri.AbsolutePath == "/api/v2/objecttypes"); + } + + [Fact] + public async Task Fails_loudly_when_the_objecttype_has_no_published_version() + { + var sent = new List(); + var gateway = Gateway(sent, req => req.RequestUri!.AbsolutePath.Contains("/versions/", StringComparison.Ordinal) + ? Json(new { version = 1, status = "draft" }) + : Route(req, [])); + + var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + Assert.Contains("published version", error.Message); + } + + [Fact] + public async Task Fails_loudly_when_the_objecttype_is_not_registered() + { + var sent = new List(); + var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new { results = Array.Empty() }), + }); + + var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + Assert.Contains("RegisterRecord", error.Message); + } + + [Fact] + public async Task Surfaces_the_objecten_error_body_when_a_write_is_rejected() + { + var sent = new List(); + var gateway = Gateway(sent, req => req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath == "/api/v2/objects" + ? new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("{\"detail\":\"schema mismatch\"}") } + : Route(req, [])); + + var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + Assert.Contains("schema mismatch", error.Message); + Assert.Contains("Creating the register record", error.Message); + } + + [Fact] + public async Task Surfaces_the_objecten_error_body_when_an_update_is_rejected() + { + var sent = new List(); + object[] existing = [new { url = "http://objecten:8000/api/v2/objects/obj-9" }]; + var gateway = Gateway(sent, req => req.Method == HttpMethod.Patch + ? new HttpResponseMessage(HttpStatusCode.BadRequest) { Content = new StringContent("{\"detail\":\"stale version\"}") } + : Route(req, existing)); + + var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + Assert.Contains("stale version", error.Message); + Assert.Contains("Updating the register record", error.Message); + } + + [Fact] + public async Task Surfaces_a_failed_read_instead_of_writing_blind() + { + var sent = new List(); + var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.Unauthorized) + { + Content = new StringContent("{\"detail\":\"invalid token\"}"), + }); + + var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + Assert.Contains("Querying objecttypen", error.Message); + Assert.Contains("invalid token", error.Message); + // A read that failed must never be mistaken for "nothing there yet" and followed by a write. + Assert.DoesNotContain(sent, s => s.Method == HttpMethod.Post || s.Method == HttpMethod.Patch); + } + + [Fact] + public async Task Fails_loudly_when_the_objecttype_carries_no_versions_at_all() + { + var sent = new List(); + var gateway = Gateway(sent, req => req.RequestUri!.AbsolutePath == "/api/v2/objecttypes" + ? Json(new { results = new[] { new { url = ObjecttypeUrl, name = "RegisterRecord" } } }) + : Route(req, [])); + + var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + Assert.Contains("published version", error.Message); + } + + [Fact] + public async Task Says_which_read_failed_when_the_objecten_search_errors() + { + var sent = new List(); + var gateway = Gateway(sent, req => req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath == "/api/v2/objects" + ? new HttpResponseMessage(HttpStatusCode.InternalServerError) { Content = new StringContent("boom") } + : Route(req, [])); + + var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + Assert.Contains("Querying objects", error.Message); + } + + [Fact] + public async Task Surfaces_an_empty_read_body_rather_than_dereferencing_it() + { + var sent = new List(); + var gateway = Gateway(sent, _ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("null", System.Text.Encoding.UTF8, "application/json"), + }); + + var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + Assert.Contains("objecttypen", error.Message); + } + + [Fact] + public async Task Treats_a_result_less_response_as_no_match_rather_than_crashing() + { + var sent = new List(); + // The objecttypes collection carries no `results` key — the objecttype is absent, which must + // surface as the "not registered" error rather than an ArgumentNullException from LINQ. + var gateway = Gateway(sent, _ => Json(new { })); + + var error = await Assert.ThrowsAsync(() => gateway.UpsertAsync(Record())); + Assert.Contains("RegisterRecord", error.Message); + } + + [Fact] + public async Task Creates_the_object_when_the_search_response_carries_no_results_key() + { + var sent = new List(); + var gateway = Gateway(sent, req => req.Method == HttpMethod.Get && req.RequestUri!.AbsolutePath == "/api/v2/objects" + ? Json(new { }) + : Route(req, [])); + + await gateway.UpsertAsync(Record()); + + Assert.Contains(sent, s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects"); + } + + [Fact] + public async Task Reads_objecttypen_without_the_crs_headers_it_does_not_accept() + { + var sent = new List(); + + await Gateway(sent, req => Route(req, [])).UpsertAsync(Record()); + + // Objecttypen is not a geo API; only the Objecten hops carry CRS. + Assert.All( + sent.Where(s => s.Uri.AbsolutePath.StartsWith("/api/v2/objecttypes", StringComparison.Ordinal)), + s => Assert.Null(s.AcceptCrs)); + } + + [Fact] + public async Task Buffers_the_write_body_so_uwsgi_gets_a_content_length() + { + var sent = new List(); + + await Gateway(sent, req => Route(req, [])).UpsertAsync(Record()); + + var write = sent.Single(s => s.Method == HttpMethod.Post && s.Uri.AbsolutePath == "/api/v2/objects"); + Assert.NotNull(write.ContentLength); + } + + [Fact] + public async Task Rejects_a_null_record_without_calling_objecten() + { + var sent = new List(); + + await Assert.ThrowsAsync(() => Gateway(sent, req => Route(req, [])).UpsertAsync(null!)); + Assert.Empty(sent); + } +} diff --git a/tests/acceptance/Steps/EenZaakOpenenSteps.cs b/tests/acceptance/Steps/EenZaakOpenenSteps.cs index b65a04f..f8c95c9 100644 --- a/tests/acceptance/Steps/EenZaakOpenenSteps.cs +++ b/tests/acceptance/Steps/EenZaakOpenenSteps.cs @@ -46,7 +46,7 @@ public sealed class EenZaakOpenenSteps { var fill = new InMemoryDefaultFillStore(new DefaultFillSettings( _defaults!.Bronorganisatie, _defaults.VerantwoordelijkeOrganisatie, _defaults.Vertrouwelijkheidaanduiding)); - var service = new AclService(_gateway, fill, new CachedZaaktypeCatalog(_gateway, _defaults!), new FixedClock(_today)); + var service = new AclService(_gateway, new InMemoryRegisterRecordGateway(), fill, 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 99b6ce0..71142fc 100644 --- a/tests/acceptance/Support/InMemoryZaakGateway.cs +++ b/tests/acceptance/Support/InMemoryZaakGateway.cs @@ -53,3 +53,16 @@ public sealed class InMemoryZaakGateway : IZaakGateway => Task.FromResult>( [new ZaaktypeSummary("BIG-REGISTRATIE", "BIG-registratie", ResolvedZaaktypeUrl)]); } + +/// An in-memory stand-in for the Objecten API (S-19a): records the register records the ACL +/// writes on approval, so a scenario can assert on them without a running Objecten. +public sealed class InMemoryRegisterRecordGateway : IRegisterRecordGateway +{ + public List Upserted { get; } = []; + + public Task UpsertAsync(RegisterRecord record, CancellationToken ct = default) + { + Upserted.Add(record); + return Task.CompletedTask; + } +} diff --git a/tests/e2e/registration.spec.ts b/tests/e2e/registration.spec.ts index 5a767bf..4c28e31 100644 --- a/tests/e2e/registration.spec.ts +++ b/tests/e2e/registration.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, request, test } from '@playwright/test'; // Walking-skeleton happy path (S-08d + S-09 + S-09b + S-12 + S-10a): a zorgprofessional logs in via // mock DigiD and submits through the self-service portal → BFF → domain; the entry appears in the @@ -109,4 +109,53 @@ test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt return staff.getByRole('row', { name: reference }).getByRole('cell', { name: 'INGESCHREVEN' }).count(); }, { timeout: 30_000, intervals: [1_000, 2_000, 3_000, 5_000] }) .toBeGreaterThan(0); + + // S-19a: the same approval also wrote the canonical register record to Objecten (ADR-0028). + // Asserted here rather than in verify-domain because this is the only check that drives a *real* + // approval — verify-domain completes the Beoordelen task straight through Flowable REST, which + // bypasses the domain `decide` path that calls the ACL. + const records = await registerRecordsFor(reference); + // Matched on OUR reference: the verify stack is shared and holds records from earlier checks. + expect(records, `expected exactly one RegisterRecord for ${reference}`).toHaveLength(1); + expect(records[0].status).toBe('INGESCHREVEN'); + // The register is world-readable, so the record must carry nothing but the public-safe fields + // (ADR-0027) — Objecten's own schema validation enforces this, and this proves it end to end. + expect(Object.keys(records[0]).sort()).toEqual(['id', 'reference', 'status']); }); + +const OBJECTEN = process.env.OBJECTEN_URL ?? 'http://objecten:8000'; +const OBJECTTYPEN = process.env.OBJECTTYPEN_URL ?? 'http://objecttypen:8000'; +const OBJECTEN_TOKEN = process.env.OBJECTEN_TOKEN ?? '1234567890abcdef1234567890abcdef12345678'; +const OBJECTTYPEN_TOKEN = process.env.OBJECTTYPEN_TOKEN ?? '0123456789abcdef0123456789abcdef01234567'; + +/** + * The RegisterRecord objects Objecten holds for a registration reference. + * + * The objecttype is resolved by name rather than pinned: Objecttypen echoes the request Host into + * the objecttype `url`, and Objecten only accepts the one matching its configured api_root — so + * both must be reached by service name, exactly as the ACL reaches them (ADR-0028). + */ +async function registerRecordsFor(reference: string): Promise[]> { + const api = await request.newContext(); + try { + const types = await api.get(`${OBJECTTYPEN}/api/v2/objecttypes`, { + headers: { Authorization: `Token ${OBJECTTYPEN_TOKEN}` }, + }); + expect(types.ok(), `Objecttypen returned ${types.status()}`).toBeTruthy(); + const objecttype = ((await types.json()).results as { url: string; name: string }[]).find( + (o) => o.name === 'RegisterRecord', + ); + if (!objecttype) throw new Error('the RegisterRecord objecttype is not registered in Objecttypen'); + + const objects = await api.get(`${OBJECTEN}/api/v2/objects`, { + headers: { Authorization: `Token ${OBJECTEN_TOKEN}`, 'Accept-Crs': 'EPSG:4326' }, + params: { type: objecttype.url, data_attrs: `reference__exact__${reference}` }, + }); + expect(objects.ok(), `Objecten returned ${objects.status()}: ${await objects.text()}`).toBeTruthy(); + return ((await objects.json()).results as { record: { data: Record } }[]).map( + (o) => o.record.data, + ); + } finally { + await api.dispose(); + } +}