Compare commits

..
Author SHA1 Message Date
notandClaude Opus 4.8 92b19e5d7b arch(workflow): ADR-0014 — withdrawal cancels the process via a message event (refs #12)
CI / lint (pull_request) Failing after 1m16s
CI / mutation (pull_request) Has been cancelled
CI / unit (pull_request) Failing after 1m1s
CI / build (pull_request) Failing after 53s
CI / verify-stack (pull_request) Has been cancelled
CI / frontend (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:29:56 +02:00
notandClaude Opus 4.8 18ab868724 test(verify): the domain check exercises withdrawal cancelling the Beoordelen task (refs #12)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:29:56 +02:00
notandClaude Opus 4.8 edffbb4575 feat(domain): withdrawal cancels the registratie process via a BPMN message event (refs #12)
Add an interrupting message boundary event (RegistratieIngetrokken) on the Beoordelen task that

ends the process; the Workflow Client delivers the message to the task's execution, and the

withdraw handler triggers it best-effort after the domain transition (ADR-0014).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:29:56 +02:00
notandClaude Opus 4.8 2a6874ef6d test(domain): withdrawal cancels the workflow via the Beoordelen task's execution (refs #12)
The WithdrawRegistration handler delivers the withdrawal message to the open Beoordelen task's

execution; the Workflow Client PUTs messageEventReceived (RegistratieIngetrokken). BeoordelingTask

now carries its executionId.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:29:56 +02:00
notandClaude Opus 4.8 435db81bdf feat(domain): the werkbak lists only registrations still open for beoordeling (refs #12)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:20:43 +02:00
notandClaude Opus 4.8 345191b1df test(domain): the werkbak drops a registration that is no longer open (refs #12)
A withdrawn registration may keep a lingering Beoordelen task until the workflow cancels it; the

werkbak must list only registrations still open for beoordeling, so it drops off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:20:43 +02:00
34 changed files with 117 additions and 767 deletions
-4
View File
@@ -19,9 +19,5 @@ COPY --from=build /src/dist/apps/behandel/browser /usr/share/nginx/html
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
# service name, so the token issuer matches the BFF's medewerker authority (host-consistent, ADR-0013).
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/nginx/html/config.json
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
# the nginx image's /docker-entrypoint.d before nginx starts.
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
EXPOSE 80
-4
View File
@@ -17,9 +17,5 @@ FROM nginx:1.27-alpine AS runtime
COPY apps/openbaar/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/apps/openbaar/browser /usr/share/nginx/html
# No runtime config: the openbaar register is anonymous (no OIDC authority to inject).
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
# the nginx image's /docker-entrypoint.d before nginx starts.
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
EXPOSE 80
-17
View File
@@ -1,17 +0,0 @@
#!/bin/sh
# Point nginx's reverse-proxy `resolver` at THIS container's real DNS server.
#
# The portal nginx configs use a variable proxy_pass, which needs a `resolver` so the BFF hostname is
# resolved at request time (nginx can start before the BFF is up). The config hardcodes Docker's
# embedded DNS (127.0.0.11) — correct on Docker/Docker Desktop, but rootless podman uses a
# network-specific address (aardvark, e.g. 10.89.0.1), so proxied calls 502 there. Read the actual
# nameserver from /etc/resolv.conf and substitute it, so the reverse proxy works on any engine.
#
# Runs from the nginx image's /docker-entrypoint.d/ before nginx starts. On Docker the nameserver IS
# 127.0.0.11, so the substitution is a no-op. Guarded (no `set -e`) so it's safe whether the nginx
# entrypoint executes or sources it.
ns="$(awk '/^nameserver/{print $2; exit}' /etc/resolv.conf 2>/dev/null)"
if [ -n "$ns" ] && [ "$ns" != "127.0.0.11" ]; then
sed -i "s/resolver 127\.0\.0\.11/resolver $ns/" /etc/nginx/conf.d/default.conf 2>/dev/null || true
echo "portal-nginx-resolver: set resolver to $ns"
fi
-4
View File
@@ -19,9 +19,5 @@ COPY --from=build /src/dist/apps/self-service/browser /usr/share/nginx/html
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
# service name, so the token issuer matches the BFF's authority (host-consistent, ADR-0010).
RUN printf '{ "authority": "http://keycloak:8080/realms/digid" }\n' > /usr/share/nginx/html/config.json
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
# the nginx image's /docker-entrypoint.d before nginx starts.
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
EXPOSE 80
@@ -3,29 +3,9 @@
<utrecht-heading-1>Zelfservice — BIG-registratie</utrecht-heading-1>
@if (submitted()) {
@if (withdrawn()) {
<p utrecht-paragraph role="status">
Uw registratie met referentie {{ reference() }} is ingetrokken.
</p>
} @else {
<p utrecht-paragraph role="status">
Uw registratie is ontvangen. Referentie: {{ reference() }}.
</p>
@if (withdrawFailed()) {
<p utrecht-paragraph role="alert">
Het intrekken van uw registratie is niet gelukt. Probeer het opnieuw.
</p>
}
<button
utrecht-button
appearance="secondary-action-button"
type="button"
[disabled]="withdrawing()"
(click)="withdraw()"
>
Trek aanvraag in
</button>
}
<p utrecht-paragraph role="status">
Uw registratie is ontvangen. Referentie: {{ reference() }}.
</p>
} @else {
<p utrecht-paragraph>U bent ingelogd met BSN {{ bsn() }}.</p>
@if (failed()) {
@@ -17,22 +17,12 @@ class FakeAuth extends AuthService {
}
}
function providers(
post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
withdraw = vi.fn().mockReturnValue(of(undefined)),
) {
function providers(post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' }))) {
return {
post,
withdraw,
providers: [
{ provide: AuthService, useClass: FakeAuth },
{
provide: BffApiV1Service,
useValue: {
postSelfServiceRegistrations: post,
postSelfServiceRegistrationsIdWithdraw: withdraw,
},
},
{ provide: BffApiV1Service, useValue: { postSelfServiceRegistrations: post } },
],
};
}
@@ -66,36 +56,6 @@ describe('RegistrationPage', () => {
expect(screen.getByRole('button', { name: /indienen/i })).toBeTruthy();
});
it('offers to withdraw after submitting, and withdrawing confirms', async () => {
const { withdraw, providers: p } = providers();
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
await screen.findByText(/ontvangen/i);
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
// The withdrawal is keyed by the reference the submit returned, and the page confirms it.
expect(withdraw).toHaveBeenCalledWith('reg-9');
expect(await screen.findByText(/ingetrokken/i)).toBeTruthy();
});
it('surfaces a withdraw failure and keeps the action available', async () => {
const { providers: p } = providers(
vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
vi.fn().mockReturnValue(throwError(() => new Error('withdraw rejected'))),
);
await render(RegistrationPage, { providers: p });
fireEvent.click(screen.getByRole('button', { name: /indienen/i }));
await screen.findByText(/ontvangen/i);
fireEvent.click(await screen.findByRole('button', { name: /trek aanvraag in/i }));
expect(await screen.findByRole('alert')).toBeTruthy();
expect(screen.queryByText(/is ingetrokken/i)).toBeNull();
expect(screen.getByRole('button', { name: /trek aanvraag in/i })).toBeTruthy();
});
it('has no WCAG 2.1 AA violations on the submit page', async () => {
// The portal is Dutch; the real index.html sets lang. Set it here so the document-level
// html-has-lang rule reflects the app, not the bare jsdom document.
@@ -6,8 +6,7 @@ import { UtrechtComponentsModule } from 'ui';
/**
* The self-service submit page: a signed-in zorgprofessional confirms and submits their BIG
* registration. The bsn comes from the DigiD token (not a form field), so this is a confirm-and-
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c). After
* submitting they can withdraw it — "trek aanvraag in" — keyed by that reference (S-11c).
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c).
*/
@Component({
selector: 'app-registration-page',
@@ -23,9 +22,6 @@ export class RegistrationPage {
protected readonly reference = signal<string | undefined>(undefined);
protected readonly submitted = signal(false);
protected readonly failed = signal(false);
protected readonly withdrawing = signal(false);
protected readonly withdrawn = signal(false);
protected readonly withdrawFailed = signal(false);
submit(): void {
this.submitting.set(true);
@@ -43,24 +39,4 @@ export class RegistrationPage {
},
});
}
withdraw(): void {
const reference = this.reference();
if (!reference) {
return;
}
this.withdrawing.set(true);
this.withdrawFailed.set(false);
this.bff.postSelfServiceRegistrationsIdWithdraw(reference).subscribe({
next: () => {
this.withdrawn.set(true);
this.withdrawing.set(false);
},
// Surface the failure instead of swallowing it: keep the action so the user can retry.
error: () => {
this.withdrawFailed.set(true);
this.withdrawing.set(false);
},
});
}
}
@@ -29,12 +29,11 @@ task; the Workflow Client correlates a `RegistratieIngetrokken` message to the t
Client only delivers the message; it never reaches into Flowable to delete an instance. This keeps
the workflow's control flow in the workflow (§8.2) and leaves an audit trail in Flowable history
(the process ended via the ingetrokken path, not a raw delete).
- **Correlated by the registration's own process instance.** The aggregate records its Flowable
process instance id at submit, so the `WithdrawRegistration` handler correlates directly by that
id — no task lookup. The Workflow Client asks Flowable for the execution **subscribed to** the
`RegistratieIngetrokken` message in that instance and delivers `messageEventReceived` to it.
Targeting the subscribed execution (not the user task's execution — a message boundary event's
subscription lives on its own execution) is what makes the correlation land.
- **Correlated via the Beoordelen task's execution.** The `WithdrawRegistration` handler already
knows the registration; it finds the open `Beoordelen` task for it (the same task-query the werkbak
uses, §8.2) and asks the Workflow Client to deliver the withdrawal message to that task's execution
(`messageEventReceived`). No separate correlation store is needed — the werkbak task set is the
authoritative correlation, exactly as the beoordeling decision reuses it (ADR-0013).
- **Best-effort, mirroring the beoordeling.** If no open `Beoordelen` task is found (the process has
not yet parked there — the `OpenZaakAanmaken` window — or has already ended), the withdrawal still
stands: the aggregate is INGETROKKEN and the werkbak filters it out regardless (S-11b). We complete
-23
View File
@@ -275,26 +275,3 @@ ACL → NRC → event-subscriber → projection → openbaar register shows INGE
> The full round-trip — DigiD submit → public INGEDIEND → behandelaar goedkeurt in the werkbak →
> public INGESCHREVEN — is the Playwright happy path (`tests/e2e/registration.spec.ts`), which now
> drives the behandel portal in place of the old admin endpoint.
## S-11 — Withdrawal: "trek aanvraag in" (#12, ADR-0014)
A zorgprofessional can withdraw their own still-open registration from the self-service portal. The
withdrawal is owner-scoped (the BFF forwards the DigiD token's bsn; the domain only lets the owner
withdraw) and cancels the running workflow via a BPMN message event, so the case leaves the
behandelaar's werkbak.
```text
# 1. Log in and submit at the self-service portal (http://localhost:8140/, jan-burger / test123),
# note the "Referentie" on the confirmation.
# 2. Click "Trek aanvraag in" → the page confirms the registration is ingetrokken.
# 3. In the behandel werkbak (http://localhost:8142/, merel-behandelaar) the registration no longer
# appears — its Beoordelen task was cancelled.
```
**The path:** self-service → BFF `POST /self-service/registrations/{id}/withdraw` (DigiD, owner-scoped)
→ domain sets INGETROKKEN + correlates the `RegistratieIngetrokken` message to the process → the
interrupting boundary event ends it → the werkbak drops the case.
> DigiD submit → trek aanvraag in → ingetrokken is the Playwright happy path
> (`tests/e2e/withdrawal.spec.ts`); the owner-scoping + workflow cancellation are covered by the
> `Een registratie intrekken` acceptance scenarios and the domain live check.
-16
View File
@@ -151,19 +151,3 @@ frontend work is the medewerker realm auth and the werkbak/decide page. Wiring r
to assert the medewerker token attaches to `/behandel/*` (and not to the anonymous openbaar call).
The full DigiD-submit → behandel-decide → public INGESCHREVEN round-trip is the Playwright happy
path.
## Self-service withdrawal: "trek aanvraag in" (S-11c, #12)
The submit confirmation grows a **"Trek aanvraag in"** action so a zorgprofessional can withdraw the
registration they just submitted (`apps/self-service`, on the existing `RegistrationPage`).
- **Keyed by the reference, owner-scoped at the BFF.** The button calls the generated
`postSelfServiceRegistrationsIdWithdraw(reference)` with the reference the submit returned. The
DigiD token (attached by the interceptor) carries the bsn the BFF forwards; the domain only lets
the owner withdraw (a mismatch is 404). No extra identity is entered in the UI.
- **Same confirm-and-surface pattern as submit.** A secondary-action button; on success the page
switches to an ingetrokken confirmation; a failure is surfaced (`role="alert"`) and the action
stays available to retry — mirroring how submit handles its failure rather than swallowing it.
- **Testing.** Component tests (`@testing-library/angular`, mocked BFF) cover the button appearing
after submit, the reference being passed, the ingetrokken confirmation, and the failure path; the
browser round-trip is `tests/e2e/withdrawal.spec.ts`.
+1 -126
View File
@@ -17,12 +17,7 @@
#
# Port map (host):
# 8000 OpenZaak · 8001 Open Notificaties · 8080 BFF · 8090 Flowable REST
# 8100 ACL · 8130 Domain · 8180 Keycloak (all admin: admin / admin — dev only)
# 8140 self-service portal · 8141 openbaar register · 8142 behandel portal
#
# Portal OIDC on the HOST: browse the portals at their 8140/8141/8142 ports and log in via
# Keycloak on localhost:8180 (KC_HOSTNAME below pins the issuer there; the BFF still validates
# in-network via keycloak:8080). Test users are in docs/synthetic-data.md.
# 8100 ACL · 8180 Keycloak (all admin: admin / admin — dev only)
services:
@@ -210,12 +205,6 @@ services:
KEYCLOAK_ADMIN_PASSWORD: admin
KC_HEALTH_ENABLED: "true"
KC_HTTP_ENABLED: "true"
# Pin the frontend/issuer URL to the host-published address so a browser on the host and the
# tokens it gets both use localhost:8180. KC_HOSTNAME_BACKCHANNEL_DYNAMIC lets in-network
# callers (the BFF via keycloak:8080) still resolve token/jwks endpoints to their request host,
# so the BFF validates the localhost:8180 issuer while fetching keys over the compose network.
KC_HOSTNAME: http://localhost:8180
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
ports:
- "8180:8080"
volumes:
@@ -306,14 +295,6 @@ services:
context: ../services/bff
dockerfile: Dockerfile
image: register-referentie/bff:dev
environment:
# Reach Keycloak over the compose network for metadata/keys; the discovered issuer is the
# host-pinned localhost:8180 (KC_HOSTNAME above), which is what browser tokens carry — so
# validation matches without the BFF ever needing to resolve localhost:8180 itself.
Keycloak__Authority: http://keycloak:8080/realms/digid
Keycloak__MedewerkerAuthority: http://keycloak:8080/realms/medewerker
Downstream__Domain__BaseUrl: http://domain:8080/
Downstream__Projection__BaseUrl: http://projection-api:8080/
ports:
- "8080:8080"
healthcheck:
@@ -322,39 +303,6 @@ services:
timeout: 3s
retries: 5
start_period: 10s
depends_on:
domain:
condition: service_healthy
projection-api:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg]
# ── BIG Domain Service (S-05) ─────────────────────────────────────────────
domain:
build:
context: ../services/domain
dockerfile: Dockerfile
image: register-referentie/domain:dev
environment:
Flowable__BaseUrl: http://flowable-rest:8080/flowable-rest/
Flowable__Username: rest-admin
Flowable__Password: test
Acl__BaseUrl: http://acl:8080/
ports:
- "8130:8080"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
acl:
condition: service_healthy
flowable-init:
condition: service_completed_successfully
networks: [cg]
# ── Read projection (S-06) ────────────────────────────────────────────────
@@ -380,10 +328,6 @@ services:
image: register-referentie/event-subscriber:dev
environment:
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
# The subscriber enriches the projection with each zaak's reference by asking the ACL — the only
# code allowed to read ZGW (§8.1, #78). Required: startup throws without it (parity with the
# canonical compose).
Acl__BaseUrl: http://acl:8080/
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
ports:
- "8110:8080"
@@ -396,8 +340,6 @@ services:
depends_on:
projection-db:
condition: service_healthy
acl:
condition: service_healthy
networks: [cg]
projection-api:
@@ -420,73 +362,6 @@ services:
condition: service_healthy
networks: [cg]
# ── Portals (S-08/S-09/S-12) ──────────────────────────────────────────────
# nginx serves each Angular app and reverse-proxies its endpoint group to the BFF (same-origin).
# The images bake config.json with the compose authority (keycloak:8080), which a HOST browser
# can't resolve — so here we bind-mount a config.json pointing at the host-published localhost:8180
# (matching KC_HOSTNAME). openbaar is anonymous and needs no config.
self-service:
build:
context: ..
dockerfile: apps/self-service/Dockerfile
image: register-referentie/self-service:dev
ports:
- "8140:80"
volumes:
- ./local-config/self-service.config.json:/usr/share/nginx/html/config.json:ro,z
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
bff:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg]
openbaar:
build:
context: ..
dockerfile: apps/openbaar/Dockerfile
image: register-referentie/openbaar:dev
ports:
- "8141:80"
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
bff:
condition: service_healthy
networks: [cg]
behandel:
build:
context: ..
dockerfile: apps/behandel/Dockerfile
image: register-referentie/behandel:dev
ports:
- "8142:80"
volumes:
- ./local-config/behandel.config.json:/usr/share/nginx/html/config.json:ro,z
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
depends_on:
bff:
condition: service_healthy
keycloak:
condition: service_started
networks: [cg]
volumes:
oz-db:
nrc-db:
-3
View File
@@ -1,3 +0,0 @@
{
"authority": "http://localhost:8180/realms/medewerker"
}
@@ -1,3 +0,0 @@
{
"authority": "http://localhost:8180/realms/digid"
}
+1 -3
View File
@@ -142,10 +142,8 @@ done
[ -n "$task_id2" ] || { echo "FAIL — no Beoordelen task appeared for registration $reg_id2" >&2; docker logs "$dom" 2>&1 | tail -15 >&2; exit 1; }
echo ">> Beoordelen task $task_id2 is waiting; withdrawing the registration via the domain"
# Owner-scoped: the withdraw carries the same bsn the registration was submitted with (S-11c).
docker run --rm --network "$net" curlimages/curl:latest \
-fsS -X POST "http://$dom_ip:8080/registrations/$reg_id2/withdraw" \
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' >/dev/null
-fsS -X POST "http://$dom_ip:8080/registrations/$reg_id2/withdraw" >/dev/null
echo ">> asserting the process was cancelled (no Beoordelen task remains for the registration)"
gone=""
@@ -192,40 +192,6 @@ export class BffApiV1Service {
);
}
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientBodyOptions): Observable<TData>;
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
postSelfServiceRegistrationsIdWithdraw<TData = void>(id: string, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
postSelfServiceRegistrationsIdWithdraw<TData = void>(
id: string, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
if (options?.observe === 'events') {
return this.http.post<TData>(
`/self-service/registrations/${id}/withdraw`,
undefined,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
}
);
}
if (options?.observe === 'response') {
return this.http.post<TData>(
`/self-service/registrations/${id}/withdraw`,
undefined,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
}
);
}
return this.http.post<TData>(
`/self-service/registrations/${id}/withdraw`,
undefined,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'body',
}
);
}
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientBodyOptions): Observable<TData>;
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
-16
View File
@@ -22,11 +22,6 @@ public interface IDomainClient
{
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
/// <summary>Withdraw the caller's own registration ("trek aanvraag in"). Owner-scoped by
/// <paramref name="bsn"/>. Returns <c>false</c> when the domain reports the registration is
/// unknown or not the caller's (404), so the BFF can relay a 404 rather than a 500.</summary>
Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default);
/// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary>
Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default);
@@ -52,17 +47,6 @@ public sealed class DomainClient(HttpClient http) : IDomainClient
return new SubmitAccepted(dto.RegistrationId, dto.Status);
}
public async Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
{
using var response = await http.PostAsJsonAsync(
$"registrations/{registrationId}/withdraw", new { bsn }, ct);
// The domain 404s an unknown or not-owned registration; relay that rather than fail hard.
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return false;
response.EnsureSuccessStatusCode();
return true;
}
public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
=> await http.GetFromJsonAsync<List<WerkbakItem>>("behandel/werkbak", ct) ?? [];
-18
View File
@@ -86,24 +86,6 @@ app.MapPost("/self-service/registrations", async (ClaimsPrincipal user, IDomainC
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized);
// Self-service withdrawal (S-11): the signed-in zorgprofessional withdraws their own registration.
// The bsn comes from the DigiD token and is forwarded to the domain, which owner-scopes the action;
// a registration that is unknown or not the caller's comes back 404 (ownership is not revealed).
app.MapPost("/self-service/registrations/{id}/withdraw", async (string id, ClaimsPrincipal user, IDomainClient domain, CancellationToken ct) =>
{
var bsn = user.FindFirstValue("bsn");
if (string.IsNullOrWhiteSpace(bsn))
return Results.BadRequest("The token carries no bsn claim.");
var withdrawn = await domain.WithdrawRegistrationAsync(id, bsn, ct);
return withdrawn ? Results.NoContent() : Results.NotFound();
})
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
// Openbaar register: an anonymous public lookup that exposes only public-safe fields (S-09).
app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) =>
{
-12
View File
@@ -82,18 +82,6 @@ internal sealed class FakeDomainClient : IDomainClient
return Task.FromResult(Result);
}
public (string RegistrationId, string Bsn)? Withdrawn { get; private set; }
/// <summary>Whether the fake domain reports the withdrawal as done (true → 204) or not-found/not-owned
/// (false → 404). Tests set this to exercise the relay.</summary>
public bool WithdrawSucceeds { get; set; } = true;
public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
{
Withdrawn = (registrationId, bsn);
return Task.FromResult(WithdrawSucceeds);
}
public (string RegistrationId, string Besluit)? Decided { get; private set; }
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
@@ -71,46 +71,5 @@ public class SelfServiceEndpointTests
Assert.Equal("reg-123", body!.RegistrationId);
}
private static HttpRequestMessage Withdraw(string? bearer, string id = "reg-123")
{
var request = new HttpRequestMessage(HttpMethod.Post, $"/self-service/registrations/{id}/withdraw");
if (bearer is not null)
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearer);
return request;
}
[Fact]
public async Task Rejects_a_withdrawal_without_a_token()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Withdraw(bearer: null));
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
Assert.Null(factory.Domain.Withdrawn);
}
[Fact]
public async Task Withdraws_the_callers_registration_forwarding_the_id_and_bsn()
{
using var factory = new BffFactory();
var response = await factory.CreateClient().SendAsync(Withdraw(TestTokens.Valid("123456782"), "reg-9"));
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
Assert.Equal(("reg-9", "123456782"), factory.Domain.Withdrawn);
}
[Fact]
public async Task Relays_not_found_when_the_registration_is_unknown_or_not_the_callers()
{
using var factory = new BffFactory();
factory.Domain.WithdrawSucceeds = false;
var response = await factory.CreateClient().SendAsync(Withdraw(TestTokens.Valid("123456782")));
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
private sealed record SubmitAcceptedDto(string RegistrationId, string Status);
}
-31
View File
@@ -30,37 +30,6 @@
}
}
},
"/self-service/registrations/{id}/withdraw": {
"post": {
"tags": [
"Bff.Api"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "No Content"
},
"400": {
"description": "Bad Request"
},
"401": {
"description": "Unauthorized"
},
"404": {
"description": "Not Found"
}
}
}
},
"/openbaar/register": {
"get": {
"tags": [
+6 -11
View File
@@ -76,19 +76,16 @@ app.MapPost("/registrations/{id}/decide", async (string id, DecideRequest body,
});
// Withdraw a registration (S-11): the zorgprofessional pulls their own still-open submission back,
// advancing it to INGETROKKEN and cancelling its workflow. Owner-scoped by the caller's bsn (the BFF
// forwards it from the DigiD token, S-11c); a registration that is unknown or not the caller's is
// 404 (indistinguishable, so ownership isn't leaked). Idempotent.
app.MapPost("/registrations/{id}/withdraw", async (string id, WithdrawRequest body, WithdrawRegistration withdraw, CancellationToken ct) =>
// advancing it to INGETROKKEN. Idempotent. The BFF reaches this behind a digid token, owner-scoped
// to the caller's bsn (S-11c); the domain trusts its callers (§8.3). Cancelling the running Flowable
// process is a later sub-slice (S-11b).
app.MapPost("/registrations/{id}/withdraw", async (string id, WithdrawRegistration withdraw, CancellationToken ct) =>
{
if (!Guid.TryParse(id, out var guid))
return Results.NotFound();
if (string.IsNullOrWhiteSpace(body?.Bsn))
return Results.BadRequest(new { error = "A bsn is required to withdraw a registration." });
var outcome = await withdraw.HandleAsync(new WithdrawRegistrationCommand(new RegistrationId(guid), body.Bsn), ct);
return outcome == WithdrawOutcome.Withdrawn ? Results.NoContent() : Results.NotFound();
await withdraw.HandleAsync(new WithdrawRegistrationCommand(new RegistrationId(guid)), ct);
return Results.NoContent();
});
// The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open
@@ -116,8 +113,6 @@ public sealed record SubmitRegistrationRequest(string Bsn);
public sealed record DecideRequest(string Besluit);
public sealed record WithdrawRequest(string Bsn);
public sealed record RegistrationResponse(string RegistrationId, string Status, string? ZaakUrl);
public partial class Program;
+9 -10
View File
@@ -15,14 +15,6 @@ public interface IWorkflowClient
/// aggregate. Returns the process instance id.
/// </summary>
Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default);
/// <summary>
/// Cancel a running <c>registratie</c> process on withdrawal (S-11): correlate the
/// <c>RegistratieIngetrokken</c> message to the instance, tripping the interrupting message event
/// that ends it (ADR-0014). Best-effort — if the instance is not waiting on that message (already
/// ended, or not yet parked) it is a no-op; the aggregate is INGETROKKEN regardless.
/// </summary>
Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default);
}
/// <summary>
@@ -62,11 +54,18 @@ public interface IUserTaskClient
/// <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>Deliver the withdrawal message to a <c>Beoordelen</c> task's execution, tripping the
/// process's interrupting message boundary event so the registratie process cancels (S-11,
/// ADR-0014). The registration itself is already INGETROKKEN in the domain; this ends its
/// workflow so the case leaves the werkbak.</summary>
Task WithdrawBeoordelingAsync(string executionId, 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);
/// complete it), the execution it runs in (needed to deliver the withdrawal message to its boundary
/// event), and the registration it carries as a process variable.</summary>
public sealed record BeoordelingTask(string TaskId, string ExecutionId, RegistrationId RegistrationId);
/// <summary>
/// Persistence port for the <see cref="Registration"/> aggregate. In-memory for the minimal slice
@@ -2,54 +2,43 @@ using Big.Domain;
namespace Big.Application;
/// <summary>A zorgprofessional's request to withdraw their own registration ("trek aanvraag in").
/// <paramref name="Bsn"/> is the authenticated caller (from the DigiD token, forwarded by the BFF):
/// only the registration's own bsn may withdraw it.</summary>
public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId, string Bsn);
/// <summary>The outcome of a withdrawal request.</summary>
public enum WithdrawOutcome
{
/// <summary>The registration is now (or already was) INGETROKKEN.</summary>
Withdrawn,
/// <summary>No registration with that id belongs to the caller — unknown, or owned by someone
/// else (the two are deliberately indistinguishable, so the endpoint reveals neither).</summary>
NotFound,
}
/// <summary>A zorgprofessional's request to withdraw their own registration ("trek aanvraag in").</summary>
public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId);
/// <summary>
/// The withdrawal use case (S-11): a zorgprofessional pulls a still-open registration back. It
/// advances the aggregate to INGETROKKEN, persists it, then cancels the running registratie process
/// by correlating the withdrawal message to its instance (ADR-0014), so the case leaves the
/// behandelaar's werkbak. Idempotent — a repeated or redelivered withdrawal of an already-withdrawn
/// registration is a no-op (not persisted or cancelled again). Cancelling is best-effort: if the
/// registration never started a process the withdrawal still stands (the process cancel is skipped),
/// mirroring how <see cref="BeoordeelRegistratie"/> completes its task best-effort.
/// by delivering the withdrawal message to its open <c>Beoordelen</c> task (ADR-0014), so the case
/// leaves the behandelaar's werkbak. Idempotent — a repeated or redelivered withdrawal of an
/// already-withdrawn registration is a no-op (not persisted or cancelled again). Cancelling is
/// best-effort: if no <c>Beoordelen</c> task is open (the process has not parked there yet, or has
/// already ended) the withdrawal still stands — mirroring <see cref="BeoordeelRegistratie"/>.
/// </summary>
public sealed class WithdrawRegistration(IRegistrationStore store, IWorkflowClient workflow)
public sealed class WithdrawRegistration(IRegistrationStore store, IUserTaskClient tasks)
{
public async Task<WithdrawOutcome> HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default)
public async Task HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(command);
var registration = await store.GetAsync(command.RegistrationId, ct);
// Unknown, or not the caller's registration: report NotFound either way (don't reveal which).
if (registration is null || registration.Bsn != command.Bsn)
return WithdrawOutcome.NotFound;
var registration = await store.GetAsync(command.RegistrationId, ct)
?? throw new InvalidOperationException($"No registration {command.RegistrationId} to withdraw.");
// A repeated withdrawal is a no-op: don't persist or cancel the already-withdrawn one again.
if (registration.Status == RegistrationStatus.Ingetrokken)
return WithdrawOutcome.Withdrawn;
return;
registration.Withdraw();
await store.SaveAsync(registration, ct);
await CancelWorkflowTaskAsync(command.RegistrationId, ct);
}
// Cancel the running process (if one was started) so its Beoordelen task leaves the werkbak.
if (registration.ProcessInstanceId is not null)
await workflow.WithdrawProcessAsync(registration.ProcessInstanceId, ct);
return WithdrawOutcome.Withdrawn;
// Cancel the workflow: deliver the withdrawal message to the open Beoordelen task's execution so
// the process's boundary event ends it. If none is open the withdrawal still stands.
private async Task CancelWorkflowTaskAsync(RegistrationId registrationId, CancellationToken ct)
{
var open = await tasks.GetOpenBeoordelingenAsync(ct);
var task = open.FirstOrDefault(t => t.RegistrationId == registrationId);
if (task is not null)
await tasks.WithdrawBeoordelingAsync(task.ExecutionId, ct);
}
}
@@ -66,7 +66,7 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
"service/query/tasks", request, ct);
var tasks = page?.Data ?? [];
return [.. tasks.Select(t => new BeoordelingTask(t.Id, RegistrationId.Parse(t.RegistrationId())))];
return [.. tasks.Select(t => new BeoordelingTask(t.Id, t.ExecutionId, RegistrationId.Parse(t.RegistrationId())))];
}
public async Task ClaimAsync(string taskId, string behandelaar, CancellationToken ct = default)
@@ -86,33 +86,16 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
response.EnsureSuccessStatusCode();
}
public async Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default)
public async Task WithdrawBeoordelingAsync(string executionId, CancellationToken ct = default)
{
// Correlate the withdrawal message to the instance: find the execution subscribed to it (the
// interrupting message event's own execution — NOT the user task's), then deliver
// messageEventReceived to that execution so the process ends (ADR-0014). If nothing is
// subscribed (the process is not parked at Beoordelen) this is a best-effort no-op.
var subscribed = await GetAsync<ExecutionQueryResult>(
$"service/runtime/executions?messageEventSubscriptionName={IngetrokkenMessage}&processInstanceId={processInstanceId}",
ct);
var execution = subscribed?.Data?.FirstOrDefault();
if (execution is null)
return;
// Deliver the withdrawal message to the Beoordelen task's execution, tripping the process's
// interrupting message boundary event so the registratie instance ends (ADR-0014). Flowable
// takes messageEventReceived as a PUT on the subscribed execution.
var request = new MessageEventRequest("messageEventReceived", IngetrokkenMessage);
using var response = await SendAsync(
$"service/runtime/executions/{execution.Id}", request, ct, HttpMethod.Put);
response.EnsureSuccessStatusCode();
}
private async Task<TResponse?> GetAsync<TResponse>(string path, CancellationToken ct)
{
var message = new HttpRequestMessage(HttpMethod.Get, new Uri(options.BaseUrl, path));
message.Headers.Authorization = new AuthenticationHeaderValue("Basic", BasicCredentials());
using var response = await http.SendAsync(message, ct);
using var response = await SendAsync(
$"service/runtime/executions/{executionId}", request, ct, HttpMethod.Put);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<TResponse>(ct);
}
private async Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken ct)
@@ -171,6 +154,7 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
private sealed record UserTaskDto(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("executionId")] string ExecutionId,
// 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)
@@ -188,11 +172,6 @@ public sealed class FlowableWorkflowClient(HttpClient http, FlowableOptions opti
private sealed record ProcessInstance([property: JsonPropertyName("id")] string Id);
private sealed record ExecutionQueryResult(
[property: JsonPropertyName("data")] IReadOnlyList<ExecutionDto>? Data);
private sealed record ExecutionDto([property: JsonPropertyName("id")] string Id);
private sealed record AcquiredJob(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("variables")] IReadOnlyList<Variable> Variables)
@@ -19,7 +19,7 @@ public class BeoordeelRegistratieTests
}
private static FakeUserTaskClient TaskFor(Registration registration) =>
new([new BeoordelingTask("task-1", registration.Id)]);
new([new BeoordelingTask("task-1", "exec-1", registration.Id)]);
[Fact]
public async Task Goedkeuren_sets_the_zaak_status_via_the_acl_and_marks_the_registration_ingeschreven()
+7 -7
View File
@@ -32,7 +32,6 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
: IWorkflowClient
{
public RegistrationId? StartedFor { get; private set; }
public string? WithdrawnProcessInstanceId { get; private set; }
public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
{
@@ -40,12 +39,6 @@ internal sealed class FakeWorkflowClient(string processInstanceId = "proc-1", Ac
StartedFor = registrationId;
return Task.FromResult(processInstanceId);
}
public Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default)
{
WithdrawnProcessInstanceId = processInstanceId;
return Task.CompletedTask;
}
}
/// <summary>A fake user-task client for the werkbak/decision use cases: returns a scripted set of
@@ -54,6 +47,7 @@ internal sealed class FakeUserTaskClient(IReadOnlyList<BeoordelingTask> open) :
{
public (string TaskId, string Behandelaar)? Claimed { get; private set; }
public (string TaskId, BeoordelingsBesluit Besluit)? Completed { get; private set; }
public string? WithdrawnExecutionId { get; private set; }
public Task<IReadOnlyList<BeoordelingTask>> GetOpenBeoordelingenAsync(CancellationToken ct = default)
=> Task.FromResult(open);
@@ -69,6 +63,12 @@ internal sealed class FakeUserTaskClient(IReadOnlyList<BeoordelingTask> open) :
Completed = (taskId, besluit);
return Task.CompletedTask;
}
public Task WithdrawBeoordelingAsync(string executionId, CancellationToken ct = default)
{
WithdrawnExecutionId = executionId;
return Task.CompletedTask;
}
}
/// <summary>A fake ACL client that records the bsn it was asked to open a zaak for and returns a
@@ -158,13 +158,14 @@ public class FlowableWorkflowClientTests
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK,
$$"""
{"data":[{"id":"task-1","variables":[{"name":"registrationId","type":"string","value":"{{rid}}"}]}],"total":1}
{"data":[{"id":"task-1","executionId":"exec-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("exec-1", task.ExecutionId);
Assert.Equal(rid, task.RegistrationId);
Assert.Equal(HttpMethod.Post, capture.Seen!.Method);
Assert.Equal("http://flowable/flowable-rest/service/query/tasks",
@@ -243,58 +244,29 @@ public class FlowableWorkflowClientTests
}
[Fact]
public async Task Withdraw_process_correlates_the_message_to_the_subscribed_execution()
public async Task Withdraw_beoordeling_delivers_the_message_to_the_task_execution()
{
HttpRequestMessage? getReq = null;
HttpRequestMessage? putReq = null;
string? putBody = null;
var client = Client(new StubHandler(async req =>
{
if (req.Method == HttpMethod.Get)
{
getReq = req;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"data":[{"id":"exec-9"}]}""", Encoding.UTF8, "application/json"),
};
}
putReq = req;
putBody = await req.Content!.ReadAsStringAsync();
return new HttpResponseMessage(HttpStatusCode.OK);
}));
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.OK));
await client.WithdrawProcessAsync("pi-1");
await client.WithdrawBeoordelingAsync("exec-1");
// 1. Find the execution subscribed to the withdrawal message for this instance.
Assert.Equal(HttpMethod.Get, getReq!.Method);
Assert.Contains("service/runtime/executions", getReq.RequestUri!.ToString());
Assert.Contains("messageEventSubscriptionName=RegistratieIngetrokken", getReq.RequestUri!.Query);
Assert.Contains("processInstanceId=pi-1", getReq.RequestUri!.Query);
// 2. Deliver messageEventReceived to that execution (PUT), tripping the interrupting event.
Assert.Equal(HttpMethod.Put, putReq!.Method);
Assert.Equal("http://flowable/flowable-rest/service/runtime/executions/exec-9",
putReq.RequestUri!.ToString());
Assert.Contains("\"action\":\"messageEventReceived\"", putBody);
Assert.Contains("\"messageName\":\"RegistratieIngetrokken\"", putBody);
// A PUT messageEventReceived on the execution trips the Beoordelen boundary event (ADR-0014).
Assert.Equal(HttpMethod.Put, capture.Seen!.Method);
Assert.Equal("http://flowable/flowable-rest/service/runtime/executions/exec-1",
capture.Seen.RequestUri!.ToString());
Assert.Equal("rest-admin:test", DecodeBasic(capture.Seen));
Assert.Contains("\"action\":\"messageEventReceived\"", capture.Body);
Assert.Contains("\"messageName\":\"RegistratieIngetrokken\"", capture.Body);
}
[Fact]
public async Task Withdraw_process_is_a_no_op_when_no_execution_is_subscribed()
public async Task Withdraw_beoordeling_throws_when_flowable_rejects_the_request()
{
var methods = new List<HttpMethod>();
var client = Client(new StubHandler(req =>
{
methods.Add(req.Method);
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"data":[]}""", Encoding.UTF8, "application/json"),
});
}));
var capture = new RequestCapture();
var client = Client(capture.Responds(HttpStatusCode.InternalServerError));
await client.WithdrawProcessAsync("pi-1");
// No subscribed execution → no message delivered (best-effort), no throw.
Assert.DoesNotContain(HttpMethod.Put, methods);
await Assert.ThrowsAsync<HttpRequestException>(() => client.WithdrawBeoordelingAsync("exec-1"));
}
[Fact]
+3 -3
View File
@@ -18,7 +18,7 @@ public class WerkbakTests
registration.AttachZaak(FakeAclClient.DefaultZaakUrl);
registration.TakeIntoBehandeling();
store.Seed(registration);
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", registration.Id)]);
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1",registration.Id)]);
var werkbak = new Werkbak(tasks, store);
var items = await werkbak.GetAsync();
@@ -41,7 +41,7 @@ public class WerkbakTests
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 tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1",RegistrationId.New())]);
var werkbak = new Werkbak(tasks, new FakeRegistrationStore());
Assert.Empty(await werkbak.GetAsync());
@@ -56,7 +56,7 @@ public class WerkbakTests
var registration = Registration.Submit("123456782");
registration.Withdraw();
store.Seed(registration);
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", registration.Id)]);
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1",registration.Id)]);
var werkbak = new Werkbak(tasks, store);
Assert.Empty(await werkbak.GetAsync());
@@ -5,101 +5,75 @@ namespace Big.Tests;
public class WithdrawRegistrationTests
{
private const string Bsn = "123456782";
private static Registration Submitted(string processInstanceId = "proc-1")
{
var registration = Registration.Submit(Bsn);
registration.RecordProcessStarted(processInstanceId);
return registration;
}
private static WithdrawRegistrationCommand Command(RegistrationId id, string bsn = Bsn) => new(id, bsn);
private static FakeUserTaskClient NoTasks() => new([]);
[Fact]
public async Task Withdrawing_marks_the_registration_ingetrokken_and_persists_it()
{
var store = new FakeRegistrationStore();
var registration = Submitted();
var registration = Registration.Submit("123456782");
store.Seed(registration);
var handler = new WithdrawRegistration(store, new FakeWorkflowClient());
var handler = new WithdrawRegistration(store, NoTasks());
var outcome = await handler.HandleAsync(Command(registration.Id));
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
Assert.Equal(WithdrawOutcome.Withdrawn, outcome);
var saved = await store.GetAsync(registration.Id);
Assert.Equal(RegistrationStatus.Ingetrokken, saved!.Status);
Assert.Equal(1, store.SaveCount);
}
[Fact]
public async Task Withdrawing_cancels_the_running_process()
public async Task Withdrawing_cancels_the_open_beoordelen_task_via_its_execution()
{
var store = new FakeRegistrationStore();
var registration = Submitted("proc-42");
var registration = Registration.Submit("123456782");
store.Seed(registration);
var workflow = new FakeWorkflowClient();
var handler = new WithdrawRegistration(store, workflow);
var tasks = new FakeUserTaskClient([new BeoordelingTask("task-1", "exec-1", registration.Id)]);
var handler = new WithdrawRegistration(store, tasks);
await handler.HandleAsync(Command(registration.Id));
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
// The process the registration recorded at submit is cancelled (ADR-0014).
Assert.Equal("proc-42", workflow.WithdrawnProcessInstanceId);
// The withdrawal message is delivered to the Beoordelen task's execution, tripping the
// boundary event that ends the process (ADR-0014).
Assert.Equal("exec-1", tasks.WithdrawnExecutionId);
}
[Fact]
public async Task Withdrawing_before_a_process_was_started_still_marks_ingetrokken()
public async Task Withdrawing_with_no_open_task_still_marks_ingetrokken()
{
// If the process has not parked at Beoordelen yet (or already ended), the withdrawal stands:
// the aggregate is INGETROKKEN and nothing is cancelled.
var store = new FakeRegistrationStore();
var registration = Registration.Submit(Bsn); // no RecordProcessStarted
var registration = Registration.Submit("123456782");
store.Seed(registration);
var workflow = new FakeWorkflowClient();
var handler = new WithdrawRegistration(store, workflow);
var tasks = NoTasks();
var handler = new WithdrawRegistration(store, tasks);
await handler.HandleAsync(Command(registration.Id));
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
Assert.Null(workflow.WithdrawnProcessInstanceId);
}
[Fact]
public async Task A_different_bsn_cannot_withdraw_the_registration()
{
// Owner-scoping: only the zorgprofessional who submitted may withdraw. Another bsn is told
// NotFound (we don't reveal the registration exists) and nothing is changed.
var store = new FakeRegistrationStore();
var registration = Submitted();
store.Seed(registration);
var workflow = new FakeWorkflowClient();
var handler = new WithdrawRegistration(store, workflow);
var outcome = await handler.HandleAsync(Command(registration.Id, bsn: "999999990"));
Assert.Equal(WithdrawOutcome.NotFound, outcome);
Assert.Equal(RegistrationStatus.Ingediend, (await store.GetAsync(registration.Id))!.Status);
Assert.Equal(0, store.SaveCount);
Assert.Null(workflow.WithdrawnProcessInstanceId);
Assert.Null(tasks.WithdrawnExecutionId);
}
[Fact]
public async Task Rejects_a_null_command_without_touching_the_store()
{
var store = new FakeRegistrationStore();
var handler = new WithdrawRegistration(store, new FakeWorkflowClient());
var handler = new WithdrawRegistration(store, NoTasks());
await Assert.ThrowsAsync<ArgumentNullException>(() => handler.HandleAsync(null!));
Assert.Equal(0, store.SaveCount);
}
[Fact]
public async Task Withdrawing_an_unknown_registration_is_not_found()
public async Task Withdrawing_an_unknown_registration_throws()
{
var store = new FakeRegistrationStore();
var handler = new WithdrawRegistration(store, new FakeWorkflowClient());
var handler = new WithdrawRegistration(store, NoTasks());
var outcome = await handler.HandleAsync(Command(RegistrationId.New()));
Assert.Equal(WithdrawOutcome.NotFound, outcome);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => handler.HandleAsync(new WithdrawRegistrationCommand(RegistrationId.New())));
Assert.Contains("No registration", ex.Message);
Assert.Equal(0, store.SaveCount);
}
@@ -107,16 +81,14 @@ public class WithdrawRegistrationTests
public async Task Re_withdrawing_an_already_ingetrokken_registration_is_idempotent()
{
var store = new FakeRegistrationStore();
var registration = Submitted();
var registration = Registration.Submit("123456782");
store.Seed(registration);
var workflow = new FakeWorkflowClient();
var handler = new WithdrawRegistration(store, workflow);
var handler = new WithdrawRegistration(store, NoTasks());
await handler.HandleAsync(Command(registration.Id));
var second = await handler.HandleAsync(Command(registration.Id));
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
// The second withdrawal is a no-op: still Withdrawn, but the aggregate is not persisted again.
Assert.Equal(WithdrawOutcome.Withdrawn, second);
// The second withdrawal is a no-op: the aggregate is not persisted again.
Assert.Equal(1, store.SaveCount);
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
}
@@ -1,22 +0,0 @@
# language: en
# Drives S-11 (#12). A zorgprofessional withdraws their own submitted registration ("trek aanvraag
# in"): it advances to INGETROKKEN and its running workflow is cancelled (ADR-0014). Only the owner
# may withdraw — another bsn is told not-found. Exercised against in-memory stand-ins for the store
# and the Workflow Client; the live Flowable message correlation is verified by the domain check.
Feature: Een registratie intrekken
Als zorgprofessional wil ik mijn ingediende registratie kunnen intrekken
zodat een aanvraag die ik niet meer wil niet in behandeling blijft.
Scenario: De zorgprofessional trekt zijn eigen registratie in
Given a submitted registration with a running process
When the zorgprofessional withdraws it
Then the withdrawal succeeds
And the registration has status "INGETROKKEN"
And the running process is cancelled
Scenario: Een andere zorgprofessional kan de registratie niet intrekken
Given a submitted registration with a running process
When a different zorgprofessional tries to withdraw it
Then the withdrawal is reported not found
And the registration has status "INGEDIEND"
And the running process is not cancelled
@@ -1,59 +0,0 @@
using Acceptance.Support;
using Big.Application;
using Big.Domain;
using Reqnroll;
using Xunit;
namespace Acceptance.Steps;
/// <summary>Bindings for <c>EenRegistratieIntrekken.feature</c> (S-11). Submits a registration (which
/// records its process) and then applies the WithdrawRegistration 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 steps in the other features.</summary>
[Binding]
[Scope(Feature = "Een registratie intrekken")]
public sealed class EenRegistratieIntrekkenSteps
{
private const string OwnerBsn = "123456782";
private readonly InMemoryRegistrationStore _store = new();
private readonly InMemoryWorkflowClient _workflow = new();
private RegistrationId _id;
private WithdrawOutcome _outcome;
[Given("a submitted registration with a running process")]
public async Task GivenASubmittedRegistrationWithARunningProcess()
=> _id = await new SubmitRegistration(_store, _workflow).HandleAsync(new SubmitRegistrationCommand(OwnerBsn));
[When("the zorgprofessional withdraws it")]
public async Task WhenTheZorgprofessionalWithdrawsIt()
=> _outcome = await new WithdrawRegistration(_store, _workflow).HandleAsync(
new WithdrawRegistrationCommand(_id, OwnerBsn));
[When("a different zorgprofessional tries to withdraw it")]
public async Task WhenADifferentZorgprofessionalTriesToWithdrawIt()
=> _outcome = await new WithdrawRegistration(_store, _workflow).HandleAsync(
new WithdrawRegistrationCommand(_id, "999999990"));
[Then("the withdrawal succeeds")]
public void ThenTheWithdrawalSucceeds() => Assert.Equal(WithdrawOutcome.Withdrawn, _outcome);
[Then("the withdrawal is reported not found")]
public void ThenTheWithdrawalIsReportedNotFound() => Assert.Equal(WithdrawOutcome.NotFound, _outcome);
[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 running process is cancelled")]
public void ThenTheRunningProcessIsCancelled()
=> Assert.Equal(InMemoryWorkflowClient.StartedProcessInstanceId, _workflow.WithdrawnProcessInstanceId);
[Then("the running process is not cancelled")]
public void ThenTheRunningProcessIsNotCancelled()
=> Assert.Null(_workflow.WithdrawnProcessInstanceId);
}
@@ -69,9 +69,6 @@ public sealed class CapturingDomainClient : IDomainClient
return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend"));
}
public Task<bool> WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default)
=> Task.FromResult(true);
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<WerkbakItem>>([]);
@@ -11,19 +11,12 @@ public sealed class InMemoryWorkflowClient : IWorkflowClient
public const string StartedProcessInstanceId = "proc-acc-1";
public RegistrationId? StartedFor { get; private set; }
public string? WithdrawnProcessInstanceId { get; private set; }
public Task<string> StartRegistrationProcessAsync(RegistrationId registrationId, CancellationToken ct = default)
{
StartedFor = registrationId;
return Task.FromResult(StartedProcessInstanceId);
}
public Task WithdrawProcessAsync(string processInstanceId, CancellationToken ct = default)
{
WithdrawnProcessInstanceId = processInstanceId;
return Task.CompletedTask;
}
}
/// <summary>An in-memory ACL stand-in: records the bsn it opened a zaak for and returns a fixed URL,
-27
View File
@@ -1,27 +0,0 @@
import { expect, test } from '@playwright/test';
// S-11 (Flow 3): a zorgprofessional logs in via mock DigiD, submits a registration, then withdraws
// it ("trek aanvraag in") from the self-service portal. The withdrawal goes portal → BFF (owner-
// scoped by the DigiD token's bsn) → domain, which cancels the running workflow (ADR-0014); the page
// then confirms the registration is ingetrokken.
test('DigiD submit → trek aanvraag in → self-service confirms ingetrokken', async ({ page }) => {
// Visiting the guarded page redirects to the Keycloak (mock DigiD) login.
await page.goto('/');
await page.locator('#username').fill('jan-burger');
await page.locator('#password').fill('test123');
await page.locator('#kc-login').click();
await expect(page.getByRole('heading', { name: /Zelfservice/i })).toBeVisible();
await page.getByRole('button', { name: /indienen/i }).click();
// The BFF accepted it and the page shows the confirmation with the reference.
await expect(page.getByText(/ontvangen/i)).toBeVisible();
// Withdraw it. The confirmation of withdrawal appears only after the decide POST completes (204),
// so awaiting the "ingetrokken" text also proves the request landed — no premature navigation.
await page.getByRole('button', { name: /trek aanvraag in/i }).click();
await expect(page.getByText(/is ingetrokken/i)).toBeVisible();
});