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
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:
@@ -126,6 +126,20 @@ public sealed record SubmitApplicationRequest(
|
||||
|
||||
public sealed record SubmitApplicationResponse(string Referentie, AanvraagStatusDto Status);
|
||||
|
||||
// --- Beoordeling (WP-65): the behandelportal's case-detail screen. ---
|
||||
|
||||
public sealed record BeoordelingDocumentDto(string DocumentId, string CategoryId, string FileName);
|
||||
|
||||
/// Decision flag (ADR-0001): the FE renders "may I decide", it never recomputes the
|
||||
/// lifecycle. One flag today because all three decision actions share one rule
|
||||
/// (BeoordelingRules.CanDecide); split into per-action flags if that ever diverges.
|
||||
public sealed record BeoordelingDecisionsDto(bool CanBesluiten);
|
||||
|
||||
public sealed record BeoordelingViewDto(
|
||||
ApplicationSummaryDto Aanvraag,
|
||||
IReadOnlyList<BeoordelingDocumentDto> Documenten,
|
||||
BeoordelingDecisionsDto Decisions);
|
||||
|
||||
// --- Brief (letter composition) contracts ---
|
||||
// Rich text is a serialisable node tree; the node union is flattened with a `Type`
|
||||
// discriminator + nullable fields, the same wire convention as AanvraagStatusDto.
|
||||
|
||||
@@ -83,6 +83,19 @@ public static class DocumentStore
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Documents by DocumentId (WP-65's beoordeling detail reads an aanvraag's already-
|
||||
/// linked documents) — the DocumentId-keyed counterpart of <see cref="ByLocalIds"/>, which is
|
||||
/// keyed by the wizard's own LocalId instead.</summary>
|
||||
public static IReadOnlyList<StoredDocument> ByIds(IEnumerable<string> documentIds)
|
||||
{
|
||||
var set = documentIds.ToHashSet();
|
||||
lock (_gate)
|
||||
{
|
||||
using var db = Db.Create();
|
||||
return db.Documents.Where(d => set.Contains(d.DocumentId)).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Persist the DRC url an OpenZaak upload (WP-51) registered for a document.</summary>
|
||||
public static void SetDrcUrl(string documentId, string drcUrl)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using BigRegister.Api.Data;
|
||||
|
||||
namespace BigRegister.Domain.Beoordeling;
|
||||
|
||||
/// <summary>
|
||||
/// SERVER-OWNED rules for the behandelportal's case-treatment decision (WP-65). Read-side
|
||||
/// today (<see cref="CanDecide"/> only, backing the beoordeling detail screen's decision
|
||||
/// flag) — the decision-recording rules (which besluit is legal, whether it needs a
|
||||
/// toelichting) land alongside the mutation endpoint in this WP's second half.
|
||||
/// </summary>
|
||||
public static class BeoordelingRules
|
||||
{
|
||||
/// A behandelaar may record a decision while the aanvraag is in an open, non-terminal
|
||||
/// status. Concept never reaches here (the endpoint 404s it before calling this); a case
|
||||
/// already `Goedgekeurd`/`Afgewezen` is final.
|
||||
public static bool CanDecide(AanvraagStatusTag current) =>
|
||||
current is AanvraagStatusTag.Ingediend or AanvraagStatusTag.InBehandeling
|
||||
or AanvraagStatusTag.MeerInfoGevraagd;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using System.Text.Json.Serialization;
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Api.Data;
|
||||
using BigRegister.Domain.Authorization;
|
||||
using BigRegister.Domain.Beoordeling;
|
||||
using BigRegister.Domain.Diplomas;
|
||||
using BigRegister.Domain.Documents;
|
||||
using BigRegister.Domain.Features;
|
||||
@@ -413,13 +414,34 @@ api.MapGet("/admin/cases", (HttpContext ctx, IZaakSource zaken) => CasesAdmin(ct
|
||||
// Cross-owner like /admin/cases, but gated by the medewerker capability (`CanBeoordelen`,
|
||||
// WP-62) rather than the admin role, and pre-filtered to the two "still open" status tags —
|
||||
// a behandelaar never needs to see a Concept (not their business yet) or a terminal case.
|
||||
api.MapGet("/werkvoorraad", (HttpContext ctx, IZaakSource zaken) => Werkvoorraad(ctx, () =>
|
||||
api.MapGet("/werkvoorraad", (HttpContext ctx, IZaakSource zaken) => Beoordelen(ctx, "werkvoorraad", () =>
|
||||
Results.Ok(zaken.ListCases(DateTimeOffset.UtcNow)
|
||||
.Where(c => c.Status.Tag is "Ingediend" or "InBehandeling")
|
||||
.ToList())))
|
||||
.Produces<List<ApplicationSummaryDto>>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden);
|
||||
|
||||
// --- Beoordeling (WP-65): one aanvraag's case-treatment detail — read side only (recording
|
||||
// a decision is WP-65's second half). Reads through IZaakSource.ListCases (no new seam method:
|
||||
// adding one now would force an OpenZaak get-by-id + mapper, which is WP-66's surface) — O(n)
|
||||
// over a POC-sized table. A Concept isn't a case a behandelaar can treat yet, so it 404s here
|
||||
// same as an unknown id (only /applications/{id}, citizen-scoped, shows a Concept).
|
||||
api.MapGet("/beoordeling/{id}", (string id, HttpContext ctx, IZaakSource zaken) =>
|
||||
Beoordelen(ctx, $"aanvraag/{id}", () =>
|
||||
{
|
||||
var c = zaken.ListCases(DateTimeOffset.UtcNow).FirstOrDefault(x => x.Id == id);
|
||||
if (c is null || c.Status.Tag == "Concept") return Results.NotFound();
|
||||
var docs = DocumentStore.ByIds(c.DocumentIds)
|
||||
.Select(d => new BeoordelingDocumentDto(d.DocumentId, d.CategoryId, d.FileName)).ToList();
|
||||
var masked = c with { Owner = MaskTail(c.Owner!, 3) };
|
||||
var decisions = new BeoordelingDecisionsDto(
|
||||
BeoordelingRules.CanDecide(Enum.Parse<AanvraagStatusTag>(c.Status.Tag)));
|
||||
return Results.Ok(new BeoordelingViewDto(masked, docs, decisions));
|
||||
}))
|
||||
.Produces<BeoordelingViewDto>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// OpenZaak's Notificaties API (NRC) calls this on every zaak event once an `abonnement` is
|
||||
// provisioned (WP-52, out-of-band — see openzaak-integration.md, no app code subscribes it).
|
||||
// The caller is NRC, not a user: no Principal, so this audits via AuthzAuditStore directly
|
||||
@@ -708,14 +730,15 @@ IResult CasesAdmin(HttpContext ctx, Func<IResult> action)
|
||||
statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
// One gate for the werkvoorraad read — the enforce twin of `CanBeoordelen` (WP-62/64).
|
||||
// Unlike the other *Admin gates above, this checks the CallerIdentity directly (medewerker
|
||||
// rollen), not a role-only Principal — a zorgverlener with X-Role=admin still gets denied.
|
||||
IResult Werkvoorraad(HttpContext ctx, Func<IResult> action)
|
||||
// One gate for every behandelaar endpoint (werkvoorraad, WP-64; beoordeling detail, WP-65) —
|
||||
// the enforce twin of `CanBeoordelen` (WP-62). Unlike the other *Admin gates above, this
|
||||
// checks the CallerIdentity directly (medewerker rollen), not a role-only Principal — a
|
||||
// zorgverlener with X-Role=admin still gets denied. `resource` feeds the denial's audit row.
|
||||
IResult Beoordelen(HttpContext ctx, string resource, Func<IResult> action)
|
||||
{
|
||||
if (Authz.CanBeoordelen(ctx.Caller())) return action();
|
||||
AuditAuthz(ctx, "aanvraag:beoordelen", "werkvoorraad", false, Authz.ResolvePrincipal(ctx));
|
||||
return Results.Problem(detail: "Alleen een behandelaar mag de werkvoorraad bekijken.",
|
||||
AuditAuthz(ctx, "aanvraag:beoordelen", resource, false, Authz.ResolvePrincipal(ctx));
|
||||
return Results.Problem(detail: "Alleen een behandelaar mag aanvragen beoordelen.",
|
||||
statusCode: StatusCodes.Status403Forbidden);
|
||||
}
|
||||
|
||||
|
||||
@@ -798,6 +798,48 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/beoordeling/{id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BeoordelingViewDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/admin/cases/{id}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
@@ -1606,6 +1648,52 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"BeoordelingDecisionsDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"canBesluiten": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"BeoordelingDocumentDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"documentId": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"categoryId": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"fileName": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"BeoordelingViewDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"aanvraag": {
|
||||
"$ref": "#/components/schemas/ApplicationSummaryDto"
|
||||
},
|
||||
"documenten": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/BeoordelingDocumentDto"
|
||||
},
|
||||
"nullable": true
|
||||
},
|
||||
"decisions": {
|
||||
"$ref": "#/components/schemas/BeoordelingDecisionsDto"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"BriefDecisionsDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using BigRegister.Api.Data;
|
||||
using BigRegister.Domain.Beoordeling;
|
||||
using BigRegister.Domain.Diplomas;
|
||||
using BigRegister.Domain.Documents;
|
||||
using BigRegister.Domain.Registrations;
|
||||
@@ -179,3 +181,15 @@ public class SubmissionRuleTests
|
||||
public void Phone_change_is_validated(string telefoon, string? expected) =>
|
||||
Assert.Equal(expected, SubmissionRules.RejectPhoneChange(telefoon));
|
||||
}
|
||||
|
||||
public class BeoordelingRuleTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(AanvraagStatusTag.Ingediend, true)]
|
||||
[InlineData(AanvraagStatusTag.InBehandeling, true)]
|
||||
[InlineData(AanvraagStatusTag.MeerInfoGevraagd, true)]
|
||||
[InlineData(AanvraagStatusTag.Goedgekeurd, false)]
|
||||
[InlineData(AanvraagStatusTag.Afgewezen, false)]
|
||||
public void Only_open_statuses_are_decidable(AanvraagStatusTag tag, bool expected) =>
|
||||
Assert.Equal(expected, BeoordelingRules.CanDecide(tag));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user