- fix(deps): pin @babel/core ^7.29.7 via overrides → npm audit 0 (dev+prod),
no --force / no Angular downgrade; README corrected
- feat(brief): field-level PII reveal (PRD-0002 §5c) — CaseContext BIG-nummer
ships masked; step-up-stubbed (X-Step-Up), audited POST /brief/reveal-bignummer
unmasks it; drafter-only capability, deny-by-default. Realized on the BIG-nummer
(no BSN on the wire)
- feat(authz): no-PII AuditAuthz log for reveal attempts + org-admin denials (§8)
- feat(routes): wire capabilityGuard('orgtemplate:edit') onto brief/huisstijl (§6)
- test: backend +5 (Authz + reveal endpoint), FE +3 (adapter boundary, store swap)
- docs: PRD-0002 §5c/§9, WP-18 follow-up, README
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
238 lines
10 KiB
C#
238 lines
10 KiB
C#
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using BigRegister.Api.Contracts;
|
|
using BigRegister.Api.Data;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
|
|
namespace BigRegister.Tests;
|
|
|
|
/// <summary>
|
|
/// The brief is a single process-global demo entity, so each test resets it first
|
|
/// (tests within a class run sequentially in xUnit). Role is the dev-only X-Role
|
|
/// header: absent = drafter, "approver" = a different identity.
|
|
/// </summary>
|
|
public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
|
{
|
|
private readonly HttpClient _client = factory.CreateClient();
|
|
|
|
private static LetterBlockDto FreeText(string id) =>
|
|
new("freeText", id, new RichTextBlockDto(new[] { new ParagraphDto(new[] { new RichTextNodeDto("text", Text: "inhoud") }) }));
|
|
|
|
// Fill every REQUIRED section with a block so submit is allowed.
|
|
private static SaveBriefRequest FilledFrom(BriefDto brief)
|
|
{
|
|
var i = 0;
|
|
var sections = brief.Sections
|
|
.Select(s => new LetterSectionDto(s.SectionKey, s.Title, s.Required, s.Required ? new[] { FreeText($"local-{++i}") } : s.Blocks))
|
|
.ToList();
|
|
return new SaveBriefRequest(sections);
|
|
}
|
|
|
|
private async Task<BriefDto> Get()
|
|
{
|
|
BriefStore.Reset();
|
|
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
|
|
return view!.Brief;
|
|
}
|
|
|
|
private HttpRequestMessage Post(string path, string? role = null, object? body = null)
|
|
{
|
|
var req = new HttpRequestMessage(HttpMethod.Post, path);
|
|
if (role is not null) req.Headers.Add("X-Role", role);
|
|
if (body is not null) req.Content = JsonContent.Create(body);
|
|
return req;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Get_creates_a_draft_from_the_template_with_scoped_passages()
|
|
{
|
|
var brief = await Get();
|
|
Assert.Equal("draft", brief.Status.Tag);
|
|
Assert.Equal(new[] { "aanhef", "kern", "slot" }, brief.Sections.Select(s => s.SectionKey));
|
|
// aanhef + slot are locked, predefined and prefilled; only kern is editable + empty.
|
|
var aanhef = brief.Sections.Single(s => s.SectionKey == "aanhef");
|
|
Assert.True(aanhef.Locked);
|
|
Assert.NotEmpty(aanhef.Blocks);
|
|
Assert.True(brief.Sections.Single(s => s.SectionKey == "slot").Locked);
|
|
var kern = brief.Sections.Single(s => s.SectionKey == "kern");
|
|
Assert.False(kern.Locked);
|
|
Assert.Empty(kern.Blocks);
|
|
|
|
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
|
|
// global passages + the arts-scoped one; no other-beroep passages leak in.
|
|
Assert.Contains(view!.AvailablePassages, p => p.PassageId == "p-kern-arts");
|
|
Assert.All(view.AvailablePassages, p => Assert.True(p.Scope == "global" || p.Beroep == "arts"));
|
|
|
|
// Guided-drafting tags (WP-brief-v3): positief + negatief + reason-specific negatief.
|
|
Assert.Contains(view.AvailablePassages, p => p.Besluit == "positief");
|
|
Assert.Contains(view.AvailablePassages, p => p.Besluit == "negatief" && p.Reason == "onvoldoende_scholing");
|
|
|
|
// Case context is joined onto the screen DTO for the behandel scherm header.
|
|
// The BIG-nummer ships MASKED by default (PRD-0002 §5c) — reveal is a separate call.
|
|
Assert.Equal("********601", view.CaseContext.BigNummer);
|
|
Assert.Equal("arts", view.CaseContext.Beroep);
|
|
Assert.False(string.IsNullOrWhiteSpace(view.CaseContext.ZorgverlenerNaam));
|
|
Assert.False(string.IsNullOrWhiteSpace(view.CaseContext.AanvraagReferentie));
|
|
}
|
|
|
|
// --- Field-level PII reveal (PRD-0002 §5c/§5d, phase P2) ---
|
|
|
|
[Fact]
|
|
public async Task Reveal_returns_the_unmasked_BIG_nummer_for_the_drafter_with_step_up()
|
|
{
|
|
BriefStore.Reset();
|
|
var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/brief/reveal-bignummer");
|
|
req.Headers.Add("X-Step-Up", "true"); // no X-Role → drafter (the capable role)
|
|
var res = await _client.SendAsync(req);
|
|
|
|
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
|
|
var body = await res.Content.ReadFromJsonAsync<RevealBigNummerResponse>();
|
|
Assert.Equal("19012345601", body!.BigNummer);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Reveal_is_forbidden_without_the_step_up()
|
|
{
|
|
BriefStore.Reset();
|
|
var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/brief/reveal-bignummer"); // drafter, no step-up
|
|
var res = await _client.SendAsync(req);
|
|
Assert.Equal(HttpStatusCode.Forbidden, res.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Reveal_is_forbidden_for_a_role_without_the_capability()
|
|
{
|
|
BriefStore.Reset();
|
|
var req = new HttpRequestMessage(HttpMethod.Post, "/api/v1/brief/reveal-bignummer");
|
|
req.Headers.Add("X-Role", "approver");
|
|
req.Headers.Add("X-Step-Up", "true"); // capability missing → still denied
|
|
var res = await _client.SendAsync(req);
|
|
Assert.Equal(HttpStatusCode.Forbidden, res.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Save_is_drafter_only()
|
|
{
|
|
var brief = await Get();
|
|
var save = FilledFrom(brief);
|
|
|
|
var approver = Post("/api/v1/brief", role: "approver");
|
|
approver.Method = HttpMethod.Put;
|
|
approver.Content = JsonContent.Create(save);
|
|
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(approver)).StatusCode);
|
|
|
|
Assert.Equal(HttpStatusCode.OK, (await _client.PutAsJsonAsync("/api/v1/brief", save)).StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Submit_blocks_on_empty_required_section_then_succeeds_when_filled()
|
|
{
|
|
await Get();
|
|
// Nothing filled yet → required sections empty → 409.
|
|
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/submit"))).StatusCode);
|
|
|
|
var brief = (await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief"))!.Brief;
|
|
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
|
|
|
var res = await _client.SendAsync(Post("/api/v1/brief/submit"));
|
|
res.EnsureSuccessStatusCode();
|
|
var submitted = await res.Content.ReadFromJsonAsync<BriefViewDto>();
|
|
Assert.Equal("submitted", submitted!.Brief.Status.Tag);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Drafter_cannot_approve_own_letter_but_a_different_reviewer_can()
|
|
{
|
|
var brief = await Get();
|
|
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
|
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
|
|
|
// drafter role approving own letter → 403
|
|
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(Post("/api/v1/brief/approve"))).StatusCode);
|
|
|
|
var res = await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
|
|
res.EnsureSuccessStatusCode();
|
|
Assert.Equal("approved", (await res.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief.Status.Tag);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Reject_returns_comments_and_editing_reopens_to_draft()
|
|
{
|
|
var brief = await Get();
|
|
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
|
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
|
|
|
var rejected = await (await _client.SendAsync(
|
|
Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen.")))).Content.ReadFromJsonAsync<BriefViewDto>();
|
|
Assert.Equal("rejected", rejected!.Brief.Status.Tag);
|
|
Assert.Equal("Graag aanvullen.", rejected.Brief.Status.Comments);
|
|
|
|
// A drafter save on a rejected letter reopens it to draft.
|
|
var reopened = await (await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief))).Content.ReadFromJsonAsync<BriefViewDto>();
|
|
Assert.Equal("draft", reopened!.Brief.Status.Tag);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Send_only_from_approved()
|
|
{
|
|
var brief = await Get();
|
|
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
|
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
|
|
|
// submitted (not approved) → send 409
|
|
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/send"))).StatusCode);
|
|
|
|
await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
|
|
var res = await _client.SendAsync(Post("/api/v1/brief/send"));
|
|
res.EnsureSuccessStatusCode();
|
|
Assert.Equal("sent", (await res.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief.Status.Tag);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Decisions_on_the_view_mirror_the_acting_principal_and_live_status()
|
|
{
|
|
var brief = await Get();
|
|
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
|
|
Assert.True(view!.Decisions.CanEdit); // default (no X-Role) = drafter, draft status
|
|
Assert.False(view.Decisions.CanApprove);
|
|
|
|
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
|
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
|
|
|
var asApprover = await _client.SendAsync(
|
|
new HttpRequestMessage(HttpMethod.Get, "/api/v1/brief") { Headers = { { "X-Role", "approver" } } });
|
|
var approverView = await asApprover.Content.ReadFromJsonAsync<BriefViewDto>();
|
|
Assert.True(approverView!.Decisions.CanApprove);
|
|
Assert.False(approverView.Decisions.CanEdit); // approver never edits
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Me_returns_no_capabilities_for_drafter_and_the_brief_set_for_approver()
|
|
{
|
|
var asDrafter = await _client.GetFromJsonAsync<MeDto>("/api/v1/me");
|
|
Assert.Empty(asDrafter!.Capabilities);
|
|
|
|
var res = await _client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "/api/v1/me") { Headers = { { "X-Role", "approver" } } });
|
|
var asApprover = await res.Content.ReadFromJsonAsync<MeDto>();
|
|
Assert.Equal(new[] { "brief:approve", "brief:reject", "brief:send" }, asApprover!.Capabilities);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Reset_recreates_a_fresh_draft_with_locked_prefilled_sections()
|
|
{
|
|
var brief = await Get();
|
|
// Advance out of draft so the reset back to draft is observable.
|
|
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
|
|
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
|
|
|
var res = await _client.SendAsync(Post("/api/v1/brief/reset"));
|
|
res.EnsureSuccessStatusCode();
|
|
var view = await res.Content.ReadFromJsonAsync<BriefViewDto>();
|
|
Assert.Equal("draft", view!.Brief.Status.Tag);
|
|
var aanhef = view.Brief.Sections.Single(s => s.SectionKey == "aanhef");
|
|
Assert.True(aanhef.Locked);
|
|
Assert.NotEmpty(aanhef.Blocks);
|
|
Assert.Empty(view.Brief.Sections.Single(s => s.SectionKey == "kern").Blocks);
|
|
}
|
|
}
|