test: bootstrap test infrastructure with critical coverage (QA-C1 through QA-C6)
QA-C1: Create xUnit test project, add to solution, wire dependencies
- api/tests/ProposalSystem.Tests with xUnit + FluentAssertions + NSubstitute
- InMemory EF Core provider for isolated DB tests
QA-C2: Proposal state machine transition tests (16 tests)
- Valid: InReview->Approved, Approved->Sent, Sent->Revised
- Invalid: InReview->Sent, Approved->Revised, Draft->Approved, etc.
- Edge cases: idempotency, missing line items, revision line item copying
- Audit and job publisher verification
QA-C3: Authorization attribute tests (16 tests)
- Controller-level [Authorize] on all controllers except AuthController
- Role requirements: admins/sysadmins on admin actions
- Dispatcher exclusion from admin/sysadmin routes
- SysAdmin-only user management enforcement
QA-C4: InternalApiKeyMiddleware tests (8 tests)
- Valid key sets claims and calls next
- Invalid key passes through to JWT (no 401/403)
- Missing key/empty config disables middleware
- Documents API-C1 vulnerability (key works on any path)
QA-C5: ProtectedRoute and RoleGuard tests (12 tests, vitest)
- ProtectedRoute: renders children when authenticated, redirects when not
- RoleGuard: role-based access, dispatcher/admin/sysadmin enforcement
- authSlice: setUser, logout, expired token handling
QA-C6: Lambda SQS handler tests (19 tests, pytest)
- pdf-generate: batch processing, failure reporting, malformed body
- suggestions: batch processing, proposal-not-found skip, AI item preservation
- API key caching, retry helpers
Total: 107 tests (76 .NET + 12 web + 19 Python), all passing.
2026-05-27 17:31:18 -04:00
|
|
|
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))]
|
2026-06-18 12:49:47 -04:00
|
|
|
[InlineData(typeof(PricingLibraryController))]
|
test: bootstrap test infrastructure with critical coverage (QA-C1 through QA-C6)
QA-C1: Create xUnit test project, add to solution, wire dependencies
- api/tests/ProposalSystem.Tests with xUnit + FluentAssertions + NSubstitute
- InMemory EF Core provider for isolated DB tests
QA-C2: Proposal state machine transition tests (16 tests)
- Valid: InReview->Approved, Approved->Sent, Sent->Revised
- Invalid: InReview->Sent, Approved->Revised, Draft->Approved, etc.
- Edge cases: idempotency, missing line items, revision line item copying
- Audit and job publisher verification
QA-C3: Authorization attribute tests (16 tests)
- Controller-level [Authorize] on all controllers except AuthController
- Role requirements: admins/sysadmins on admin actions
- Dispatcher exclusion from admin/sysadmin routes
- SysAdmin-only user management enforcement
QA-C4: InternalApiKeyMiddleware tests (8 tests)
- Valid key sets claims and calls next
- Invalid key passes through to JWT (no 401/403)
- Missing key/empty config disables middleware
- Documents API-C1 vulnerability (key works on any path)
QA-C5: ProtectedRoute and RoleGuard tests (12 tests, vitest)
- ProtectedRoute: renders children when authenticated, redirects when not
- RoleGuard: role-based access, dispatcher/admin/sysadmin enforcement
- authSlice: setUser, logout, expired token handling
QA-C6: Lambda SQS handler tests (19 tests, pytest)
- pdf-generate: batch processing, failure reporting, malformed body
- suggestions: batch processing, proposal-not-found skip, AI item preservation
- API key caching, retry helpers
Total: 107 tests (76 .NET + 12 web + 19 Python), all passing.
2026-05-27 17:31:18 -04:00
|
|
|
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
|
2026-06-18 12:49:47 -04:00
|
|
|
|
|
|
|
|
#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
|
test: bootstrap test infrastructure with critical coverage (QA-C1 through QA-C6)
QA-C1: Create xUnit test project, add to solution, wire dependencies
- api/tests/ProposalSystem.Tests with xUnit + FluentAssertions + NSubstitute
- InMemory EF Core provider for isolated DB tests
QA-C2: Proposal state machine transition tests (16 tests)
- Valid: InReview->Approved, Approved->Sent, Sent->Revised
- Invalid: InReview->Sent, Approved->Revised, Draft->Approved, etc.
- Edge cases: idempotency, missing line items, revision line item copying
- Audit and job publisher verification
QA-C3: Authorization attribute tests (16 tests)
- Controller-level [Authorize] on all controllers except AuthController
- Role requirements: admins/sysadmins on admin actions
- Dispatcher exclusion from admin/sysadmin routes
- SysAdmin-only user management enforcement
QA-C4: InternalApiKeyMiddleware tests (8 tests)
- Valid key sets claims and calls next
- Invalid key passes through to JWT (no 401/403)
- Missing key/empty config disables middleware
- Documents API-C1 vulnerability (key works on any path)
QA-C5: ProtectedRoute and RoleGuard tests (12 tests, vitest)
- ProtectedRoute: renders children when authenticated, redirects when not
- RoleGuard: role-based access, dispatcher/admin/sysadmin enforcement
- authSlice: setUser, logout, expired token handling
QA-C6: Lambda SQS handler tests (19 tests, pytest)
- pdf-generate: batch processing, failure reporting, malformed body
- suggestions: batch processing, proposal-not-found skip, AI item preservation
- API key caching, retry helpers
Total: 107 tests (76 .NET + 12 web + 19 Python), all passing.
2026-05-27 17:31:18 -04:00
|
|
|
}
|