Compare commits

...

8 Commits

Author SHA1 Message Date
6e65738c04 fix(bff): pin test JWT schemes to a static config so no OIDC metadata fetch (refs #13)
All checks were successful
CI / lint (pull_request) Successful in 1m28s
CI / build (pull_request) Successful in 1m18s
CI / unit (pull_request) Successful in 1m40s
CI / frontend (pull_request) Successful in 3m2s
CI / mutation (pull_request) Successful in 6m16s
CI / verify-stack (pull_request) Successful in 5m14s
The medewerker scheme intermittently hung ~2s fetching OIDC metadata from its
(unreachable) test authority and then 401'd — clearing Authority alone left
JwtBearer's PostConfigure free to build a ConfigurationManager under CI timing. Give
both test schemes a StaticConfigurationManager so metadata is never fetched, making
token validation deterministic. Also harden AddRealmRoles to never throw (a throw in
OnTokenValidated surfaces as a 401) — a malformed realm_access yields no roles (403).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:24:36 +02:00
e0db3ace6e test(acceptance): implement GetWerkbakAsync on the acceptance domain stub (refs #13)
Some checks failed
CI / lint (pull_request) Successful in 1m25s
CI / build (pull_request) Successful in 1m13s
CI / unit (pull_request) Failing after 1m17s
CI / frontend (pull_request) Successful in 2m44s
CI / mutation (pull_request) Successful in 5m18s
CI / verify-stack (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:09:54 +02:00
6f5a61dea6 docs(architecture): ADR-0013 — behandel-portal wiring (auth, werkbak, decision) (refs #13)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:09:14 +02:00
1a6daecc47 feat(bff): medewerker-realm auth + behandelaar policy + GET /behandel/werkbak (refs #13)
Adds a second JWT bearer scheme for the medewerker realm; on validation it lifts
Keycloak's realm_access.roles onto the principal so the behandelaar policy can
require the role. /behandel/werkbak proxies the domain werkbak behind that policy
(401 without a token, 403 without the role). openapi.json + api-client regenerated;
Keycloak__MedewerkerAuthority wired into compose.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:07:11 +02:00
08515b9bba test(bff): /behandel/werkbak needs a medewerker token with the behandelaar role (refs #13)
Red — the medewerker JWT scheme, the behandelaar policy, and the werkbak endpoint
do not exist yet (endpoint 404s).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:03:36 +02:00
69db103e0e feat(domain): werkbak query + GET /behandel/werkbak (refs #13)
The Werkbak use-case reads the open Beoordelen tasks (§8.2) and enriches each with
its aggregate's bsn + status; exposed as GET /behandel/werkbak for the BFF to proxy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:01:33 +02:00
ffd59c0aac test(domain): werkbak query lists open beoordelingen enriched from the store (refs #13)
Red — the Werkbak use-case and WerkbakItem do not exist yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:00:46 +02:00
4085bdead7 feat(#13): S-12b — Workflow Client user-tasks + Beoordelen userTask (#83)
All checks were successful
CI / lint (push) Successful in 1m23s
CI / build (push) Successful in 1m10s
CI / unit (push) Successful in 1m14s
CI / frontend (push) Successful in 2m23s
CI / mutation (push) Successful in 5m45s
CI / verify-stack (push) Successful in 7m7s
## What & why

Second sub-slice of **S-12 (#13)** — the **Workflow Client gains behandelaar user-task operations**, and the process model gains the beoordeling step.

- **BPMN:** `registratie.bpmn` now parks at a `Beoordelen` **userTask** (candidate group `behandelaar`) after `OpenZaakAanmaken`; `registrationId` rides along as a process variable so the werkbak can correlate each task to its aggregate.
- **Workflow Client** (`IUserTaskClient`, the only code that talks to Flowable §8.2):
  - `GetOpenBeoordelingenAsync()` — the werkbak (open `Beoordelen` tasks + their `registrationId`)
  - `ClaimAsync(taskId, behandelaar)`
  - `CompleteBeoordelingAsync(taskId, besluit)` — carries the decision into the process as the `besluit` variable
- **Live integration:** `verify-domain` now drives the full user-task lifecycle against a real Flowable — after the worker opens the zaak, it polls for the task, claims it as `merel-behandelaar`, completes it (`goedkeuren`), and asserts the process finishes. This proves the exact REST contract (`service/runtime/tasks/query` + `…/{id}` claim/complete) the client depends on.

The walking skeleton is unaffected: the temporary `/approve` path still sets the zaak status directly; wiring the domain decision to *complete this task* (and driving the werkbak from the BFF) lands in **S-12c**.

## Definition of Done

- [x] Linked issue: #13 (umbrella; `refs`, does not close)
- [x] Tests first; red → green
- [x] Unit + acceptance green (`make unit`): domain 76, acceptance 9 (acl/event-subscriber/bff unaffected)
- [x] Mutation ≥ break(90): **domain 100%** (killed the new survivors *and* the pre-existing `FlowableWorkflowClient` baseline)
- [x] Live Flowable user-task lifecycle asserted in `verify-domain`
- [ ] CI green (pending)

Part of #13.

Reviewed-on: #83
2026-07-15 08:53:33 +00:00
20 changed files with 748 additions and 33 deletions

View File

@@ -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.

View File

@@ -349,6 +349,8 @@ services:
# Keycloak (start-dev) derives the issuer from the request host, so the BFF authority and the # 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. # verify token request both use keycloak:8080 to keep the issuer consistent.
Keycloak__Authority: http://keycloak:8080/realms/digid 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__Domain__BaseUrl: http://domain:8080/
Downstream__Projection__BaseUrl: http://projection-api:8080/ Downstream__Projection__BaseUrl: http://projection-api:8080/
ports: ports:

View File

@@ -51,18 +51,71 @@ loc="$(docker run --rm --network "$net" curlimages/curl:latest \
echo ">> registration accepted at $loc" echo ">> registration accepted at $loc"
echo ">> polling the domain until the worker records the opened zaak" echo ">> polling the domain until the worker records the opened zaak"
zaak_ok=""
for _ in $(seq 1 30); do for _ in $(seq 1 30); do
body="$(docker run --rm --network "$net" curlimages/curl:latest \ body="$(docker run --rm --network "$net" curlimages/curl:latest \
-fsS "http://$dom_ip:8080$loc" 2>/dev/null || true)" -fsS "http://$dom_ip:8080$loc" 2>/dev/null || true)"
if echo "$body" | grep -q '/zaken/api/v1/zaken/'; then if echo "$body" | grep -q '/zaken/api/v1/zaken/'; then
echo "OK — the domain opened a zaak and recorded it on the registration:" echo "OK — the domain opened a zaak and recorded it on the registration:"
echo "$body" | cut -c1-300 echo "$body" | cut -c1-300
exit 0 zaak_ok=1
break
fi fi
sleep 2 sleep 2
done done
echo "FAIL — the registration never received a zaak URL" >&2 if [ -z "$zaak_ok" ]; then
echo "--- domain log ---" >&2; docker logs "$dom" 2>&1 | tail -15 >&2 echo "FAIL — the registration never received a zaak URL" >&2
acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)" echo "--- domain log ---" >&2; docker logs "$dom" 2>&1 | tail -15 >&2
[ -n "$acl" ] && { echo "--- acl log ---" >&2; docker logs "$acl" 2>&1 | tail -15 >&2; } acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)"
exit 1 [ -n "$acl" ] && { echo "--- acl log ---" >&2; docker logs "$acl" 2>&1 | tail -15 >&2; }
exit 1
fi
# ── S-12b: the process now parks at the Beoordelen user task. Exercise the exact Flowable REST
# contract the Workflow Client uses (query/claim/complete) against the live engine, reaching
# flowable-rest by container IP (same in-network constraint as above). ──────────────────────────
fl="$(docker ps -q --filter 'name=flowable-rest' | head -1)"
[ -n "$fl" ] || { echo "ERROR: no running flowable-rest container" >&2; exit 1; }
fl_base="http://$(ip "$fl"):8080/flowable-rest/service"
reg_id="${loc##*/}"
# Extracts the Beoordelen task id for our registration from a Flowable task-query response on stdin.
# Tolerates an empty/non-JSON body (a transient failure during the poll) by printing nothing.
task_for_reg() { REG_ID="$reg_id" python3 -c "import os,sys,json
try:
d=json.load(sys.stdin)
except Exception:
d={}
rid=os.environ['REG_ID']
# Flowable's task-query returns the included process variables under 'variables'.
print(next((t['id'] for t in (d.get('data') or [])
if any(v.get('name')=='registrationId' and v.get('value')==rid for v in (t.get('variables') or []))), ''))"; }
flcurl() { docker run --rm --network "$net" curlimages/curl:latest -fsS -u rest-admin:test "$@"; }
query='{"processDefinitionKey":"registratie","taskDefinitionKey":"Beoordelen","includeProcessVariables":true}'
echo ">> polling Flowable for the Beoordelen user task (werkbak)"
task_id=""
for _ in $(seq 1 30); do
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query" 2>/dev/null || true)"
task_id="$(printf '%s' "$resp" | task_for_reg)"
[ -n "$task_id" ] && break
sleep 2
done
[ -n "$task_id" ] || { echo "FAIL — no Beoordelen task appeared for registration $reg_id" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo ">> Beoordelen task $task_id is waiting"
echo ">> claiming the task as merel-behandelaar"
flcurl -X POST "$fl_base/runtime/tasks/$task_id" -H 'Content-Type: application/json' \
-d '{"action":"claim","assignee":"merel-behandelaar"}' >/dev/null
echo ">> completing the beoordeling (goedkeuren)"
flcurl -X POST "$fl_base/runtime/tasks/$task_id" -H 'Content-Type: application/json' \
-d '{"action":"complete","variables":[{"name":"besluit","type":"string","value":"goedkeuren"}]}' >/dev/null
echo ">> asserting the process finished (no Beoordelen task remains for the registration)"
resp="$(flcurl -X POST "$fl_base/query/tasks" -H 'Content-Type: application/json' -d "$query")"
still="$(printf '%s' "$resp" | task_for_reg)"
[ -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"
exit 0

View File

@@ -36,6 +36,12 @@ export interface SubmitAccepted {
status: string; status: string;
} }
export interface WerkbakItem {
registrationId: string;
bsn: string;
status: string;
}
export type GetOpenbaarRegisterParams = { export type GetOpenbaarRegisterParams = {
q?: string; q?: string;
}; };
@@ -215,4 +221,35 @@ export class BffApiV1Service {
); );
} }
getBehandelWerkbak<TData = WerkbakItem[]>( options?: HttpClientBodyOptions): Observable<TData>;
getBehandelWerkbak<TData = WerkbakItem[]>( options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
getBehandelWerkbak<TData = WerkbakItem[]>( options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
getBehandelWerkbak<TData = WerkbakItem[]>(
options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
if (options?.observe === 'events') {
return this.http.get<TData>(
`/behandel/werkbak`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
}
);
}
if (options?.observe === 'response') {
return this.http.get<TData>(
`/behandel/werkbak`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
}
);
}
return this.http.get<TData>(
`/behandel/werkbak`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'body',
}
);
}
}; };

View File

@@ -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> /// <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); 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> /// <summary>Port to the Domain Service (§8.3: the BFF is the portals' only backend; it fans out).</summary>
public interface IDomainClient public interface IDomainClient
{ {
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default); 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> /// <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); 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); private sealed record DomainResponse(string RegistrationId, string Status, string? ZaakUrl);
} }

View File

@@ -1,4 +1,6 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json;
using System.Text.Json.Serialization;
using Bff.Api; using Bff.Api;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
@@ -6,6 +8,10 @@ var builder = WebApplication.CreateBuilder(args);
var keycloakAuthority = builder.Configuration["Keycloak:Authority"] var keycloakAuthority = builder.Configuration["Keycloak:Authority"]
?? throw new InvalidOperationException("Missing 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"] var domainBaseUrl = builder.Configuration["Downstream:Domain:BaseUrl"]
?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'"); ?? throw new InvalidOperationException("Missing configuration 'Downstream:Domain:BaseUrl'");
var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"] var projectionBaseUrl = builder.Configuration["Downstream:Projection:BaseUrl"]
@@ -19,8 +25,28 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
options.Authority = keycloakAuthority; options.Authority = keycloakAuthority;
options.RequireHttpsMetadata = false; options.RequireHttpsMetadata = false;
options.TokenValidationParameters.ValidateAudience = 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). // 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)); 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); .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(); 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. // Exposed so the test host (WebApplicationFactory<Program>) can boot the app.
public partial class Program; public partial class Program;

View File

@@ -7,7 +7,8 @@
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"Keycloak": { "Keycloak": {
"Authority": "http://localhost:8180/realms/digid" "Authority": "http://localhost:8180/realms/digid",
"MedewerkerAuthority": "http://localhost:8180/realms/medewerker"
}, },
"Downstream": { "Downstream": {
"Domain": { "BaseUrl": "http://localhost:8130/" }, "Domain": { "BaseUrl": "http://localhost:8130/" },

View 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);
}
}

View File

@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost; using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
@@ -23,24 +24,19 @@ internal sealed class BffFactory : WebApplicationFactory<Program>
public FakeDomainClient Domain { get; } = new(); public FakeDomainClient Domain { get; } = new();
public FakeProjectionClient Projection { get; } = new(); public FakeProjectionClient Projection { get; } = new();
protected override void ConfigureWebHost(IWebHostBuilder builder) private static void ValidateWithTestKey(IServiceCollection services, string scheme) =>
services.Configure<JwtBearerOptions>(scheme, options =>
{ {
builder.UseSetting("Keycloak:Authority", "https://keycloak.invalid/realms/digid"); // Validate locally against the test key and NEVER reach out for OIDC metadata. A static
builder.UseSetting("Downstream:Domain:BaseUrl", "http://domain.invalid/"); // configuration manager guarantees this regardless of Configure/PostConfigure ordering —
builder.UseSetting("Downstream:Projection:BaseUrl", "http://projection.invalid/"); // clearing Authority alone left the medewerker scheme fetching metadata under CI timing
// (2s hang → 401), because JwtBearer's PostConfigure could still build a ConfigurationManager.
builder.ConfigureTestServices(services =>
{
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.Authority = null;
options.MetadataAddress = null!; options.MetadataAddress = null!;
options.RequireHttpsMetadata = false; options.RequireHttpsMetadata = false;
options.Configuration = new OpenIdConnectConfiguration(); options.Configuration = new OpenIdConnectConfiguration();
options.ConfigurationManager =
new StaticConfigurationManager<OpenIdConnectConfiguration>(new OpenIdConnectConfiguration());
options.TokenValidationParameters = new TokenValidationParameters options.TokenValidationParameters = new TokenValidationParameters
{ {
ValidateIssuer = false, ValidateIssuer = false,
@@ -51,6 +47,24 @@ internal sealed class BffFactory : WebApplicationFactory<Program>
ClockSkew = TimeSpan.Zero, 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/");
builder.ConfigureTestServices(services =>
{
services.AddSingleton<IDomainClient>(Domain);
services.AddSingleton<IProjectionClient>(Projection);
// 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 string? SubmittedBsn { get; private set; }
public SubmitAccepted Result { get; set; } = new("reg-123", "Ingediend"); public SubmitAccepted Result { get; set; } = new("reg-123", "Ingediend");
public List<WerkbakItem> Werkbak { get; } = [];
public Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default) public Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default)
{ {
SubmittedBsn = bsn; SubmittedBsn = bsn;
return Task.FromResult(Result); 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> /// <summary>Serves a configurable set of projection rows.</summary>

View File

@@ -17,6 +17,22 @@ internal static class TestTokens
new SymmetricSecurityKey(Encoding.UTF8.GetBytes("a-different-signing-key-256-bits-long-indeed-yes!")), new SymmetricSecurityKey(Encoding.UTF8.GetBytes("a-different-signing-key-256-bits-long-indeed-yes!")),
expired: false); 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) private static string Create(string bsn, SymmetricSecurityKey key, bool expired)
{ {
var handler = new JsonWebTokenHandler(); var handler = new JsonWebTokenHandler();

View File

@@ -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": { "components": {
@@ -100,6 +128,25 @@
"type": "string" "type": "string"
} }
} }
},
"WerkbakItem": {
"required": [
"registrationId",
"bsn",
"status"
],
"type": "object",
"properties": {
"registrationId": {
"type": "string"
},
"bsn": {
"type": "string"
},
"status": {
"type": "string"
}
}
} }
} }
}, },

View File

@@ -20,11 +20,13 @@ builder.Services.AddSingleton<IRegistrationStore, InMemoryRegistrationStore>();
builder.Services.AddHttpClient<FlowableWorkflowClient>(); builder.Services.AddHttpClient<FlowableWorkflowClient>();
builder.Services.AddTransient<IWorkflowClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>()); builder.Services.AddTransient<IWorkflowClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddTransient<IExternalWorkerClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>()); builder.Services.AddTransient<IExternalWorkerClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddTransient<IUserTaskClient>(sp => sp.GetRequiredService<FlowableWorkflowClient>());
builder.Services.AddHttpClient<IAclClient, AclHttpClient>(); builder.Services.AddHttpClient<IAclClient, AclHttpClient>();
builder.Services.AddScoped<SubmitRegistration>(); builder.Services.AddScoped<SubmitRegistration>();
builder.Services.AddScoped<ApproveRegistration>(); builder.Services.AddScoped<ApproveRegistration>();
builder.Services.AddScoped<BeoordeelRegistratie>(); builder.Services.AddScoped<BeoordeelRegistratie>();
builder.Services.AddScoped<Werkbak>();
builder.Services.AddScoped<OpenZaakWorker>(); builder.Services.AddScoped<OpenZaakWorker>();
builder.Services.AddScoped<OpenZaakJobProcessor>(); builder.Services.AddScoped<OpenZaakJobProcessor>();
@@ -72,6 +74,12 @@ app.MapPost("/registrations/{id}/decide", async (string id, DecideRequest body,
return Results.NoContent(); 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). // 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) => app.MapGet("/registrations/{id}", async (string id, IRegistrationStore store, CancellationToken ct) =>
{ {

View File

@@ -36,6 +36,30 @@ public interface IAclClient
Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default); Task ApproveZaakAsync(Uri zaakUrl, CancellationToken ct = default);
} }
/// <summary>
/// The port to the behandelaar's user tasks in the workflow engine (S-12). Implemented by the
/// Workflow Client — the only code that talks to Flowable (§8.2). The application lists the open
/// <c>Beoordelen</c> tasks (the werkbak), claims one for a behandelaar, and completes it with the
/// decision; it never names Flowable's REST shapes.
/// </summary>
public interface IUserTaskClient
{
/// <summary>The werkbak: the <c>Beoordelen</c> tasks awaiting a behandelaar, each with the
/// registration it belongs to.</summary>
Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default);
/// <summary>Claim a beoordeling task for a behandelaar (assigns it to them).</summary>
Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default);
/// <summary>Complete a beoordeling task, carrying the decision into the process as the
/// <c>besluit</c> variable so the workflow can continue on the chosen branch.</summary>
Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default);
}
/// <summary>A <c>Beoordelen</c> user task in the werkbak: the Flowable task id (needed to claim and
/// complete it) and the registration it carries as a process variable.</summary>
public sealed record BeoordelingTask(string TaskId, RegistrationId RegistrationId);
/// <summary> /// <summary>
/// Persistence port for the <see cref="Registration"/> aggregate. In-memory for the minimal slice /// Persistence port for the <see cref="Registration"/> aggregate. In-memory for the minimal slice
/// (ADR-0009); an EF-backed store is a documented follow-up, and this port keeps that change additive. /// (ADR-0009); an EF-backed store is a documented follow-up, and this port keeps that change additive.

View 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;
}
}

View File

@@ -15,12 +15,14 @@ namespace Big.Infrastructure;
/// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009). /// The REST contract here is the one verified against a live flowable-rest engine (ADR-0009).
/// </summary> /// </summary>
public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options) public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions options)
: IWorkflowClient, IExternalWorkerClient : IWorkflowClient, IExternalWorkerClient, IUserTaskClient
{ {
private const string Topic = "OpenZaakAanmaken"; private const string Topic = "OpenZaakAanmaken";
private const string ProcessDefinitionKey = "registratie"; private const string ProcessDefinitionKey = "registratie";
private const string BeoordelenTaskKey = "Beoordelen";
private const string RegistrationIdVariable = "registrationId"; private const string RegistrationIdVariable = "registrationId";
private const string ZaakUrlVariable = "zaakUrl"; private const string ZaakUrlVariable = "zaakUrl";
private const string BesluitVariable = "besluit";
public async Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default) public async Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
{ {
@@ -55,6 +57,34 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
public async Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default)
{
var request = new TaskQueryRequest(ProcessDefinitionKey, BeoordelenTaskKey, IncludeProcessVariables: true);
var page = await PostAsync<TaskQueryRequest, TaskQueryResult>(
"service/query/tasks", request, ct);
var tasks = page?.Data ?? [];
return [.. tasks.Select(t => new BeoordelingTask(t.Id, RegistrationId.Parse(t.RegistrationId())))];
}
public async Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default)
{
using var response = await SendAsync(
$"service/runtime/tasks/{taskId}", new ClaimTaskRequest("claim", behandelaar), ct);
response.EnsureSuccessStatusCode();
}
public async Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default)
{
var request = new CompleteTaskRequest(
"complete",
[new Variable(BesluitVariable, "string", besluit.ToString().ToLowerInvariant())]);
using var response = await SendAsync($"service/runtime/tasks/{taskId}", request, ct);
response.EnsureSuccessStatusCode();
}
private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct) private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct)
{ {
using var response = await SendAsync(path, body, ct); using var response = await SendAsync(path, body, ct);
@@ -89,6 +119,34 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
[property: JsonPropertyName("workerId")] string WorkerId, [property: JsonPropertyName("workerId")] string WorkerId,
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables); [property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables);
private sealed record TaskQueryRequest(
[property: JsonPropertyName("processDefinitionKey")] string ProcessDefinitionKey,
[property: JsonPropertyName("taskDefinitionKey")] string TaskDefinitionKey,
[property: JsonPropertyName("includeProcessVariables")] bool IncludeProcessVariables);
private sealed record ClaimTaskRequest(
[property: JsonPropertyName("action")] string Action,
[property: JsonPropertyName("assignee")] string Assignee);
private sealed record CompleteTaskRequest(
[property: JsonPropertyName("action")] string Action,
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables);
private sealed record TaskQueryResult(
[property: JsonPropertyName("data")] IReadOnlyList<UserTaskDto>? Data);
private sealed record UserTaskDto(
[property: JsonPropertyName("id")] string Id,
// Flowable's task-query returns the (included) process variables under "variables", not
// "processVariables"; the request opts in via includeProcessVariables.
[property: JsonPropertyName("variables")] IReadOnlyList<Variable>? Variables)
{
/// <summary>The registration id this task carries as a process variable.</summary>
public string RegistrationId() =>
Variables?.SingleOrDefault(v => v.Name == "registrationId")?.Value
?? throw new InvalidOperationException($"Beoordelen task {Id} carries no registrationId variable.");
}
private sealed record Variable( private sealed record Variable(
[property: JsonPropertyName("name")] string Name, [property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("type")] string Type, [property: JsonPropertyName("type")] string Type,

View File

@@ -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 /// <summary>A fake ACL client that records the bsn it was asked to open a zaak for and returns a
/// fixed zaak URL.</summary> /// fixed zaak URL.</summary>
internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient internal sealed class FakeAclClient(Uri? zaakUrl = null) : IAclClient

View File

@@ -1,5 +1,6 @@
using System.Net; using System.Net;
using System.Text; using System.Text;
using Big.Application;
using Big.Domain; using Big.Domain;
using Big.Infrastructure; using Big.Infrastructure;
@@ -127,6 +128,17 @@ public class FlowableWorkflowClientTests
() => client.StartRegistrationProcessAsync(RegistrationId.New())); () => client.StartRegistrationProcessAsync(RegistrationId.New()));
} }
[Fact]
public async Task Start_throws_when_flowable_returns_an_empty_body()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.Created, "null"));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => client.StartRegistrationProcessAsync(RegistrationId.New()));
Assert.Contains("empty process-instance", ex.Message);
}
[Fact] [Fact]
public async Task Complete_throws_when_flowable_rejects_the_request() public async Task Complete_throws_when_flowable_rejects_the_request()
{ {
@@ -136,4 +148,107 @@ public class FlowableWorkflowClientTests
await Assert.ThrowsAsync<HttpRequestException>( await Assert.ThrowsAsync<HttpRequestException>(
() => client.CompleteOpenZaakJobAsync("job-1", new Uri("http://openzaak/zaken/api/v1/zaken/abc"))); () => client.CompleteOpenZaakJobAsync("job-1", new Uri("http://openzaak/zaken/api/v1/zaken/abc")));
} }
// ── S-12b: behandelaar user tasks (werkbak / claim / complete) ────────────────────────────────
[Fact]
public async Task Open_beoordelingen_queries_beoordelen_tasks_with_their_registration_id()
{
var rid = RegistrationId.New();
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK,
$$"""
{"data":[{"id":"task-1","variables":[{"name":"registrationId","type":"string","value":"{{rid}}"}]}],"total":1}
"""));
var tasks = await client.GetOpenBeoordelingenAsync();
var task = Assert.Single(tasks);
Assert.Equal("task-1", task.TaskId);
Assert.Equal(rid, task.RegistrationId);
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://flowable/flowable-rest/service/query/tasks",
capture.Seen.RequestUri!.ToString());
Assert.Equal("rest-admin:test", DecodeBasic(capture.Seen));
Assert.Contains("\"processDefinitionKey\":\"registratie\"", capture.Body);
Assert.Contains("\"taskDefinitionKey\":\"Beoordelen\"", capture.Body);
Assert.Contains("\"includeProcessVariables\":true", capture.Body);
}
[Theory]
[InlineData("""{"data":[],"total":0}""")]
[InlineData("""{"data":null,"total":0}""")]
public async Task Open_beoordelingen_is_empty_when_no_tasks_are_waiting(string body)
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK, body));
Assert.Empty(await client.GetOpenBeoordelingenAsync());
Assert.NotNull(capture.Seen);
}
[Fact]
public async Task Open_beoordelingen_throws_when_a_task_carries_no_registration_id()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK,
"""{"data":[{"id":"task-1","variables":[]}],"total":1}"""));
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => client.GetOpenBeoordelingenAsync());
Assert.Contains("task-1", ex.Message);
Assert.Contains("registrationId", ex.Message);
}
[Fact]
public async Task Claim_assigns_the_task_to_the_behandelaar()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK));
await client.ClaimAsync("task-1", "merel-behandelaar");
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://flowable/flowable-rest/service/runtime/tasks/task-1",
capture.Seen.RequestUri!.ToString());
Assert.Contains("\"action\":\"claim\"", capture.Body);
Assert.Contains("\"assignee\":\"merel-behandelaar\"", capture.Body);
}
[Fact]
public async Task Claim_throws_when_flowable_rejects_the_request()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.Conflict));
await Assert.ThrowsAsync<HttpRequestException>(() => client.ClaimAsync("task-1", "merel-behandelaar"));
}
[Theory]
[InlineData(BeoordelingsBesluit.Goedkeuren, "goedkeuren")]
[InlineData(BeoordelingsBesluit.Afwijzen, "afwijzen")]
public async Task Complete_posts_the_besluit_variable_to_the_task(BeoordelingsBesluit besluit, string expected)
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK));
await client.CompleteBeoordelingAsync("task-1", besluit);
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://flowable/flowable-rest/service/runtime/tasks/task-1",
capture.Seen.RequestUri!.ToString());
Assert.Contains("\"action\":\"complete\"", capture.Body);
Assert.Contains("\"name\":\"besluit\"", capture.Body);
Assert.Contains("\"type\":\"string\"", capture.Body);
Assert.Contains($"\"value\":\"{expected}\"", capture.Body);
}
[Fact]
public async Task Complete_beoordeling_throws_when_flowable_rejects_the_request()
{
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.InternalServerError));
await Assert.ThrowsAsync<HttpRequestException>(
() => client.CompleteBeoordelingAsync("task-1", BeoordelingsBesluit.Goedkeuren));
}
} }

View 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());
}
}

View File

@@ -68,6 +68,9 @@ public sealed class CapturingDomainClient : IDomainClient
SubmittedBsn = bsn; SubmittedBsn = bsn;
return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend")); return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend"));
} }
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<WerkbakItem>>([]);
} }
/// <summary>Serves configurable projection rows.</summary> /// <summary>Serves configurable projection rows.</summary>

View File

@@ -6,9 +6,12 @@
xmlns:omgdi="http://www.omg.org/spec/DD/20100524/DI" xmlns:omgdi="http://www.omg.org/spec/DD/20100524/DI"
targetNamespace="http://respellion.nl/big"> targetNamespace="http://respellion.nl/big">
<!-- S-03: minimal "Registratie ontvangen" flow. <!-- Registratie ontvangen flow.
start -> external-worker task (OpenZaakAanmaken) -> end. start -> external-worker task (OpenZaakAanmaken) -> Beoordelen (behandelaar user task) -> end.
The external task is handled by the Workflow Client / ACL in later slices. --> S-03 added the external task (Workflow Client / ACL). S-12 adds the behandelaar's beoordeling
as a user task: the process parks here until a behandelaar claims and completes it with a
`besluit` (goedkeuren/afwijzen). The registrationId set at start rides along as a process
variable so the werkbak can correlate each task back to its aggregate. -->
<process id="registratie" name="Registratie ontvangen" isExecutable="true"> <process id="registratie" name="Registratie ontvangen" isExecutable="true">
<startEvent id="start" name="Registratie ontvangen"/> <startEvent id="start" name="Registratie ontvangen"/>
@@ -19,9 +22,13 @@
flowable:type="external-worker" flowable:type="external-worker"
flowable:topic="OpenZaakAanmaken"/> flowable:topic="OpenZaakAanmaken"/>
<sequenceFlow id="flow2" sourceRef="OpenZaakAanmaken" targetRef="end"/> <sequenceFlow id="flow2" sourceRef="OpenZaakAanmaken" targetRef="Beoordelen"/>
<endEvent id="end" name="Zaak aangemaakt"/> <userTask id="Beoordelen" name="Beoordelen" flowable:candidateGroups="behandelaar"/>
<sequenceFlow id="flow3" sourceRef="Beoordelen" targetRef="end"/>
<endEvent id="end" name="Registratie beoordeeld"/>
</process> </process>
<bpmndi:BPMNDiagram id="diagram"> <bpmndi:BPMNDiagram id="diagram">
@@ -32,8 +39,11 @@
<bpmndi:BPMNShape id="s_task" bpmnElement="OpenZaakAanmaken"> <bpmndi:BPMNShape id="s_task" bpmnElement="OpenZaakAanmaken">
<omgdc:Bounds x="200" y="85" width="120" height="60"/> <omgdc:Bounds x="200" y="85" width="120" height="60"/>
</bpmndi:BPMNShape> </bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_beoordelen" bpmnElement="Beoordelen">
<omgdc:Bounds x="390" y="85" width="120" height="60"/>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="s_end" bpmnElement="end"> <bpmndi:BPMNShape id="s_end" bpmnElement="end">
<omgdc:Bounds x="400" y="100" width="30" height="30"/> <omgdc:Bounds x="580" y="100" width="30" height="30"/>
</bpmndi:BPMNShape> </bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="e_flow1" bpmnElement="flow1"> <bpmndi:BPMNEdge id="e_flow1" bpmnElement="flow1">
<omgdi:waypoint x="130" y="115"/> <omgdi:waypoint x="130" y="115"/>
@@ -41,7 +51,11 @@
</bpmndi:BPMNEdge> </bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_flow2" bpmnElement="flow2"> <bpmndi:BPMNEdge id="e_flow2" bpmnElement="flow2">
<omgdi:waypoint x="320" y="115"/> <omgdi:waypoint x="320" y="115"/>
<omgdi:waypoint x="400" y="115"/> <omgdi:waypoint x="390" y="115"/>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="e_flow3" bpmnElement="flow3">
<omgdi:waypoint x="510" y="115"/>
<omgdi:waypoint x="580" y="115"/>
</bpmndi:BPMNEdge> </bpmndi:BPMNEdge>
</bpmndi:BPMNPlane> </bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram> </bpmndi:BPMNDiagram>