proposal-system/api/tests/ProposalSystem.Tests/Middleware/InternalApiKeyMiddlewareTests.cs

198 lines
7.8 KiB
C#
Raw Normal View History

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.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;
/// <summary>
/// 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.
/// </summary>
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();
}
/// <summary>
/// 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.
/// </summary>
[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<bool> nextCalled) CreateMiddleware(string? configuredKey)
{
var wasNextCalled = false;
RequestDelegate next = _ =>
{
wasNextCalled = true;
return Task.CompletedTask;
};
var configData = new Dictionary<string, string?>();
if (configuredKey != null)
{
configData["INTERNAL_API_KEY"] = configuredKey;
}
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(configData)
.Build();
var logger = Substitute.For<ILogger<InternalApiKeyMiddleware>>();
var middleware = new InternalApiKeyMiddleware(next, configuration, logger);
var context = new DefaultHttpContext();
return (middleware, context, () => wasNextCalled);
}
}