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>
151 lines
6.8 KiB
C#
151 lines
6.8 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Net.Http.Json;
|
|
using System.Text.RegularExpressions;
|
|
using BigRegister.Api.Contracts;
|
|
using BigRegister.Api.Data;
|
|
using BigRegister.Domain.Features;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
|
|
namespace BigRegister.Tests;
|
|
|
|
/// The persisted authz/PII-reveal audit trail is queryable, data-minimised (no PII).
|
|
public class AuthzAuditTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
|
{
|
|
private readonly HttpClient _client = factory.CreateClient();
|
|
|
|
private HttpRequestMessage Admin(HttpMethod method, string path)
|
|
{
|
|
var req = new HttpRequestMessage(method, path);
|
|
req.Headers.Add("X-Role", "admin");
|
|
return req;
|
|
}
|
|
|
|
private async Task<List<AuthzAuditDto>> AuditLog()
|
|
{
|
|
var res = await _client.SendAsync(Admin(HttpMethod.Get, "/api/v1/admin/audit"));
|
|
res.EnsureSuccessStatusCode();
|
|
return (await res.Content.ReadFromJsonAsync<List<AuthzAuditDto>>())!;
|
|
}
|
|
|
|
private async Task<string> UploadAsOwner()
|
|
{
|
|
var form = new MultipartFormDataContent();
|
|
var file = new ByteArrayContent(new byte[] { 1, 2, 3 });
|
|
file.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
|
form.Add(file, "file", "diploma.pdf");
|
|
form.Add(new StringContent("diploma"), "categoryId");
|
|
form.Add(new StringContent("local-rb08"), "localId");
|
|
form.Add(new StringContent("registratie"), "wizardId");
|
|
var res = await _client.PostAsync("/api/v1/uploads", form);
|
|
res.EnsureSuccessStatusCode();
|
|
return (await res.Content.ReadFromJsonAsync<UploadResponse>())!.DocumentId;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_denied_admin_action_is_recorded()
|
|
{
|
|
// No X-Role → drafter → 403 on an admin endpoint → a deny entry.
|
|
Assert.Equal(HttpStatusCode.Forbidden, (await _client.GetAsync("/api/v1/admin/cases")).StatusCode);
|
|
Assert.Contains(await AuditLog(), e => e.Action == "cases:manage" && e.Decision == "deny");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_reveal_attempt_is_recorded()
|
|
{
|
|
// Drafter (capable role) without X-Step-Up → reveal denied → recorded.
|
|
var res = await _client.PostAsync("/api/v1/brief/reveal-bignummer", null);
|
|
Assert.Equal(HttpStatusCode.Forbidden, res.StatusCode);
|
|
Assert.Contains(await AuditLog(), e => e.Action == "brief:reveal-bignummer");
|
|
}
|
|
|
|
/// BIO-007: the trail used to record only denials, so `/beheer/audit` could answer
|
|
/// "who was turned away" but not "who changed this" — for a register whose integrity is the
|
|
/// product, the wrong half. Every gate now audits the real decision.
|
|
[Fact]
|
|
public async Task An_allowed_admin_action_is_recorded()
|
|
{
|
|
(await _client.SendAsync(Admin(HttpMethod.Get, "/api/v1/admin/cases"))).EnsureSuccessStatusCode();
|
|
Assert.Contains(await AuditLog(), e => e.Action == "cases:manage" && e.Decision == "allow" && e.Role == "Admin");
|
|
}
|
|
|
|
/// BIO-003: the admin upload delete used to be gated by a standalone X-Admin
|
|
/// header, outside Authz and writing no AuthzAuditStore row at all. Routing it through
|
|
/// CasesAdmin (cases:manage) gives it the same allow-path row every other admin-cases
|
|
/// endpoint gets, for free. `CasesAdmin` audits under a fixed "cases"
|
|
/// resource shared with the other admin-cases endpoints, so this asserts a **count**
|
|
/// increase — reading the store directly (not via `GET /admin/audit`, itself a
|
|
/// `CasesAdmin` endpoint that would write its own row and confound the count) —
|
|
/// rather than mere presence, which this class's other cases:manage calls would
|
|
/// already satisfy even without the fix.
|
|
[Fact]
|
|
public async Task An_admin_upload_delete_is_recorded()
|
|
{
|
|
bool IsCasesManageAllow(AuthzAuditEntry e) =>
|
|
e.Action == "cases:manage" && e.Decision == "allow" && e.Role == "Admin";
|
|
|
|
var documentId = await UploadAsOwner();
|
|
var before = AuthzAuditStore.List().Count(IsCasesManageAllow);
|
|
|
|
(await _client.SendAsync(Admin(HttpMethod.Delete, $"/api/v1/admin/uploads/{documentId}")))
|
|
.EnsureSuccessStatusCode();
|
|
|
|
Assert.Equal(before + 1, AuthzAuditStore.List().Count(IsCasesManageAllow));
|
|
}
|
|
|
|
/// The flag toggle writes no log line of its own, so the audit row is the only record that
|
|
/// it happened — a bare "feature-flags" resource would not say which flag.
|
|
[Fact]
|
|
public async Task A_feature_flag_toggle_records_which_flag_changed()
|
|
{
|
|
var toggle = Admin(HttpMethod.Put, $"/api/v1/admin/flags/{FeatureFlags.InschrijvingOpen}");
|
|
toggle.Content = JsonContent.Create(new { enabled = false });
|
|
(await _client.SendAsync(toggle)).EnsureSuccessStatusCode();
|
|
|
|
Assert.Contains(await AuditLog(), e =>
|
|
e.Action == "flags:manage" && e.Decision == "allow" &&
|
|
e.Resource == $"feature-flags/{FeatureFlags.InschrijvingOpen}=False");
|
|
}
|
|
|
|
/// Every brief transition funnels through LogBrief, so all four are covered by the audit
|
|
/// call living there. The allow side is asserted in
|
|
/// <c>BriefEndpointTests.Submit_succeeds_when_required_sections_filled</c>, which already has
|
|
/// the fill-the-sections scaffolding; this is the refused side — a rejected transition must
|
|
/// leave a row rather than being dropped.
|
|
[Fact]
|
|
public async Task A_refused_brief_transition_is_recorded()
|
|
{
|
|
// No brief exists for this subject and nothing is filled in → illegal transition.
|
|
Assert.Equal(HttpStatusCode.Conflict, (await _client.PostAsync("/api/v1/brief/submit", null)).StatusCode);
|
|
Assert.Contains(await AuditLog(), e => e.Action == "brief:submit" && e.Decision == "deny");
|
|
}
|
|
|
|
/// BIO-008: the schema test below asserts on **column names**, so a BSN inside a
|
|
/// column called `Resource` was invisible to it — and one was there, concatenated as
|
|
/// `"brief/" + Bsn`. This asserts on the stored **values** instead. Four documents
|
|
/// promise this trail holds no PII; this is the test that makes the promise checkable.
|
|
[Fact]
|
|
public async Task No_audit_row_carries_a_subjects_bsn()
|
|
{
|
|
const string subject = "999999990";
|
|
var reveal = new HttpRequestMessage(HttpMethod.Post, "/api/v1/brief/reveal-bignummer");
|
|
reveal.Headers.Add("X-Subject", subject);
|
|
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(reveal)).StatusCode);
|
|
|
|
var bsns = new[] { subject, DocumentStore.DemoOwner };
|
|
foreach (var e in await AuditLog())
|
|
foreach (var field in new[] { e.Action, e.Resource, e.Decision, e.Role, e.At, e.CorrelationId })
|
|
Assert.DoesNotContain(bsns, bsn => field.Contains(bsn, StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void The_audit_schema_carries_no_pii()
|
|
{
|
|
var names = typeof(AuthzAuditEntry).GetProperties().Select(p => p.Name).ToArray();
|
|
Assert.Equal(
|
|
new[] { "At", "Action", "Resource", "Decision", "Role", "CorrelationId", "Id" }.OrderBy(x => x),
|
|
names.OrderBy(x => x));
|
|
Assert.DoesNotContain(names, n => Regex.IsMatch(n, "naam|name|bsn|value|waarde", RegexOptions.IgnoreCase));
|
|
}
|
|
}
|