feat(behandelportal): WP-65a beoordeling detail (read) + fix unreachable medewerker login
CI / changes (pull_request) Successful in 17s
CI / lint (pull_request) Failing after 54s
CI / frontend (pull_request) Successful in 2m38s
CI / storybook-a11y (pull_request) Failing after 3m28s
CI / backend (pull_request) Successful in 2m1s
CI / semgrep (pull_request) Successful in 1m9s
CI / e2e (pull_request) Successful in 2m55s
CI / api-client-drift (pull_request) Successful in 2m1s

New GET /beoordeling/{id} shows one aanvraag's status, linked documents, and a
canBesluiten decision flag, gated by the same CanBeoordelen capability as the
werkvoorraad list. Reads through IZaakSource.ListCases rather than a new seam
method (WP-66 needs one anyway for the real write); owner BSN is masked.

Fixes a real gap found while wiring this up: the behandelportal's login was still
WP-61's copied citizen/BSN DigiD flow, so nothing ever sent X-Medewerker and the
werkvoorraad screen (WP-64) always denied in a real browser. A dev-only
medewerkerInterceptor (mirrors the existing ?role= stand-in as ?rollen=) fixes that.

WP-65's own Risks note authorized splitting read from write across sessions given
its size; this is the read half. The decision-recording mutation is next (65b).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-08-03 09:01:09 +02:00
co-authored by Claude Sonnet 5
parent fe69caee63
commit 4133b30e5d
29 changed files with 1195 additions and 58 deletions
@@ -0,0 +1,130 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using BigRegister.Api.Contracts;
using Microsoft.AspNetCore.Mvc.Testing;
namespace BigRegister.Tests;
/// WP-65 (read side): one aanvraag's case-treatment detail, gated by the same medewerker
/// capability (`CanBeoordelen`, WP-62) as the werkvoorraad list (WP-64).
public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
private static HttpRequestMessage AsBehandelaar(HttpMethod method, string path)
{
var req = new HttpRequestMessage(method, path);
req.Headers.Add("X-Medewerker", "medewerker-1");
return req;
}
private static MultipartFormDataContent UploadForm(string localId, string categoryId, string fileName)
{
var content = new MultipartFormDataContent();
var file = new ByteArrayContent(new byte[] { 1, 2, 3 });
file.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
content.Add(file, "file", fileName);
content.Add(new StringContent(categoryId), "categoryId");
content.Add(new StringContent(localId), "localId");
content.Add(new StringContent("registratie"), "wizardId");
return content;
}
/// A manual (never auto-approved) case with one linked document, so it stays
/// InBehandeling/decidable regardless of test timing (the 8s auto-approval window
/// would otherwise make a duo-registratie/herregistratie fixture flaky).
private async Task<(ApplicationDetailDto App, string DocumentId)> CreateManualCaseWithDocument()
{
var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" });
var a = (await created.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
var localId = Guid.NewGuid().ToString();
var upload = await _client.PostAsync("/api/v1/uploads", UploadForm(localId, "diploma", "diploma.pdf"));
upload.EnsureSuccessStatusCode();
var doc = (await upload.Content.ReadFromJsonAsync<UploadResponse>())!;
var submit = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new
{
diplomaHerkomst = "handmatig",
documents = new[] { new { categoryId = "diploma", channel = "digital", documentId = doc.DocumentId } },
});
submit.EnsureSuccessStatusCode();
return (a, doc.DocumentId!);
}
private Task DeleteAsAdmin(string id) => _client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/admin/cases/{id}")
{
Headers = { { "X-Role", "admin" } },
});
[Fact]
public async Task Detail_shows_status_documents_and_a_masked_owner()
{
var (a, documentId) = await CreateManualCaseWithDocument();
try
{
var res = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{a.Id}"));
res.EnsureSuccessStatusCode();
var view = (await res.Content.ReadFromJsonAsync<BeoordelingViewDto>())!;
Assert.Equal("InBehandeling", view.Aanvraag.Status.Tag);
Assert.Single(view.Documenten);
Assert.Equal(documentId, view.Documenten[0].DocumentId);
Assert.Equal("diploma", view.Documenten[0].CategoryId);
Assert.True(view.Decisions.CanBesluiten);
// masked: not empty, but not the full 9-digit BSN either
var owner = view.Aanvraag.Owner!;
Assert.NotEmpty(owner);
Assert.Contains('*', owner);
}
finally
{
await DeleteAsAdmin(a.Id);
}
}
[Fact]
public async Task Concept_and_unknown_id_are_not_found()
{
var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" });
var concept = (await created.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
try
{
var conceptRes = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{concept.Id}"));
Assert.Equal(HttpStatusCode.NotFound, conceptRes.StatusCode);
var unknownRes = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, "/api/v1/beoordeling/does-not-exist"));
Assert.Equal(HttpStatusCode.NotFound, unknownRes.StatusCode);
}
finally
{
await _client.DeleteAsync($"/api/v1/applications/{concept.Id}");
}
}
[Fact]
public async Task Zorgverlener_is_forbidden_even_with_admin_role()
{
var (a, _) = await CreateManualCaseWithDocument();
try
{
var req = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/beoordeling/{a.Id}");
req.Headers.Add("X-Role", "admin"); // admin role, but no X-Medewerker — still a zorgverlener
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(req)).StatusCode);
}
finally
{
await DeleteAsAdmin(a.Id);
}
}
[Fact]
public async Task Medewerker_without_behandelaar_rol_is_forbidden()
{
var req = new HttpRequestMessage(HttpMethod.Get, "/api/v1/beoordeling/anything");
req.Headers.Add("X-Medewerker", "medewerker-2");
req.Headers.Add("X-Rollen", "geen");
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(req)).StatusCode);
}
}