From d21b1c5edba62e10d1b526a3fd794b34e6314a67 Mon Sep 17 00:00:00 2001
From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com>
Date: Wed, 27 May 2026 17:31:18 -0400
Subject: [PATCH] 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.
---
api/ProposalSystem.sln | 6 +
.../AuthorizationAttributeTests.cs | 183 +++
.../Helpers/DbContextFactory.cs | 22 +
.../InternalApiKeyMiddlewareTests.cs | 197 +++
.../ProposalSystem.Tests.csproj | 34 +
.../Services/ProposalStateMachineTests.cs | 430 ++++++
.../CreateLineItemValidatorTests.cs | 129 ++
.../CreateProposalValidatorTests.cs | 181 +++
lambdas/pytest.ini | 3 +
lambdas/tests/__init__.py | 0
lambdas/tests/conftest.py | 19 +
lambdas/tests/helpers.py | 21 +
lambdas/tests/requirements-test.txt | 4 +
lambdas/tests/test_pdf_generate.py | 151 +++
lambdas/tests/test_suggestions.py | 217 +++
web/package-lock.json | 1178 ++++++++++++++++-
web/package.json | 15 +-
web/src/app/__tests__/authSlice.test.ts | 114 ++
.../__tests__/ProtectedRoute.test.tsx | 153 +++
web/src/test/setup.ts | 1 +
web/vitest.config.ts | 12 +
21 files changed, 3053 insertions(+), 17 deletions(-)
create mode 100644 api/tests/ProposalSystem.Tests/Controllers/AuthorizationAttributeTests.cs
create mode 100644 api/tests/ProposalSystem.Tests/Helpers/DbContextFactory.cs
create mode 100644 api/tests/ProposalSystem.Tests/Middleware/InternalApiKeyMiddlewareTests.cs
create mode 100644 api/tests/ProposalSystem.Tests/ProposalSystem.Tests.csproj
create mode 100644 api/tests/ProposalSystem.Tests/Services/ProposalStateMachineTests.cs
create mode 100644 api/tests/ProposalSystem.Tests/Validators/CreateLineItemValidatorTests.cs
create mode 100644 api/tests/ProposalSystem.Tests/Validators/CreateProposalValidatorTests.cs
create mode 100644 lambdas/pytest.ini
create mode 100644 lambdas/tests/__init__.py
create mode 100644 lambdas/tests/conftest.py
create mode 100644 lambdas/tests/helpers.py
create mode 100644 lambdas/tests/requirements-test.txt
create mode 100644 lambdas/tests/test_pdf_generate.py
create mode 100644 lambdas/tests/test_suggestions.py
create mode 100644 web/src/app/__tests__/authSlice.test.ts
create mode 100644 web/src/components/__tests__/ProtectedRoute.test.tsx
create mode 100644 web/src/test/setup.ts
create mode 100644 web/vitest.config.ts
diff --git a/api/ProposalSystem.sln b/api/ProposalSystem.sln
index f34f266..baad3fb 100644
--- a/api/ProposalSystem.sln
+++ b/api/ProposalSystem.sln
@@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProposalSystem.Application"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProposalSystem.Infrastructure", "src\ProposalSystem.Infrastructure\ProposalSystem.Infrastructure.csproj", "{A1B2C3D4-1111-2222-3333-444455559999}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProposalSystem.Tests", "tests\ProposalSystem.Tests\ProposalSystem.Tests.csproj", "{A1B2C3D4-1111-2222-3333-44445555AAAA}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -33,5 +35,9 @@ Global
{A1B2C3D4-1111-2222-3333-444455559999}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-1111-2222-3333-444455559999}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-1111-2222-3333-444455559999}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A1B2C3D4-1111-2222-3333-44445555AAAA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A1B2C3D4-1111-2222-3333-44445555AAAA}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A1B2C3D4-1111-2222-3333-44445555AAAA}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A1B2C3D4-1111-2222-3333-44445555AAAA}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
diff --git a/api/tests/ProposalSystem.Tests/Controllers/AuthorizationAttributeTests.cs b/api/tests/ProposalSystem.Tests/Controllers/AuthorizationAttributeTests.cs
new file mode 100644
index 0000000..b6e4a41
--- /dev/null
+++ b/api/tests/ProposalSystem.Tests/Controllers/AuthorizationAttributeTests.cs
@@ -0,0 +1,183 @@
+using System.Reflection;
+using FluentAssertions;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using ProposalSystem.Api.Controllers;
+using Xunit;
+
+namespace ProposalSystem.Tests.Controllers;
+
+///
+/// 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.
+///
+public class AuthorizationAttributeTests
+{
+ #region Controller-Level Authorization
+
+ [Fact(DisplayName = "QA-C3: ProposalsController requires authentication")]
+ public void ProposalsController_HasAuthorizeAttribute()
+ {
+ typeof(ProposalsController)
+ .GetCustomAttribute()
+ .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();
+ 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()
+ .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();
+ 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();
+ // 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();
+ 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();
+ // 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();
+ 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();
+ 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();
+ 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))]
+ public void AllControllers_HaveAuthorizeAttribute(Type controllerType)
+ {
+ var attr = controllerType.GetCustomAttribute();
+ attr.Should().NotBeNull(
+ $"{controllerType.Name} must have [Authorize] attribute to prevent unauthenticated access");
+ }
+
+ #endregion
+}
diff --git a/api/tests/ProposalSystem.Tests/Helpers/DbContextFactory.cs b/api/tests/ProposalSystem.Tests/Helpers/DbContextFactory.cs
new file mode 100644
index 0000000..729fd3e
--- /dev/null
+++ b/api/tests/ProposalSystem.Tests/Helpers/DbContextFactory.cs
@@ -0,0 +1,22 @@
+using Microsoft.EntityFrameworkCore;
+using ProposalSystem.Infrastructure.Data;
+
+namespace ProposalSystem.Tests.Helpers;
+
+///
+/// Creates isolated in-memory DbContext instances for unit tests.
+/// Each call produces a uniquely-named database so tests don't leak state.
+///
+public static class DbContextFactory
+{
+ public static ProposalDbContext Create()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(databaseName: $"TestDb_{Guid.NewGuid()}")
+ .Options;
+
+ var context = new ProposalDbContext(options);
+ context.Database.EnsureCreated();
+ return context;
+ }
+}
diff --git a/api/tests/ProposalSystem.Tests/Middleware/InternalApiKeyMiddlewareTests.cs b/api/tests/ProposalSystem.Tests/Middleware/InternalApiKeyMiddlewareTests.cs
new file mode 100644
index 0000000..ff889ab
--- /dev/null
+++ b/api/tests/ProposalSystem.Tests/Middleware/InternalApiKeyMiddlewareTests.cs
@@ -0,0 +1,197 @@
+using System.Security.Claims;
+using FluentAssertions;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using ProposalSystem.Api.Middleware;
+using Xunit;
+
+namespace ProposalSystem.Tests.Middleware;
+
+///
+/// QA-C4: InternalApiKeyMiddleware tests.
+/// Validates that internal API key authentication works correctly:
+/// - Valid key on any path sets system claims and calls next (current behavior)
+/// - Invalid key logs warning but still calls next (passes through to JWT)
+/// - Missing key header passes through to next middleware (JWT auth)
+/// - Empty key in config disables the middleware entirely
+///
+/// Note: API-C1 audit finding identified that this middleware applies globally
+/// and bypasses JWT on ANY route when a valid key is provided. These tests
+/// document the current behavior; the fix should scope keys to /internal/ paths.
+///
+public class InternalApiKeyMiddlewareTests
+{
+ private const string ValidApiKey = "test-internal-api-key-secret-value";
+
+ [Fact(DisplayName = "QA-C4: Valid key sets system claims and calls next")]
+ public async Task ValidKey_SetsClaimsAndCallsNext()
+ {
+ // Arrange
+ var (middleware, context, nextCalled) = CreateMiddleware(ValidApiKey);
+ context.Request.Headers["X-Internal-Api-Key"] = ValidApiKey;
+ context.Request.Path = "/api/proposals/123";
+
+ // Act
+ await middleware.InvokeAsync(context);
+
+ // Assert
+ nextCalled().Should().BeTrue("next middleware should be called");
+ context.User.Identity!.IsAuthenticated.Should().BeTrue();
+ context.User.Identity!.AuthenticationType.Should().Be("InternalApiKey");
+ context.User.FindFirst(ClaimTypes.NameIdentifier)!.Value.Should().Be("system");
+ context.User.FindFirst("sub")!.Value.Should().Be("system-lambda-caller");
+ context.User.FindFirst(ClaimTypes.Role)!.Value.Should().Be("admins");
+ context.User.FindFirst("cognito:groups")!.Value.Should().Be("admins");
+ context.User.FindFirst(ClaimTypes.Email)!.Value.Should().Be("system@proposal-system.internal");
+ }
+
+ [Fact(DisplayName = "QA-C4: Invalid key does not set claims but still calls next (falls through to JWT)")]
+ public async Task InvalidKey_DoesNotSetClaims_CallsNext()
+ {
+ // Arrange
+ var (middleware, context, nextCalled) = CreateMiddleware(ValidApiKey);
+ context.Request.Headers["X-Internal-Api-Key"] = "wrong-key";
+ context.Request.Path = "/api/proposals/123";
+
+ // Act
+ await middleware.InvokeAsync(context);
+
+ // Assert
+ nextCalled().Should().BeTrue("next middleware should still be called for JWT to handle");
+ context.User.Identity!.IsAuthenticated.Should().BeFalse(
+ "invalid key should not authenticate; JWT middleware handles auth next");
+ }
+
+ [Fact(DisplayName = "QA-C4: Missing key header passes through to next middleware")]
+ public async Task MissingKeyHeader_PassesThrough()
+ {
+ // Arrange
+ var (middleware, context, nextCalled) = CreateMiddleware(ValidApiKey);
+ // No X-Internal-Api-Key header set
+ context.Request.Path = "/api/proposals";
+
+ // Act
+ await middleware.InvokeAsync(context);
+
+ // Assert
+ nextCalled().Should().BeTrue("request should pass through to next middleware");
+ context.User.Identity!.IsAuthenticated.Should().BeFalse();
+ }
+
+ [Fact(DisplayName = "QA-C4: Empty key in config disables API key check entirely")]
+ public async Task EmptyKeyInConfig_DisablesMiddleware()
+ {
+ // Arrange - empty config key means middleware is effectively disabled
+ var (middleware, context, nextCalled) = CreateMiddleware("");
+ context.Request.Headers["X-Internal-Api-Key"] = "any-key-value";
+ context.Request.Path = "/api/proposals/123";
+
+ // Act
+ await middleware.InvokeAsync(context);
+
+ // Assert
+ nextCalled().Should().BeTrue("next middleware should be called");
+ context.User.Identity!.IsAuthenticated.Should().BeFalse(
+ "middleware is disabled when config key is empty");
+ }
+
+ [Fact(DisplayName = "QA-C4: Null config key disables API key check")]
+ public async Task NullConfigKey_DisablesMiddleware()
+ {
+ // Arrange - null config key (INTERNAL_API_KEY not set)
+ var (middleware, context, nextCalled) = CreateMiddleware(null);
+ context.Request.Headers["X-Internal-Api-Key"] = "any-key-value";
+
+ // Act
+ await middleware.InvokeAsync(context);
+
+ // Assert
+ nextCalled().Should().BeTrue();
+ context.User.Identity!.IsAuthenticated.Should().BeFalse();
+ }
+
+ [Fact(DisplayName = "QA-C4: Empty header value passes through")]
+ public async Task EmptyHeaderValue_PassesThrough()
+ {
+ // Arrange
+ var (middleware, context, nextCalled) = CreateMiddleware(ValidApiKey);
+ context.Request.Headers["X-Internal-Api-Key"] = "";
+
+ // Act
+ await middleware.InvokeAsync(context);
+
+ // Assert
+ nextCalled().Should().BeTrue();
+ context.User.Identity!.IsAuthenticated.Should().BeFalse();
+ }
+
+ [Fact(DisplayName = "QA-C4: Timing-safe comparison used (key differs by one char)")]
+ public async Task SimilarKey_DoesNotAuthenticate()
+ {
+ // This test verifies that a key differing by just one character
+ // is still rejected (CryptographicOperations.FixedTimeEquals)
+ var (middleware, context, nextCalled) = CreateMiddleware(ValidApiKey);
+ context.Request.Headers["X-Internal-Api-Key"] = ValidApiKey + "x";
+
+ // Act
+ await middleware.InvokeAsync(context);
+
+ // Assert
+ nextCalled().Should().BeTrue();
+ context.User.Identity!.IsAuthenticated.Should().BeFalse();
+ }
+
+ ///
+ /// API-C1 documents that the middleware currently authenticates on ANY path.
+ /// This test verifies the current (vulnerable) behavior so that when
+ /// path-scoping is added, this test can be updated to verify the fix.
+ ///
+ [Fact(DisplayName = "QA-C4/API-C1: Valid key currently authenticates on any path (documents vulnerability)")]
+ public async Task ValidKey_AuthenticatesOnAnyPath_DocumentsApiC1()
+ {
+ // The middleware does not scope to /internal/ paths — API-C1 finding.
+ // This test documents the current behavior.
+ var paths = new[] { "/api/proposals", "/api/admin/dashboard", "/api/users", "/health" };
+
+ foreach (var path in paths)
+ {
+ var (middleware, context, nextCalled) = CreateMiddleware(ValidApiKey);
+ context.Request.Headers["X-Internal-Api-Key"] = ValidApiKey;
+ context.Request.Path = path;
+
+ await middleware.InvokeAsync(context);
+
+ context.User.Identity!.IsAuthenticated.Should().BeTrue(
+ $"API-C1: middleware currently authenticates on {path} (should be scoped to /internal/ paths)");
+ }
+ }
+
+ private static (InternalApiKeyMiddleware middleware, HttpContext context, Func nextCalled) CreateMiddleware(string? configuredKey)
+ {
+ var wasNextCalled = false;
+ RequestDelegate next = _ =>
+ {
+ wasNextCalled = true;
+ return Task.CompletedTask;
+ };
+
+ var configData = new Dictionary();
+ if (configuredKey != null)
+ {
+ configData["INTERNAL_API_KEY"] = configuredKey;
+ }
+
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(configData)
+ .Build();
+
+ var logger = Substitute.For>();
+
+ var middleware = new InternalApiKeyMiddleware(next, configuration, logger);
+ var context = new DefaultHttpContext();
+
+ return (middleware, context, () => wasNextCalled);
+ }
+}
diff --git a/api/tests/ProposalSystem.Tests/ProposalSystem.Tests.csproj b/api/tests/ProposalSystem.Tests/ProposalSystem.Tests.csproj
new file mode 100644
index 0000000..41c44cf
--- /dev/null
+++ b/api/tests/ProposalSystem.Tests/ProposalSystem.Tests.csproj
@@ -0,0 +1,34 @@
+
+
+
+ net8.0
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
+
+
diff --git a/api/tests/ProposalSystem.Tests/Services/ProposalStateMachineTests.cs b/api/tests/ProposalSystem.Tests/Services/ProposalStateMachineTests.cs
new file mode 100644
index 0000000..0942811
--- /dev/null
+++ b/api/tests/ProposalSystem.Tests/Services/ProposalStateMachineTests.cs
@@ -0,0 +1,430 @@
+using FluentAssertions;
+using NSubstitute;
+using ProposalSystem.Application.Interfaces;
+using ProposalSystem.Domain.Entities;
+using ProposalSystem.Infrastructure.Services;
+using ProposalSystem.Tests.Helpers;
+using Xunit;
+
+namespace ProposalSystem.Tests.Services;
+
+///
+/// QA-C2: Proposal state machine transition tests.
+/// Verifies that only valid state transitions succeed and invalid ones throw.
+/// State machine: InReview -> Approved -> Sent -> Revised (creates new proposal).
+///
+public class ProposalStateMachineTests : IDisposable
+{
+ private readonly Infrastructure.Data.ProposalDbContext _db;
+ private readonly ICurrentUserService _currentUser;
+ private readonly IAuditService _audit;
+ private readonly IJobPublisher _jobPublisher;
+ private readonly IProposalNumberGenerator _numberGenerator;
+ private readonly ProposalService _sut;
+
+ public ProposalStateMachineTests()
+ {
+ _db = DbContextFactory.Create();
+ _currentUser = Substitute.For();
+ _audit = Substitute.For();
+ _jobPublisher = Substitute.For();
+ _numberGenerator = Substitute.For();
+
+ var userId = Guid.NewGuid();
+ _currentUser.UserId.Returns(userId);
+ _currentUser.Role.Returns(UserRole.Admin);
+
+ // InMemory provider enforces FK constraints; create the required User entity
+ _db.Users.Add(new User
+ {
+ Id = userId,
+ CognitoSub = $"sub-{userId}",
+ Email = "admin@test.com",
+ DisplayName = "Test Admin",
+ Role = UserRole.Admin,
+ CreatedAt = DateTime.UtcNow,
+ UpdatedAt = DateTime.UtcNow,
+ });
+ _db.SaveChanges();
+
+ _sut = new ProposalService(_db, _currentUser, _numberGenerator, _audit, _jobPublisher);
+ }
+
+ public void Dispose()
+ {
+ _db.Dispose();
+ }
+
+ #region Valid Transitions
+
+ [Fact(DisplayName = "QA-C2: InReview -> Approved succeeds when line items have prices")]
+ public async Task ApproveAsync_InReview_TransitionsToApproved()
+ {
+ // Arrange
+ var proposal = CreateProposal(ProposalStatus.InReview);
+ proposal.LineItems.Add(new LineItem
+ {
+ Id = Guid.NewGuid(),
+ ProposalId = proposal.Id,
+ Description = "HVAC duct replacement",
+ Quantity = 1,
+ Unit = "each",
+ TotalPrice = 5000m,
+ PricingMode = PricingMode.TotalPrice,
+ Source = LineItemSource.AI,
+ });
+ _db.Proposals.Add(proposal);
+ await _db.SaveChangesAsync();
+
+ // Act
+ var result = await _sut.ApproveAsync(proposal.Id);
+
+ // Assert
+ result.Status.Should().Be(ProposalStatus.Approved);
+ result.ApprovedById.Should().Be(_currentUser.UserId);
+ result.TotalBidAmount.Should().Be(5000m);
+ }
+
+ [Fact(DisplayName = "QA-C2: Approved -> Sent succeeds")]
+ public async Task MarkSentAsync_Approved_TransitionsToSent()
+ {
+ // Arrange
+ var proposal = CreateProposal(ProposalStatus.Approved);
+ _db.Proposals.Add(proposal);
+ await _db.SaveChangesAsync();
+
+ // Act
+ var result = await _sut.MarkSentAsync(proposal.Id);
+
+ // Assert
+ result.Status.Should().Be(ProposalStatus.Sent);
+ result.SentAt.Should().NotBeNull();
+ }
+
+ [Fact(DisplayName = "QA-C2: Sent -> Revised creates new revision proposal")]
+ public async Task ReviseAsync_Sent_CreatesNewProposalInReview()
+ {
+ // Arrange
+ var proposal = CreateProposal(ProposalStatus.Sent);
+ proposal.LineItems.Add(new LineItem
+ {
+ Id = Guid.NewGuid(),
+ ProposalId = proposal.Id,
+ Description = "Pipe repair",
+ Quantity = 2,
+ Unit = "hours",
+ UnitPrice = 150m,
+ TotalPrice = 300m,
+ PricingMode = PricingMode.UnitPrice,
+ Source = LineItemSource.Manual,
+ });
+ _db.Proposals.Add(proposal);
+ await _db.SaveChangesAsync();
+
+ // Act
+ var result = await _sut.ReviseAsync(proposal.Id);
+
+ // Assert
+ result.Status.Should().Be(ProposalStatus.InReview);
+ result.ParentProposalId.Should().Be(proposal.Id);
+ result.CurrentRevision.Should().Be(proposal.CurrentRevision + 1);
+ result.ProposalNumber.Should().Contain("-R");
+
+ // Original proposal should now be Revised
+ var original = await _db.Proposals.FindAsync(proposal.Id);
+ original!.Status.Should().Be(ProposalStatus.Revised);
+ }
+
+ [Fact(DisplayName = "QA-C2: Approve is idempotent on already-approved proposal")]
+ public async Task ApproveAsync_AlreadyApproved_ReturnsWithoutError()
+ {
+ // Arrange
+ var proposal = CreateProposal(ProposalStatus.Approved);
+ _db.Proposals.Add(proposal);
+ await _db.SaveChangesAsync();
+
+ // Act
+ var result = await _sut.ApproveAsync(proposal.Id);
+
+ // Assert
+ result.Status.Should().Be(ProposalStatus.Approved);
+ }
+
+ [Fact(DisplayName = "QA-C2: MarkSent is idempotent on already-sent proposal")]
+ public async Task MarkSentAsync_AlreadySent_ReturnsWithoutError()
+ {
+ // Arrange
+ var proposal = CreateProposal(ProposalStatus.Sent);
+ _db.Proposals.Add(proposal);
+ await _db.SaveChangesAsync();
+
+ // Act
+ var result = await _sut.MarkSentAsync(proposal.Id);
+
+ // Assert
+ result.Status.Should().Be(ProposalStatus.Sent);
+ }
+
+ #endregion
+
+ #region Invalid Transitions
+
+ [Fact(DisplayName = "QA-C2: InReview -> Sent is invalid, must approve first")]
+ public async Task MarkSentAsync_InReview_ThrowsInvalidOperation()
+ {
+ // Arrange
+ var proposal = CreateProposal(ProposalStatus.InReview);
+ _db.Proposals.Add(proposal);
+ await _db.SaveChangesAsync();
+
+ // Act
+ var act = () => _sut.MarkSentAsync(proposal.Id);
+
+ // Assert
+ await act.Should().ThrowAsync()
+ .WithMessage("*approved*");
+ }
+
+ [Fact(DisplayName = "QA-C2: Approved -> Revised is invalid, must send first")]
+ public async Task ReviseAsync_Approved_ThrowsInvalidOperation()
+ {
+ // Arrange
+ var proposal = CreateProposal(ProposalStatus.Approved);
+ _db.Proposals.Add(proposal);
+ await _db.SaveChangesAsync();
+
+ // Act
+ var act = () => _sut.ReviseAsync(proposal.Id);
+
+ // Assert
+ await act.Should().ThrowAsync()
+ .WithMessage("*sent*");
+ }
+
+ [Fact(DisplayName = "QA-C2: Draft -> Approved is invalid")]
+ public async Task ApproveAsync_Draft_ThrowsInvalidOperation()
+ {
+ // Arrange
+ var proposal = CreateProposal(ProposalStatus.Draft);
+ proposal.LineItems.Add(new LineItem
+ {
+ Id = Guid.NewGuid(),
+ ProposalId = proposal.Id,
+ Description = "Some work",
+ Quantity = 1,
+ Unit = "each",
+ TotalPrice = 100m,
+ PricingMode = PricingMode.TotalPrice,
+ Source = LineItemSource.Manual,
+ });
+ _db.Proposals.Add(proposal);
+ await _db.SaveChangesAsync();
+
+ // Act
+ var act = () => _sut.ApproveAsync(proposal.Id);
+
+ // Assert
+ await act.Should().ThrowAsync()
+ .WithMessage("*in review*");
+ }
+
+ [Fact(DisplayName = "QA-C2: InReview -> Revised is invalid")]
+ public async Task ReviseAsync_InReview_ThrowsInvalidOperation()
+ {
+ // Arrange
+ var proposal = CreateProposal(ProposalStatus.InReview);
+ _db.Proposals.Add(proposal);
+ await _db.SaveChangesAsync();
+
+ // Act
+ var act = () => _sut.ReviseAsync(proposal.Id);
+
+ // Assert
+ await act.Should().ThrowAsync()
+ .WithMessage("*sent*");
+ }
+
+ [Fact(DisplayName = "QA-C2: Sent -> Approved is invalid")]
+ public async Task MarkSentAsync_Sent_StaysIdempotent_But_ApproveThrows()
+ {
+ // Sent cannot go back to Approved
+ var proposal = CreateProposal(ProposalStatus.Sent);
+ _db.Proposals.Add(proposal);
+ await _db.SaveChangesAsync();
+
+ var act = () => _sut.ApproveAsync(proposal.Id);
+
+ await act.Should().ThrowAsync()
+ .WithMessage("*in review*");
+ }
+
+ [Fact(DisplayName = "QA-C2: Cannot approve proposal without priced line items")]
+ public async Task ApproveAsync_NoLineItems_ThrowsInvalidOperation()
+ {
+ // Arrange - proposal InReview but no line items
+ var proposal = CreateProposal(ProposalStatus.InReview);
+ _db.Proposals.Add(proposal);
+ await _db.SaveChangesAsync();
+
+ // Act
+ var act = () => _sut.ApproveAsync(proposal.Id);
+
+ // Assert
+ await act.Should().ThrowAsync()
+ .WithMessage("*priced line items*");
+ }
+
+ [Fact(DisplayName = "QA-C2: Cannot approve proposal with zero-price line items only")]
+ public async Task ApproveAsync_AllZeroPriceLineItems_ThrowsInvalidOperation()
+ {
+ // Arrange
+ var proposal = CreateProposal(ProposalStatus.InReview);
+ proposal.LineItems.Add(new LineItem
+ {
+ Id = Guid.NewGuid(),
+ ProposalId = proposal.Id,
+ Description = "Unprice item",
+ Quantity = 1,
+ Unit = "each",
+ TotalPrice = 0m,
+ PricingMode = PricingMode.TotalPrice,
+ Source = LineItemSource.AI,
+ });
+ _db.Proposals.Add(proposal);
+ await _db.SaveChangesAsync();
+
+ // Act
+ var act = () => _sut.ApproveAsync(proposal.Id);
+
+ // Assert
+ await act.Should().ThrowAsync()
+ .WithMessage("*priced line items*");
+ }
+
+ [Fact(DisplayName = "QA-C2: Approve/Send/Revise on nonexistent proposal throws KeyNotFoundException")]
+ public async Task StateTransitions_NonexistentProposal_ThrowsKeyNotFound()
+ {
+ var missingId = Guid.NewGuid();
+
+ var approveAct = () => _sut.ApproveAsync(missingId);
+ var sendAct = () => _sut.MarkSentAsync(missingId);
+ var reviseAct = () => _sut.ReviseAsync(missingId);
+
+ await approveAct.Should().ThrowAsync();
+ await sendAct.Should().ThrowAsync();
+ await reviseAct.Should().ThrowAsync();
+ }
+
+ #endregion
+
+ #region Revision Edge Cases
+
+ [Fact(DisplayName = "QA-C2: Revision copies line items from parent")]
+ public async Task ReviseAsync_CopiesLineItems()
+ {
+ // Arrange
+ var proposal = CreateProposal(ProposalStatus.Sent);
+ proposal.LineItems.Add(new LineItem
+ {
+ Id = Guid.NewGuid(),
+ ProposalId = proposal.Id,
+ Description = "Item 1",
+ Quantity = 5,
+ Unit = "sq ft",
+ UnitPrice = 10m,
+ TotalPrice = 50m,
+ PricingMode = PricingMode.UnitPrice,
+ SortOrder = 1,
+ Source = LineItemSource.Manual,
+ });
+ proposal.LineItems.Add(new LineItem
+ {
+ Id = Guid.NewGuid(),
+ ProposalId = proposal.Id,
+ Description = "Item 2",
+ Quantity = 1,
+ Unit = "each",
+ TotalPrice = 200m,
+ PricingMode = PricingMode.TotalPrice,
+ SortOrder = 2,
+ Source = LineItemSource.AI,
+ });
+ _db.Proposals.Add(proposal);
+ await _db.SaveChangesAsync();
+
+ // Act
+ var result = await _sut.ReviseAsync(proposal.Id);
+
+ // Assert
+ var revision = await _db.Proposals.FindAsync(result.Id);
+ var revisionItems = _db.LineItems.Where(li => li.ProposalId == result.Id).ToList();
+ revisionItems.Should().HaveCount(2);
+ revisionItems.Should().AllSatisfy(li => li.ProposalId.Should().Be(result.Id));
+ revisionItems.Select(li => li.Description).Should().BeEquivalentTo("Item 1", "Item 2");
+ }
+
+ [Fact(DisplayName = "QA-C2: Audit is logged for approve transition")]
+ public async Task ApproveAsync_LogsAuditEvent()
+ {
+ // Arrange
+ var proposal = CreateProposal(ProposalStatus.InReview);
+ proposal.LineItems.Add(new LineItem
+ {
+ Id = Guid.NewGuid(),
+ ProposalId = proposal.Id,
+ Description = "Work item",
+ Quantity = 1,
+ Unit = "each",
+ TotalPrice = 1000m,
+ PricingMode = PricingMode.TotalPrice,
+ Source = LineItemSource.Manual,
+ });
+ _db.Proposals.Add(proposal);
+ await _db.SaveChangesAsync();
+
+ // Act
+ await _sut.ApproveAsync(proposal.Id);
+
+ // Assert
+ await _audit.Received(1).LogAsync(AuditAction.Approve, proposal.Id, Arg.Any(), Arg.Any());
+ }
+
+ [Fact(DisplayName = "QA-C2: MarkSent publishes library-ingest job")]
+ public async Task MarkSentAsync_PublishesLibraryIngestJob()
+ {
+ // Arrange
+ var proposal = CreateProposal(ProposalStatus.Approved);
+ _db.Proposals.Add(proposal);
+ await _db.SaveChangesAsync();
+
+ // Act
+ await _sut.MarkSentAsync(proposal.Id);
+
+ // Assert
+ await _jobPublisher.Received(1).PublishAsync("library-ingest", Arg.Any