diff --git a/docs/architecture/adr-0013-behandel-portal-wiring.md b/docs/architecture/adr-0013-behandel-portal-wiring.md new file mode 100644 index 0000000..2c04a83 --- /dev/null +++ b/docs/architecture/adr-0013-behandel-portal-wiring.md @@ -0,0 +1,76 @@ +# ADR-0013: Behandel-portal wiring — multi-realm BFF auth, werkbak from Flowable tasks, decision completes the task + +- **Status:** Accepted +- **Date:** 2026-07-15 +- **Deciders:** Respellion engineering +- **Relates to:** #84 (adr-proposal), S-12 (#13); builds on ADR-0010 (BFF OIDC), ADR-0011 (approval status flow), ADR-0009 (external-task worker), ADR-0008 (read projection) + +## Context + +S-12 adds the behandel-portal: a behandelaar logs in, sees a **werkbak** of registrations awaiting +beoordeling, and decides each (goedkeuren/afwijzen). Three questions had no obvious answer and shape +the whole slice. + +1. **Which realm authenticates behandelaars, and how does the BFF accept it?** Citizens use the + `digid` realm (ADR-0010); staff use a separate `medewerker` realm with roles (`behandelaar`, + `teamlead`). Keycloak realms are distinct issuers with distinct signing keys, so the BFF's single + `digid`-realm JWT validation rejects a medewerker token outright. +2. **Where does the werkbak get its data?** The registrations awaiting beoordeling could come from + the read projection (status-filtered rows) or from the Flowable `Beoordelen` user tasks (S-12b). +3. **How does a decision correlate to the workflow?** The process parks at the `Beoordelen` user + task; the decision must advance it, and also apply the domain transition (ADR-0011). + +## Decision + +**The BFF validates a second realm for behandel endpoints; the werkbak is the set of open Flowable +`Beoordelen` tasks (read through the domain); and a decision both applies the domain transition and +completes the Flowable task.** + +- **Multi-realm BFF auth.** The BFF registers a second JWT bearer scheme (`medewerker`, authority = + the medewerker realm) alongside the default `digid` scheme. `/behandel/*` endpoints require an + authorization policy bound to the `medewerker` scheme **and** the `behandelaar` role. Keycloak puts + realm roles in the nested `realm_access.roles` claim, which ASP.NET does not map automatically, so + the scheme's `OnTokenValidated` lifts those roles onto the principal as role claims. Self-service + keeps the `digid` scheme. Audience validation stays off (ADR-0010's deferred hardening). +- **Werkbak = Flowable user tasks (via the domain).** The domain's `Werkbak` query reads the open + `Beoordelen` tasks from the Workflow Client (§8.2, `IUserTaskClient`) and enriches each with its + aggregate's bsn + status; `GET /behandel/werkbak` exposes it and the BFF proxies it behind the + behandelaar policy. The list **is** the authoritative set of claimable/decidable work items, so a + decision acts on a real task with no separate correlation store. The read projection stays the + anonymous openbaar model — we do **not** project `IN_BEHANDELING` or populate staff-only personal + data (both deferred in ADR-0008) just to render a staff view. +- **Decision completes the task (S-12c-2).** A behandelaar decision applies the domain transition + (aggregate + ACL for approval, per ADR-0011) **and** completes the Flowable `Beoordelen` task + (looked up by registrationId), so the process advances. Implemented in the next sub-slice; recorded + here so the boundary is decided up front. + +Delivery is split: **S-12c-1** (this PR) = multi-realm auth + werkbak read; **S-12c-2** = the decide +endpoint + task completion. + +## Consequences + +**Positive** + +- Staff and citizens are cleanly separated by realm; the `behandelaar` role gates the behandel API. +- The werkbak reflects exactly what a behandelaar can act on; claim/decide need no extra correlation. +- No premature projection changes — the openbaar read model stays focused and personal-data-free. +- Only the ACL/Workflow Client talk to their peers; the BFF still fans out only to domain/projection + (§8.3). + +**Negative / costs** + +- The BFF now depends on two Keycloak realms being reachable (`Keycloak:MedewerkerAuthority`). +- Rendering the werkbak fans out to Flowable (one task query) plus a store read per task — acceptable + for the caseload sizes here; a denormalized staff read model is an additive follow-up if needed. +- Realm separation (distinct issuers/keys) is validated live, not in the BFF unit tests, where issuer + validation is off and one test key signs both realms; the tests exercise the role-based authorization. + +## Alternatives considered + +- **Werkbak from the read projection** — rejected for now: needs new plumbing to project + `IN_BEHANDELING` and to populate staff-only bsn/naam (deferred, ADR-0008), plus a separate way to + find the Flowable task at decide-time. Revisit if a high-volume denormalized staff view is needed. +- **One JWT scheme accepting both realms (issuer validation off)** — rejected: trusting multiple + issuers without validation is a security regression; two schemes keep each realm's issuer/key checked. +- **A dedicated behandel BFF/service** — rejected as premature; one BFF with per-endpoint policies is + enough at this size and keeps §8.3 simple. diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 167de80..c107ca0 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -349,6 +349,8 @@ services: # Keycloak (start-dev) derives the issuer from the request host, so the BFF authority and the # verify token request both use keycloak:8080 to keep the issuer consistent. Keycloak__Authority: http://keycloak:8080/realms/digid + # Behandelaars authenticate against the medewerker realm; the BFF validates it for /behandel/* (S-12c). + Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker Downstream__Domain__BaseUrl: http://domain:8080/ Downstream__Projection__BaseUrl: http://projection-api:8080/ ports: diff --git a/libs/api-client/src/lib/generated/bff-api.ts b/libs/api-client/src/lib/generated/bff-api.ts index 5f76b6b..ca0d967 100644 --- a/libs/api-client/src/lib/generated/bff-api.ts +++ b/libs/api-client/src/lib/generated/bff-api.ts @@ -36,6 +36,12 @@ export interface SubmitAccepted { status: string; } +export interface WerkbakItem { + registrationId: string; + bsn: string; + status: string; +} + export type GetOpenbaarRegisterParams = { q?: string; }; @@ -215,4 +221,35 @@ export class BffApiV1Service { ); } + getBehandelWerkbak( options?: HttpClientBodyOptions): Observable; + getBehandelWerkbak( options?: HttpClientEventOptions): Observable>; + getBehandelWerkbak( options?: HttpClientResponseOptions): Observable>; + getBehandelWerkbak( + options?: HttpClientObserveOptions): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.get( + `/behandel/werkbak`,{ + ...(options as Omit, 'observe'>), + observe: 'events', + } + ); + } + + if (options?.observe === 'response') { + return this.http.get( + `/behandel/werkbak`,{ + ...(options as Omit, 'observe'>), + observe: 'response', + } + ); + } + + return this.http.get( + `/behandel/werkbak`,{ + ...(options as Omit, 'observe'>), + observe: 'body', + } + ); + } + }; diff --git a/services/bff/Bff.Api/DownstreamClients.cs b/services/bff/Bff.Api/DownstreamClients.cs index c03f166..8571641 100644 --- a/services/bff/Bff.Api/DownstreamClients.cs +++ b/services/bff/Bff.Api/DownstreamClients.cs @@ -13,10 +13,17 @@ public sealed record ProjectionEntry(string Id, string Status, string? Reference /// A public-safe openbaar register row — only non-sensitive fields leave the BFF. public sealed record OpenbaarEntry(string Id, string Status, string? Reference); +/// A behandelaar's werkbak row: a registration awaiting beoordeling, with the bsn + status a +/// behandelaar sees (staff view — reached only behind medewerker/behandelaar authorization, S-12c). +public sealed record WerkbakItem(string RegistrationId, string Bsn, string Status); + /// Port to the Domain Service (§8.3: the BFF is the portals' only backend; it fans out). public interface IDomainClient { Task SubmitRegistrationAsync(string bsn, CancellationToken ct = default); + + /// The behandelaar's werkbak — registrations awaiting beoordeling. + Task> GetWerkbakAsync(CancellationToken ct = default); } /// Port to the read projection. @@ -37,6 +44,9 @@ public sealed class DomainClient(HttpClient http) : IDomainClient return new SubmitAccepted(dto.RegistrationId, dto.Status); } + public async Task> GetWerkbakAsync(CancellationToken ct = default) + => await http.GetFromJsonAsync>("behandel/werkbak", ct) ?? []; + private sealed record DomainResponse(string RegistrationId, string Status, string? ZaakUrl); } diff --git a/services/bff/Bff.Api/Program.cs b/services/bff/Bff.Api/Program.cs index d705bd5..871ae31 100644 --- a/services/bff/Bff.Api/Program.cs +++ b/services/bff/Bff.Api/Program.cs @@ -1,4 +1,6 @@ using System.Security.Claims; +using System.Text.Json; +using System.Text.Json.Serialization; using Bff.Api; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -6,6 +8,10 @@ var builder = WebApplication.CreateBuilder(args); var keycloakAuthority = builder.Configuration["Keycloak:Authority"] ?? throw new InvalidOperationException("Missing configuration 'Keycloak:Authority'"); +// Behandelaars authenticate against a *different* Keycloak realm (medewerker) than citizens (digid), +// so the BFF validates a second issuer for the behandel endpoints (ADR-0013). +var medewerkerAuthority = builder.Configuration["Keycloak:MedewerkerAuthority"] + ?? throw new InvalidOperationException("Missing configuration 'Keycloak:MedewerkerAuthority'"); var domainBaseUrl = builder.Configuration["Downstream:Domain:BaseUrl"] ?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'"); var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"] @@ -19,8 +25,28 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) options.Authority = keycloakAuthority; options.RequireHttpsMetadata = false; options.TokenValidationParameters.ValidateAudience = false; + }) + // The medewerker realm — behandel endpoints only. On validation we lift Keycloak's realm roles + // (the nested realm_access.roles claim) into role claims so authorization policies can require them. + .AddJwtBearer(BehandelAuth.Scheme, options => + { + options.Authority = medewerkerAuthority; + options.RequireHttpsMetadata = false; + options.TokenValidationParameters.ValidateAudience = false; + options.Events = new JwtBearerEvents + { + OnTokenValidated = context => + { + BehandelAuth.AddRealmRoles(context.Principal); + return Task.CompletedTask; + }, + }; }); -builder.Services.AddAuthorization(); +builder.Services.AddAuthorization(options => + options.AddPolicy(BehandelAuth.Policy, policy => policy + .AddAuthenticationSchemes(BehandelAuth.Scheme) + .RequireAuthenticatedUser() + .RequireRole(BehandelAuth.BehandelaarRole))); // The BFF is the portals' only backend; it fans out to the domain and projection (§8.3). builder.Services.AddHttpClient(c => c.BaseAddress = new Uri(domainBaseUrl)); @@ -68,7 +94,53 @@ app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, }) .Produces>(StatusCodes.Status200OK); +// Behandelaar's werkbak: registrations awaiting beoordeling. Reached only with a medewerker-realm +// token carrying the behandelaar role; the BFF proxies the domain's werkbak (staff view, ADR-0013). +app.MapGet("/behandel/werkbak", async (IDomainClient domain, CancellationToken ct) => + Results.Ok(await domain.GetWerkbakAsync(ct))) + .RequireAuthorization(BehandelAuth.Policy) + .Produces>(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden); + app.Run(); +// Behandel (medewerker-realm) authentication + authorization wiring (ADR-0013). +internal static class BehandelAuth +{ + public const string Scheme = "medewerker"; + public const string Policy = "behandelaar"; + public const string BehandelaarRole = "behandelaar"; + + /// Lift Keycloak's realm roles (the nested realm_access.roles claim) onto the + /// principal as role claims, so RequireRole can authorize on them. + public static void AddRealmRoles(ClaimsPrincipal? principal) + { + if (principal?.Identity is not ClaimsIdentity identity) + return; + + var realmAccess = principal.FindFirst("realm_access")?.Value; + if (string.IsNullOrWhiteSpace(realmAccess)) + return; + + // A malformed realm_access claim must not fail authentication (a throw here becomes a 401); + // it simply yields no roles, so the authorization policy answers 403. + string[] roles; + try + { + roles = JsonSerializer.Deserialize(realmAccess)?.Roles ?? []; + } + catch (JsonException) + { + return; + } + + foreach (var role in roles) + identity.AddClaim(new Claim(identity.RoleClaimType, role)); + } + + private sealed record RealmAccess([property: JsonPropertyName("roles")] string[] Roles); +} + // Exposed so the test host (WebApplicationFactory) can boot the app. public partial class Program; diff --git a/services/bff/Bff.Api/appsettings.json b/services/bff/Bff.Api/appsettings.json index 7fbe3c0..489d782 100644 --- a/services/bff/Bff.Api/appsettings.json +++ b/services/bff/Bff.Api/appsettings.json @@ -7,7 +7,8 @@ }, "AllowedHosts": "*", "Keycloak": { - "Authority": "http://localhost:8180/realms/digid" + "Authority": "http://localhost:8180/realms/digid", + "MedewerkerAuthority": "http://localhost:8180/realms/medewerker" }, "Downstream": { "Domain": { "BaseUrl": "http://localhost:8130/" }, diff --git a/services/bff/Bff.Tests/BehandelEndpointTests.cs b/services/bff/Bff.Tests/BehandelEndpointTests.cs new file mode 100644 index 0000000..3d39147 --- /dev/null +++ b/services/bff/Bff.Tests/BehandelEndpointTests.cs @@ -0,0 +1,57 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Bff.Api; + +namespace Bff.Tests; + +/// +/// The behandel werkbak endpoint (S-12c): reached only with a medewerker-realm token that carries the +/// behandelaar role. A missing token is 401; an authenticated medewerker without the role is +/// 403; a behandelaar gets the werkbak (staff view, incl. bsn). +/// +public class BehandelEndpointTests +{ + private static HttpRequestMessage Werkbak(string? bearer) + { + var request = new HttpRequestMessage(HttpMethod.Get, "/behandel/werkbak"); + if (bearer is not null) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer); + return request; + } + + [Fact] + public async Task Rejects_the_werkbak_without_a_token() + { + using var factory = new BffFactory(); + + var response = await factory.CreateClient().SendAsync(Werkbak(bearer: null)); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task Rejects_a_medewerker_without_the_behandelaar_role() + { + using var factory = new BffFactory(); + + var response = await factory.CreateClient().SendAsync(Werkbak(TestTokens.Medewerker("teamlead"))); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task Serves_the_werkbak_to_a_behandelaar() + { + using var factory = new BffFactory(); + factory.Domain.Werkbak.Add(new WerkbakItem("reg-1", "123456782", "InBehandeling")); + + var response = await factory.CreateClient().SendAsync(Werkbak(TestTokens.Medewerker("behandelaar"))); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var items = await response.Content.ReadFromJsonAsync>(); + var item = Assert.Single(items!); + Assert.Equal("reg-1", item.RegistrationId); + Assert.Equal("123456782", item.Bsn); + } +} diff --git a/services/bff/Bff.Tests/BffFactory.cs b/services/bff/Bff.Tests/BffFactory.cs index 510357b..5e74fb9 100644 --- a/services/bff/Bff.Tests/BffFactory.cs +++ b/services/bff/Bff.Tests/BffFactory.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Protocols; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; @@ -23,9 +24,34 @@ internal sealed class BffFactory : WebApplicationFactory public FakeDomainClient Domain { get; } = new(); public FakeProjectionClient Projection { get; } = new(); + private static void ValidateWithTestKey(IServiceCollection services, string scheme) => + services.Configure(scheme, options => + { + // Validate locally against the test key and NEVER reach out for OIDC metadata. A static + // configuration manager guarantees this regardless of Configure/PostConfigure ordering — + // clearing Authority alone left the medewerker scheme fetching metadata under CI timing + // (2s hang → 401), because JwtBearer's PostConfigure could still build a ConfigurationManager. + options.Authority = null; + options.MetadataAddress = null!; + options.RequireHttpsMetadata = false; + options.Configuration = new OpenIdConnectConfiguration(); + options.ConfigurationManager = + new StaticConfigurationManager(new OpenIdConnectConfiguration()); + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = TestSigningKey, + ClockSkew = TimeSpan.Zero, + }; + }); + protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseSetting("Keycloak:Authority", "https://keycloak.invalid/realms/digid"); + builder.UseSetting("Keycloak:MedewerkerAuthority", "https://keycloak.invalid/realms/medewerker"); builder.UseSetting("Downstream:Domain:BaseUrl", "http://domain.invalid/"); builder.UseSetting("Downstream:Projection:BaseUrl", "http://projection.invalid/"); @@ -34,23 +60,11 @@ internal sealed class BffFactory : WebApplicationFactory services.AddSingleton(Domain); services.AddSingleton(Projection); - services.Configure(JwtBearerDefaults.AuthenticationScheme, options => - { - // Validate locally against the test key; never reach out for OIDC metadata. - options.Authority = null; - options.MetadataAddress = null!; - options.RequireHttpsMetadata = false; - options.Configuration = new OpenIdConnectConfiguration(); - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = false, - ValidateAudience = false, - ValidateLifetime = true, - ValidateIssuerSigningKey = true, - IssuerSigningKey = TestSigningKey, - ClockSkew = TimeSpan.Zero, - }; - }); + // Both realms validate locally against the test key (no live Keycloak). The medewerker + // scheme keeps its OnTokenValidated role-lifting from Program.cs — only the validation + // parameters are swapped here. + ValidateWithTestKey(services, JwtBearerDefaults.AuthenticationScheme); + ValidateWithTestKey(services, "medewerker"); }); } } @@ -60,12 +74,16 @@ internal sealed class FakeDomainClient : IDomainClient { public string? SubmittedBsn { get; private set; } public SubmitAccepted Result { get; set; } = new("reg-123", "Ingediend"); + public List Werkbak { get; } = []; public Task SubmitRegistrationAsync(string bsn, CancellationToken ct = default) { SubmittedBsn = bsn; return Task.FromResult(Result); } + + public Task> GetWerkbakAsync(CancellationToken ct = default) + => Task.FromResult>(Werkbak); } /// Serves a configurable set of projection rows. diff --git a/services/bff/Bff.Tests/TestTokens.cs b/services/bff/Bff.Tests/TestTokens.cs index b5788a5..12465f5 100644 --- a/services/bff/Bff.Tests/TestTokens.cs +++ b/services/bff/Bff.Tests/TestTokens.cs @@ -17,6 +17,22 @@ internal static class TestTokens new SymmetricSecurityKey(Encoding.UTF8.GetBytes("a-different-signing-key-256-bits-long-indeed-yes!")), expired: false); + /// A medewerker-realm token carrying the given realm roles under realm_access.roles + /// (Keycloak's shape), signed with the valid test key. Used to exercise behandel authorization. + public static string Medewerker(params string[] roles) + { + var handler = new JsonWebTokenHandler(); + return handler.CreateToken(new SecurityTokenDescriptor + { + Claims = new Dictionary + { + ["realm_access"] = new Dictionary { ["roles"] = roles }, + }, + Expires = DateTime.UtcNow.AddMinutes(30), + SigningCredentials = new SigningCredentials(BffFactory.TestSigningKey, SecurityAlgorithms.HmacSha256), + }); + } + private static string Create(string bsn, SymmetricSecurityKey key, bool expired) { var handler = new JsonWebTokenHandler(); diff --git a/services/bff/openapi.json b/services/bff/openapi.json index 92a9120..e3591c3 100644 --- a/services/bff/openapi.json +++ b/services/bff/openapi.json @@ -60,6 +60,34 @@ } } } + }, + "/behandel/werkbak": { + "get": { + "tags": [ + "Bff.Api" + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WerkbakItem" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + } + } + } } }, "components": { @@ -100,6 +128,25 @@ "type": "string" } } + }, + "WerkbakItem": { + "required": [ + "registrationId", + "bsn", + "status" + ], + "type": "object", + "properties": { + "registrationId": { + "type": "string" + }, + "bsn": { + "type": "string" + }, + "status": { + "type": "string" + } + } } } }, diff --git a/services/domain/Big.Api/Program.cs b/services/domain/Big.Api/Program.cs index d24d5c6..176ef91 100644 --- a/services/domain/Big.Api/Program.cs +++ b/services/domain/Big.Api/Program.cs @@ -26,6 +26,7 @@ builder.Services.AddHttpClient(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -73,6 +74,12 @@ app.MapPost("/registrations/{id}/decide", async (string id, DecideRequest body, return Results.NoContent(); }); +// The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open +// Beoordelen user tasks (§8.2) and enriched with bsn + status. The BFF proxies this behind +// medewerker-realm + behandelaar-role authorization; the domain trusts its callers (§8.3). +app.MapGet("/behandel/werkbak", async (Werkbak werkbak, CancellationToken ct) => + Results.Ok(await werkbak.GetAsync(ct))); + // Read a registration. Its zaak URL appears once the worker has opened the zaak (eventually). app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) => { diff --git a/services/domain/Big.Application/Werkbak.cs b/services/domain/Big.Application/Werkbak.cs new file mode 100644 index 0000000..4275d50 --- /dev/null +++ b/services/domain/Big.Application/Werkbak.cs @@ -0,0 +1,32 @@ +namespace Big.Application; + +/// One row of the behandelaar's werkbak: a registration awaiting beoordeling, with the +/// public-facing reference (its id) plus the bsn and status a behandelaar needs to triage it. +public sealed record WerkbakItem(string RegistrationId, string Bsn, string Status); + +/// +/// The werkbak query (S-12c): the registrations awaiting a behandelaar's beoordeling. It reads the +/// open Beoordelen tasks from the workflow engine (§8.2, via ) — +/// the authoritative set of work items — and enriches each with its aggregate (bsn + status). A task +/// whose registration the domain doesn't know is skipped rather than invented. +/// +public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store) +{ + public async Task> GetAsync(CancellationToken ct = default) + { + var open = await tasks.GetOpenBeoordelingenAsync(ct); + + var items = new List(open.Count); + foreach (var task in open) + { + var registration = await store.GetAsync(task.RegistrationId, ct); + if (registration is null) + continue; + + items.Add(new WerkbakItem( + registration.Id.ToString(), registration.Bsn, registration.Status.ToString())); + } + + return items; + } +} diff --git a/services/domain/Big.Tests/Fakes.cs b/services/domain/Big.Tests/Fakes.cs index cf0a37d..bfd0a54 100644 --- a/services/domain/Big.Tests/Fakes.cs +++ b/services/domain/Big.Tests/Fakes.cs @@ -41,6 +41,29 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac } } +/// A fake user-task client for the werkbak/decision use cases: returns a scripted set of +/// open beoordeling tasks and records claim/complete calls. +internal sealed class FakeUserTaskClient(IReadOnlyList open) : IUserTaskClient +{ + public (string TaskId, string Behandelaar)? Claimed { get; private set; } + public (string TaskId, BeoordelingsBesluit Besluit)? Completed { get; private set; } + + public Task> GetOpenBeoordelingenAsync(CancellationToken ct = default) + => Task.FromResult(open); + + public Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default) + { + Claimed = (taskId, behandelaar); + return Task.CompletedTask; + } + + public Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default) + { + Completed = (taskId, besluit); + return Task.CompletedTask; + } +} + /// A fake ACL client that records the bsn it was asked to open a zaak for and returns a /// fixed zaak URL. internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient diff --git a/services/domain/Big.Tests/WerkbakTests.cs b/services/domain/Big.Tests/WerkbakTests.cs new file mode 100644 index 0000000..bfbf182 --- /dev/null +++ b/services/domain/Big.Tests/WerkbakTests.cs @@ -0,0 +1,49 @@ +using Big.Application; +using Big.Domain; + +namespace Big.Tests; + +/// +/// The werkbak query (S-12c): the behandelaar's list of registrations awaiting beoordeling. It reads +/// the open Beoordelen tasks from the workflow engine (§8.2) and enriches each with its registration +/// (bsn + status) from the store. A task whose registration is unknown is skipped defensively. +/// +public class WerkbakTests +{ + [Fact] + public async Task Lists_an_item_per_open_beoordeling_enriched_from_the_registration() + { + var store = new FakeRegistrationStore(); + var registration = Registration.Submit("123456782"); + registration.AttachZaak(FakeAclClient.DefaultZaakUrl); + registration.TakeIntoBehandeling(); + store.Seed(registration); + var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", registration.Id)]); + var werkbak = new Werkbak(tasks, store); + + var items = await werkbak.GetAsync(); + + var item = Assert.Single(items); + Assert.Equal(registration.Id.ToString(), item.RegistrationId); + Assert.Equal("123456782", item.Bsn); + Assert.Equal("InBehandeling", item.Status); + } + + [Fact] + public async Task Is_empty_when_no_beoordelingen_are_open() + { + var werkbak = new Werkbak(new FakeUserTaskClient([]), new FakeRegistrationStore()); + + Assert.Empty(await werkbak.GetAsync()); + } + + [Fact] + public async Task Skips_a_task_whose_registration_is_unknown() + { + // Defensive: the werkbak never invents an item for a task the domain has no aggregate for. + var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", RegistrationId.New())]); + var werkbak = new Werkbak(tasks, new FakeRegistrationStore()); + + Assert.Empty(await werkbak.GetAsync()); + } +} diff --git a/tests/acceptance/Support/BffAcceptanceHost.cs b/tests/acceptance/Support/BffAcceptanceHost.cs index dcd94a2..574a686 100644 --- a/tests/acceptance/Support/BffAcceptanceHost.cs +++ b/tests/acceptance/Support/BffAcceptanceHost.cs @@ -68,6 +68,9 @@ public sealed class CapturingDomainClient : IDomainClient SubmittedBsn = bsn; return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend")); } + + public Task> GetWerkbakAsync(CancellationToken ct = default) + => Task.FromResult>([]); } /// Serves configurable projection rows.