Compare commits
1 Commits
feat/13-be
...
chore/rele
| Author | SHA1 | Date | |
|---|---|---|---|
| 01a32b884f |
@@ -1,76 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -349,8 +349,6 @@ 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:
|
||||||
|
|||||||
@@ -51,71 +51,18 @@ 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
|
||||||
zaak_ok=1
|
exit 0
|
||||||
break
|
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
if [ -z "$zaak_ok" ]; then
|
|
||||||
echo "FAIL — the registration never received a zaak URL" >&2
|
echo "FAIL — the registration never received a zaak URL" >&2
|
||||||
echo "--- domain log ---" >&2; docker logs "$dom" 2>&1 | tail -15 >&2
|
echo "--- domain log ---" >&2; docker logs "$dom" 2>&1 | tail -15 >&2
|
||||||
acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)"
|
acl="$(docker ps -q --filter 'name=[-_]acl[-_]' | head -1)"
|
||||||
[ -n "$acl" ] && { echo "--- acl log ---" >&2; docker logs "$acl" 2>&1 | tail -15 >&2; }
|
[ -n "$acl" ] && { echo "--- acl log ---" >&2; docker logs "$acl" 2>&1 | tail -15 >&2; }
|
||||||
exit 1
|
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
|
|
||||||
|
|||||||
@@ -24,10 +24,6 @@ import {
|
|||||||
Observable
|
Observable
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
|
|
||||||
export interface DecideRequest {
|
|
||||||
besluit: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OpenbaarEntry {
|
export interface OpenbaarEntry {
|
||||||
id: string;
|
id: string;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -40,12 +36,6 @@ 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;
|
||||||
};
|
};
|
||||||
@@ -225,73 +215,4 @@ 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',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
postBehandelRegistrationsIdDecide<TData = void>(id: string,
|
|
||||||
decideRequest: DecideRequest, options?: HttpClientBodyOptions): Observable<TData>;
|
|
||||||
postBehandelRegistrationsIdDecide<TData = void>(id: string,
|
|
||||||
decideRequest: DecideRequest, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
|
||||||
postBehandelRegistrationsIdDecide<TData = void>(id: string,
|
|
||||||
decideRequest: DecideRequest, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
|
||||||
postBehandelRegistrationsIdDecide<TData = void>(
|
|
||||||
id: string,
|
|
||||||
decideRequest: DecideRequest, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
|
|
||||||
if (options?.observe === 'events') {
|
|
||||||
return this.http.post<TData>(
|
|
||||||
`/behandel/registrations/${id}/decide`,
|
|
||||||
decideRequest,{
|
|
||||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
|
||||||
observe: 'events',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.observe === 'response') {
|
|
||||||
return this.http.post<TData>(
|
|
||||||
`/behandel/registrations/${id}/decide`,
|
|
||||||
decideRequest,{
|
|
||||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
|
||||||
observe: 'response',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.http.post<TData>(
|
|
||||||
`/behandel/registrations/${id}/decide`,
|
|
||||||
decideRequest,{
|
|
||||||
...(options as Omit<NonNullable<typeof options>, 'observe'>),
|
|
||||||
observe: 'body',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,20 +13,10 @@ 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>Apply a behandelaar's decision (<c>goedkeuren</c>/<c>afwijzen</c>) to a registration.</summary>
|
|
||||||
Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Port to the read projection.</summary>
|
/// <summary>Port to the read projection.</summary>
|
||||||
@@ -47,16 +37,6 @@ 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) ?? [];
|
|
||||||
|
|
||||||
public async Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
using var response = await http.PostAsJsonAsync(
|
|
||||||
$"registrations/{registrationId}/decide", new { besluit }, ct);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed record DomainResponse(string RegistrationId, string Status, string? ZaakUrl);
|
private sealed record DomainResponse(string RegistrationId, string Status, string? ZaakUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
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;
|
||||||
|
|
||||||
@@ -8,10 +6,6 @@ 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"]
|
||||||
@@ -25,28 +19,8 @@ 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(options =>
|
builder.Services.AddAuthorization();
|
||||||
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));
|
||||||
@@ -94,79 +68,7 @@ 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);
|
|
||||||
|
|
||||||
// A behandelaar's beoordeling on a registration (goedkeuren/afwijzen). Forwarded to the domain, which
|
|
||||||
// applies the decision and completes the workflow task (ADR-0013). Same medewerker/behandelaar gate.
|
|
||||||
app.MapPost("/behandel/registrations/{id}/decide",
|
|
||||||
async (string id, DecideRequest body, IDomainClient domain, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
if (!BehandelAuth.IsKnownBesluit(body.Besluit))
|
|
||||||
return Results.BadRequest(new { error = $"Unknown besluit '{body.Besluit}'. Expected 'goedkeuren' or 'afwijzen'." });
|
|
||||||
|
|
||||||
await domain.DecideAsync(id, body.Besluit, ct);
|
|
||||||
return Results.NoContent();
|
|
||||||
})
|
|
||||||
.RequireAuthorization(BehandelAuth.Policy)
|
|
||||||
.Produces(StatusCodes.Status204NoContent)
|
|
||||||
.Produces(StatusCodes.Status400BadRequest)
|
|
||||||
.Produces(StatusCodes.Status401Unauthorized)
|
|
||||||
.Produces(StatusCodes.Status403Forbidden);
|
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
/// <summary>The behandelaar's decision on a registration.</summary>
|
|
||||||
public sealed record DecideRequest(string Besluit);
|
|
||||||
|
|
||||||
// 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>The beoordeling vocabulary the BFF accepts (case-insensitive); an unknown besluit is a
|
|
||||||
/// 400 without troubling the domain. Mirrors the domain's <c>BeoordelingsBesluit</c>.</summary>
|
|
||||||
public static bool IsKnownBesluit(string? besluit) =>
|
|
||||||
string.Equals(besluit, "goedkeuren", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
string.Equals(besluit, "afwijzen", StringComparison.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
/// <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;
|
||||||
|
|||||||
@@ -7,8 +7,7 @@
|
|||||||
},
|
},
|
||||||
"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/" },
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static HttpRequestMessage Decide(string? bearer, string id = "reg-1", string besluit = "goedkeuren")
|
|
||||||
{
|
|
||||||
var request = new HttpRequestMessage(HttpMethod.Post, $"/behandel/registrations/{id}/decide")
|
|
||||||
{
|
|
||||||
Content = JsonContent.Create(new { besluit }),
|
|
||||||
};
|
|
||||||
if (bearer is not null)
|
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Rejects_a_decision_without_a_token()
|
|
||||||
{
|
|
||||||
using var factory = new BffFactory();
|
|
||||||
|
|
||||||
var response = await factory.CreateClient().SendAsync(Decide(bearer: null));
|
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
|
||||||
Assert.Null(factory.Domain.Decided);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Rejects_a_decision_from_a_medewerker_without_the_behandelaar_role()
|
|
||||||
{
|
|
||||||
using var factory = new BffFactory();
|
|
||||||
|
|
||||||
var response = await factory.CreateClient().SendAsync(Decide(TestTokens.Medewerker("teamlead")));
|
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
|
||||||
Assert.Null(factory.Domain.Decided);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Forwards_a_behandelaar_decision_to_the_domain()
|
|
||||||
{
|
|
||||||
using var factory = new BffFactory();
|
|
||||||
|
|
||||||
var response = await factory.CreateClient()
|
|
||||||
.SendAsync(Decide(TestTokens.Medewerker("behandelaar"), id: "reg-42", besluit: "afwijzen"));
|
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
|
||||||
Assert.Equal(("reg-42", "afwijzen"), factory.Domain.Decided);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Rejects_an_unknown_besluit_without_calling_the_domain()
|
|
||||||
{
|
|
||||||
using var factory = new BffFactory();
|
|
||||||
|
|
||||||
var response = await factory.CreateClient()
|
|
||||||
.SendAsync(Decide(TestTokens.Medewerker("behandelaar"), besluit: "misschien"));
|
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
||||||
Assert.Null(factory.Domain.Decided);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ 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;
|
||||||
|
|
||||||
@@ -24,19 +23,24 @@ 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();
|
||||||
|
|
||||||
private static void ValidateWithTestKey(IServiceCollection services, string scheme) =>
|
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||||
services.Configure<JwtBearerOptions>(scheme, options =>
|
|
||||||
{
|
{
|
||||||
// Validate locally against the test key and NEVER reach out for OIDC metadata. A static
|
builder.UseSetting("Keycloak:Authority", "https://keycloak.invalid/realms/digid");
|
||||||
// configuration manager guarantees this regardless of Configure/PostConfigure ordering —
|
builder.UseSetting("Downstream:Domain:BaseUrl", "http://domain.invalid/");
|
||||||
// clearing Authority alone left the medewerker scheme fetching metadata under CI timing
|
builder.UseSetting("Downstream:Projection:BaseUrl", "http://projection.invalid/");
|
||||||
// (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,
|
||||||
@@ -47,24 +51,6 @@ 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");
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,24 +60,12 @@ 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 (string RegistrationId, string Besluit)? Decided { get; private set; }
|
|
||||||
|
|
||||||
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
|
||||||
=> Task.FromResult<IReadOnlyList<WerkbakItem>>(Werkbak);
|
|
||||||
|
|
||||||
public Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
Decided = (registrationId, besluit);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Serves a configurable set of projection rows.</summary>
|
/// <summary>Serves a configurable set of projection rows.</summary>
|
||||||
|
|||||||
@@ -17,22 +17,6 @@ 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();
|
||||||
|
|||||||
@@ -60,90 +60,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"/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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/behandel/registrations/{id}/decide": {
|
|
||||||
"post": {
|
|
||||||
"tags": [
|
|
||||||
"Bff.Api"
|
|
||||||
],
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "id",
|
|
||||||
"in": "path",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"requestBody": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/DecideRequest"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"204": {
|
|
||||||
"description": "No Content"
|
|
||||||
},
|
|
||||||
"400": {
|
|
||||||
"description": "Bad Request"
|
|
||||||
},
|
|
||||||
"401": {
|
|
||||||
"description": "Unauthorized"
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"description": "Forbidden"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
"schemas": {
|
"schemas": {
|
||||||
"DecideRequest": {
|
|
||||||
"required": [
|
|
||||||
"besluit"
|
|
||||||
],
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"besluit": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"OpenbaarEntry": {
|
"OpenbaarEntry": {
|
||||||
"required": [
|
"required": [
|
||||||
"id",
|
"id",
|
||||||
@@ -180,25 +100,6 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"WerkbakItem": {
|
|
||||||
"required": [
|
|
||||||
"registrationId",
|
|
||||||
"bsn",
|
|
||||||
"status"
|
|
||||||
],
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"registrationId": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"bsn": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,13 +20,10 @@ 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<Werkbak>();
|
|
||||||
builder.Services.AddScoped<OpenZaakWorker>();
|
builder.Services.AddScoped<OpenZaakWorker>();
|
||||||
builder.Services.AddScoped<OpenZaakJobProcessor>();
|
builder.Services.AddScoped<OpenZaakJobProcessor>();
|
||||||
|
|
||||||
@@ -58,28 +55,6 @@ app.MapPost("/registrations/{id}/approve", async (string id, ApproveRegistration
|
|||||||
return Results.NoContent();
|
return Results.NoContent();
|
||||||
});
|
});
|
||||||
|
|
||||||
// The behandelaar's beoordeling (S-12): decide a registration goedkeuren (→ INGESCHREVEN, sets the
|
|
||||||
// zaak's final status via the ACL) or afwijzen (→ AFGEWEZEN). Idempotent. This is the domain contract
|
|
||||||
// the behandel-portal's decision reaches through the BFF; it supersedes the temporary /approve above,
|
|
||||||
// which is retired once the portal lands.
|
|
||||||
app.MapPost("/registrations/{id}/decide", async (string id, DecideRequest body, BeoordeelRegistratie beoordeel, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
if (!Guid.TryParse(id, out var guid))
|
|
||||||
return Results.NotFound();
|
|
||||||
|
|
||||||
if (!Enum.TryParse<BeoordelingsBesluit>(body.Besluit, ignoreCase: true, out var besluit))
|
|
||||||
return Results.BadRequest(new { error = $"Unknown besluit '{body.Besluit}'. Expected 'goedkeuren' or 'afwijzen'." });
|
|
||||||
|
|
||||||
await beoordeel.HandleAsync(new BeoordeelRegistratieCommand(new RegistrationId(guid), besluit), ct);
|
|
||||||
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) =>
|
||||||
{
|
{
|
||||||
@@ -97,8 +72,6 @@ await app.RunAsync();
|
|||||||
|
|
||||||
public sealed record SubmitRegistrationRequest(string Bsn);
|
public sealed record SubmitRegistrationRequest(string Bsn);
|
||||||
|
|
||||||
public sealed record DecideRequest(string Besluit);
|
|
||||||
|
|
||||||
public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl);
|
public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl);
|
||||||
|
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
using Big.Domain;
|
|
||||||
|
|
||||||
namespace Big.Application;
|
|
||||||
|
|
||||||
/// <summary>A behandelaar's beoordeling outcome, in domain language.</summary>
|
|
||||||
public enum BeoordelingsBesluit
|
|
||||||
{
|
|
||||||
/// <summary>Approve — enter the registration in the register.</summary>
|
|
||||||
Goedkeuren,
|
|
||||||
|
|
||||||
/// <summary>Reject — turn the registration down.</summary>
|
|
||||||
Afwijzen,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>A behandelaar's decision on a registration.</summary>
|
|
||||||
public sealed record BeoordeelRegistratieCommand(RegistrationId RegistrationId, BeoordelingsBesluit Besluit);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The beoordeling use case (S-12): apply a behandelaar's decision to a registration.
|
|
||||||
/// <see cref="BeoordelingsBesluit.Goedkeuren"/> sets the zaak's final status via the ACL (§8.1) and
|
|
||||||
/// advances the aggregate to INGESCHREVEN; <see cref="BeoordelingsBesluit.Afwijzen"/> advances it to
|
|
||||||
/// AFGEWEZEN in the domain (propagating a rejection to the zaak, so the openbaar projection reflects
|
|
||||||
/// it, is a later sub-slice of S-12). After applying the decision it completes the Flowable
|
|
||||||
/// <c>Beoordelen</c> task (found by registrationId) so the workflow advances (ADR-0013). Both
|
|
||||||
/// decisions are idempotent — a repeated or redelivered decision that matches the current terminal
|
|
||||||
/// state is a no-op, so the ACL is not called and the task not completed twice.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class BeoordeelRegistratie(IRegistrationStore store, IAclClient acl, IUserTaskClient tasks)
|
|
||||||
{
|
|
||||||
public async Task HandleAsync(BeoordeelRegistratieCommand command, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(command);
|
|
||||||
|
|
||||||
var registration = await store.GetAsync(command.RegistrationId, ct)
|
|
||||||
?? throw new InvalidOperationException($"No registration {command.RegistrationId} to decide.");
|
|
||||||
|
|
||||||
switch (command.Besluit)
|
|
||||||
{
|
|
||||||
case BeoordelingsBesluit.Goedkeuren:
|
|
||||||
// A repeated approval is a no-op: don't set the zaak status a second time.
|
|
||||||
if (registration.Status == RegistrationStatus.Ingeschreven)
|
|
||||||
return;
|
|
||||||
if (registration.ZaakUrl is null)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Registration {command.RegistrationId} has no zaak yet; it cannot be approved.");
|
|
||||||
await acl.ApproveZaakAsync(registration.ZaakUrl, ct);
|
|
||||||
registration.Approve();
|
|
||||||
break;
|
|
||||||
|
|
||||||
case BeoordelingsBesluit.Afwijzen:
|
|
||||||
if (registration.Status == RegistrationStatus.Afgewezen)
|
|
||||||
return;
|
|
||||||
registration.Reject();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
await store.SaveAsync(registration, ct);
|
|
||||||
await CompleteWorkflowTaskAsync(command.RegistrationId, command.Besluit, ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Advance the workflow: complete the open Beoordelen task for this registration. If none is open
|
|
||||||
// (already completed, or the process hasn't parked yet) the decision still stands — we complete
|
|
||||||
// nothing rather than fail.
|
|
||||||
private async Task CompleteWorkflowTaskAsync(RegistrationId registrationId, BeoordelingsBesluit besluit, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var open = await tasks.GetOpenBeoordelingenAsync(ct);
|
|
||||||
var task = open.FirstOrDefault(t => t.RegistrationId == registrationId);
|
|
||||||
if (task is not null)
|
|
||||||
await tasks.CompleteBeoordelingAsync(task.TaskId, besluit, ct);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -36,30 +36,6 @@ 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.
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -66,28 +66,10 @@ public sealed class Registration
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A behandelaar picks the registration up for beoordeling. Advances
|
/// Approve the registration — the behandelaar's decision to enter it in the register. Advances
|
||||||
/// <see cref="RegistrationStatus.Ingediend"/> → <see cref="RegistrationStatus.InBehandeling"/>.
|
/// <see cref="RegistrationStatus.Ingediend"/> → <see cref="RegistrationStatus.Ingeschreven"/>.
|
||||||
/// Re-taking one already <see cref="RegistrationStatus.InBehandeling"/> is a no-op (the same or
|
/// Requires an opened zaak (the approval sets that zaak's status via the ACL), and only a
|
||||||
/// another behandelaar re-opens it); a decided registration can no longer be taken into behandeling.
|
/// submitted registration can be approved: re-approving is rejected as an invalid transition.
|
||||||
/// </summary>
|
|
||||||
public void TakeIntoBehandeling()
|
|
||||||
{
|
|
||||||
if (Status == RegistrationStatus.InBehandeling)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (Status != RegistrationStatus.Ingediend)
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Registration {Id} is {Status}; only an INGEDIEND registration can be taken into behandeling.");
|
|
||||||
|
|
||||||
Status = RegistrationStatus.InBehandeling;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Approve the registration — the behandelaar's decision to enter it in the register. Advances a
|
|
||||||
/// submitted or in-behandeling registration to <see cref="RegistrationStatus.Ingeschreven"/>.
|
|
||||||
/// Requires an opened zaak (the approval sets that zaak's status via the ACL); a registration that
|
|
||||||
/// has already been decided cannot be approved again.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Approve()
|
public void Approve()
|
||||||
{
|
{
|
||||||
@@ -95,27 +77,10 @@ public sealed class Registration
|
|||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Registration {Id} has no zaak yet; it cannot be approved before its zaak is opened.");
|
$"Registration {Id} has no zaak yet; it cannot be approved before its zaak is opened.");
|
||||||
|
|
||||||
RequireOpenForDecision(nameof(Approve));
|
if (Status != RegistrationStatus.Ingediend)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Registration {Id} is {Status}; only an INGEDIEND registration can be approved.");
|
||||||
|
|
||||||
Status = RegistrationStatus.Ingeschreven;
|
Status = RegistrationStatus.Ingeschreven;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Reject the registration — the behandelaar's decision not to enter it in the register. Advances a
|
|
||||||
/// submitted or in-behandeling registration to <see cref="RegistrationStatus.Afgewezen"/>. Unlike
|
|
||||||
/// approval this needs no zaak: a registration can be rejected before or after its zaak is opened.
|
|
||||||
/// A registration that has already been decided cannot be rejected again.
|
|
||||||
/// </summary>
|
|
||||||
public void Reject()
|
|
||||||
{
|
|
||||||
RequireOpenForDecision(nameof(Reject));
|
|
||||||
Status = RegistrationStatus.Afgewezen;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A decision is only valid while the registration is still open (INGEDIEND or IN_BEHANDELING).
|
|
||||||
private void RequireOpenForDecision(string decision)
|
|
||||||
{
|
|
||||||
if (Status is not (RegistrationStatus.Ingediend or RegistrationStatus.InBehandeling))
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"Registration {Id} is {Status}; only an INGEDIEND or IN_BEHANDELING registration can be decided ({decision}).");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,13 @@
|
|||||||
namespace Big.Domain;
|
namespace Big.Domain;
|
||||||
|
|
||||||
/// <summary>The lifecycle states a <see cref="Registration"/> moves through. Submission starts in
|
/// <summary>The lifecycle states a <see cref="Registration"/> moves through. The walking
|
||||||
/// <see cref="Ingediend"/>; a behandelaar takes it <see cref="InBehandeling"/> and decides it into one
|
/// skeleton knows <see cref="Ingediend"/> and the terminal <see cref="Ingeschreven"/>; withdrawal,
|
||||||
/// of the terminal states <see cref="Ingeschreven"/> (approved) or <see cref="Afgewezen"/> (rejected).
|
/// beoordeling and herregistratie states arrive in their own slices (Iteration 2+).</summary>
|
||||||
/// Withdrawal and herregistratie states arrive in their own slices (S-11+).</summary>
|
|
||||||
public enum RegistrationStatus
|
public enum RegistrationStatus
|
||||||
{
|
{
|
||||||
/// <summary>Submitted by the zorgprofessional; the registratie process has been started.</summary>
|
/// <summary>Submitted by the zorgprofessional; the registratie process has been started.</summary>
|
||||||
Ingediend,
|
Ingediend,
|
||||||
|
|
||||||
/// <summary>Picked up by a behandelaar for beoordeling (S-12).</summary>
|
/// <summary>Approved: entered in the register. Terminal in the walking skeleton (S-09b).</summary>
|
||||||
InBehandeling,
|
|
||||||
|
|
||||||
/// <summary>Approved: entered in the register. Terminal.</summary>
|
|
||||||
Ingeschreven,
|
Ingeschreven,
|
||||||
|
|
||||||
/// <summary>Rejected by the behandelaar. Terminal.</summary>
|
|
||||||
Afgewezen,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,14 +15,12 @@ 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, IUserTaskClient
|
: IWorkflowClient, IExternalWorkerClient
|
||||||
{
|
{
|
||||||
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)
|
||||||
{
|
{
|
||||||
@@ -57,34 +55,6 @@ 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);
|
||||||
@@ -119,34 +89,6 @@ 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,
|
||||||
|
|||||||
@@ -1,168 +0,0 @@
|
|||||||
using Big.Application;
|
|
||||||
using Big.Domain;
|
|
||||||
|
|
||||||
namespace Big.Tests;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The beoordeling use case (S-12): a behandelaar's decision on a registration. Goedkeuren sets the
|
|
||||||
/// zaak's final status via the ACL (§8.1) and marks the aggregate INGESCHREVEN; Afwijzen marks it
|
|
||||||
/// AFGEWEZEN. Either way the decision also completes the Flowable Beoordelen task (found by
|
|
||||||
/// registrationId) so the process advances. All idempotent — a repeated decision is a no-op.
|
|
||||||
/// </summary>
|
|
||||||
public class BeoordeelRegistratieTests
|
|
||||||
{
|
|
||||||
private static Registration WithZaak(string bsn = "123456782")
|
|
||||||
{
|
|
||||||
var registration = Registration.Submit(bsn);
|
|
||||||
registration.AttachZaak(FakeAclClient.DefaultZaakUrl);
|
|
||||||
return registration;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static FakeUserTaskClient TaskFor(Registration registration) =>
|
|
||||||
new([new BeoordelingTask("task-1", registration.Id)]);
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Goedkeuren_sets_the_zaak_status_via_the_acl_and_marks_the_registration_ingeschreven()
|
|
||||||
{
|
|
||||||
var store = new FakeRegistrationStore();
|
|
||||||
var acl = new FakeAclClient();
|
|
||||||
var registration = WithZaak();
|
|
||||||
store.Seed(registration);
|
|
||||||
var tasks = TaskFor(registration);
|
|
||||||
var handler = new BeoordeelRegistratie(store, acl, tasks);
|
|
||||||
|
|
||||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
|
||||||
|
|
||||||
var saved = await store.GetAsync(registration.Id);
|
|
||||||
Assert.Equal(RegistrationStatus.Ingeschreven, saved!.Status);
|
|
||||||
Assert.Equal(FakeAclClient.DefaultZaakUrl, acl.ApprovedZaakUrl);
|
|
||||||
Assert.Equal(1, acl.ApproveCallCount);
|
|
||||||
Assert.Equal(1, store.SaveCount);
|
|
||||||
// The behandelaar's decision advances the workflow: the Beoordelen task is completed.
|
|
||||||
Assert.Equal(("task-1", BeoordelingsBesluit.Goedkeuren), tasks.Completed);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Afwijzen_marks_the_registration_afgewezen_without_calling_the_acl()
|
|
||||||
{
|
|
||||||
var store = new FakeRegistrationStore();
|
|
||||||
var acl = new FakeAclClient();
|
|
||||||
var registration = WithZaak();
|
|
||||||
store.Seed(registration);
|
|
||||||
var tasks = TaskFor(registration);
|
|
||||||
var handler = new BeoordeelRegistratie(store, acl, tasks);
|
|
||||||
|
|
||||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
|
|
||||||
|
|
||||||
var saved = await store.GetAsync(registration.Id);
|
|
||||||
Assert.Equal(RegistrationStatus.Afgewezen, saved!.Status);
|
|
||||||
Assert.Equal(0, acl.ApproveCallCount);
|
|
||||||
Assert.Equal(1, store.SaveCount);
|
|
||||||
Assert.Equal(("task-1", BeoordelingsBesluit.Afwijzen), tasks.Completed);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Goedkeuren_an_in_behandeling_registration_marks_it_ingeschreven()
|
|
||||||
{
|
|
||||||
var store = new FakeRegistrationStore();
|
|
||||||
var acl = new FakeAclClient();
|
|
||||||
var registration = WithZaak();
|
|
||||||
registration.TakeIntoBehandeling();
|
|
||||||
store.Seed(registration);
|
|
||||||
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
|
|
||||||
|
|
||||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
|
||||||
|
|
||||||
Assert.Equal(RegistrationStatus.Ingeschreven, (await store.GetAsync(registration.Id))!.Status);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Rejects_a_null_command_without_touching_the_store_or_acl()
|
|
||||||
{
|
|
||||||
var store = new FakeRegistrationStore();
|
|
||||||
var acl = new FakeAclClient();
|
|
||||||
var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]));
|
|
||||||
|
|
||||||
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
|
|
||||||
Assert.Equal(0, acl.ApproveCallCount);
|
|
||||||
Assert.Equal(0, store.SaveCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Deciding_an_unknown_registration_throws_and_does_not_call_the_acl()
|
|
||||||
{
|
|
||||||
var store = new FakeRegistrationStore();
|
|
||||||
var acl = new FakeAclClient();
|
|
||||||
var handler = new BeoordeelRegistratie(store, acl, new FakeUserTaskClient([]));
|
|
||||||
|
|
||||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
||||||
handler.HandleAsync(new BeoordeelRegistratieCommand(RegistrationId.New(), BeoordelingsBesluit.Goedkeuren)));
|
|
||||||
Assert.Contains("No registration", ex.Message);
|
|
||||||
Assert.Equal(0, acl.ApproveCallCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Goedkeuren_before_the_zaak_is_opened_throws_without_calling_the_acl()
|
|
||||||
{
|
|
||||||
var store = new FakeRegistrationStore();
|
|
||||||
var acl = new FakeAclClient();
|
|
||||||
var registration = Registration.Submit("123456782"); // no zaak yet
|
|
||||||
store.Seed(registration);
|
|
||||||
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
|
|
||||||
|
|
||||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
||||||
handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren)));
|
|
||||||
Assert.Contains("no zaak", ex.Message);
|
|
||||||
Assert.Equal(0, acl.ApproveCallCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Re_goedkeuren_an_already_ingeschreven_registration_is_idempotent()
|
|
||||||
{
|
|
||||||
var store = new FakeRegistrationStore();
|
|
||||||
var acl = new FakeAclClient();
|
|
||||||
var registration = WithZaak();
|
|
||||||
store.Seed(registration);
|
|
||||||
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
|
|
||||||
|
|
||||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
|
||||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
|
||||||
|
|
||||||
Assert.Equal(1, acl.ApproveCallCount);
|
|
||||||
Assert.Equal(RegistrationStatus.Ingeschreven, (await store.GetAsync(registration.Id))!.Status);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Re_afwijzen_an_already_afgewezen_registration_is_idempotent()
|
|
||||||
{
|
|
||||||
var store = new FakeRegistrationStore();
|
|
||||||
var acl = new FakeAclClient();
|
|
||||||
var registration = WithZaak();
|
|
||||||
store.Seed(registration);
|
|
||||||
var handler = new BeoordeelRegistratie(store, acl, TaskFor(registration));
|
|
||||||
|
|
||||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
|
|
||||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Afwijzen));
|
|
||||||
|
|
||||||
Assert.Equal(1, store.SaveCount);
|
|
||||||
Assert.Equal(RegistrationStatus.Afgewezen, (await store.GetAsync(registration.Id))!.Status);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Deciding_completes_no_task_when_none_is_open_for_the_registration()
|
|
||||||
{
|
|
||||||
// The task may already be gone (redelivery / manual completion). The decision still applies
|
|
||||||
// and simply completes nothing rather than failing.
|
|
||||||
var store = new FakeRegistrationStore();
|
|
||||||
var acl = new FakeAclClient();
|
|
||||||
var registration = WithZaak();
|
|
||||||
store.Seed(registration);
|
|
||||||
var tasks = new FakeUserTaskClient([]); // no open task for this registration
|
|
||||||
var handler = new BeoordeelRegistratie(store, acl, tasks);
|
|
||||||
|
|
||||||
await handler.HandleAsync(new BeoordeelRegistratieCommand(registration.Id, BeoordelingsBesluit.Goedkeuren));
|
|
||||||
|
|
||||||
Assert.Equal(RegistrationStatus.Ingeschreven, (await store.GetAsync(registration.Id))!.Status);
|
|
||||||
Assert.Null(tasks.Completed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -41,29 +41,6 @@ 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
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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;
|
||||||
|
|
||||||
@@ -128,17 +127,6 @@ 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()
|
||||||
{
|
{
|
||||||
@@ -148,107 +136,4 @@ 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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,100 +121,4 @@ public class RegistrationTests
|
|||||||
Assert.Contains("only an INGEDIEND", ex.Message);
|
Assert.Contains("only an INGEDIEND", ex.Message);
|
||||||
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
|
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Taking_a_registration_into_behandeling_moves_it_to_in_behandeling()
|
|
||||||
{
|
|
||||||
var registration = Registration.Submit("123456782");
|
|
||||||
|
|
||||||
registration.TakeIntoBehandeling();
|
|
||||||
|
|
||||||
Assert.Equal(RegistrationStatus.InBehandeling, registration.Status);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Re_taking_an_in_behandeling_registration_is_idempotent()
|
|
||||||
{
|
|
||||||
var registration = Registration.Submit("123456782");
|
|
||||||
registration.TakeIntoBehandeling();
|
|
||||||
|
|
||||||
registration.TakeIntoBehandeling();
|
|
||||||
|
|
||||||
Assert.Equal(RegistrationStatus.InBehandeling, registration.Status);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Taking_a_decided_registration_into_behandeling_is_rejected()
|
|
||||||
{
|
|
||||||
var registration = Registration.Submit("123456782");
|
|
||||||
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
|
||||||
registration.Approve();
|
|
||||||
|
|
||||||
var ex = Assert.Throws<InvalidOperationException>(() => registration.TakeIntoBehandeling());
|
|
||||||
Assert.Contains("only an INGEDIEND", ex.Message);
|
|
||||||
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Approving_an_in_behandeling_registration_with_a_zaak_sets_it_ingeschreven()
|
|
||||||
{
|
|
||||||
var registration = Registration.Submit("123456782");
|
|
||||||
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
|
||||||
registration.TakeIntoBehandeling();
|
|
||||||
|
|
||||||
registration.Approve();
|
|
||||||
|
|
||||||
Assert.Equal(RegistrationStatus.Ingeschreven, registration.Status);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Rejecting_a_registration_sets_it_afgewezen()
|
|
||||||
{
|
|
||||||
var registration = Registration.Submit("123456782");
|
|
||||||
|
|
||||||
registration.Reject();
|
|
||||||
|
|
||||||
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Rejecting_needs_no_zaak()
|
|
||||||
{
|
|
||||||
// A registration can be turned down before its zaak is ever opened, so Reject must not require one.
|
|
||||||
var registration = Registration.Submit("123456782");
|
|
||||||
|
|
||||||
registration.Reject();
|
|
||||||
|
|
||||||
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
|
|
||||||
Assert.Null(registration.ZaakUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Rejecting_an_in_behandeling_registration_sets_it_afgewezen()
|
|
||||||
{
|
|
||||||
var registration = Registration.Submit("123456782");
|
|
||||||
registration.TakeIntoBehandeling();
|
|
||||||
|
|
||||||
registration.Reject();
|
|
||||||
|
|
||||||
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Deciding_an_already_decided_registration_is_rejected()
|
|
||||||
{
|
|
||||||
var registration = Registration.Submit("123456782");
|
|
||||||
registration.Reject();
|
|
||||||
|
|
||||||
var approveEx = Assert.Throws<InvalidOperationException>(() =>
|
|
||||||
{
|
|
||||||
registration.AttachZaak(new Uri("http://openzaak/zaken/api/v1/zaken/abc"));
|
|
||||||
registration.Approve();
|
|
||||||
});
|
|
||||||
Assert.Contains("IN_BEHANDELING", approveEx.Message);
|
|
||||||
|
|
||||||
var rejectEx = Assert.Throws<InvalidOperationException>(() => registration.Reject());
|
|
||||||
Assert.Contains("Afgewezen", rejectEx.Message);
|
|
||||||
Assert.Equal(RegistrationStatus.Afgewezen, registration.Status);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# language: en
|
|
||||||
# Drives S-12 (#13). A behandelaar picks up a submitted registration for beoordeling and decides it:
|
|
||||||
# goedkeuren enters it in the register (INGESCHREVEN, the zaak's final status set via the ACL, §8.1),
|
|
||||||
# afwijzen turns it down (AFGEWEZEN). This scenario exercises the use cases against in-memory
|
|
||||||
# stand-ins for the ACL and the store; real Flowable user-task claim/complete arrives in a later
|
|
||||||
# sub-slice and is verified live.
|
|
||||||
Feature: Een registratie beoordelen
|
|
||||||
Als behandelaar wil ik een ingediende registratie beoordelen
|
|
||||||
zodat deze wordt ingeschreven of afgewezen.
|
|
||||||
|
|
||||||
Scenario: Goedkeuren schrijft de registratie in via de ACL
|
|
||||||
Given a submitted registration with an opened zaak
|
|
||||||
When the behandelaar takes it into behandeling
|
|
||||||
Then the registration has status "INBEHANDELING"
|
|
||||||
When the behandelaar decides "goedkeuren"
|
|
||||||
Then the registration has status "INGESCHREVEN"
|
|
||||||
And the zaak's final status is set via the ACL
|
|
||||||
And the beoordeling task is completed with "goedkeuren"
|
|
||||||
|
|
||||||
Scenario: Afwijzen wijst de registratie af zonder de ACL
|
|
||||||
Given a submitted registration with an opened zaak
|
|
||||||
When the behandelaar takes it into behandeling
|
|
||||||
And the behandelaar decides "afwijzen"
|
|
||||||
Then the registration has status "AFGEWEZEN"
|
|
||||||
And the ACL is not asked to set the zaak status
|
|
||||||
And the beoordeling task is completed with "afwijzen"
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
using Acceptance.Support;
|
|
||||||
using Big.Application;
|
|
||||||
using Big.Domain;
|
|
||||||
using Reqnroll;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace Acceptance.Steps;
|
|
||||||
|
|
||||||
/// <summary>Bindings for <c>EenRegistratieBeoordelen.feature</c> (S-12). Drives the TakeIntoBehandeling
|
|
||||||
/// transition and the BeoordeelRegistratie use case against in-memory ports; one instance per scenario.
|
|
||||||
/// Scoped to this feature so its "the registration has status" step does not clash with the identically
|
|
||||||
/// phrased (but differently-asserting) step in <see cref="RegistratieIndienenSteps"/>.</summary>
|
|
||||||
[Binding]
|
|
||||||
[Scope(Feature = "Een registratie beoordelen")]
|
|
||||||
public sealed class EenRegistratieBeoordelenSteps
|
|
||||||
{
|
|
||||||
private readonly InMemoryAclClient _acl = new();
|
|
||||||
private readonly InMemoryRegistrationStore _store = new();
|
|
||||||
private readonly InMemoryUserTaskClient _tasks = new();
|
|
||||||
private RegistrationId _id;
|
|
||||||
|
|
||||||
[Given("a submitted registration with an opened zaak")]
|
|
||||||
public async Task GivenASubmittedRegistrationWithAnOpenedZaak()
|
|
||||||
{
|
|
||||||
var registration = Registration.Submit("123456782");
|
|
||||||
registration.AttachZaak(InMemoryAclClient.OpenedZaakUrl);
|
|
||||||
await _store.SaveAsync(registration);
|
|
||||||
_id = registration.Id;
|
|
||||||
// The process has parked at the Beoordelen user task awaiting the behandelaar.
|
|
||||||
_tasks.Open(_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
[When("the behandelaar takes it into behandeling")]
|
|
||||||
public async Task WhenTheBehandelaarTakesItIntoBehandeling()
|
|
||||||
{
|
|
||||||
var registration = await _store.GetAsync(_id);
|
|
||||||
registration!.TakeIntoBehandeling();
|
|
||||||
await _store.SaveAsync(registration);
|
|
||||||
}
|
|
||||||
|
|
||||||
[When("the behandelaar decides \"(.*)\"")]
|
|
||||||
public async Task WhenTheBehandelaarDecides(string besluit)
|
|
||||||
=> await new BeoordeelRegistratie(_store, _acl, _tasks).HandleAsync(
|
|
||||||
new BeoordeelRegistratieCommand(_id, Enum.Parse<BeoordelingsBesluit>(besluit, ignoreCase: true)));
|
|
||||||
|
|
||||||
[Then("the registration has status \"(.*)\"")]
|
|
||||||
public async Task ThenTheRegistrationHasStatus(string expected)
|
|
||||||
{
|
|
||||||
var registration = await _store.GetAsync(_id);
|
|
||||||
Assert.NotNull(registration);
|
|
||||||
Assert.Equal(expected, registration.Status.ToString().ToUpperInvariant());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Then("the zaak's final status is set via the ACL")]
|
|
||||||
public void ThenTheZaakFinalStatusIsSetViaTheAcl()
|
|
||||||
=> Assert.Equal(InMemoryAclClient.OpenedZaakUrl, _acl.ApprovedZaakUrl);
|
|
||||||
|
|
||||||
[Then("the ACL is not asked to set the zaak status")]
|
|
||||||
public void ThenTheAclIsNotAskedToSetTheZaakStatus()
|
|
||||||
=> Assert.Null(_acl.ApprovedZaakUrl);
|
|
||||||
|
|
||||||
[Then("the beoordeling task is completed with \"(.*)\"")]
|
|
||||||
public void ThenTheBeoordelingTaskIsCompletedWith(string besluit)
|
|
||||||
=> Assert.Equal(Enum.Parse<BeoordelingsBesluit>(besluit, ignoreCase: true), _tasks.Completed?.Besluit);
|
|
||||||
}
|
|
||||||
@@ -68,12 +68,6 @@ 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>>([]);
|
|
||||||
|
|
||||||
public Task DecideAsync(string registrationId, string besluit, CancellationToken ct = default)
|
|
||||||
=> Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Serves configurable projection rows.</summary>
|
/// <summary>Serves configurable projection rows.</summary>
|
||||||
|
|||||||
@@ -43,29 +43,6 @@ public sealed class InMemoryAclClient : IAclClient
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>An in-memory user-task client for the beoordeling acceptance scenario: it holds one open
|
|
||||||
/// Beoordelen task per registration and records the besluit each is completed with.</summary>
|
|
||||||
public sealed class InMemoryUserTaskClient : IUserTaskClient
|
|
||||||
{
|
|
||||||
private readonly List<BeoordelingTask> _open = [];
|
|
||||||
|
|
||||||
public (string TaskId, BeoordelingsBesluit Besluit)? Completed { get; private set; }
|
|
||||||
|
|
||||||
public void Open(RegistrationId registrationId) => _open.Add(new BeoordelingTask($"task-{registrationId}", registrationId));
|
|
||||||
|
|
||||||
public Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default)
|
|
||||||
=> Task.FromResult<IReadOnlyList<BeoordelingTask>>(_open);
|
|
||||||
|
|
||||||
public Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default) => Task.CompletedTask;
|
|
||||||
|
|
||||||
public Task CompleteBeoordelingAsync(string taskId, BeoordelingsBesluit besluit, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
Completed = (taskId, besluit);
|
|
||||||
_open.RemoveAll(t => t.TaskId == taskId);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>An in-memory registration store for the domain acceptance scenario.</summary>
|
/// <summary>An in-memory registration store for the domain acceptance scenario.</summary>
|
||||||
public sealed class InMemoryRegistrationStore : IRegistrationStore
|
public sealed class InMemoryRegistrationStore : IRegistrationStore
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,12 +6,9 @@
|
|||||||
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">
|
||||||
|
|
||||||
<!-- Registratie ontvangen flow.
|
<!-- S-03: minimal "Registratie ontvangen" flow.
|
||||||
start -> external-worker task (OpenZaakAanmaken) -> Beoordelen (behandelaar user task) -> end.
|
start -> external-worker task (OpenZaakAanmaken) -> end.
|
||||||
S-03 added the external task (Workflow Client / ACL). S-12 adds the behandelaar's beoordeling
|
The external task is handled by the Workflow Client / ACL in later slices. -->
|
||||||
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"/>
|
||||||
@@ -22,13 +19,9 @@
|
|||||||
flowable:type="external-worker"
|
flowable:type="external-worker"
|
||||||
flowable:topic="OpenZaakAanmaken"/>
|
flowable:topic="OpenZaakAanmaken"/>
|
||||||
|
|
||||||
<sequenceFlow id="flow2" sourceRef="OpenZaakAanmaken" targetRef="Beoordelen"/>
|
<sequenceFlow id="flow2" sourceRef="OpenZaakAanmaken" targetRef="end"/>
|
||||||
|
|
||||||
<userTask id="Beoordelen" name="Beoordelen" flowable:candidateGroups="behandelaar"/>
|
<endEvent id="end" name="Zaak aangemaakt"/>
|
||||||
|
|
||||||
<sequenceFlow id="flow3" sourceRef="Beoordelen" targetRef="end"/>
|
|
||||||
|
|
||||||
<endEvent id="end" name="Registratie beoordeeld"/>
|
|
||||||
</process>
|
</process>
|
||||||
|
|
||||||
<bpmndi:BPMNDiagram id="diagram">
|
<bpmndi:BPMNDiagram id="diagram">
|
||||||
@@ -39,11 +32,8 @@
|
|||||||
<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="580" y="100" width="30" height="30"/>
|
<omgdc:Bounds x="400" 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"/>
|
||||||
@@ -51,11 +41,7 @@
|
|||||||
</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="390" y="115"/>
|
<omgdi:waypoint x="400" 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user