using System.Reflection; using FluentAssertions; using Microsoft.AspNetCore.Authorization; using ProposalSystem.Api.Controllers; using Xunit; namespace ProposalSystem.Tests.Controllers; /// /// 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. /// public class GuardedEndpointAuthorizationTests { public static readonly TheoryData 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(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"); } }