diff --git a/infra/run-domain-check.sh b/infra/run-domain-check.sh index 74d6122..329209e 100755 --- a/infra/run-domain-check.sh +++ b/infra/run-domain-check.sh @@ -142,8 +142,10 @@ 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" >/dev/null + -fsS -X POST "http://$dom_ip:8080/registrations/$reg_id2/withdraw" \ + -H 'Content-Type: application/json' -d '{"bsn":"123456782"}' >/dev/null echo ">> asserting the process was cancelled (no Beoordelen task remains for the registration)" gone="" diff --git a/libs/api-client/src/lib/generated/bff-api.ts b/libs/api-client/src/lib/generated/bff-api.ts index ca1d1c3..d227c97 100644 --- a/libs/api-client/src/lib/generated/bff-api.ts +++ b/libs/api-client/src/lib/generated/bff-api.ts @@ -192,6 +192,40 @@ export class BffApiV1Service { ); } + postSelfServiceRegistrationsIdWithdraw(id: string, options?: HttpClientBodyOptions): Observable; + postSelfServiceRegistrationsIdWithdraw(id: string, options?: HttpClientEventOptions): Observable>; + postSelfServiceRegistrationsIdWithdraw(id: string, options?: HttpClientResponseOptions): Observable>; + postSelfServiceRegistrationsIdWithdraw( + id: string, options?: HttpClientObserveOptions): Observable | AngularHttpResponse> { + if (options?.observe === 'events') { + return this.http.post( + `/self-service/registrations/${id}/withdraw`, + undefined,{ + ...(options as Omit, 'observe'>), + observe: 'events', + } + ); + } + + if (options?.observe === 'response') { + return this.http.post( + `/self-service/registrations/${id}/withdraw`, + undefined,{ + ...(options as Omit, 'observe'>), + observe: 'response', + } + ); + } + + return this.http.post( + `/self-service/registrations/${id}/withdraw`, + undefined,{ + ...(options as Omit, 'observe'>), + observe: 'body', + } + ); + } + getOpenbaarRegister(params?: GetOpenbaarRegisterParams, options?: HttpClientBodyOptions): Observable; getOpenbaarRegister(params?: GetOpenbaarRegisterParams, options?: HttpClientEventOptions): Observable>; getOpenbaarRegister(params?: GetOpenbaarRegisterParams, options?: HttpClientResponseOptions): Observable>; diff --git a/services/bff/Bff.Api/DownstreamClients.cs b/services/bff/Bff.Api/DownstreamClients.cs index 5c21c85..30b2429 100644 --- a/services/bff/Bff.Api/DownstreamClients.cs +++ b/services/bff/Bff.Api/DownstreamClients.cs @@ -22,6 +22,11 @@ public interface IDomainClient { Task SubmitRegistrationAsync(string bsn, CancellationToken ct = default); + /// Withdraw the caller's own registration ("trek aanvraag in"). Owner-scoped by + /// . Returns false 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. + Task WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default); + /// The behandelaar's werkbak — registrations awaiting beoordeling. Task> GetWerkbakAsync(CancellationToken ct = default); @@ -47,6 +52,17 @@ public sealed class DomainClient(HttpClient http) : IDomainClient return new SubmitAccepted(dto.RegistrationId, dto.Status); } + public async Task 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> GetWerkbakAsync(CancellationToken ct = default) => await http.GetFromJsonAsync>("behandel/werkbak", ct) ?? []; diff --git a/services/bff/Bff.Api/Program.cs b/services/bff/Bff.Api/Program.cs index 1f95d04..c4683ce 100644 --- a/services/bff/Bff.Api/Program.cs +++ b/services/bff/Bff.Api/Program.cs @@ -86,6 +86,24 @@ 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) => { diff --git a/services/bff/Bff.Tests/BffFactory.cs b/services/bff/Bff.Tests/BffFactory.cs index 3cf1e67..f108da8 100644 --- a/services/bff/Bff.Tests/BffFactory.cs +++ b/services/bff/Bff.Tests/BffFactory.cs @@ -82,6 +82,18 @@ internal sealed class FakeDomainClient : IDomainClient return Task.FromResult(Result); } + public (string RegistrationId, string Bsn)? Withdrawn { get; private set; } + + /// 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. + public bool WithdrawSucceeds { get; set; } = true; + + public Task 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> GetWerkbakAsync(CancellationToken ct = default) diff --git a/services/bff/Bff.Tests/SelfServiceEndpointTests.cs b/services/bff/Bff.Tests/SelfServiceEndpointTests.cs index fed9825..f7adaa9 100644 --- a/services/bff/Bff.Tests/SelfServiceEndpointTests.cs +++ b/services/bff/Bff.Tests/SelfServiceEndpointTests.cs @@ -71,5 +71,46 @@ 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); } diff --git a/services/bff/openapi.json b/services/bff/openapi.json index 272d9e3..989a7a1 100644 --- a/services/bff/openapi.json +++ b/services/bff/openapi.json @@ -30,6 +30,37 @@ } } }, + "/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": [ diff --git a/services/domain/Big.Api/Program.cs b/services/domain/Big.Api/Program.cs index 6b8dccb..46ed8fd 100644 --- a/services/domain/Big.Api/Program.cs +++ b/services/domain/Big.Api/Program.cs @@ -76,16 +76,19 @@ 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. 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) => +// 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) => { if (!Guid.TryParse(id, out var guid)) return Results.NotFound(); - await withdraw.HandleAsync(new WithdrawRegistrationCommand(new RegistrationId(guid)), ct); - return Results.NoContent(); + 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(); }); // The behandelaar's werkbak (S-12): the registrations awaiting beoordeling, read from the open @@ -113,6 +116,8 @@ 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; diff --git a/services/domain/Big.Application/WithdrawRegistration.cs b/services/domain/Big.Application/WithdrawRegistration.cs index 92f632f..9603b5b 100644 --- a/services/domain/Big.Application/WithdrawRegistration.cs +++ b/services/domain/Big.Application/WithdrawRegistration.cs @@ -2,8 +2,21 @@ using Big.Domain; namespace Big.Application; -/// A zorgprofessional's request to withdraw their own registration ("trek aanvraag in"). -public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId); +/// A zorgprofessional's request to withdraw their own registration ("trek aanvraag in"). +/// is the authenticated caller (from the DigiD token, forwarded by the BFF): +/// only the registration's own bsn may withdraw it. +public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId, string Bsn); + +/// The outcome of a withdrawal request. +public enum WithdrawOutcome +{ + /// The registration is now (or already was) INGETROKKEN. + Withdrawn, + + /// 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). + NotFound, +} /// /// The withdrawal use case (S-11): a zorgprofessional pulls a still-open registration back. It @@ -16,16 +29,19 @@ public sealed record WithdrawRegistrationCommand(RegistrationId RegistrationId); /// public sealed class WithdrawRegistration(IRegistrationStore store, IWorkflowClient workflow) { - public async Task 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) - ?? throw new InvalidOperationException($"No registration {command.RegistrationId} to withdraw."); + 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; // A repeated withdrawal is a no-op: don't persist or cancel the already-withdrawn one again. if (registration.Status == RegistrationStatus.Ingetrokken) - return; + return WithdrawOutcome.Withdrawn; registration.Withdraw(); await store.SaveAsync(registration, ct); @@ -33,5 +49,7 @@ public sealed class WithdrawRegistration(IRegistrationStore store, IWorkflowClie // 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; } } diff --git a/services/domain/Big.Tests/WithdrawRegistrationTests.cs b/services/domain/Big.Tests/WithdrawRegistrationTests.cs index 99de8b9..7e73c69 100644 --- a/services/domain/Big.Tests/WithdrawRegistrationTests.cs +++ b/services/domain/Big.Tests/WithdrawRegistrationTests.cs @@ -5,13 +5,17 @@ namespace Big.Tests; public class WithdrawRegistrationTests { + private const string Bsn = "123456782"; + private static Registration Submitted(string processInstanceId = "proc-1") { - var registration = Registration.Submit("123456782"); + var registration = Registration.Submit(Bsn); registration.RecordProcessStarted(processInstanceId); return registration; } + private static WithdrawRegistrationCommand Command(RegistrationId id, string bsn = Bsn) => new(id, bsn); + [Fact] public async Task Withdrawing_marks_the_registration_ingetrokken_and_persists_it() { @@ -20,8 +24,9 @@ public class WithdrawRegistrationTests store.Seed(registration); var handler = new WithdrawRegistration(store, new FakeWorkflowClient()); - await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); + var outcome = await handler.HandleAsync(Command(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); @@ -36,7 +41,7 @@ public class WithdrawRegistrationTests var workflow = new FakeWorkflowClient(); var handler = new WithdrawRegistration(store, workflow); - await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); + await handler.HandleAsync(Command(registration.Id)); // The process the registration recorded at submit is cancelled (ADR-0014). Assert.Equal("proc-42", workflow.WithdrawnProcessInstanceId); @@ -45,20 +50,37 @@ public class WithdrawRegistrationTests [Fact] 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 registration = Registration.Submit("123456782"); // no RecordProcessStarted + var registration = Registration.Submit(Bsn); // no RecordProcessStarted store.Seed(registration); var workflow = new FakeWorkflowClient(); var handler = new WithdrawRegistration(store, workflow); - await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); + await handler.HandleAsync(Command(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); + } + [Fact] public async Task Rejects_a_null_command_without_touching_the_store() { @@ -70,14 +92,14 @@ public class WithdrawRegistrationTests } [Fact] - public async Task Withdrawing_an_unknown_registration_throws() + public async Task Withdrawing_an_unknown_registration_is_not_found() { var store = new FakeRegistrationStore(); var handler = new WithdrawRegistration(store, new FakeWorkflowClient()); - var ex = await Assert.ThrowsAsync( - () => handler.HandleAsync(new WithdrawRegistrationCommand(RegistrationId.New()))); - Assert.Contains("No registration", ex.Message); + var outcome = await handler.HandleAsync(Command(RegistrationId.New())); + + Assert.Equal(WithdrawOutcome.NotFound, outcome); Assert.Equal(0, store.SaveCount); } @@ -90,10 +112,11 @@ public class WithdrawRegistrationTests var workflow = new FakeWorkflowClient(); var handler = new WithdrawRegistration(store, workflow); - await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); - await handler.HandleAsync(new WithdrawRegistrationCommand(registration.Id)); + await handler.HandleAsync(Command(registration.Id)); + var second = await handler.HandleAsync(Command(registration.Id)); - // The second withdrawal is a no-op: the aggregate is not persisted again. + // The second withdrawal is a no-op: still Withdrawn, but the aggregate is not persisted again. + Assert.Equal(WithdrawOutcome.Withdrawn, second); Assert.Equal(1, store.SaveCount); Assert.Equal(RegistrationStatus.Ingetrokken, (await store.GetAsync(registration.Id))!.Status); } diff --git a/tests/acceptance/Support/BffAcceptanceHost.cs b/tests/acceptance/Support/BffAcceptanceHost.cs index b574377..5374b6f 100644 --- a/tests/acceptance/Support/BffAcceptanceHost.cs +++ b/tests/acceptance/Support/BffAcceptanceHost.cs @@ -69,6 +69,9 @@ public sealed class CapturingDomainClient : IDomainClient return Task.FromResult(new SubmitAccepted("reg-acc-1", "Ingediend")); } + public Task WithdrawRegistrationAsync(string registrationId, string bsn, CancellationToken ct = default) + => Task.FromResult(true); + public Task> GetWerkbakAsync(CancellationToken ct = default) => Task.FromResult>([]);