mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-07 16:18:57 +00:00
Gemini scanner sweep (token-bypass), GPT-4.1 cross-review, and the 6-detector /sh-security-review fan-out ran against 6acfdab..HEAD; every confirmed finding fixed: HIGH (deployment blockers, logic detector): - CONC-L1: suggestions lambda's bulk line-item PUT sent no proposalVersion — every AI suggestion job would 422 and be silently swallowed. Now fetches the proposal's rowVersion, echoes it, and retries once with a fresh token on 409. Pytest updated (38 green). - CONC-L2: mobile admin surface (update/approve/send/revise, bulk line items) sent no tokens — the entire mobile admin workflow would 422. Tokens threaded through mobile api layer + workspace/line-item screens with 409 refetch handling. tsc clean. MEDIUM-adjacent (scanner): - VendorProposalsController: the VendorTotalCost write on Proposal now bumps Version (was a silent lost-update path bypassing the guard). - FilesController: GeneratePDF audit staged into the same SaveChanges. LOW (detectors): - 409 envelope is schema-validated client-side (proposalConcurrencyConflictSchema.safeParse) and id-checked before seeding the react-query cache; malformed state degrades to invalidation (INJ-409-01/WEB-CONC-L1). - ProposalConcurrencyException.CurrentState typed ProposalResponse? so an EF entity can never serialize into the 409 body (SC-1). - Guard caller contract documented + GuardedEndpointAuthorizationTests reflection tripwire: guard-reaching endpoints must stay admin-gated (AUTHZ-CG-01). - Pre-check currentState now loads display navigations so both 409 paths return the same shape (CONC-L3). - Save chain's trailing getById failure no longer misreports a committed save; falls back to invalidation (CONC-L4). Also caught during fix verification: the handler's manual currentState serialization lacked JsonStringEnumConverter — enums would serialize as numbers, client schema validation would reject every guarded 409, and the state would always be discarded. Now matches the MVC pipeline and is pinned by a wire test. 193 xUnit / 70 vitest / 38 pytest green; mobile + shared tsc clean; Playwright smoke 2/2.
44 lines
2 KiB
C#
44 lines
2 KiB
C#
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");
|
|
}
|
|
}
|