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 { 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> AuditLog() { var res = await _client.SendAsync(Admin(HttpMethod.Get, "/api/v1/admin/audit")); res.EnsureSuccessStatusCode(); return (await res.Content.ReadFromJsonAsync>())!; } private async Task 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())!.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 /// BriefEndpointTests.Submit_succeeds_when_required_sections_filled, 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)); } }