Files
atomic-design-poc/backend/tests/BigRegister.Tests/BriefEndpointTests.cs
T
ehoandClaude Sonnet 5 8560746d15 refactor: strip WP-/RB- ticket refs from backend (RD-19)
The backend half of the sweep RD-18 did for the front end. git blame
holds the provenance and stays correct when the code moves; the
comment names a closed ticket and tells the reader nothing the
sentence around it does not.

public/letter.css and LetterHtml.golden.html change together, because
the renderer inlines the CSS and the golden file snapshots the
result.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 21:48:08 +02:00

308 lines
12 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);
}
/// `GET /brief` no longer seeds a brief on first call, so every test that
/// needs one present creates it explicitly through `POST /brief/reset`
/// (`BriefStore.ResetAndCreate`) — the same command the "start over" affordance uses.
private async Task<BriefDto> SeedBrief()
{
BriefStore.Reset();
var res = await _client.PostAsync("/api/v1/brief/reset", null);
var view = await res.Content.ReadFromJsonAsync<BriefViewDto>();
Assert.NotNull(view);
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;
}
// --- CQ-007: GET /brief is a pure query — it must not create a row. ---
[Fact]
public async Task Get_returns_404_and_writes_no_row_when_no_brief_exists_for_the_owner()
{
BriefStore.Reset();
var res = await _client.GetAsync("/api/v1/brief");
Assert.Equal(HttpStatusCode.NotFound, res.StatusCode);
// The non-idempotent write CQ-007 flagged: a GET that allocated a row on first call.
// Assert directly against the store, not only the HTTP status, so a regression that
// reintroduces GetOrCreate-style seeding fails here even if the response shape stays 404.
Assert.Null(BriefStore.Get(DocumentStore.DemoOwner));
}
[Fact]
public async Task SeedBrief_creates_a_draft_with_expected_sections_locked_and_empty()
{
var brief = await SeedBrief();
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);
}
[Fact]
public async Task Get_offers_only_global_and_arts_scoped_besluit_tagged_passages()
{
await SeedBrief();
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
Assert.NotNull(view);
// 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");
}
[Fact]
public async Task Get_joins_the_case_context_with_the_BIG_nummer_masked()
{
await SeedBrief();
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
Assert.NotNull(view);
// 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.NotNull(body);
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 SeedBrief();
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()
{
await SeedBrief();
// Nothing filled yet → required sections empty → 409.
Assert.Equal(HttpStatusCode.Conflict, (await _client.SendAsync(Post("/api/v1/brief/submit"))).StatusCode);
}
[Fact]
public async Task Submit_succeeds_when_required_sections_filled()
{
await SeedBrief();
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
Assert.NotNull(view);
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(view.Brief));
var res = await _client.SendAsync(Post("/api/v1/brief/submit"));
res.EnsureSuccessStatusCode();
var submitted = await res.Content.ReadFromJsonAsync<BriefViewDto>();
Assert.NotNull(submitted);
Assert.Equal("submitted", submitted.Brief.Status.Tag);
// BIO-007: the allow side of the transition leaves a row, not just a log line.
// Resource is the bare "brief" — never the owner's BSN.
Assert.Contains(AuthzAuditStore.List(),
e => e.Action == "brief:submit" && e.Decision == "allow" && e.Resource == "brief");
}
[Fact]
public async Task Drafter_cannot_approve_own_letter_but_a_different_reviewer_can()
{
var brief = await SeedBrief();
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();
var approved = await res.Content.ReadFromJsonAsync<BriefViewDto>();
Assert.NotNull(approved);
Assert.Equal("approved", approved.Brief.Status.Tag);
}
[Fact]
public async Task Reject_returns_comments()
{
var brief = await SeedBrief();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
var rejectRes = await _client.SendAsync(
Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen.")));
var rejected = await rejectRes.Content.ReadFromJsonAsync<BriefViewDto>();
Assert.NotNull(rejected);
Assert.Equal("rejected", rejected.Brief.Status.Tag);
Assert.Equal("Graag aanvullen.", rejected.Brief.Status.Comments);
}
[Fact]
public async Task Editing_a_rejected_letter_reopens_it_to_draft()
{
var brief = await SeedBrief();
await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
await _client.SendAsync(Post("/api/v1/brief/submit"));
await _client.SendAsync(
Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen.")));
// A drafter save on a rejected letter reopens it to draft.
var putRes = await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief));
var reopened = await putRes.Content.ReadFromJsonAsync<BriefViewDto>();
Assert.NotNull(reopened);
Assert.Equal("draft", reopened.Brief.Status.Tag);
}
[Fact]
public async Task Send_only_from_approved()
{
var brief = await SeedBrief();
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();
var sent = await res.Content.ReadFromJsonAsync<BriefViewDto>();
Assert.NotNull(sent);
Assert.Equal("sent", sent.Brief.Status.Tag);
}
[Fact]
public async Task Decisions_on_the_view_mirror_the_acting_principal_and_live_status()
{
var brief = await SeedBrief();
var view = await _client.GetFromJsonAsync<BriefViewDto>("/api/v1/brief");
Assert.NotNull(view);
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.NotNull(approverView);
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.NotNull(asDrafter);
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.NotNull(asApprover);
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 SeedBrief();
// 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.NotNull(view);
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);
}
}