Compare commits

..

3 Commits

Author SHA1 Message Date
3afa5a552a test(verify): the domain withdrawal check sends the owner bsn (refs #12)
All checks were successful
CI / lint (pull_request) Successful in 1m16s
CI / build (pull_request) Successful in 1m13s
CI / unit (pull_request) Successful in 1m17s
CI / frontend (pull_request) Successful in 2m33s
CI / mutation (pull_request) Successful in 5m29s
CI / verify-stack (pull_request) Successful in 11m48s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 13:54:45 +02:00
8591b89e0c feat(bff): POST /self-service/registrations/{id}/withdraw, owner-scoped (refs #12)
The domain withdraw command carries the caller's bsn and owner-scopes the aggregate; unknown or

not-owned is 404 (indistinguishable). The BFF forwards the DigiD token's bsn and relays 204/404.

Regenerate the OpenAPI spec + Angular client for the new endpoint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 13:54:45 +02:00
248f57024e test(bff): self-service withdraw is owner-scoped and relays not-found (refs #12)
The withdraw handler returns an outcome and refuses a bsn that doesn't own the registration; the

BFF endpoint requires a digid token, forwards id+bsn, and relays the domain's 404.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 13:54:45 +02:00
11 changed files with 7 additions and 363 deletions

View File

@@ -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>
}
} @else {
<p utrecht-paragraph>U bent ingelogd met BSN {{ bsn() }}.</p>
@if (failed()) {

View File

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

View File

@@ -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);
},
});
}
}

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.

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

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) ────────────────────────────────────────────────
@@ -414,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:

View File

@@ -1,3 +0,0 @@
{
"authority": "http://localhost:8180/realms/medewerker"
}

View File

@@ -1,3 +0,0 @@
{
"authority": "http://localhost:8180/realms/digid"
}

View File

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

View File

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

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