proposal-system/api/tests/ProposalSystem.Tests/Controllers/GuardedEndpointAuthorizationTests.cs

45 lines
2 KiB
C#
Raw Permalink Normal View History

using System.Reflection;
using FluentAssertions;
using Microsoft.AspNetCore.Authorization;
using ProposalSystem.Api.Controllers;
using Xunit;
namespace ProposalSystem.Tests.Controllers;
/// <summary>
/// AUTHZ-CG-01 tripwire: ProposalConcurrencyGuard's 409 currentState embeds the
/// full ProposalResponse with no ownership filtering, so every endpoint that can
/// reach the guard must stay admin-gated. If a new/changed endpoint wires a
/// guarded mutation to a dispatcher-reachable route, this test fails the build
/// until the guard gains an ownership predicate.
/// </summary>
public class GuardedEndpointAuthorizationTests
{
public static readonly TheoryData<Type, string> GuardedEndpoints = new()
{
{ typeof(ProposalsController), "Update" },
{ typeof(ProposalsController), "Approve" },
{ typeof(ProposalsController), "ReturnToReview" },
{ typeof(ProposalsController), "MarkSent" },
{ typeof(ProposalsController), "Revise" },
{ typeof(LineItemsController), "BulkUpdate" },
};
[Theory(DisplayName = "Guard-reaching endpoints require admins/sysadmins")]
[MemberData(nameof(GuardedEndpoints))]
public void GuardedEndpoint_RequiresAdminRole(Type controller, string actionName)
{
var action = controller.GetMethod(actionName, BindingFlags.Public | BindingFlags.Instance);
action.Should().NotBeNull($"{controller.Name}.{actionName} should exist — update GuardedEndpoints if renamed");
var authorize = action!.GetCustomAttributes<AuthorizeAttribute>(inherit: true)
.FirstOrDefault(a => a.Roles != null);
authorize.Should().NotBeNull(
$"{controller.Name}.{actionName} reaches ProposalConcurrencyGuard and must carry a role-restricted [Authorize]");
authorize!.Roles.Should().Contain("admins").And.Contain("sysadmins");
authorize.Roles.Should().NotContain("dispatchers",
"the guard's 409 currentState has no ownership filter — see ProposalConcurrencyGuard caller contract");
}
}