## What & why First half of **S-12c** (behandel-portal backend), per **ADR-0013** (decisions recorded in #84): - **BFF multi-realm auth.** A second JWT bearer scheme (`medewerker`) alongside the default `digid` scheme. On validation it lifts Keycloak's `realm_access.roles` onto the principal, and a `behandelaar` policy (medewerker scheme + `behandelaar` role) gates `/behandel/*`. Self-service keeps the digid scheme. - **Werkbak = Flowable tasks.** The domain `Werkbak` query reads the open `Beoordelen` tasks (§8.2, S-12b's `IUserTaskClient`) and enriches each with its aggregate's bsn + status; `GET /behandel/werkbak` (domain) is proxied by the BFF `GET /behandel/werkbak` behind the behandelaar policy. The read projection stays the anonymous openbaar model (no premature `IN_BEHANDELING`/personal-data plumbing — deferred in ADR-0008). Behavior: `/behandel/werkbak` is **401** without a token, **403** for a medewerker lacking the role, **200 + werkbak** for a behandelaar. **S-12c-2** (next): `POST /behandel/registrations/{id}/decide` → domain decision + complete the Flowable task. ## Definition of Done - [x] Linked issue: #13 (umbrella, `refs`); closes the adr-proposal #84 - [x] Tests first; red → green per layer - [x] Unit + acceptance green (`make unit`): domain 78, bff 23, acceptance 9 (+ acl/event-subscriber unaffected) - [x] api-client `test` green; openapi.json regenerated (drift guard passes) - [x] Mutation ≥ break(90): **domain 100%, bff 100%** - [x] ADR-0013 added; `Keycloak__MedewerkerAuthority` wired into compose - [ ] CI green (pending) Part of #13. closes #84 Reviewed-on: #85
This commit was merged in pull request #85.
This commit is contained in:
@@ -13,10 +13,17 @@ public sealed record ProjectionEntry(string Id, string Status, string? Reference
|
||||
/// <summary>A public-safe openbaar register row — only non-sensitive fields leave the BFF.</summary>
|
||||
public sealed record OpenbaarEntry(string Id, string Status, string? Reference);
|
||||
|
||||
/// <summary>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).</summary>
|
||||
public sealed record WerkbakItem(string RegistrationId, string Bsn, string Status);
|
||||
|
||||
/// <summary>Port to the Domain Service (§8.3: the BFF is the portals' only backend; it fans out).</summary>
|
||||
public interface IDomainClient
|
||||
{
|
||||
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
|
||||
|
||||
/// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary>
|
||||
Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Port to the read projection.</summary>
|
||||
@@ -37,6 +44,9 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
|
||||
return new SubmitAccepted(dto.RegistrationId, dto.Status);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||
=> await http.GetFromJsonAsync<List<WerkbakItem>>("behandel/werkbak", ct) ?? [];
|
||||
|
||||
private sealed record DomainResponse(string RegistrationId, string Status, string? ZaakUrl);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<IDomainClient, DomainClient>(c => c.BaseAddress = new Uri(domainBaseUrl));
|
||||
@@ -68,7 +94,53 @@ app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection,
|
||||
})
|
||||
.Produces<IReadOnlyList<OpenbaarEntry>>(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<IReadOnlyList<WerkbakItem>>(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";
|
||||
|
||||
/// <summary>Lift Keycloak's realm roles (the nested <c>realm_access.roles</c> claim) onto the
|
||||
/// principal as role claims, so <c>RequireRole</c> can authorize on them.</summary>
|
||||
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>(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<Program>) can boot the app.
|
||||
public partial class Program;
|
||||
|
||||
@@ -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/" },
|
||||
|
||||
57
services/bff/Bff.Tests/BehandelEndpointTests.cs
Normal file
57
services/bff/Bff.Tests/BehandelEndpointTests.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Bff.Api;
|
||||
|
||||
namespace Bff.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The behandel werkbak endpoint (S-12c): reached only with a medewerker-realm token that carries the
|
||||
/// <c>behandelaar</c> role. A missing token is 401; an authenticated medewerker without the role is
|
||||
/// 403; a behandelaar gets the werkbak (staff view, incl. bsn).
|
||||
/// </summary>
|
||||
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<List<WerkbakItem>>();
|
||||
var item = Assert.Single(items!);
|
||||
Assert.Equal("reg-1", item.RegistrationId);
|
||||
Assert.Equal("123456782", item.Bsn);
|
||||
}
|
||||
}
|
||||
@@ -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<Program>
|
||||
public FakeDomainClient Domain { get; } = new();
|
||||
public FakeProjectionClient Projection { get; } = new();
|
||||
|
||||
private static void ValidateWithTestKey(IServiceCollection services, string scheme) =>
|
||||
services.Configure<JwtBearerOptions>(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<OpenIdConnectConfiguration>(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<Program>
|
||||
services.AddSingleton<IDomainClient>(Domain);
|
||||
services.AddSingleton<IProjectionClient>(Projection);
|
||||
|
||||
services.Configure<JwtBearerOptions>(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<WerkbakItem> Werkbak { get; } = [];
|
||||
|
||||
public Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default)
|
||||
{
|
||||
SubmittedBsn = bsn;
|
||||
return Task.FromResult(Result);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||
=> Task.FromResult<IReadOnlyList<WerkbakItem>>(Werkbak);
|
||||
}
|
||||
|
||||
/// <summary>Serves a configurable set of projection rows.</summary>
|
||||
|
||||
@@ -17,6 +17,22 @@ internal static class TestTokens
|
||||
new SymmetricSecurityKey(Encoding.UTF8.GetBytes("a-different-signing-key-256-bits-long-indeed-yes!")),
|
||||
expired: false);
|
||||
|
||||
/// <summary>A medewerker-realm token carrying the given realm roles under <c>realm_access.roles</c>
|
||||
/// (Keycloak's shape), signed with the valid test key. Used to exercise behandel authorization.</summary>
|
||||
public static string Medewerker(params string[] roles)
|
||||
{
|
||||
var handler = new JsonWebTokenHandler();
|
||||
return handler.CreateToken(new SecurityTokenDescriptor
|
||||
{
|
||||
Claims = new Dictionary<string, object>
|
||||
{
|
||||
["realm_access"] = new Dictionary<string, object> { ["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();
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ builder.Services.AddHttpClient<IAclClient, AclHttpClient>();
|
||||
builder.Services.AddScoped<SubmitRegistration>();
|
||||
builder.Services.AddScoped<ApproveRegistration>();
|
||||
builder.Services.AddScoped<BeoordeelRegistratie>();
|
||||
builder.Services.AddScoped<Werkbak>();
|
||||
builder.Services.AddScoped<OpenZaakWorker>();
|
||||
builder.Services.AddScoped<OpenZaakJobProcessor>();
|
||||
|
||||
@@ -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) =>
|
||||
{
|
||||
|
||||
32
services/domain/Big.Application/Werkbak.cs
Normal file
32
services/domain/Big.Application/Werkbak.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
namespace Big.Application;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public sealed record WerkbakItem(string RegistrationId, string Bsn, string Status);
|
||||
|
||||
/// <summary>
|
||||
/// The werkbak query (S-12c): the registrations awaiting a behandelaar's beoordeling. It reads the
|
||||
/// open <c>Beoordelen</c> tasks from the workflow engine (§8.2, via <see cref="IUserTaskClient"/>) —
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class Werkbak(IUserTaskClient tasks, IRegistrationStore store)
|
||||
{
|
||||
public async Task<IReadOnlyList<WerkbakItem>> GetAsync(CancellationToken ct = default)
|
||||
{
|
||||
var open = await tasks.GetOpenBeoordelingenAsync(ct);
|
||||
|
||||
var items = new List<WerkbakItem>(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;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,29 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A fake user-task client for the werkbak/decision use cases: returns a scripted set of
|
||||
/// open beoordeling tasks and records claim/complete calls.</summary>
|
||||
internal sealed class FakeUserTaskClient(IReadOnlyList<BeoordelingTask> open) : IUserTaskClient
|
||||
{
|
||||
public (string TaskId, string Behandelaar)? Claimed { get; private set; }
|
||||
public (string TaskId, BeoordelingsBesluit Besluit)? Completed { get; private set; }
|
||||
|
||||
public Task<IReadOnlyList<BeoordelingTask>> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A fake ACL client that records the bsn it was asked to open a zaak for and returns a
|
||||
/// fixed zaak URL.</summary>
|
||||
internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient
|
||||
|
||||
49
services/domain/Big.Tests/WerkbakTests.cs
Normal file
49
services/domain/Big.Tests/WerkbakTests.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Big.Application;
|
||||
using Big.Domain;
|
||||
|
||||
namespace Big.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user