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>
289 lines
11 KiB
C#
289 lines
11 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Net.Http.Json;
|
|
using BigRegister.Api.Contracts;
|
|
using BigRegister.Api.Data;
|
|
using Microsoft.AspNetCore.Mvc.Testing;
|
|
|
|
namespace BigRegister.Tests;
|
|
|
|
/// WP-65 (read side): one aanvraag's case-treatment detail, gated by the same medewerker
|
|
/// capability (`CanBeoordelen`, WP-62) as the werkvoorraad list (WP-64).
|
|
public class BeoordelingTests(TestWebApplicationFactory factory) : IClassFixture<TestWebApplicationFactory>
|
|
{
|
|
private readonly HttpClient _client = factory.CreateClient();
|
|
|
|
private static HttpRequestMessage AsBehandelaar(HttpMethod method, string path)
|
|
{
|
|
var req = new HttpRequestMessage(method, path);
|
|
req.Headers.Add("X-Medewerker", "medewerker-1");
|
|
return req;
|
|
}
|
|
|
|
private static MultipartFormDataContent UploadForm(string localId, string categoryId, string fileName)
|
|
{
|
|
var content = new MultipartFormDataContent();
|
|
var file = new ByteArrayContent(new byte[] { 1, 2, 3 });
|
|
file.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
|
content.Add(file, "file", fileName);
|
|
content.Add(new StringContent(categoryId), "categoryId");
|
|
content.Add(new StringContent(localId), "localId");
|
|
content.Add(new StringContent("registratie"), "wizardId");
|
|
return content;
|
|
}
|
|
|
|
/// A manual (never auto-approved) case with one linked document, so it stays
|
|
/// InBehandeling/decidable regardless of test timing (the 8s auto-approval window
|
|
/// would otherwise make a duo-registratie/herregistratie fixture flaky).
|
|
private async Task<(ApplicationDetailDto App, string DocumentId)> CreateManualCaseWithDocument()
|
|
{
|
|
var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" });
|
|
var a = (await created.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
|
|
|
|
var localId = Guid.NewGuid().ToString();
|
|
var upload = await _client.PostAsync("/api/v1/uploads", UploadForm(localId, "diploma", "diploma.pdf"));
|
|
upload.EnsureSuccessStatusCode();
|
|
var doc = (await upload.Content.ReadFromJsonAsync<UploadResponse>())!;
|
|
|
|
var submit = await _client.PostAsJsonAsync($"/api/v1/applications/{a.Id}/submit", new
|
|
{
|
|
diplomaHerkomst = "handmatig",
|
|
documents = new[] { new { categoryId = "diploma", channel = "digital", documentId = doc.DocumentId } },
|
|
});
|
|
submit.EnsureSuccessStatusCode();
|
|
return (a, doc.DocumentId!);
|
|
}
|
|
|
|
private Task DeleteAsAdmin(string id) => _client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/admin/cases/{id}")
|
|
{
|
|
Headers = { { "X-Role", "admin" } },
|
|
});
|
|
|
|
[Fact]
|
|
public async Task Detail_shows_status_documents_and_a_masked_owner()
|
|
{
|
|
var (a, documentId) = await CreateManualCaseWithDocument();
|
|
try
|
|
{
|
|
var res = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{a.Id}"));
|
|
res.EnsureSuccessStatusCode();
|
|
var view = (await res.Content.ReadFromJsonAsync<BeoordelingViewDto>())!;
|
|
|
|
Assert.Equal("InBehandeling", view.Aanvraag.Status.Tag);
|
|
Assert.Single(view.Documenten);
|
|
Assert.Equal(documentId, view.Documenten[0].DocumentId);
|
|
Assert.Equal("diploma", view.Documenten[0].CategoryId);
|
|
Assert.True(view.Decisions.CanBesluiten);
|
|
// masked: not empty, but not the full 9-digit BSN either
|
|
var owner = view.Aanvraag.Owner!;
|
|
Assert.NotEmpty(owner);
|
|
Assert.Contains('*', owner);
|
|
}
|
|
finally
|
|
{
|
|
await DeleteAsAdmin(a.Id);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Concept_and_unknown_id_are_not_found()
|
|
{
|
|
var created = await _client.PostAsJsonAsync("/api/v1/applications", new { type = "registratie" });
|
|
var concept = (await created.Content.ReadFromJsonAsync<ApplicationDetailDto>())!;
|
|
try
|
|
{
|
|
var conceptRes = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{concept.Id}"));
|
|
Assert.Equal(HttpStatusCode.NotFound, conceptRes.StatusCode);
|
|
|
|
var unknownRes = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, "/api/v1/beoordeling/does-not-exist"));
|
|
Assert.Equal(HttpStatusCode.NotFound, unknownRes.StatusCode);
|
|
}
|
|
finally
|
|
{
|
|
await _client.DeleteAsync($"/api/v1/applications/{concept.Id}");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Zorgverlener_is_forbidden_even_with_admin_role()
|
|
{
|
|
var (a, _) = await CreateManualCaseWithDocument();
|
|
try
|
|
{
|
|
var req = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/beoordeling/{a.Id}");
|
|
req.Headers.Add("X-Role", "admin"); // admin role, but no X-Medewerker — still a zorgverlener
|
|
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(req)).StatusCode);
|
|
}
|
|
finally
|
|
{
|
|
await DeleteAsAdmin(a.Id);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Medewerker_without_behandelaar_rol_is_forbidden()
|
|
{
|
|
var req = new HttpRequestMessage(HttpMethod.Get, "/api/v1/beoordeling/anything");
|
|
req.Headers.Add("X-Medewerker", "medewerker-2");
|
|
req.Headers.Add("X-Rollen", "geen");
|
|
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(req)).StatusCode);
|
|
}
|
|
|
|
private Task<HttpResponseMessage> PostBesluit(string id, object body)
|
|
{
|
|
var req = AsBehandelaar(HttpMethod.Post, $"/api/v1/beoordeling/{id}/besluit");
|
|
req.Content = JsonContent.Create(body);
|
|
return _client.SendAsync(req);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Goedkeuren_advances_status_to_Goedgekeurd()
|
|
{
|
|
var (a, _) = await CreateManualCaseWithDocument();
|
|
try
|
|
{
|
|
var res = await PostBesluit(a.Id, new { besluit = "Goedkeuren" });
|
|
res.EnsureSuccessStatusCode();
|
|
var body = (await res.Content.ReadFromJsonAsync<RecordBesluitResponse>())!;
|
|
Assert.Equal("Goedgekeurd", body.Status.Tag);
|
|
|
|
var detail = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{a.Id}"));
|
|
var view = (await detail.Content.ReadFromJsonAsync<BeoordelingViewDto>())!;
|
|
Assert.Equal("Goedgekeurd", view.Aanvraag.Status.Tag);
|
|
Assert.False(view.Decisions.CanBesluiten); // terminal — no further decision allowed
|
|
|
|
// RB-07/BIO-007: the gate records that a behandelaar was allowed to act; this records
|
|
// what they decided, which is the question /beheer/audit exists to answer.
|
|
Assert.Contains(AuthzAuditStore.List(), e =>
|
|
e.Action == "aanvraag:besluit" && e.Decision == "allow" &&
|
|
e.Resource == $"aanvraag/{a.Id}/Goedkeuren");
|
|
}
|
|
finally
|
|
{
|
|
await DeleteAsAdmin(a.Id);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Afwijzen_requires_a_toelichting()
|
|
{
|
|
var (a, _) = await CreateManualCaseWithDocument();
|
|
try
|
|
{
|
|
var missing = await PostBesluit(a.Id, new { besluit = "Afwijzen" });
|
|
Assert.Equal(HttpStatusCode.BadRequest, missing.StatusCode);
|
|
|
|
var res = await PostBesluit(a.Id, new { besluit = "Afwijzen", toelichting = "Diploma niet erkend" });
|
|
res.EnsureSuccessStatusCode();
|
|
var body = (await res.Content.ReadFromJsonAsync<RecordBesluitResponse>())!;
|
|
Assert.Equal("Afgewezen", body.Status.Tag);
|
|
Assert.Equal("Diploma niet erkend", body.Status.Reden);
|
|
}
|
|
finally
|
|
{
|
|
await DeleteAsAdmin(a.Id);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MeerInfoOpvragen_is_still_decidable_afterwards()
|
|
{
|
|
var (a, _) = await CreateManualCaseWithDocument();
|
|
try
|
|
{
|
|
var res = await PostBesluit(a.Id, new { besluit = "MeerInfoOpvragen", toelichting = "Stuur een geldig diploma" });
|
|
res.EnsureSuccessStatusCode();
|
|
var body = (await res.Content.ReadFromJsonAsync<RecordBesluitResponse>())!;
|
|
Assert.Equal("MeerInfoGevraagd", body.Status.Tag);
|
|
|
|
var detail = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{a.Id}"));
|
|
var view = (await detail.Content.ReadFromJsonAsync<BeoordelingViewDto>())!;
|
|
Assert.True(view.Decisions.CanBesluiten); // not terminal — a decision can still follow
|
|
}
|
|
finally
|
|
{
|
|
await DeleteAsAdmin(a.Id);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Already_decided_case_rejects_a_further_besluit()
|
|
{
|
|
var (a, _) = await CreateManualCaseWithDocument();
|
|
try
|
|
{
|
|
(await PostBesluit(a.Id, new { besluit = "Goedkeuren" })).EnsureSuccessStatusCode();
|
|
var again = await PostBesluit(a.Id, new { besluit = "Afwijzen", toelichting = "te laat" });
|
|
Assert.Equal(HttpStatusCode.Conflict, again.StatusCode);
|
|
}
|
|
finally
|
|
{
|
|
await DeleteAsAdmin(a.Id);
|
|
}
|
|
}
|
|
|
|
// WP-68 (F2): the transition-legality check now runs inside RecordBesluit's write lock, so
|
|
// two besluiten racing on the same still-open aanvraag can't both pass the check before
|
|
// either writes — exactly one commits, the other sees the now-terminal status.
|
|
[Fact]
|
|
public async Task Concurrent_besluiten_on_the_same_aanvraag_yield_exactly_one_success()
|
|
{
|
|
var (a, _) = await CreateManualCaseWithDocument();
|
|
try
|
|
{
|
|
var results = await Task.WhenAll(
|
|
PostBesluit(a.Id, new { besluit = "Goedkeuren" }),
|
|
PostBesluit(a.Id, new { besluit = "Afwijzen", toelichting = "race" }));
|
|
|
|
var winner = Assert.Single(results, r => r.StatusCode == HttpStatusCode.OK);
|
|
Assert.Single(results, r => r.StatusCode == HttpStatusCode.Conflict);
|
|
|
|
// The persisted outcome must match whichever request actually won the race, not just
|
|
// "some" besluit — the loser's write must never have landed.
|
|
var winningTag = (await winner.Content.ReadFromJsonAsync<RecordBesluitResponse>())!.Status.Tag;
|
|
var detail = await _client.SendAsync(AsBehandelaar(HttpMethod.Get, $"/api/v1/beoordeling/{a.Id}"));
|
|
var finalTag = (await detail.Content.ReadFromJsonAsync<BeoordelingViewDto>())!.Aanvraag.Status.Tag;
|
|
Assert.Equal(winningTag, finalTag);
|
|
}
|
|
finally
|
|
{
|
|
await DeleteAsAdmin(a.Id);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unknown_id_404s()
|
|
{
|
|
// Given no case exists with this id.
|
|
// When a besluit is posted against it...
|
|
var notFound = await PostBesluit("does-not-exist", new { besluit = "Goedkeuren" });
|
|
|
|
// Then the endpoint answers 404, not a decision.
|
|
Assert.Equal(HttpStatusCode.NotFound, notFound.StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Zorgverlener_is_forbidden_from_deciding()
|
|
{
|
|
// Given a decidable case.
|
|
var (a, _) = await CreateManualCaseWithDocument();
|
|
try
|
|
{
|
|
// When a zorgverlener (no X-Medewerker) posts a besluit against it...
|
|
var req = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/beoordeling/{a.Id}/besluit")
|
|
{
|
|
Content = JsonContent.Create(new { besluit = "Goedkeuren" }),
|
|
};
|
|
req.Headers.Add("X-Role", "admin"); // zorgverlener, no X-Medewerker
|
|
var response = await _client.SendAsync(req);
|
|
|
|
// Then the request is forbidden — deciding is a behandelaar-only capability.
|
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
|
}
|
|
finally
|
|
{
|
|
await DeleteAsAdmin(a.Id);
|
|
}
|
|
}
|
|
}
|