diff --git a/apps/beheer/src/app/app.html b/apps/beheer/src/app/app.html index 0680b43..7031d80 100644 --- a/apps/beheer/src/app/app.html +++ b/apps/beheer/src/app/app.html @@ -1 +1,5 @@ + diff --git a/apps/beheer/src/app/app.routes.ts b/apps/beheer/src/app/app.routes.ts index 942b1be..03aa788 100644 --- a/apps/beheer/src/app/app.routes.ts +++ b/apps/beheer/src/app/app.routes.ts @@ -1,7 +1,9 @@ import { Route } from '@angular/router'; import { authenticatedGuard } from 'auth'; import { CatalogusPage } from './catalogus/catalogus-page'; +import { DefaultFillPage } from './default-fill/default-fill-page'; export const appRoutes: Route[] = [ { path: '', component: CatalogusPage, canActivate: [authenticatedGuard] }, + { path: 'default-fill', component: DefaultFillPage, canActivate: [authenticatedGuard] }, ]; diff --git a/apps/beheer/src/app/default-fill/default-fill-page.html b/apps/beheer/src/app/default-fill/default-fill-page.html new file mode 100644 index 0000000..ea2e48e --- /dev/null +++ b/apps/beheer/src/app/default-fill/default-fill-page.html @@ -0,0 +1,60 @@ +
+ + Default-fill +

+ De ZGW-standaardwaarden die de ACL op elke nieuwe zaak invult (ADR-0003). Een wijziging geldt + voor de eerstvolgende zaak. +

+ + @if (loading()) { +

Bezig met laden…

+ } @else if (loaded()) { +
+

+
+ +

+

+
+ +

+

+
+ +

+ +
+ + @if (saved()) { +

De standaardwaarden zijn opgeslagen.

+ } + @if (failed()) { +

+ Opslaan is niet gelukt. Controleer of je als beheerder bent ingelogd en probeer het opnieuw. +

+ } + } @else if (failed()) { +

+ Kon de standaardwaarden niet laden. Controleer of je als beheerder bent ingelogd en probeer + het opnieuw. +

+ } +
+
diff --git a/apps/beheer/src/app/default-fill/default-fill-page.spec.ts b/apps/beheer/src/app/default-fill/default-fill-page.spec.ts new file mode 100644 index 0000000..53011c5 --- /dev/null +++ b/apps/beheer/src/app/default-fill/default-fill-page.spec.ts @@ -0,0 +1,90 @@ +import { signal } from '@angular/core'; +import { fireEvent, render, screen } from '@testing-library/angular'; +import { of, throwError } from 'rxjs'; +import { BeheerDefaultFill, BffApiV1Service } from 'api-client'; +import { AuthService } from 'auth'; +import { axe } from 'vitest-axe'; +import { DefaultFillPage } from './default-fill-page'; + +const current: BeheerDefaultFill = { + bronorganisatie: '517439943', + verantwoordelijkeOrganisatie: '517439943', + vertrouwelijkheidaanduiding: 'openbaar', +}; + +class FakeAuth extends AuthService { + readonly isAuthenticated = signal(true); + readonly bsn = signal(undefined); + override readonly roles = signal(['beheerder']); + login(): void { + /* not exercised */ + } + logout(): void { + /* not exercised */ + } +} + +function setup( + overrides: { + getBeheerDefaultFill?: ReturnType; + putBeheerDefaultFill?: ReturnType; + } = {}, +) { + const getBeheerDefaultFill = overrides.getBeheerDefaultFill ?? vi.fn().mockReturnValue(of(current)); + const putBeheerDefaultFill = overrides.putBeheerDefaultFill ?? vi.fn().mockReturnValue(of(undefined)); + return { + getBeheerDefaultFill, + putBeheerDefaultFill, + providers: [ + { provide: BffApiV1Service, useValue: { getBeheerDefaultFill, putBeheerDefaultFill } }, + { provide: AuthService, useClass: FakeAuth }, + ], + }; +} + +describe('DefaultFillPage', () => { + it('loads the current default-fill into the form on open', async () => { + const { getBeheerDefaultFill, providers } = setup(); + await render(DefaultFillPage, { providers }); + + expect(getBeheerDefaultFill).toHaveBeenCalled(); + const bron = (await screen.findByLabelText('Bronorganisatie')) as HTMLInputElement; + expect(bron.value).toBe('517439943'); + }); + + it('saves the edited values via the BFF', async () => { + const { putBeheerDefaultFill, providers } = setup(); + await render(DefaultFillPage, { providers }); + + const bron = (await screen.findByLabelText('Bronorganisatie')) as HTMLInputElement; + fireEvent.input(bron, { target: { value: '999999999' } }); + fireEvent.click(screen.getByRole('button', { name: /opslaan/i })); + + expect(putBeheerDefaultFill).toHaveBeenCalledWith( + expect.objectContaining({ bronorganisatie: '999999999', vertrouwelijkheidaanduiding: 'openbaar' }), + ); + expect(await screen.findByText(/standaardwaarden zijn opgeslagen/i)).toBeTruthy(); + }); + + it('surfaces a save failure instead of swallowing it', async () => { + const { providers } = setup({ + putBeheerDefaultFill: vi.fn().mockReturnValue(throwError(() => new Error('403'))), + }); + await render(DefaultFillPage, { providers }); + + fireEvent.click(await screen.findByRole('button', { name: /opslaan/i })); + + expect(await screen.findByText(/opslaan is niet gelukt/i)).toBeTruthy(); + }); + + it('has no WCAG 2.1 AA violations', async () => { + document.documentElement.lang = 'nl'; + const { container } = await render(DefaultFillPage, { providers: setup().providers }); + + const results = await axe(container, { + runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] }, + }); + + expect(results.violations).toEqual([]); + }); +}); diff --git a/apps/beheer/src/app/default-fill/default-fill-page.ts b/apps/beheer/src/app/default-fill/default-fill-page.ts new file mode 100644 index 0000000..7cb58e6 --- /dev/null +++ b/apps/beheer/src/app/default-fill/default-fill-page.ts @@ -0,0 +1,72 @@ +import { Component, inject, signal } from '@angular/core'; +import { BeheerDefaultFill, BffApiV1Service } from 'api-client'; +import { UtrechtComponentsModule } from 'ui'; + +/** + * The beheer default-fill editor (S-15b): a beheerder reads and edits the ZGW default-fill values the + * ACL stamps on every zaak (ADR-0003). Load and save go through the BFF (`/beheer/default-fill`), + * which proxies the ACL (ADR-0025). A save takes effect on the next zaak (the ACL reads it per zaak). + */ +@Component({ + selector: 'app-default-fill-page', + imports: [UtrechtComponentsModule], + templateUrl: './default-fill-page.html', +}) +export class DefaultFillPage { + private readonly bff = inject(BffApiV1Service); + + protected readonly bronorganisatie = signal(''); + protected readonly verantwoordelijkeOrganisatie = signal(''); + protected readonly vertrouwelijkheidaanduiding = signal(''); + protected readonly loading = signal(false); + protected readonly loaded = signal(false); + protected readonly saving = signal(false); + protected readonly failed = signal(false); + protected readonly saved = signal(false); + + constructor() { + this.load(); + } + + load(): void { + this.loading.set(true); + this.failed.set(false); + this.saved.set(false); + this.bff.getBeheerDefaultFill().subscribe({ + next: (d: BeheerDefaultFill) => { + this.bronorganisatie.set(d.bronorganisatie); + this.verantwoordelijkeOrganisatie.set(d.verantwoordelijkeOrganisatie); + this.vertrouwelijkheidaanduiding.set(d.vertrouwelijkheidaanduiding); + this.loading.set(false); + this.loaded.set(true); + }, + error: () => { + this.loading.set(false); + this.loaded.set(true); + this.failed.set(true); + }, + }); + } + + save(): void { + this.saving.set(true); + this.failed.set(false); + this.saved.set(false); + this.bff + .putBeheerDefaultFill({ + bronorganisatie: this.bronorganisatie(), + verantwoordelijkeOrganisatie: this.verantwoordelijkeOrganisatie(), + vertrouwelijkheidaanduiding: this.vertrouwelijkheidaanduiding(), + }) + .subscribe({ + next: () => { + this.saving.set(false); + this.saved.set(true); + }, + error: () => { + this.saving.set(false); + this.failed.set(true); + }, + }); + } +} diff --git a/docs/architecture/adr-0026-mutable-default-fill-store.md b/docs/architecture/adr-0026-mutable-default-fill-store.md new file mode 100644 index 0000000..f263298 --- /dev/null +++ b/docs/architecture/adr-0026-mutable-default-fill-store.md @@ -0,0 +1,61 @@ +# ADR-0026: Runtime-mutable ACL default-fill (in-memory store, seeded from config) + +- **Status:** Accepted +- **Date:** 2026-07-24 +- **Deciders:** Respellion engineering +- **Slice:** S-15b (#131), second of the S-15 (#16) split + +## Context + +ADR-0003 made the ACL *default-fill* the ZGW-mandatory fields it stamps on every +zaak, supplied as static configuration (`Acl:Defaults`, read once at startup as an +immutable singleton). S-15b lets a beheerder **edit** those values from the portal +and have the next zaak reflect them — so the defaults must become mutable at runtime. + +Two questions: **what** is editable, and **where** the mutable state lives. + +## Decision + +**Make the three ZGW default-fill fields a runtime-mutable, in-memory store +(`IDefaultFillStore`), seeded from `Acl:Defaults` at startup. The ACL reads it per +zaak; the beheer `PUT /default-fill` replaces it.** + +### Only the three ZGW fill fields are editable + +`Acl:Defaults` also carries the S-27 catalog-resolution keys (`ZaaktypeIdentificatie`, +`InformatieobjecttypeOmschrijving`). Those feed the resolved-URL cache +(`CachedZaaktypeCatalog`, ADR-0021); editing them at runtime would leave a stale cache +and is catalogus *wiring*, not "default fill". So they **stay static config** and are +out of scope for the CRUD. The editable set is exactly `Bronorganisatie`, +`VerantwoordelijkeOrganisatie`, `Vertrouwelijkheidaanduiding` (`DefaultFillSettings`). + +### In-memory, not persisted + +The store is a thread-safe in-memory singleton. **An edit is lost on restart**, when it +reverts to the configured env. That is acceptable for this reference app: the slice +demonstrates the *pattern* (beheer edits config that the ACL honours), not durable +config management. The ACL stays stateless — no DB, no EF, no migration, no extra +compose service. + +- ponytail ceiling: no persistence, no audit trail, no optimistic concurrency. +- Upgrade path: back `IDefaultFillStore` with a DB (or an Objecten record) if durable, + audited, multi-instance config is needed — the port stays the same. + +## Consequences + +**Positive** + +- Demoable end to end (edit in portal → next zaak reflects it) with minimal moving parts. +- The read path is per-zaak, so no restart and no cache concerns for the ZGW fields. + +**Negative / costs** + +- Edits don't survive a restart and aren't shared across replicas (single-instance + assumption). Documented ceiling above. +- Two sources of default config now (static keys on `AclDefaults`, mutable fields in the + store) — a deliberate split by editability. + +## Coupling rules touched (CLAUDE.md §8) + +None new. The BFF→ACL edge already exists (ADR-0025); this adds a read/write pair on it. +The ACL remains the owner of the ZGW-facing config. diff --git a/docs/demo-script.md b/docs/demo-script.md index 58b8770..2fb41c8 100644 --- a/docs/demo-script.md +++ b/docs/demo-script.md @@ -5,6 +5,28 @@ copy-pasteable walkthrough against a local `make up` stack. --- +## S-15b — Beheer-portal: default-fill configuration editor (#131, ADR-0026) + +**Outcome:** a beheerder edits the ACL's ZGW **default-fill** values (bronorganisatie, +verantwoordelijke organisatie, vertrouwelijkheidaanduiding) from the beheer portal, and the next zaak +is stamped with the new values — no restart. Path: portal → BFF `GET/PUT /beheer/default-fill` +(beheerder role) → ACL `GET/PUT /default-fill` → a runtime-mutable in-memory store the ACL reads per +zaak (ADR-0026). The S-27 catalog-resolution keys stay static config (editing them would desync the +zaaktype cache). Store is in-memory: an edit reverts to the configured env on restart. + +```bash +make up +# 1. Log in as bram-beheerder / test123 → "Default-fill" tab → change a value → Opslaan. +open http://localhost:8143/default-fill +# +# 2. Automated: the ACL uses the current default-fill per zaak (unit) and the endpoints are behind the +# beheerder role (BFF unit): +# Acl.Tests → AclServiceTests.Opening_a_zaak_reflects_a_default_fill_update +# Bff.Tests → BeheerDefaultFillEndpointTests +``` + +--- + ## S-15a — Beheer-portal: read-only catalogus viewer (#130, ADR-0025) **Outcome:** a new **beheer** portal (medewerker realm, like behandel) shows the ZTC catalogus — diff --git a/libs/api-client/src/lib/generated/bff-api.ts b/libs/api-client/src/lib/generated/bff-api.ts index 1828d6e..e806978 100644 --- a/libs/api-client/src/lib/generated/bff-api.ts +++ b/libs/api-client/src/lib/generated/bff-api.ts @@ -24,6 +24,12 @@ import { Observable } from 'rxjs'; +export interface BeheerDefaultFill { + bronorganisatie: string; + verantwoordelijkeOrganisatie: string; + vertrouwelijkheidaanduiding: string; +} + export interface BeheerZaaktype { identificatie: string; omschrijving: string; @@ -446,4 +452,69 @@ export class BffApiV1Service { ); } + getBeheerDefaultFill( options?: HttpClientBodyOptions): Observable; + getBeheerDefaultFill( options?: HttpClientEventOptions): Observable>; + getBeheerDefaultFill( options?: HttpClientResponseOptions): Observable>; + getBeheerDefaultFill( + options?: HttpClientObserveOptions): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.get( + `/beheer/default-fill`,{ + ...(options as Omit, 'observe'>), + observe: 'events', + } + ); + } + + if (options?.observe === 'response') { + return this.http.get( + `/beheer/default-fill`,{ + ...(options as Omit, 'observe'>), + observe: 'response', + } + ); + } + + return this.http.get( + `/beheer/default-fill`,{ + ...(options as Omit, 'observe'>), + observe: 'body', + } + ); + } + + putBeheerDefaultFill(beheerDefaultFill: BeheerDefaultFill, options?: HttpClientBodyOptions): Observable; + putBeheerDefaultFill(beheerDefaultFill: BeheerDefaultFill, options?: HttpClientEventOptions): Observable>; + putBeheerDefaultFill(beheerDefaultFill: BeheerDefaultFill, options?: HttpClientResponseOptions): Observable>; + putBeheerDefaultFill( + beheerDefaultFill: BeheerDefaultFill, options?: HttpClientObserveOptions): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.put( + `/beheer/default-fill`, + beheerDefaultFill,{ + ...(options as Omit, 'observe'>), + observe: 'events', + } + ); + } + + if (options?.observe === 'response') { + return this.http.put( + `/beheer/default-fill`, + beheerDefaultFill,{ + ...(options as Omit, 'observe'>), + observe: 'response', + } + ); + } + + return this.http.put( + `/beheer/default-fill`, + beheerDefaultFill,{ + ...(options as Omit, 'observe'>), + observe: 'body', + } + ); + } + }; diff --git a/services/acl/Acl.Api/Program.cs b/services/acl/Acl.Api/Program.cs index 21509c8..9b1b7bc 100644 --- a/services/acl/Acl.Api/Program.cs +++ b/services/acl/Acl.Api/Program.cs @@ -33,6 +33,15 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService() builder.Services.AddSingleton(sp => sp.GetRequiredService() .GetSection("Acl:OpenZaak").Get() ?? throw new InvalidOperationException("Missing configuration section 'Acl:OpenZaak'")); +// The default-fill values are held in a runtime-mutable store (S-15b, ADR-0026), seeded from the +// configured Acl:Defaults. The beheer portal edits it; the worker reads it per zaak. The S-27 +// resolution keys stay on AclDefaults (static) — see DefaultFillSettings. +builder.Services.AddSingleton(sp => +{ + var d = sp.GetRequiredService(); + return new InMemoryDefaultFillStore( + new DefaultFillSettings(d.Bronorganisatie, d.VerantwoordelijkeOrganisatie, d.Vertrouwelijkheidaanduiding)); +}); builder.Services.AddHttpClient(); // Singleton so the resolved zaaktype/informatieobjecttype URLs are cached across requests (S-27). builder.Services.AddSingleton(); @@ -91,6 +100,22 @@ app.MapPost("/documenten", async (StoreDocumentRequest body, AclService acl, Can app.MapGet("/catalogi/zaaktypen", async (AclService acl, CancellationToken ct) => Results.Ok(await acl.ListZaaktypenAsync(ct))); +// Read the current default-fill settings (beheer config viewer, S-15b). +app.MapGet("/default-fill", (AclService acl) => Results.Ok(acl.GetDefaultFill())); + +// Update the default-fill settings from the beheer portal (S-15b). Behind beheerder authorization at +// the BFF; the ACL validates the values are present (the three ZGW-mandatory fields). +app.MapPut("/default-fill", (DefaultFillSettings body, AclService acl) => +{ + if (string.IsNullOrWhiteSpace(body.Bronorganisatie) || + string.IsNullOrWhiteSpace(body.VerantwoordelijkeOrganisatie) || + string.IsNullOrWhiteSpace(body.Vertrouwelijkheidaanduiding)) + return Results.BadRequest(new { error = "bronorganisatie, verantwoordelijkeOrganisatie and vertrouwelijkheidaanduiding are all required." }); + + acl.UpdateDefaultFill(body); + return Results.NoContent(); +}); + app.Run(); public sealed record OpenZaakRequest(string Bsn, string Reference); diff --git a/services/acl/Acl.Application/AclService.cs b/services/acl/Acl.Application/AclService.cs index 52bb9d7..28e83f9 100644 --- a/services/acl/Acl.Application/AclService.cs +++ b/services/acl/Acl.Application/AclService.cs @@ -2,12 +2,15 @@ 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, IZaaktypeCatalog catalog, IClock clock) +public sealed class AclService(IZaakGateway gateway, IDefaultFillStore fill, IZaaktypeCatalog catalog, IClock clock) { public async Task OpenZaakAsync(DomainRegistration registration, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(registration); + // Read the current default-fill per zaak (not at construction), so a beheerder edit (S-15b) + // takes effect on the next zaak without a restart. + var defaults = fill.Current; var request = new ZaakRequest( defaults.Bronorganisatie, defaults.VerantwoordelijkeOrganisatie, @@ -47,6 +50,17 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaak public Task> ListZaaktypenAsync(CancellationToken ct = default) => gateway.ListZaaktypenAsync(ct); + /// The current default-fill settings, for the beheer config viewer (S-15b). + public DefaultFillSettings GetDefaultFill() => fill.Current; + + /// Replace the default-fill settings from the beheer portal (S-15b). Takes effect on the + /// next zaak (the fill is read per zaak, not cached). + public void UpdateDefaultFill(DefaultFillSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + fill.Update(settings); + } + /// The zaak's reference (its ZGW identificatie), for the read projection (#78). public Task GetZaakReferenceAsync(Uri zaakUrl, CancellationToken ct = default) { @@ -68,6 +82,7 @@ public sealed class AclService(IZaakGateway gateway, AclDefaults defaults, IZaak ArgumentException.ThrowIfNullOrWhiteSpace(fileName); ArgumentException.ThrowIfNullOrWhiteSpace(contentType); + var defaults = fill.Current; var request = new DocumentRequest( defaults.Bronorganisatie, await catalog.GetInformatieobjecttypeUrlAsync(ct), diff --git a/services/acl/Acl.Application/DefaultFillSettings.cs b/services/acl/Acl.Application/DefaultFillSettings.cs new file mode 100644 index 0000000..2978c4e --- /dev/null +++ b/services/acl/Acl.Application/DefaultFillSettings.cs @@ -0,0 +1,10 @@ +namespace Acl.Application; + +/// The ZGW default-fill values a beheerder can edit at runtime (S-15b) — the mandatory fields +/// the ACL stamps on every zaak (ADR-0003). The S-27 catalog-resolution keys (zaaktype identificatie, +/// informatieobjecttype omschrijving) stay static config: editing them would desync the resolved-URL +/// cache, and they're catalogus wiring rather than "default fill". +public sealed record DefaultFillSettings( + string Bronorganisatie, + string VerantwoordelijkeOrganisatie, + string Vertrouwelijkheidaanduiding); diff --git a/services/acl/Acl.Application/IDefaultFillStore.cs b/services/acl/Acl.Application/IDefaultFillStore.cs new file mode 100644 index 0000000..d434dec --- /dev/null +++ b/services/acl/Acl.Application/IDefaultFillStore.cs @@ -0,0 +1,14 @@ +namespace Acl.Application; + +/// Holds the ACL's current default-fill values, editable at runtime through the beheer portal +/// (S-15b). Seeded from config at startup. +/// +/// ponytail: in-memory only — an edit is lost on restart, when it reverts to the configured env +/// (ADR-0026). Adequate for the reference demo; back it with a DB if durable, audited config is needed. +/// +public interface IDefaultFillStore +{ + DefaultFillSettings Current { get; } + + void Update(DefaultFillSettings settings); +} diff --git a/services/acl/Acl.Application/InMemoryDefaultFillStore.cs b/services/acl/Acl.Application/InMemoryDefaultFillStore.cs new file mode 100644 index 0000000..b6f5d5a --- /dev/null +++ b/services/acl/Acl.Application/InMemoryDefaultFillStore.cs @@ -0,0 +1,20 @@ +namespace Acl.Application; + +/// In-memory (ADR-0026), seeded from config. Thread-safe: the +/// hosted worker reads per zaak while the beheer endpoint may update it. +public sealed class InMemoryDefaultFillStore(DefaultFillSettings seed) : IDefaultFillStore +{ + private readonly object _gate = new(); + private DefaultFillSettings _current = seed; + + public DefaultFillSettings Current + { + get { lock (_gate) return _current; } + } + + public void Update(DefaultFillSettings settings) + { + ArgumentNullException.ThrowIfNull(settings); + lock (_gate) _current = settings; + } +} diff --git a/services/acl/Acl.Tests/AclServiceTests.cs b/services/acl/Acl.Tests/AclServiceTests.cs index 905dc9f..6b726f8 100644 --- a/services/acl/Acl.Tests/AclServiceTests.cs +++ b/services/acl/Acl.Tests/AclServiceTests.cs @@ -84,8 +84,11 @@ public class AclServiceTests InformatieobjecttypeOmschrijving = "Diploma", }; + private static InMemoryDefaultFillStore FillFrom(AclDefaults d) => + new(new DefaultFillSettings(d.Bronorganisatie, d.VerantwoordelijkeOrganisatie, d.Vertrouwelijkheidaanduiding)); + private static AclService ServiceWith(FakeGateway gateway, AclDefaults defaults, DateOnly today) => - new(gateway, defaults, new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today)); + new(gateway, FillFrom(defaults), new CachedZaaktypeCatalog(gateway, defaults), new FixedClock(today)); private sealed class FixedClock(DateOnly today) : IClock { @@ -113,6 +116,22 @@ public class AclServiceTests Assert.Equal("reg-77", req.Identificatie); } + [Fact] + public async Task Opening_a_zaak_reflects_a_default_fill_update(/* S-15b */) + { + var gateway = new FakeGateway(); + var service = ServiceWith(gateway, Defaults(), new DateOnly(2026, 6, 4)); + + // A beheerder edits the default-fill; the very next zaak must use the new values (read per zaak). + service.UpdateDefaultFill(new DefaultFillSettings("999999999", "888888888", "vertrouwelijk")); + await service.OpenZaakAsync(new DomainRegistration("123456782", "reg-1")); + + var req = gateway.Captured!; + Assert.Equal("999999999", req.Bronorganisatie); + Assert.Equal("888888888", req.VerantwoordelijkeOrganisatie); + Assert.Equal("vertrouwelijk", req.Vertrouwelijkheidaanduiding); + } + [Fact] public async Task Rejects_a_null_registration_without_calling_the_gateway() { diff --git a/services/acl/Acl.Tests/DefaultFillStoreTests.cs b/services/acl/Acl.Tests/DefaultFillStoreTests.cs new file mode 100644 index 0000000..596aefc --- /dev/null +++ b/services/acl/Acl.Tests/DefaultFillStoreTests.cs @@ -0,0 +1,29 @@ +using Acl.Application; + +namespace Acl.Tests; + +public class DefaultFillStoreTests +{ + private static DefaultFillSettings Seed() => new("517439943", "517439943", "openbaar"); + + [Fact] + public void Seeds_from_the_supplied_settings() + { + var store = new InMemoryDefaultFillStore(Seed()); + + Assert.Equal("517439943", store.Current.Bronorganisatie); + Assert.Equal("openbaar", store.Current.Vertrouwelijkheidaanduiding); + } + + [Fact] + public void Updating_replaces_the_current_settings() + { + var store = new InMemoryDefaultFillStore(Seed()); + + store.Update(new DefaultFillSettings("999999999", "888888888", "vertrouwelijk")); + + Assert.Equal("999999999", store.Current.Bronorganisatie); + Assert.Equal("888888888", store.Current.VerantwoordelijkeOrganisatie); + Assert.Equal("vertrouwelijk", store.Current.Vertrouwelijkheidaanduiding); + } +} diff --git a/services/bff/Bff.Api/DownstreamClients.cs b/services/bff/Bff.Api/DownstreamClients.cs index f9761d5..8696b66 100644 --- a/services/bff/Bff.Api/DownstreamClients.cs +++ b/services/bff/Bff.Api/DownstreamClients.cs @@ -59,12 +59,26 @@ public interface IProjectionClient /// internal reference, not shown in the portal. public sealed record BeheerZaaktype(string Identificatie, string Omschrijving); -/// Port to the ACL for read-only catalogus queries (beheer portal, S-15a). The BFF reaches the -/// ACL directly for this read: the catalogus isn't a domain concern, and the ACL is the only code -/// allowed to read the ZGW Catalogi API (§8.1, ADR-0025). +/// The ACL default-fill settings the beheer portal reads + edits (S-15b): the three ZGW-mandatory +/// fields the ACL stamps on every zaak (ADR-0003). +public sealed record BeheerDefaultFill( + string Bronorganisatie, + string VerantwoordelijkeOrganisatie, + string Vertrouwelijkheidaanduiding); + +/// Port to the ACL for beheer queries (beheer portal). The BFF reaches the ACL directly: these +/// aren't a domain concern, and the ACL is the only code allowed to read/own the ZGW-facing config +/// (§8.1, ADR-0025). public interface IAclClient { + /// The published catalogus zaaktypen, read-only (S-15a). Task> GetZaaktypenAsync(CancellationToken ct = default); + + /// The current default-fill settings (S-15b). + Task GetDefaultFillAsync(CancellationToken ct = default); + + /// Replace the default-fill settings (S-15b). + Task UpdateDefaultFillAsync(BeheerDefaultFill settings, CancellationToken ct = default); } /// Calls the Domain Service's POST /registrations. @@ -141,4 +155,14 @@ public sealed class AclClient(HttpClient http) : IAclClient { public async Task> GetZaaktypenAsync(CancellationToken ct = default) => await http.GetFromJsonAsync>("catalogi/zaaktypen", ct) ?? []; + + public async Task GetDefaultFillAsync(CancellationToken ct = default) + => await http.GetFromJsonAsync("default-fill", ct) + ?? throw new InvalidOperationException("The ACL returned an empty default-fill response."); + + public async Task UpdateDefaultFillAsync(BeheerDefaultFill settings, CancellationToken ct = default) + { + using var response = await http.PutAsJsonAsync("default-fill", settings, ct); + response.EnsureSuccessStatusCode(); + } } diff --git a/services/bff/Bff.Api/Program.cs b/services/bff/Bff.Api/Program.cs index dd9a8f2..ee2a36e 100644 --- a/services/bff/Bff.Api/Program.cs +++ b/services/bff/Bff.Api/Program.cs @@ -228,6 +228,25 @@ app.MapGet("/beheer/catalogi/zaaktypen", async (IAclClient acl, CancellationToke .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status403Forbidden); +// Beheer default-fill config (S-15b): read + edit the ACL's default-fill values. Behind medewerker- +// realm + beheerder authorization; the BFF proxies the ACL (ADR-0025). The ACL validates the values. +app.MapGet("/beheer/default-fill", async (IAclClient acl, CancellationToken ct) => + Results.Ok(await acl.GetDefaultFillAsync(ct))) + .RequireAuthorization(BeheerAuth.Policy) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); + +app.MapPut("/beheer/default-fill", async (BeheerDefaultFill body, IAclClient acl, CancellationToken ct) => +{ + await acl.UpdateDefaultFillAsync(body, ct); + return Results.NoContent(); +}) + .RequireAuthorization(BeheerAuth.Policy) + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); + app.Run(); /// The behandelaar's decision on a registration. diff --git a/services/bff/Bff.Tests/BeheerDefaultFillEndpointTests.cs b/services/bff/Bff.Tests/BeheerDefaultFillEndpointTests.cs new file mode 100644 index 0000000..472c664 --- /dev/null +++ b/services/bff/Bff.Tests/BeheerDefaultFillEndpointTests.cs @@ -0,0 +1,84 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Bff.Api; + +namespace Bff.Tests; + +/// +/// The beheer default-fill config endpoints (S-15b): read (GET) and edit (PUT) the ACL's default-fill, +/// reached only with a medewerker-realm token carrying the beheerder role. Missing token → 401; +/// a medewerker without the role → 403; a beheerder reads and updates via the ACL client. +/// +public class BeheerDefaultFillEndpointTests +{ + private static HttpRequestMessage Get(string? bearer) + { + var r = new HttpRequestMessage(HttpMethod.Get, "/beheer/default-fill"); + if (bearer is not null) r.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer); + return r; + } + + private static HttpRequestMessage Put(string? bearer, object body) + { + var r = new HttpRequestMessage(HttpMethod.Put, "/beheer/default-fill") { Content = JsonContent.Create(body) }; + if (bearer is not null) r.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer); + return r; + } + + [Fact] + public async Task Rejects_read_without_a_token() + { + using var factory = new BffFactory(); + var response = await factory.CreateClient().SendAsync(Get(bearer: null)); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Rejects_a_medewerker_without_the_beheerder_role() + { + using var factory = new BffFactory(); + var response = await factory.CreateClient().SendAsync(Get(TestTokens.Medewerker("behandelaar"))); + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task Serves_the_current_default_fill_to_a_beheerder() + { + using var factory = new BffFactory(); + factory.Acl.DefaultFill = new BeheerDefaultFill("517439943", "517439943", "openbaar"); + + var response = await factory.CreateClient().SendAsync(Get(TestTokens.Medewerker("beheerder"))); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(); + Assert.Equal("517439943", body!.Bronorganisatie); + Assert.Equal("openbaar", body.Vertrouwelijkheidaanduiding); + } + + [Fact] + public async Task Updates_the_default_fill_via_the_acl_for_a_beheerder() + { + using var factory = new BffFactory(); + + var response = await factory.CreateClient().SendAsync( + Put(TestTokens.Medewerker("beheerder"), + new { bronorganisatie = "999999999", verantwoordelijkeOrganisatie = "888888888", vertrouwelijkheidaanduiding = "vertrouwelijk" })); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + Assert.Equal("999999999", factory.Acl.Updated!.Bronorganisatie); + Assert.Equal("vertrouwelijk", factory.Acl.Updated.Vertrouwelijkheidaanduiding); + } + + [Fact] + public async Task Rejects_an_update_from_a_non_beheerder() + { + using var factory = new BffFactory(); + + var response = await factory.CreateClient().SendAsync( + Put(TestTokens.Medewerker("behandelaar"), new { bronorganisatie = "1", verantwoordelijkeOrganisatie = "2", vertrouwelijkheidaanduiding = "openbaar" })); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Null(factory.Acl.Updated); + } +} diff --git a/services/bff/Bff.Tests/BffFactory.cs b/services/bff/Bff.Tests/BffFactory.cs index 7f1141e..278a61c 100644 --- a/services/bff/Bff.Tests/BffFactory.cs +++ b/services/bff/Bff.Tests/BffFactory.cs @@ -142,11 +142,24 @@ internal sealed class FakeProjectionClient : IProjectionClient => Task.FromResult>(Entries); } -/// Serves a configurable set of catalogus zaaktypen (beheer viewer, S-15a). +/// Serves catalogus zaaktypen (S-15a) and holds the default-fill settings (S-15b). internal sealed class FakeAclClient : IAclClient { public List Zaaktypen { get; } = []; public Task> GetZaaktypenAsync(CancellationToken ct = default) => Task.FromResult>(Zaaktypen); + + public BeheerDefaultFill DefaultFill { get; set; } = new("517439943", "517439943", "openbaar"); + public BeheerDefaultFill? Updated { get; private set; } + + public Task GetDefaultFillAsync(CancellationToken ct = default) + => Task.FromResult(DefaultFill); + + public Task UpdateDefaultFillAsync(BeheerDefaultFill settings, CancellationToken ct = default) + { + Updated = settings; + DefaultFill = settings; + return Task.CompletedTask; + } } diff --git a/services/bff/openapi.json b/services/bff/openapi.json index 10b790d..a9a985c 100644 --- a/services/bff/openapi.json +++ b/services/bff/openapi.json @@ -255,10 +255,80 @@ } } } + }, + "/beheer/default-fill": { + "get": { + "tags": [ + "Bff.Api" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BeheerDefaultFill" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + } + }, + "put": { + "tags": [ + "Bff.Api" + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BeheerDefaultFill" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "No Content" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + } + } } }, "components": { "schemas": { + "BeheerDefaultFill": { + "required": [ + "bronorganisatie", + "verantwoordelijkeOrganisatie", + "vertrouwelijkheidaanduiding" + ], + "type": "object", + "properties": { + "bronorganisatie": { + "type": "string" + }, + "verantwoordelijkeOrganisatie": { + "type": "string" + }, + "vertrouwelijkheidaanduiding": { + "type": "string" + } + } + }, "BeheerZaaktype": { "required": [ "identificatie", diff --git a/tests/acceptance/Steps/EenZaakOpenenSteps.cs b/tests/acceptance/Steps/EenZaakOpenenSteps.cs index 493fb55..b65a04f 100644 --- a/tests/acceptance/Steps/EenZaakOpenenSteps.cs +++ b/tests/acceptance/Steps/EenZaakOpenenSteps.cs @@ -44,7 +44,9 @@ public sealed class EenZaakOpenenSteps [When("the domain asks the ACL to open a zaak")] public async Task WhenTheDomainAsksTheAclToOpenAZaak() { - var service = new AclService(_gateway, _defaults!, new CachedZaaktypeCatalog(_gateway, _defaults!), new FixedClock(_today)); + var fill = new InMemoryDefaultFillStore(new DefaultFillSettings( + _defaults!.Bronorganisatie, _defaults.VerantwoordelijkeOrganisatie, _defaults.Vertrouwelijkheidaanduiding)); + var service = new AclService(_gateway, fill, new CachedZaaktypeCatalog(_gateway, _defaults!), new FixedClock(_today)); _returnedUrl = await service.OpenZaakAsync(_registration!); } diff --git a/tests/e2e/default-fill.spec.ts b/tests/e2e/default-fill.spec.ts new file mode 100644 index 0000000..8ff8d03 --- /dev/null +++ b/tests/e2e/default-fill.spec.ts @@ -0,0 +1,26 @@ +import { expect, test } from '@playwright/test'; + +// S-15b: a beheerder edits the ACL default-fill in the beheer portal and gets a saved confirmation. +// Runs against the shared verify stack; it edits + saves (the ACL store is in-memory, ADR-0026) and +// asserts the confirmation, without depending on another test's state. +test('a beheerder edits and saves the default-fill', async ({ page }) => { + await page.goto('http://beheer/'); + + // Keycloak medewerker-realm login (same realm as behandel). + await page.locator('#username').fill('bram-beheerder'); + await page.locator('#password').fill('test123'); + await page.locator('#kc-login').click(); + + await expect(page.getByRole('heading', { name: /Catalogus/i })).toBeVisible(); + + // Navigate to the default-fill editor and change a value. + await page.getByRole('link', { name: /Default-fill/i }).click(); + await expect(page.getByRole('heading', { name: /Default-fill/i })).toBeVisible(); + + const bron = page.getByLabel('Bronorganisatie'); + await expect(bron).toBeVisible(); + await bron.fill('517439943'); + await page.getByRole('button', { name: /Opslaan/i }).click(); + + await expect(page.getByText(/standaardwaarden zijn opgeslagen/i)).toBeVisible(); +});