Compare commits
9 Commits
fix/portal
...
feat/12-wi
| Author | SHA1 | Date | |
|---|---|---|---|
| ddfdedc302 | |||
| 66af21853f | |||
| b8ee6c6460 | |||
| 92b19e5d7b | |||
| 18ab868724 | |||
| edffbb4575 | |||
| 2a6874ef6d | |||
| 435db81bdf | |||
| 345191b1df |
@@ -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
|
# 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).
|
# 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
|
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
|
EXPOSE 80
|
||||||
|
|||||||
@@ -17,9 +17,5 @@ FROM nginx:1.27-alpine AS runtime
|
|||||||
COPY apps/openbaar/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY apps/openbaar/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY --from=build /src/dist/apps/openbaar/browser /usr/share/nginx/html
|
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).
|
# 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
|
EXPOSE 80
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
# 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).
|
# 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
|
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
|
EXPOSE 80
|
||||||
|
|||||||
@@ -3,29 +3,9 @@
|
|||||||
<utrecht-heading-1>Zelfservice — BIG-registratie</utrecht-heading-1>
|
<utrecht-heading-1>Zelfservice — BIG-registratie</utrecht-heading-1>
|
||||||
|
|
||||||
@if (submitted()) {
|
@if (submitted()) {
|
||||||
@if (withdrawn()) {
|
<p utrecht-paragraph role="status">
|
||||||
<p utrecht-paragraph role="status">
|
Uw registratie is ontvangen. Referentie: {{ reference() }}.
|
||||||
Uw registratie met referentie {{ reference() }} is ingetrokken.
|
</p>
|
||||||
</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 {
|
} @else {
|
||||||
<p utrecht-paragraph>U bent ingelogd met BSN {{ bsn() }}.</p>
|
<p utrecht-paragraph>U bent ingelogd met BSN {{ bsn() }}.</p>
|
||||||
@if (failed()) {
|
@if (failed()) {
|
||||||
|
|||||||
@@ -17,22 +17,12 @@ class FakeAuth extends AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function providers(
|
function providers(post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' }))) {
|
||||||
post = vi.fn().mockReturnValue(of({ registrationId: 'reg-9', status: 'Ingediend' })),
|
|
||||||
withdraw = vi.fn().mockReturnValue(of(undefined)),
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
post,
|
post,
|
||||||
withdraw,
|
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: AuthService, useClass: FakeAuth },
|
{ provide: AuthService, useClass: FakeAuth },
|
||||||
{
|
{ provide: BffApiV1Service, useValue: { postSelfServiceRegistrations: post } },
|
||||||
provide: BffApiV1Service,
|
|
||||||
useValue: {
|
|
||||||
postSelfServiceRegistrations: post,
|
|
||||||
postSelfServiceRegistrationsIdWithdraw: withdraw,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -66,36 +56,6 @@ describe('RegistrationPage', () => {
|
|||||||
expect(screen.getByRole('button', { name: /indienen/i })).toBeTruthy();
|
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 () => {
|
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
|
// 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.
|
// 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
|
* 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-
|
* 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
|
* submit flow that posts to the BFF and shows the returned reference (ADR-0010; S-08c).
|
||||||
* submitting they can withdraw it — "trek aanvraag in" — keyed by that reference (S-11c).
|
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-registration-page',
|
selector: 'app-registration-page',
|
||||||
@@ -23,9 +22,6 @@ export class RegistrationPage {
|
|||||||
protected readonly reference = signal<string | undefined>(undefined);
|
protected readonly reference = signal<string | undefined>(undefined);
|
||||||
protected readonly submitted = signal(false);
|
protected readonly submitted = signal(false);
|
||||||
protected readonly failed = signal(false);
|
protected readonly failed = signal(false);
|
||||||
protected readonly withdrawing = signal(false);
|
|
||||||
protected readonly withdrawn = signal(false);
|
|
||||||
protected readonly withdrawFailed = signal(false);
|
|
||||||
|
|
||||||
submit(): void {
|
submit(): void {
|
||||||
this.submitting.set(true);
|
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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 →
|
> 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
|
> 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.
|
> 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.
|
|
||||||
|
|||||||
@@ -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).
|
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
|
The full DigiD-submit → behandel-decide → public INGESCHREVEN round-trip is the Playwright happy
|
||||||
path.
|
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`.
|
|
||||||
|
|||||||
@@ -17,12 +17,7 @@
|
|||||||
#
|
#
|
||||||
# Port map (host):
|
# Port map (host):
|
||||||
# 8000 OpenZaak · 8001 Open Notificaties · 8080 BFF · 8090 Flowable REST
|
# 8000 OpenZaak · 8001 Open Notificaties · 8080 BFF · 8090 Flowable REST
|
||||||
# 8100 ACL · 8130 Domain · 8180 Keycloak (all admin: admin / admin — dev only)
|
# 8100 ACL · 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.
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
@@ -210,12 +205,6 @@ services:
|
|||||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||||
KC_HEALTH_ENABLED: "true"
|
KC_HEALTH_ENABLED: "true"
|
||||||
KC_HTTP_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:
|
ports:
|
||||||
- "8180:8080"
|
- "8180:8080"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -306,14 +295,6 @@ services:
|
|||||||
context: ../services/bff
|
context: ../services/bff
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: register-referentie/bff:dev
|
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:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -322,39 +303,6 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 10s
|
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]
|
networks: [cg]
|
||||||
|
|
||||||
# ── Read projection (S-06) ────────────────────────────────────────────────
|
# ── Read projection (S-06) ────────────────────────────────────────────────
|
||||||
@@ -380,10 +328,6 @@ services:
|
|||||||
image: register-referentie/event-subscriber:dev
|
image: register-referentie/event-subscriber:dev
|
||||||
environment:
|
environment:
|
||||||
ConnectionStrings__Projection: Host=projection-db;Database=projection;Username=projection;Password=projection
|
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}
|
EventSubscriber__Webhook__AuthToken: ${NOTIFICATION_WEBHOOK_TOKEN:-Bearer big-reference-notifications}
|
||||||
ports:
|
ports:
|
||||||
- "8110:8080"
|
- "8110:8080"
|
||||||
@@ -396,8 +340,6 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
projection-db:
|
projection-db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
acl:
|
|
||||||
condition: service_healthy
|
|
||||||
networks: [cg]
|
networks: [cg]
|
||||||
|
|
||||||
projection-api:
|
projection-api:
|
||||||
@@ -420,73 +362,6 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks: [cg]
|
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:
|
volumes:
|
||||||
oz-db:
|
oz-db:
|
||||||
nrc-db:
|
nrc-db:
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"authority": "http://localhost:8180/realms/medewerker"
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"authority": "http://localhost:8180/realms/digid"
|
|
||||||
}
|
|
||||||
@@ -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; }
|
[ -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"
|
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 \
|
docker run --rm --network "$net" curlimages/curl:latest \
|
||||||
-fsS -X POST "http://$dom_ip:8080/registrations/$reg_id2/withdraw" \
|
-fsS -X POST "http://$dom_ip:8080/registrations/$reg_id2/withdraw" >/dev/null
|
||||||
-H 'Content-Type: application/json' -d '{"bsn":"123456782"}' >/dev/null
|
|
||||||
|
|
||||||
echo ">> asserting the process was cancelled (no Beoordelen task remains for the registration)"
|
echo ">> asserting the process was cancelled (no Beoordelen task remains for the registration)"
|
||||||
gone=""
|
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?: HttpClientBodyOptions): Observable<TData>;
|
||||||
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
|
||||||
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
|
||||||
|
|||||||
@@ -22,11 +22,6 @@ public interface IDomainClient
|
|||||||
{
|
{
|
||||||
Task<SubmitAccepted> SubmitRegistrationAsync(string bsn, CancellationToken ct = default);
|
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>
|
/// <summary>The behandelaar's werkbak — registrations awaiting beoordeling.</summary>
|
||||||
Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default);
|
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);
|
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)
|
public async Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||||
=> await http.GetFromJsonAsync<List<WerkbakItem>>("behandel/werkbak", ct) ?? [];
|
=> await http.GetFromJsonAsync<List<WerkbakItem>>("behandel/werkbak", ct) ?? [];
|
||||||
|
|
||||||
|
|||||||
@@ -86,24 +86,6 @@ app.MapPost("/self-service/registrations", async (ClaimsPrincipal user, IDomainC
|
|||||||
.Produces(StatusCodes.Status400BadRequest)
|
.Produces(StatusCodes.Status400BadRequest)
|
||||||
.Produces(StatusCodes.Status401Unauthorized);
|
.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).
|
// 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) =>
|
app.MapGet("/openbaar/register", async (string? q, IProjectionClient projection, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -82,18 +82,6 @@ internal sealed class FakeDomainClient : IDomainClient
|
|||||||
return Task.FromResult(Result);
|
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 (string RegistrationId, string Besluit)? Decided { get; private set; }
|
||||||
|
|
||||||
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||||
|
|||||||
@@ -71,46 +71,5 @@ public class SelfServiceEndpointTests
|
|||||||
Assert.Equal("reg-123", body!.RegistrationId);
|
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);
|
private sealed record SubmitAcceptedDto(string RegistrationId, string Status);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
"/openbaar/register": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -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,
|
// 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
|
// advancing it to INGETROKKEN. Idempotent. The BFF reaches this behind a digid token, owner-scoped
|
||||||
// forwards it from the DigiD token, S-11c); a registration that is unknown or not the caller's is
|
// to the caller's bsn (S-11c); the domain trusts its callers (§8.3). Cancelling the running Flowable
|
||||||
// 404 (indistinguishable, so ownership isn't leaked). Idempotent.
|
// process is a later sub-slice (S-11b).
|
||||||
app.MapPost("/registrations/{id}/withdraw", async (string id, WithdrawRequest body, WithdrawRegistration withdraw, CancellationToken ct) =>
|
app.MapPost("/registrations/{id}/withdraw", async (string id, WithdrawRegistration withdraw, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
if (!Guid.TryParse(id, out var guid))
|
if (!Guid.TryParse(id, out var guid))
|
||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(body?.Bsn))
|
await withdraw.HandleAsync(new WithdrawRegistrationCommand(new RegistrationId(guid)), ct);
|
||||||
return Results.BadRequest(new { error = "A bsn is required to withdraw a registration." });
|
return Results.NoContent();
|
||||||
|
|
||||||
var outcome = await withdraw.HandleAsync(new WithdrawRegistrationCommand(new RegistrationId(guid), body.Bsn), ct);
|
|
||||||
return outcome == WithdrawOutcome.Withdrawn ? Results.NoContent() : Results.NotFound();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open
|
// 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 DecideRequest(string Besluit);
|
||||||
|
|
||||||
public sealed record WithdrawRequest(string Bsn);
|
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
@@ -2,21 +2,8 @@ using Big.Domain;
|
|||||||
|
|
||||||
namespace Big.Application;
|
namespace Big.Application;
|
||||||
|
|
||||||
/// <summary>A zorgprofessional's request to withdraw their own registration ("trek aanvraag in").
|
/// <summary>A zorgprofessional's request to withdraw their own registration ("trek aanvraag in").</summary>
|
||||||
/// <paramref name="Bsn"/> is the authenticated caller (from the DigiD token, forwarded by the BFF):
|
public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId);
|
||||||
/// 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>
|
/// <summary>
|
||||||
/// The withdrawal use case (S-11): a zorgprofessional pulls a still-open registration back. It
|
/// The withdrawal use case (S-11): a zorgprofessional pulls a still-open registration back. It
|
||||||
@@ -29,19 +16,16 @@ public enum WithdrawOutcome
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class WithdrawRegistration(IRegistrationStore store, IWorkflowClient workflow)
|
public sealed class WithdrawRegistration(IRegistrationStore store, IWorkflowClient workflow)
|
||||||
{
|
{
|
||||||
public async Task<WithdrawOutcome> HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default)
|
public async Task HandleAsync(WithdrawRegistrationCommand command, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(command);
|
ArgumentNullException.ThrowIfNull(command);
|
||||||
|
|
||||||
var registration = await store.GetAsync(command.RegistrationId, ct);
|
var registration = await store.GetAsync(command.RegistrationId, ct)
|
||||||
|
?? throw new InvalidOperationException($"No registration {command.RegistrationId} to withdraw.");
|
||||||
// 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;
|
|
||||||
|
|
||||||
// A repeated withdrawal is a no-op: don't persist or cancel the already-withdrawn one again.
|
// A repeated withdrawal is a no-op: don't persist or cancel the already-withdrawn one again.
|
||||||
if (registration.Status == RegistrationStatus.Ingetrokken)
|
if (registration.Status == RegistrationStatus.Ingetrokken)
|
||||||
return WithdrawOutcome.Withdrawn;
|
return;
|
||||||
|
|
||||||
registration.Withdraw();
|
registration.Withdraw();
|
||||||
await store.SaveAsync(registration, ct);
|
await store.SaveAsync(registration, ct);
|
||||||
@@ -49,7 +33,5 @@ public sealed class WithdrawRegistration(IRegistrationStore store, IWorkflowClie
|
|||||||
// Cancel the running process (if one was started) so its Beoordelen task leaves the werkbak.
|
// Cancel the running process (if one was started) so its Beoordelen task leaves the werkbak.
|
||||||
if (registration.ProcessInstanceId is not null)
|
if (registration.ProcessInstanceId is not null)
|
||||||
await workflow.WithdrawProcessAsync(registration.ProcessInstanceId, ct);
|
await workflow.WithdrawProcessAsync(registration.ProcessInstanceId, ct);
|
||||||
|
|
||||||
return WithdrawOutcome.Withdrawn;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,17 +5,13 @@ namespace Big.Tests;
|
|||||||
|
|
||||||
public class WithdrawRegistrationTests
|
public class WithdrawRegistrationTests
|
||||||
{
|
{
|
||||||
private const string Bsn = "123456782";
|
|
||||||
|
|
||||||
private static Registration Submitted(string processInstanceId = "proc-1")
|
private static Registration Submitted(string processInstanceId = "proc-1")
|
||||||
{
|
{
|
||||||
var registration = Registration.Submit(Bsn);
|
var registration = Registration.Submit("123456782");
|
||||||
registration.RecordProcessStarted(processInstanceId);
|
registration.RecordProcessStarted(processInstanceId);
|
||||||
return registration;
|
return registration;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static WithdrawRegistrationCommand Command(RegistrationId id, string bsn = Bsn) => new(id, bsn);
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Withdrawing_marks_the_registration_ingetrokken_and_persists_it()
|
public async Task Withdrawing_marks_the_registration_ingetrokken_and_persists_it()
|
||||||
{
|
{
|
||||||
@@ -24,9 +20,8 @@ public class WithdrawRegistrationTests
|
|||||||
store.Seed(registration);
|
store.Seed(registration);
|
||||||
var handler = new WithdrawRegistration(store, new FakeWorkflowClient());
|
var handler = new WithdrawRegistration(store, new FakeWorkflowClient());
|
||||||
|
|
||||||
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);
|
var saved = await store.GetAsync(registration.Id);
|
||||||
Assert.Equal(RegistrationStatus.Ingetrokken, saved!.Status);
|
Assert.Equal(RegistrationStatus.Ingetrokken, saved!.Status);
|
||||||
Assert.Equal(1, store.SaveCount);
|
Assert.Equal(1, store.SaveCount);
|
||||||
@@ -41,7 +36,7 @@ public class WithdrawRegistrationTests
|
|||||||
var workflow = new FakeWorkflowClient();
|
var workflow = new FakeWorkflowClient();
|
||||||
var handler = new WithdrawRegistration(store, workflow);
|
var handler = new WithdrawRegistration(store, workflow);
|
||||||
|
|
||||||
await handler.HandleAsync(Command(registration.Id));
|
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
|
||||||
|
|
||||||
// The process the registration recorded at submit is cancelled (ADR-0014).
|
// The process the registration recorded at submit is cancelled (ADR-0014).
|
||||||
Assert.Equal("proc-42", workflow.WithdrawnProcessInstanceId);
|
Assert.Equal("proc-42", workflow.WithdrawnProcessInstanceId);
|
||||||
@@ -50,37 +45,20 @@ public class WithdrawRegistrationTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Withdrawing_before_a_process_was_started_still_marks_ingetrokken()
|
public async Task Withdrawing_before_a_process_was_started_still_marks_ingetrokken()
|
||||||
{
|
{
|
||||||
|
// A registration with no recorded process (e.g. start never happened) can still be withdrawn;
|
||||||
|
// there is simply no process to cancel.
|
||||||
var store = new FakeRegistrationStore();
|
var store = new FakeRegistrationStore();
|
||||||
var registration = Registration.Submit(Bsn); // no RecordProcessStarted
|
var registration = Registration.Submit("123456782"); // no RecordProcessStarted
|
||||||
store.Seed(registration);
|
store.Seed(registration);
|
||||||
var workflow = new FakeWorkflowClient();
|
var workflow = new FakeWorkflowClient();
|
||||||
var handler = new WithdrawRegistration(store, workflow);
|
var handler = new WithdrawRegistration(store, workflow);
|
||||||
|
|
||||||
await handler.HandleAsync(Command(registration.Id));
|
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
|
||||||
|
|
||||||
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
|
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
|
||||||
Assert.Null(workflow.WithdrawnProcessInstanceId);
|
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Rejects_a_null_command_without_touching_the_store()
|
public async Task Rejects_a_null_command_without_touching_the_store()
|
||||||
{
|
{
|
||||||
@@ -92,14 +70,14 @@ public class WithdrawRegistrationTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Withdrawing_an_unknown_registration_is_not_found()
|
public async Task Withdrawing_an_unknown_registration_throws()
|
||||||
{
|
{
|
||||||
var store = new FakeRegistrationStore();
|
var store = new FakeRegistrationStore();
|
||||||
var handler = new WithdrawRegistration(store, new FakeWorkflowClient());
|
var handler = new WithdrawRegistration(store, new FakeWorkflowClient());
|
||||||
|
|
||||||
var outcome = await handler.HandleAsync(Command(RegistrationId.New()));
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => handler.HandleAsync(new WithdrawRegistrationCommand(RegistrationId.New())));
|
||||||
Assert.Equal(WithdrawOutcome.NotFound, outcome);
|
Assert.Contains("No registration", ex.Message);
|
||||||
Assert.Equal(0, store.SaveCount);
|
Assert.Equal(0, store.SaveCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,11 +90,10 @@ public class WithdrawRegistrationTests
|
|||||||
var workflow = new FakeWorkflowClient();
|
var workflow = new FakeWorkflowClient();
|
||||||
var handler = new WithdrawRegistration(store, workflow);
|
var handler = new WithdrawRegistration(store, workflow);
|
||||||
|
|
||||||
await handler.HandleAsync(Command(registration.Id));
|
await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id));
|
||||||
var second = await handler.HandleAsync(Command(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.
|
// The second withdrawal is a no-op: the aggregate is not persisted again.
|
||||||
Assert.Equal(WithdrawOutcome.Withdrawn, second);
|
|
||||||
Assert.Equal(1, store.SaveCount);
|
Assert.Equal(1, store.SaveCount);
|
||||||
Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status);
|
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"));
|
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)
|
public Task<IReadOnlyList<WerkbakItem>> GetWerkbakAsync(CancellationToken ct = default)
|
||||||
=> Task.FromResult<IReadOnlyList<WerkbakItem>>([]);
|
=> Task.FromResult<IReadOnlyList<WerkbakItem>>([]);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user