All five authorization gates audited only their deny branch, so /beheer/audit could answer "who was turned away" but never "who changed this" — for a register whose integrity is the product, the wrong half. Nothing recorded the flag toggle, either org-template write, the admin case or upload delete, the three brief transitions, or the besluit; the comment claiming endpoints log their own effect held for two of the eight. Each gate now computes the decision once, audits it, and then acts. The row is written by the gate rather than the endpoint, so a new admin endpoint cannot be added that forgets to audit itself. Same reasoning for the brief: every transition already funnelled through LogBrief for its log line, so the audit row goes there too — submit/approve/reject/send in one place, with the transition's own outcome as the decision, so a 403 or 409 is as visible as a success. FlagsAdmin gained a per-call resource, the one deviation from BIO-007's minimal remediation: the toggle endpoint writes no log line of its own, so a constant "feature-flags" row would say a flag changed without saying which. It now records feature-flags/<key>=<value>. OrgAdmin and CasesAdmin keep coarse refs because those endpoints do log the specific object. The besluit gets a second row: the gate records that a behandelaar was allowed to act, aanvraag:besluit records what they decided. Row volume goes up — StamdataAdmin gates read endpoints, so admin page loads now write rows. That is what auditing the allow path means; it is also what would make retention on AuthzAuditStore necessary later. Closes CQ-004's outstanding half and unblocks signing ADR-C-009. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
288 lines
12 KiB
C#
288 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);
|
|
}
|
|
|
|
private async Task<BriefDto> Get()
|
|
{
|
|
BriefStore.Reset();
|
|
var view = await _client.GetFromJsonAsync<BriefViewDto>("/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<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 Get();
|
|
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 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<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);
|
|
|
|
// 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<BriefViewDto>();
|
|
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<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 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<BriefViewDto>();
|
|
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<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 Get();
|
|
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 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.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);
|
|
}
|
|
}
|