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); } }