proposal-system/api/tests/ProposalSystem.Tests/Controllers/AuthorizationAttributeTests.cs
Adam Moussa 5d84399a0d
Some checks are pending
Deploy / Deploy to AWS (push) Waiting to run
feat: pricing library — curated priced items feed the RAG corpus (#127)
Adds a managed pricing library so admins can seed/curate reference priced items
directly, instead of the corpus being populated only by ingesting Sent proposals.
v1 PR5.

API:
- PricingLibraryItem entity + migration; /api/pricing-library CRUD (admin), with
  GET {id} reachable by internal Lambda callers (admins role via internal key).
- Create/update publish an ADDITIVE library-ingest SQS job {pricingLibraryItemId},
  wrapped so a publish failure never rolls back the save.

Lambda (library-ingest):
- Additive event-shape branch: pricingLibraryItemId -> fetch item, format markdown,
  upload to pricing-library/{category}/{id}.md, trigger KB sync. The existing
  proposalId path is byte-for-byte unchanged. Explicit error when neither id present;
  warns when both present.

Web:
- Pricing Library management page (/admin/pricing-library): list / create / edit / delete.

GPT-4.1 cross-review on the event-shape change: no BLOCK (neither/both-id handling
applied). Verified: api 159 tests; web tsc + 26 tests; lambdas ruff + 37 pytest.
2026-06-18 12:49:47 -04:00

227 lines
9.4 KiB
C#

using System.Reflection;
using FluentAssertions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using ProposalSystem.Api.Controllers;
using Xunit;
namespace ProposalSystem.Tests.Controllers;
/// <summary>
/// QA-C3: Authorization attribute tests.
/// Verifies that controllers and actions have correct [Authorize] and role requirements.
/// These are static metadata tests — they verify the security annotations exist
/// and are correct without needing to spin up an HTTP server.
/// </summary>
public class AuthorizationAttributeTests
{
#region Controller-Level Authorization
[Fact(DisplayName = "QA-C3: ProposalsController requires authentication")]
public void ProposalsController_HasAuthorizeAttribute()
{
typeof(ProposalsController)
.GetCustomAttribute<AuthorizeAttribute>()
.Should().NotBeNull("ProposalsController should require authentication");
}
[Fact(DisplayName = "QA-C3: AdminController requires admins or sysadmins role")]
public void AdminController_RequiresAdminOrSysAdminRole()
{
var attr = typeof(AdminController).GetCustomAttribute<AuthorizeAttribute>();
attr.Should().NotBeNull("AdminController should require authentication");
attr!.Roles.Should().NotBeNull();
attr.Roles!.Split(',').Select(r => r.Trim()).Should()
.Contain("admins").And.Contain("sysadmins");
}
[Fact(DisplayName = "QA-C3: UsersController requires authentication")]
public void UsersController_HasAuthorizeAttribute()
{
typeof(UsersController)
.GetCustomAttribute<AuthorizeAttribute>()
.Should().NotBeNull("UsersController should require authentication");
}
#endregion
#region Action-Level Role Requirements
[Theory(DisplayName = "QA-C3: Admin-only proposal actions require admins/sysadmins role")]
[InlineData("Update")]
[InlineData("Approve")]
[InlineData("MarkSent")]
[InlineData("Revise")]
[InlineData("GetAudit")]
[InlineData("GetSimilar")]
[InlineData("GenerateSuggestions")]
[InlineData("Regenerate")]
[InlineData("AddSimilarReference")]
public void ProposalsController_AdminActions_RequireAdminRole(string methodName)
{
var method = typeof(ProposalsController).GetMethod(methodName);
method.Should().NotBeNull($"ProposalsController should have a {methodName} method");
var attr = method!.GetCustomAttribute<AuthorizeAttribute>();
attr.Should().NotBeNull($"{methodName} should have [Authorize] attribute");
attr!.Roles.Should().NotBeNull($"{methodName} should specify roles");
attr.Roles!.Split(',').Select(r => r.Trim()).Should()
.Contain("admins", $"{methodName} should allow admins")
.And.Contain("sysadmins", $"{methodName} should allow sysadmins");
}
[Theory(DisplayName = "QA-C3: Dispatcher-accessible proposal actions do NOT have role restrictions")]
[InlineData("Create")]
[InlineData("GetAll")]
[InlineData("GetById")]
[InlineData("GetHistory")]
[InlineData("GetStats")]
public void ProposalsController_DispatcherActions_NoRoleRestriction(string methodName)
{
var method = typeof(ProposalsController).GetMethod(methodName);
method.Should().NotBeNull($"ProposalsController should have a {methodName} method");
var attr = method!.GetCustomAttribute<AuthorizeAttribute>();
// These methods rely on controller-level [Authorize] but have no role restriction
if (attr != null)
{
attr.Roles.Should().BeNullOrEmpty($"{methodName} should be accessible to all authenticated users");
}
}
[Theory(DisplayName = "QA-C3: SysAdmin-only user management actions require sysadmins role")]
[InlineData("GetAll")]
[InlineData("UpdateRole")]
public void UsersController_SysAdminActions_RequireSysAdminRole(string methodName)
{
var method = typeof(UsersController).GetMethod(methodName);
method.Should().NotBeNull($"UsersController should have a {methodName} method");
var attr = method!.GetCustomAttribute<AuthorizeAttribute>();
attr.Should().NotBeNull($"{methodName} should have [Authorize] attribute");
attr!.Roles.Should().NotBeNull($"{methodName} should specify roles");
attr.Roles!.Split(',').Select(r => r.Trim()).Should()
.Contain("sysadmins", $"{methodName} should require sysadmins role");
}
[Fact(DisplayName = "QA-C3: UsersController.GetMe is accessible to all authenticated users")]
public void UsersController_GetMe_NoRoleRestriction()
{
var method = typeof(UsersController).GetMethod("GetMe");
method.Should().NotBeNull();
var attr = method!.GetCustomAttribute<AuthorizeAttribute>();
// GetMe should rely on controller-level [Authorize] without role restriction
if (attr != null)
{
attr.Roles.Should().BeNullOrEmpty("GetMe should be accessible to all authenticated users");
}
}
#endregion
#region Role Hierarchy Verification
[Fact(DisplayName = "QA-C3: Dispatchers cannot access admin dashboard")]
public void AdminController_NotAccessibleToDispatchers()
{
var attr = typeof(AdminController).GetCustomAttribute<AuthorizeAttribute>();
attr.Should().NotBeNull();
attr!.Roles.Should().NotBeNull();
var roles = attr.Roles!.Split(',').Select(r => r.Trim()).ToList();
roles.Should().NotContain("dispatchers",
"dispatchers must not have access to admin controller");
}
[Fact(DisplayName = "QA-C3: Dispatchers cannot access user management")]
public void UsersController_GetAll_NotAccessibleToDispatchers()
{
var method = typeof(UsersController).GetMethod("GetAll");
var attr = method!.GetCustomAttribute<AuthorizeAttribute>();
attr.Should().NotBeNull();
attr!.Roles.Should().NotBeNull();
var roles = attr.Roles!.Split(',').Select(r => r.Trim()).ToList();
roles.Should().NotContain("dispatchers",
"dispatchers must not have access to user management");
}
[Fact(DisplayName = "QA-C3: Admins cannot access sysadmin-only user management")]
public void UsersController_UpdateRole_NotAccessibleToAdmins()
{
var method = typeof(UsersController).GetMethod("UpdateRole");
var attr = method!.GetCustomAttribute<AuthorizeAttribute>();
attr.Should().NotBeNull();
var roles = attr!.Roles!.Split(',').Select(r => r.Trim()).ToList();
roles.Should().NotContain("admins",
"admins must not have access to role management (sysadmins only)");
}
#endregion
#region All Controllers Must Have [Authorize]
[Theory(DisplayName = "QA-C3: All API controllers (except AuthController) require authentication")]
[InlineData(typeof(ProposalsController))]
[InlineData(typeof(AdminController))]
[InlineData(typeof(UsersController))]
[InlineData(typeof(CustomersController))]
[InlineData(typeof(LineItemsController))]
[InlineData(typeof(GeneratedPdfsController))]
[InlineData(typeof(FilesController))]
[InlineData(typeof(VendorProposalsController))]
[InlineData(typeof(PricingLibraryController))]
public void AllControllers_HaveAuthorizeAttribute(Type controllerType)
{
var attr = controllerType.GetCustomAttribute<AuthorizeAttribute>();
attr.Should().NotBeNull(
$"{controllerType.Name} must have [Authorize] attribute to prevent unauthenticated access");
}
#endregion
#region PR5: PricingLibraryController Authorization
[Fact(DisplayName = "PR5: PricingLibraryController requires authentication")]
public void PricingLibraryController_HasAuthorizeAttribute()
{
typeof(PricingLibraryController)
.GetCustomAttribute<AuthorizeAttribute>()
.Should().NotBeNull("PricingLibraryController should require authentication");
}
[Theory(DisplayName = "PR5: PricingLibrary admin actions require admins/sysadmins role")]
[InlineData("List")]
[InlineData("GetById")]
[InlineData("Create")]
[InlineData("Update")]
[InlineData("Delete")]
public void PricingLibraryController_AdminActions_RequireAdminRole(string methodName)
{
var method = typeof(PricingLibraryController).GetMethod(methodName);
method.Should().NotBeNull($"PricingLibraryController should have a {methodName} method");
var attr = method!.GetCustomAttribute<AuthorizeAttribute>();
attr.Should().NotBeNull($"{methodName} should have [Authorize] attribute");
attr!.Roles.Should().NotBeNull($"{methodName} should specify roles");
attr.Roles!.Split(',').Select(r => r.Trim()).Should()
.Contain("admins", $"{methodName} should allow admins (including internal Lambda callers)")
.And.Contain("sysadmins", $"{methodName} should allow sysadmins");
}
[Fact(DisplayName = "PR5: PricingLibrary GetById is reachable by internal Lambda callers (admins role)")]
public void PricingLibraryController_GetById_AllowsAdminsRole()
{
var method = typeof(PricingLibraryController).GetMethod("GetById");
method.Should().NotBeNull();
var attr = method!.GetCustomAttribute<AuthorizeAttribute>();
attr.Should().NotBeNull();
attr!.Roles.Should().Contain("admins",
"GetById must be reachable by the internal API key middleware which assigns the 'admins' role");
}
#endregion
}