using System.Net; using System.Net.Http.Json; using BigRegister.Api.Contracts; using BigRegister.Api.Data; using Microsoft.AspNetCore.Mvc.Testing; namespace BigRegister.Tests; /// /// 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. /// public class BriefEndpointTests(TestWebApplicationFactory factory) : IClassFixture { 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 Get() { BriefStore.Reset(); var view = await _client.GetFromJsonAsync("/api/v1/brief"); 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; } [Fact] public async Task Get_creates_a_draft_with_expected_sections_locked_and_empty() { 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); } [Fact] public async Task Get_offers_only_global_and_arts_scoped_besluit_tagged_passages() { await Get(); var view = await _client.GetFromJsonAsync("/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 Get(); var view = await _client.GetFromJsonAsync("/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(); 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 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() { await Get(); // 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 Get(); var view = await _client.GetFromJsonAsync("/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(); Assert.NotNull(submitted); Assert.Equal("submitted", submitted.Brief.Status.Tag); // RB-07/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 (RB-02). 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 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(); var approved = await res.Content.ReadFromJsonAsync(); Assert.NotNull(approved); Assert.Equal("approved", approved.Brief.Status.Tag); } [Fact] public async Task Reject_returns_comments() { var brief = await Get(); 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(); 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 Get(); 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(); Assert.NotNull(reopened); 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(); var sent = await res.Content.ReadFromJsonAsync(); 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 Get(); var view = await _client.GetFromJsonAsync("/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(); 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("/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(); 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 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(); 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); } }