feat(brief): WP-18 — ABAC capability spine (PRD-0002 phase P1)
Replace the FE-computed authorization anti-pattern in BriefStore.editable (derived from the unverified X-Role header) with server-computed decision flags, mirroring the existing HerregistratieDecisionsDto pattern: - Backend: Authz.cs is the single authorization helper — the SAME check (Authz.CanActOn) both gates BriefStore.Review's mutations and computes the BriefDecisionsDto flags shipped on every brief response, so emit and enforce can never drift. New GET /me returns coarse, role-derived capabilities (PRD-0002 SS6). - Every brief endpoint (including send, previously ungated on HttpContext) now returns a fresh BriefViewDto so decisions never go stale after a mutation. - FE: brief.store.ts reads canEdit/canApprove/canReject/canSend off the loaded decisions instead of computing them from currentRole(); the brief.machine carries decisions through every status transition. - New shared/domain/capability.ts + shared/application/access.store.ts + shared/infrastructure/me.adapter.ts: the general capability-spine infrastructure (AccessStore.can(), capabilityGuard) for future routes. Deviates from the original WP-18 draft by NOT renaming auth/domain's Session to a Principal union — ADR-0002 explicitly defers that refactor until a second actor exists, and the brief workflow's drafter/approver identity turned out to be a separate axis from the SSP login session entirely. See docs/backlog/WP-18-abac-capability-spine.md for the full as-built record. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -147,7 +147,14 @@ public sealed record BriefDto(
|
||||
IReadOnlyList<LetterSectionDto> Sections,
|
||||
BriefStatusDto Status, string DrafterId);
|
||||
|
||||
public sealed record BriefViewDto(BriefDto Brief, IReadOnlyList<LibraryPassageDto> AvailablePassages);
|
||||
// Decision flags for the CURRENT acting principal + this brief's live status
|
||||
// (PRD-0002 phase P1) — the FE renders these, it never recomputes them.
|
||||
public sealed record BriefDecisionsDto(bool CanEdit, bool CanApprove, bool CanReject, bool CanSend);
|
||||
|
||||
public sealed record BriefViewDto(BriefDto Brief, IReadOnlyList<LibraryPassageDto> AvailablePassages, BriefDecisionsDto Decisions);
|
||||
|
||||
public sealed record SaveBriefRequest(IReadOnlyList<LetterSectionDto> Sections);
|
||||
public sealed record RejectBriefRequest(string Comments);
|
||||
|
||||
// PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks.
|
||||
public sealed record MeDto(IReadOnlyList<string> Capabilities);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Domain.Authorization;
|
||||
|
||||
namespace BigRegister.Api.Data;
|
||||
|
||||
@@ -72,11 +73,13 @@ public static class BriefStore
|
||||
}
|
||||
}
|
||||
|
||||
public static (Outcome, BriefEntity?) Approve(string owner, string actingId, string at) =>
|
||||
Review(owner, actingId, e => new BriefStatusDto("approved", ApprovedBy: actingId, ApprovedAt: at));
|
||||
public static (Outcome, BriefEntity?) Approve(string owner, Principal principal, string at) =>
|
||||
Review(owner, principal, BriefAction.Approve, () =>
|
||||
new BriefStatusDto("approved", ApprovedBy: Authz.ActingId(principal), ApprovedAt: at));
|
||||
|
||||
public static (Outcome, BriefEntity?) Reject(string owner, string actingId, string comments, string at) =>
|
||||
Review(owner, actingId, e => new BriefStatusDto("rejected", RejectedBy: actingId, RejectedAt: at, Comments: comments));
|
||||
public static (Outcome, BriefEntity?) Reject(string owner, Principal principal, string comments, string at) =>
|
||||
Review(owner, principal, BriefAction.Reject, () =>
|
||||
new BriefStatusDto("rejected", RejectedBy: Authz.ActingId(principal), RejectedAt: at, Comments: comments));
|
||||
|
||||
public static (Outcome, BriefEntity?) Send(string owner, string at)
|
||||
{
|
||||
@@ -109,15 +112,18 @@ public static class BriefStore
|
||||
}
|
||||
|
||||
// Approve/reject share the guard: must be submitted, and the approver must differ
|
||||
// from the drafter (a drafter cannot approve their own letter).
|
||||
private static (Outcome, BriefEntity?) Review(string owner, string actingId, Func<BriefEntity, BriefStatusDto> next)
|
||||
// from the drafter (a drafter cannot approve their own letter). The SoD check is
|
||||
// Authz.CanActOn — the SAME check the screen DTO's decision flags use — checked
|
||||
// BEFORE the status guard so Forbidden vs Conflict ordering matches the old
|
||||
// inline check exactly.
|
||||
private static (Outcome, BriefEntity?) Review(string owner, Principal principal, BriefAction action, Func<BriefStatusDto> next)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_byOwner.TryGetValue(owner, out var e)) return (Outcome.Conflict, null);
|
||||
if (actingId == e.DrafterId) return (Outcome.Forbidden, null);
|
||||
if (!Authz.CanActOn(action, principal, e.DrafterId)) return (Outcome.Forbidden, null);
|
||||
if (e.Status.Tag != "submitted") return (Outcome.Conflict, null);
|
||||
e.Status = next(e);
|
||||
e.Status = next();
|
||||
return (Outcome.Ok, e);
|
||||
}
|
||||
}
|
||||
|
||||
59
backend/src/BigRegister.Api/Domain/Authorization/Authz.cs
Normal file
59
backend/src/BigRegister.Api/Domain/Authorization/Authz.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Api.Data;
|
||||
|
||||
namespace BigRegister.Domain.Authorization;
|
||||
|
||||
public enum PrincipalRole { Drafter, Approver }
|
||||
|
||||
/// <summary>
|
||||
/// The acting identity for this request. dev stub — NOT a security boundary: resolved
|
||||
/// from the client-asserted X-Role header (mirrors the FE's `?role=` toggle). A real
|
||||
/// system builds this from verified AD/OIDC claims (PRD-0002 §3, §7); everything else
|
||||
/// in this file — the capability model, the single Authz.Can check both emitting and
|
||||
/// enforcing — carries over unchanged once that swap happens.
|
||||
/// </summary>
|
||||
public sealed record Principal(PrincipalRole Role);
|
||||
|
||||
public enum BriefAction { Approve, Reject, Send }
|
||||
|
||||
/// <summary>
|
||||
/// Single source of truth for brief authorization (PRD-0002 phase P1). The SAME
|
||||
/// check both computes the decision flags shipped on the screen DTO (emit) and
|
||||
/// gates the mutation endpoints (enforce) — so the two can never drift, closing the
|
||||
/// classic broken-object-level-authorization gap (PRD-0002 §7).
|
||||
/// </summary>
|
||||
public static class Authz
|
||||
{
|
||||
public static Principal ResolvePrincipal(HttpContext ctx) =>
|
||||
new(ctx.Request.Headers["X-Role"].ToString() == "approver" ? PrincipalRole.Approver : PrincipalRole.Drafter);
|
||||
|
||||
public static string ActingId(Principal principal) =>
|
||||
principal.Role == PrincipalRole.Approver ? BriefStore.ApproverId : BriefStore.DrafterId;
|
||||
|
||||
/// Coarse, resource-independent capabilities for `GET /me` (nav/menu-level — NOT
|
||||
/// tied to any specific brief's live status; contrast Decisions below).
|
||||
public static IReadOnlyList<string> RoleCapabilities(Principal principal) =>
|
||||
principal.Role == PrincipalRole.Approver
|
||||
? new[] { "brief:approve", "brief:reject", "brief:send" }
|
||||
: Array.Empty<string>();
|
||||
|
||||
/// Role + four-eyes (SoD) check only — no status. This is the exact check
|
||||
/// BriefStore.Review enforces before its status guard; kept separate from
|
||||
/// Decisions() below so enforcement ORDER (Forbidden before Conflict) matches
|
||||
/// today's behavior exactly.
|
||||
public static bool CanActOn(BriefAction action, Principal principal, string drafterId) => action switch
|
||||
{
|
||||
BriefAction.Approve or BriefAction.Reject => ActingId(principal) != drafterId,
|
||||
BriefAction.Send => true, // sending is a mechanical dispatch step, not role-gated today
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// Resource-aware decision for the screen DTO: "would this action succeed right
|
||||
/// now" — role/SoD AND the brief's current status. This is what the UI renders;
|
||||
/// it never re-derives these booleans itself.
|
||||
public static BriefDecisionsDto Decisions(Principal principal, string status, string drafterId) => new(
|
||||
CanEdit: principal.Role == PrincipalRole.Drafter && status is "draft" or "rejected",
|
||||
CanApprove: CanActOn(BriefAction.Approve, principal, drafterId) && status == "submitted",
|
||||
CanReject: CanActOn(BriefAction.Reject, principal, drafterId) && status == "submitted",
|
||||
CanSend: CanActOn(BriefAction.Send, principal, drafterId) && status == "approved");
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using BigRegister.Api.Contracts;
|
||||
using BigRegister.Api.Data;
|
||||
using BigRegister.Domain.Authorization;
|
||||
using BigRegister.Domain.Diplomas;
|
||||
using BigRegister.Domain.Documents;
|
||||
using BigRegister.Domain.Intake;
|
||||
@@ -239,76 +240,81 @@ api.MapPost("/applications/{id}/submit", (string id, SubmitApplicationRequest re
|
||||
.ProducesProblem(StatusCodes.Status409Conflict)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// --- Brief (letter composition). One demo brief per owner; the server owns the
|
||||
// status machine + role rules. Role is a dev-only stand-in via X-Role (mirrors the
|
||||
// X-Admin seam and the FE ?role= toggle) — no real identities in this POC. ---
|
||||
// PRD-0002 §6: coarse, role-derived capabilities for nav/menu-level checks (NOT
|
||||
// tied to a specific brief's live status — see BriefDecisionsDto for that).
|
||||
api.MapGet("/me", (HttpContext ctx) => new MeDto(Authz.RoleCapabilities(Authz.ResolvePrincipal(ctx))))
|
||||
.Produces<MeDto>();
|
||||
|
||||
api.MapGet("/brief", () =>
|
||||
// --- Brief (letter composition). One demo brief per owner; the server owns the
|
||||
// status machine + authorization (Authz, PRD-0002 phase P1). Principal is a
|
||||
// dev-only stand-in via X-Role (mirrors the X-Admin seam and the FE ?role=
|
||||
// toggle) — no real identities in this POC. ---
|
||||
|
||||
api.MapGet("/brief", (HttpContext ctx) =>
|
||||
{
|
||||
var e = BriefStore.GetOrCreate(DocumentStore.DemoOwner);
|
||||
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep));
|
||||
return ToView(ctx, e);
|
||||
})
|
||||
.Produces<BriefViewDto>();
|
||||
|
||||
api.MapPut("/brief", (SaveBriefRequest req, HttpContext ctx) =>
|
||||
{
|
||||
var (_, isDrafter) = BriefRole(ctx);
|
||||
return BriefResult(BriefStore.Save(DocumentStore.DemoOwner, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken.");
|
||||
var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter;
|
||||
return BriefResult(ctx, BriefStore.Save(DocumentStore.DemoOwner, req.Sections, isDrafter), "Alleen de opsteller mag de brief bewerken.");
|
||||
})
|
||||
.Produces<BriefDto>()
|
||||
.Produces<BriefViewDto>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
.ProducesProblem(StatusCodes.Status409Conflict);
|
||||
|
||||
api.MapPost("/brief/submit", (HttpContext ctx) =>
|
||||
{
|
||||
var (_, isDrafter) = BriefRole(ctx);
|
||||
var isDrafter = Authz.ResolvePrincipal(ctx).Role == PrincipalRole.Drafter;
|
||||
var r = BriefStore.Submit(DocumentStore.DemoOwner, isDrafter, Now());
|
||||
LogBrief("submit", r);
|
||||
return BriefResult(r, "Alleen de opsteller mag indienen.");
|
||||
return BriefResult(ctx, r, "Alleen de opsteller mag indienen.");
|
||||
})
|
||||
.WithName("briefSubmit") // distinct name so the generated client method isn't `submit2`
|
||||
.Produces<BriefDto>()
|
||||
.Produces<BriefViewDto>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
.ProducesProblem(StatusCodes.Status409Conflict);
|
||||
|
||||
api.MapPost("/brief/approve", (HttpContext ctx) =>
|
||||
{
|
||||
var (acting, _) = BriefRole(ctx);
|
||||
var r = BriefStore.Approve(DocumentStore.DemoOwner, acting, Now());
|
||||
var r = BriefStore.Approve(DocumentStore.DemoOwner, Authz.ResolvePrincipal(ctx), Now());
|
||||
LogBrief("approve", r);
|
||||
return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
|
||||
return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn.");
|
||||
})
|
||||
.Produces<BriefDto>()
|
||||
.Produces<BriefViewDto>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
.ProducesProblem(StatusCodes.Status409Conflict);
|
||||
|
||||
api.MapPost("/brief/reject", (RejectBriefRequest req, HttpContext ctx) =>
|
||||
{
|
||||
var (acting, _) = BriefRole(ctx);
|
||||
var r = BriefStore.Reject(DocumentStore.DemoOwner, acting, req.Comments, Now());
|
||||
var r = BriefStore.Reject(DocumentStore.DemoOwner, Authz.ResolvePrincipal(ctx), req.Comments, Now());
|
||||
LogBrief("reject", r);
|
||||
return BriefResult(r, "De beoordelaar mag niet de opsteller zijn.");
|
||||
return BriefResult(ctx, r, "De beoordelaar mag niet de opsteller zijn.");
|
||||
})
|
||||
.Produces<BriefDto>()
|
||||
.Produces<BriefViewDto>()
|
||||
.ProducesProblem(StatusCodes.Status403Forbidden)
|
||||
.ProducesProblem(StatusCodes.Status409Conflict);
|
||||
|
||||
api.MapPost("/brief/send", () =>
|
||||
api.MapPost("/brief/send", (HttpContext ctx) =>
|
||||
{
|
||||
// Send-time placeholder linting is FE-authoritative in this slice (no C# parity
|
||||
// port); the backend only guards the approved→sent transition.
|
||||
// port); the backend only guards the approved→sent transition (not role-gated
|
||||
// today — see Authz.CanActOn(Send, …), a mechanical dispatch step).
|
||||
var r = BriefStore.Send(DocumentStore.DemoOwner, Now());
|
||||
LogBrief("send", r);
|
||||
return BriefResult(r, "Versturen kan niet in deze status.");
|
||||
return BriefResult(ctx, r, "Versturen kan niet in deze status.");
|
||||
})
|
||||
.Produces<BriefDto>()
|
||||
.Produces<BriefViewDto>()
|
||||
.ProducesProblem(StatusCodes.Status409Conflict);
|
||||
|
||||
api.MapPost("/brief/reset", () =>
|
||||
api.MapPost("/brief/reset", (HttpContext ctx) =>
|
||||
{
|
||||
// Demo "start over": recreate a fresh draft. No guards — showcase affordance only.
|
||||
var e = BriefStore.ResetAndCreate(DocumentStore.DemoOwner);
|
||||
return new BriefViewDto(e.ToDto(), BriefSeed.PassagesFor(e.Beroep));
|
||||
return ToView(ctx, e);
|
||||
})
|
||||
.WithName("briefReset")
|
||||
.Produces<BriefViewDto>();
|
||||
@@ -319,17 +325,16 @@ static bool IsAdmin(HttpContext ctx) => ctx.Request.Headers["X-Admin"] == "true"
|
||||
|
||||
static string Now() => DateTimeOffset.UtcNow.ToString("o");
|
||||
|
||||
// Dev-only role stand-in: X-Role: approver acts as the approver identity, anything
|
||||
// else (default) acts as the drafter.
|
||||
static (string acting, bool isDrafter) BriefRole(HttpContext ctx)
|
||||
{
|
||||
var isDrafter = ctx.Request.Headers["X-Role"].ToString() != "approver";
|
||||
return (isDrafter ? BriefStore.DrafterId : BriefStore.ApproverId, isDrafter);
|
||||
}
|
||||
BriefViewDto ToView(HttpContext ctx, BriefEntity e) => new(
|
||||
e.ToDto(),
|
||||
BriefSeed.PassagesFor(e.Beroep),
|
||||
Authz.Decisions(Authz.ResolvePrincipal(ctx), e.Status.Tag, e.DrafterId));
|
||||
|
||||
IResult BriefResult((BriefStore.Outcome outcome, BriefEntity? entity) r, string forbiddenDetail) => r.outcome switch
|
||||
// Emit (decision flags, via ToView) and enforce (Forbidden/Conflict below) both run
|
||||
// through Authz — see BriefStore.Review and Authz.CanActOn — so they cannot drift.
|
||||
IResult BriefResult(HttpContext ctx, (BriefStore.Outcome outcome, BriefEntity? entity) r, string forbiddenDetail) => r.outcome switch
|
||||
{
|
||||
BriefStore.Outcome.Ok => Results.Ok(r.entity!.ToDto()),
|
||||
BriefStore.Outcome.Ok => Results.Ok(ToView(ctx, r.entity!)),
|
||||
BriefStore.Outcome.Forbidden => Results.Problem(detail: forbiddenDetail, statusCode: StatusCodes.Status403Forbidden),
|
||||
_ => Results.Problem(detail: "Ongeldige overgang voor de huidige status van de brief.", statusCode: StatusCodes.Status409Conflict),
|
||||
};
|
||||
|
||||
@@ -641,6 +641,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/me": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"BigRegister.Api, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MeDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/brief": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -679,7 +698,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BriefDto"
|
||||
"$ref": "#/components/schemas/BriefViewDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -719,7 +738,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BriefDto"
|
||||
"$ref": "#/components/schemas/BriefViewDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -758,7 +777,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BriefDto"
|
||||
"$ref": "#/components/schemas/BriefViewDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -807,7 +826,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BriefDto"
|
||||
"$ref": "#/components/schemas/BriefViewDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -846,7 +865,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BriefDto"
|
||||
"$ref": "#/components/schemas/BriefViewDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1030,6 +1049,24 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"BriefDecisionsDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"canEdit": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"canApprove": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"canReject": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"canSend": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"BriefDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1123,6 +1160,9 @@
|
||||
"$ref": "#/components/schemas/LibraryPassageDto"
|
||||
},
|
||||
"nullable": true
|
||||
},
|
||||
"decisions": {
|
||||
"$ref": "#/components/schemas/BriefDecisionsDto"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -1469,6 +1509,19 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"MeDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"capabilities": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ParagraphDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
68
backend/tests/BigRegister.Tests/AuthzTests.cs
Normal file
68
backend/tests/BigRegister.Tests/AuthzTests.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using BigRegister.Domain.Authorization;
|
||||
using Xunit;
|
||||
|
||||
namespace BigRegister.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the single authorization helper (PRD-0002 phase P1) that both
|
||||
/// computes the brief screen's decision flags and gates the mutation endpoints.
|
||||
/// </summary>
|
||||
public class AuthzTests
|
||||
{
|
||||
private static readonly Principal Drafter = new(PrincipalRole.Drafter);
|
||||
private static readonly Principal Approver = new(PrincipalRole.Approver);
|
||||
private const string DrafterId = "demo-drafter";
|
||||
|
||||
[Fact]
|
||||
public void Drafter_may_not_approve_or_reject_even_when_submitted()
|
||||
{
|
||||
Assert.False(Authz.CanActOn(BriefAction.Approve, Drafter, DrafterId));
|
||||
Assert.False(Authz.CanActOn(BriefAction.Reject, Drafter, DrafterId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Approver_may_approve_and_reject_a_different_drafters_letter()
|
||||
{
|
||||
Assert.True(Authz.CanActOn(BriefAction.Approve, Approver, DrafterId));
|
||||
Assert.True(Authz.CanActOn(BriefAction.Reject, Approver, DrafterId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Send_is_not_role_gated()
|
||||
{
|
||||
Assert.True(Authz.CanActOn(BriefAction.Send, Drafter, DrafterId));
|
||||
Assert.True(Authz.CanActOn(BriefAction.Send, Approver, DrafterId));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("draft")]
|
||||
[InlineData("rejected")]
|
||||
public void Decisions_CanEdit_true_for_drafter_in_editable_statuses(string status)
|
||||
{
|
||||
Assert.True(Authz.Decisions(Drafter, status, DrafterId).CanEdit);
|
||||
Assert.False(Authz.Decisions(Approver, status, DrafterId).CanEdit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decisions_CanApprove_requires_submitted_status_and_approver_role()
|
||||
{
|
||||
Assert.True(Authz.Decisions(Approver, "submitted", DrafterId).CanApprove);
|
||||
Assert.False(Authz.Decisions(Approver, "draft", DrafterId).CanApprove);
|
||||
Assert.False(Authz.Decisions(Drafter, "submitted", DrafterId).CanApprove);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decisions_CanSend_requires_approved_status_only()
|
||||
{
|
||||
Assert.True(Authz.Decisions(Drafter, "approved", DrafterId).CanSend);
|
||||
Assert.True(Authz.Decisions(Approver, "approved", DrafterId).CanSend);
|
||||
Assert.False(Authz.Decisions(Approver, "submitted", DrafterId).CanSend);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoleCapabilities_are_empty_for_drafter_and_the_three_brief_capabilities_for_approver()
|
||||
{
|
||||
Assert.Empty(Authz.RoleCapabilities(Drafter));
|
||||
Assert.Equal(new[] { "brief:approve", "brief:reject", "brief:send" }, Authz.RoleCapabilities(Approver));
|
||||
}
|
||||
}
|
||||
@@ -90,8 +90,8 @@ public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClass
|
||||
|
||||
var res = await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
var submitted = await res.Content.ReadFromJsonAsync<BriefDto>();
|
||||
Assert.Equal("submitted", submitted!.Status.Tag);
|
||||
var submitted = await res.Content.ReadFromJsonAsync<BriefViewDto>();
|
||||
Assert.Equal("submitted", submitted!.Brief.Status.Tag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -106,7 +106,7 @@ public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClass
|
||||
|
||||
var res = await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
Assert.Equal("approved", (await res.Content.ReadFromJsonAsync<BriefDto>())!.Status.Tag);
|
||||
Assert.Equal("approved", (await res.Content.ReadFromJsonAsync<BriefViewDto>())!.Brief.Status.Tag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -117,13 +117,13 @@ public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClass
|
||||
await _client.SendAsync(Post("/api/v1/brief/submit"));
|
||||
|
||||
var rejected = await (await _client.SendAsync(
|
||||
Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen.")))).Content.ReadFromJsonAsync<BriefDto>();
|
||||
Assert.Equal("rejected", rejected!.Status.Tag);
|
||||
Assert.Equal("Graag aanvullen.", rejected.Status.Comments);
|
||||
Post("/api/v1/brief/reject", role: "approver", body: new RejectBriefRequest("Graag aanvullen.")))).Content.ReadFromJsonAsync<BriefViewDto>();
|
||||
Assert.Equal("rejected", rejected!.Brief.Status.Tag);
|
||||
Assert.Equal("Graag aanvullen.", rejected.Brief.Status.Comments);
|
||||
|
||||
// A drafter save on a rejected letter reopens it to draft.
|
||||
var reopened = await (await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief))).Content.ReadFromJsonAsync<BriefDto>();
|
||||
Assert.Equal("draft", reopened!.Status.Tag);
|
||||
var reopened = await (await _client.PutAsJsonAsync("/api/v1/brief", FilledFrom(brief))).Content.ReadFromJsonAsync<BriefViewDto>();
|
||||
Assert.Equal("draft", reopened!.Brief.Status.Tag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -139,7 +139,36 @@ public class BriefEndpointTests(WebApplicationFactory<Program> factory) : IClass
|
||||
await _client.SendAsync(Post("/api/v1/brief/approve", role: "approver"));
|
||||
var res = await _client.SendAsync(Post("/api/v1/brief/send"));
|
||||
res.EnsureSuccessStatusCode();
|
||||
Assert.Equal("sent", (await res.Content.ReadFromJsonAsync<BriefDto>())!.Status.Tag);
|
||||
Assert.Equal("sent", (await res.Content.ReadFromJsonAsync<BriefViewDto>())!.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.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.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.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.Equal(new[] { "brief:approve", "brief:reject", "brief:send" }, asApprover!.Capabilities);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user