using GB5Shared.Connection; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Moq; using PartnerSL.Middleware; using PartnerSL.Services; using Xunit; namespace PartnerTests; /// /// Covers PartnerSL.Middleware.ApiKeyAuthMiddleware — specifically the fail-open bug this test /// project was created to close (Thread 7 of the plan tracker). No live SQL Server is available /// in this environment, so only paths reachable without an actual TPARTNERAPIKEY row are covered /// here: the no-header pass-through, the internal-path skip, and — the one that matters most — /// the DB-lookup-throws case. That last one is fully testable without a live DB because /// IApplicationConnection.Gb5SystemConnectionString() is itself mockable to throw, which is /// exactly the first thing InvokeAsync awaits inside its try block. /// public class ApiKeyAuthMiddlewareTests { private static DomainCacheService BuildDomainCacheService() { // TryResolve (the only method this middleware calls) reads straight from the injected // IMemoryCache and never touches _scopeFactory — a real cache + an unexercised // IServiceScopeFactory mock is enough, mirroring this repo's established "not exercised, // trivial mock" pattern (e.g. ClientAuthBLLTests' EventLogPublish). return new DomainCacheService( new Mock().Object, new MemoryCache(new MemoryCacheOptions()), NullLogger.Instance); } private static HttpContext BuildHttpContext(string? apiKeyHeader, string path = "/partner/Something") { var context = new DefaultHttpContext(); context.Request.Path = path; if (apiKeyHeader is not null) context.Request.Headers["X-Api-Key"] = apiKeyHeader; return context; } [Fact] public async Task Test_NoApiKeyHeader_PassesThrough_NeverTouchesDb() { var appConnection = new Mock(); var nextCalled = false; RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; }; var middleware = new ApiKeyAuthMiddleware(next, NullLogger.Instance); var context = BuildHttpContext(apiKeyHeader: null); await middleware.InvokeAsync(context, appConnection.Object, BuildDomainCacheService()); Assert.True(nextCalled); Assert.Equal(200, context.Response.StatusCode); // DefaultHttpContext's untouched default appConnection.Verify(a => a.Gb5SystemConnectionString(), Times.Never); } [Theory] [InlineData("/dapr/subscribe")] [InlineData("/swagger/index.html")] [InlineData("/healthz")] [InlineData("/")] public async Task Test_InternalPath_PassesThrough_EvenWithApiKeyHeaderPresent(string internalPath) { var appConnection = new Mock(); var nextCalled = false; RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; }; var middleware = new ApiKeyAuthMiddleware(next, NullLogger.Instance); var context = BuildHttpContext(apiKeyHeader: "some-key-value", path: internalPath); await middleware.InvokeAsync(context, appConnection.Object, BuildDomainCacheService()); Assert.True(nextCalled); appConnection.Verify(a => a.Gb5SystemConnectionString(), Times.Never); } [Fact] public async Task Test_DbLookupThrows_RejectsWithUnauthorized_DoesNotCallNext() { // The actual regression test: before the fix, this exact scenario (Gb5SystemConnectionString // throwing — standing in for any DB/Vault outage during the API-key lookup) resulted in the // middleware logging a warning and calling next() anyway, silently authenticating an // unverified request. It must now reject with 401 and never call next(). var appConnection = new Mock(); appConnection.Setup(a => a.Gb5SystemConnectionString()) .ThrowsAsync(new InvalidOperationException("simulated DB/Vault outage")); var nextCalled = false; RequestDelegate next = _ => { nextCalled = true; return Task.CompletedTask; }; var middleware = new ApiKeyAuthMiddleware(next, NullLogger.Instance); var context = BuildHttpContext(apiKeyHeader: "some-key-value"); context.Response.Body = new MemoryStream(); await middleware.InvokeAsync(context, appConnection.Object, BuildDomainCacheService()); Assert.False(nextCalled); Assert.Equal(401, context.Response.StatusCode); } }