diff --git a/src/TickerQ.Dashboard/DependencyInjection/ServiceCollectionExtensions.cs b/src/TickerQ.Dashboard/DependencyInjection/ServiceCollectionExtensions.cs index 022d994a..0ef8c8f8 100644 --- a/src/TickerQ.Dashboard/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/TickerQ.Dashboard/DependencyInjection/ServiceCollectionExtensions.cs @@ -160,6 +160,16 @@ internal static void UseDashboardWithEndpoints(this IA dashboardApp.UseRouting(); dashboardApp.UseCors("TickerQ_Dashboard_CORS"); + // Add ASP.NET Core authorization middleware when auth is enabled. + // This is required because Host-mode endpoints use RequireAuthorization(), + // and ASP.NET Core's EndpointMiddleware throws InvalidOperationException + // if no AuthorizationMiddleware exists between UseRouting() and UseEndpoints(). + // The host app's UseAuthorization() does not propagate into Map() branches. + if (config.Auth.IsEnabled) + { + dashboardApp.UseAuthorization(); + } + // Add authentication middleware (only protects API endpoints) if (config.Auth.IsEnabled) { diff --git a/tests/TickerQ.Tests/DashboardAuthorizationPipelineTests.cs b/tests/TickerQ.Tests/DashboardAuthorizationPipelineTests.cs new file mode 100644 index 00000000..4f53b1ec --- /dev/null +++ b/tests/TickerQ.Tests/DashboardAuthorizationPipelineTests.cs @@ -0,0 +1,229 @@ +using System.Net; +using System.Reflection; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using TickerQ.Dashboard.DependencyInjection; + +namespace TickerQ.Tests; + +/// +/// Integration tests for the dashboard authorization middleware pipeline. +/// Covers issue #408: InvalidOperationException when using Host authentication because +/// UseAuthorization() was missing in the dashboard's Map() branch pipeline. +/// +/// These tests reproduce the exact pattern used by the dashboard pipeline: +/// a Map() branch with UseRouting() + UseEndpoints() containing endpoints with +/// RequireAuthorization() metadata. Without UseAuthorization() in the branch, +/// ASP.NET Core's EndpointMiddleware throws InvalidOperationException. +/// +public class DashboardAuthorizationPipelineTests +{ + private static readonly MethodInfo MapPathBaseAwareMethod = + typeof(ServiceCollectionExtensions).GetMethod( + "MapPathBaseAware", + BindingFlags.NonPublic | BindingFlags.Static)!; + + /// + /// Reproduces issue #408: a Map() branch pipeline with UseRouting() + UseEndpoints() + /// where endpoints have RequireAuthorization() metadata but UseAuthorization() is + /// present in the branch. Should NOT throw InvalidOperationException. + /// + [Fact] + public async Task BranchPipeline_WithUseAuthorization_AndRequireAuthorization_DoesNotThrow() + { + using var host = await CreateTestHost(includeUseAuthorization: true); + var client = host.GetTestClient(); + + var response = await client.GetAsync("/dashboard/api/test"); + + // Should get 401 (unauthenticated) but NOT throw InvalidOperationException + Assert.True( + response.StatusCode == HttpStatusCode.Unauthorized || + response.StatusCode == HttpStatusCode.OK, + $"Expected 401 or 200, got {(int)response.StatusCode}"); + } + + /// + /// Verifies the bug condition: without UseAuthorization() in the branch pipeline, + /// endpoints with RequireAuthorization() cause InvalidOperationException. + /// This test documents the exact failure that issue #408 reports. + /// + [Fact] + public async Task BranchPipeline_WithoutUseAuthorization_AndRequireAuthorization_Throws() + { + using var host = await CreateTestHost(includeUseAuthorization: false); + var client = host.GetTestClient(); + + // Without UseAuthorization(), ASP.NET Core's EndpointMiddleware throws + var ex = await Assert.ThrowsAsync( + () => client.GetAsync("/dashboard/api/test")); + + Assert.Contains("authorization", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Endpoints marked with AllowAnonymous should work even with UseAuthorization() + /// and no authenticated user, matching the dashboard's /api/auth/info behavior. + /// + [Fact] + public async Task BranchPipeline_AllowAnonymousEndpoint_Returns200() + { + using var host = await CreateTestHost(includeUseAuthorization: true); + var client = host.GetTestClient(); + + var response = await client.GetAsync("/dashboard/api/anonymous"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal("anonymous-ok", body); + } + + /// + /// When auth is not configured (no RequireAuthorization on endpoints), + /// UseAuthorization() is not needed and endpoints work without it. + /// This covers the non-auth dashboard configuration path. + /// + [Fact] + public async Task BranchPipeline_NoAuthEndpoints_WorksWithout_UseAuthorization() + { + using var host = await CreateTestHostNoAuth(); + var client = host.GetTestClient(); + + var response = await client.GetAsync("/dashboard/api/test"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal("no-auth-ok", body); + } + + /// + /// Creates a test host that mirrors the dashboard's Map() branch pipeline pattern: + /// UseRouting() → UseCors() → [optional UseAuthorization()] → UseEndpoints() with + /// endpoints that have RequireAuthorization() metadata. + /// + private static async Task CreateTestHost(bool includeUseAuthorization) + { + var host = await new HostBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder.UseTestServer(); + webBuilder.ConfigureServices(services => + { + services.AddRouting(); + services.AddAuthorization(); + services.AddAuthentication("Test") + .AddScheme("Test", _ => { }); + services.AddCors(options => + { + options.AddPolicy("TestCORS", b => b.AllowAnyOrigin()); + }); + }); + webBuilder.Configure(app => + { + // Use MapPathBaseAware to mirror the dashboard's exact pipeline + MapPathBaseAwareMethod.Invoke(null, new object[] + { + app, "/dashboard", new Action(branch => + { + branch.UseRouting(); + branch.UseCors("TestCORS"); + + if (includeUseAuthorization) + { + branch.UseAuthorization(); + } + + branch.UseEndpoints(endpoints => + { + endpoints.MapGet("/api/test", () => "ok") + .RequireAuthorization() + .RequireCors("TestCORS"); + + endpoints.MapGet("/api/anonymous", () => "anonymous-ok") + .AllowAnonymous() + .RequireCors("TestCORS"); + }); + }) + }); + + app.Run(async context => + { + await context.Response.WriteAsync("fallthrough"); + }); + }); + }) + .StartAsync(); + + return host; + } + + /// + /// Creates a test host with no authorization on endpoints (mirrors no-auth dashboard config). + /// + private static async Task CreateTestHostNoAuth() + { + var host = await new HostBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder.UseTestServer(); + webBuilder.ConfigureServices(services => + { + services.AddRouting(); + services.AddCors(options => + { + options.AddPolicy("TestCORS", b => b.AllowAnyOrigin()); + }); + }); + webBuilder.Configure(app => + { + MapPathBaseAwareMethod.Invoke(null, new object[] + { + app, "/dashboard", new Action(branch => + { + branch.UseRouting(); + branch.UseCors("TestCORS"); + + branch.UseEndpoints(endpoints => + { + endpoints.MapGet("/api/test", () => "no-auth-ok") + .RequireCors("TestCORS"); + }); + }) + }); + + app.Run(async context => + { + await context.Response.WriteAsync("fallthrough"); + }); + }); + }) + .StartAsync(); + + return host; + } + + /// + /// Minimal test authentication handler that always returns no-result (unauthenticated). + /// + private class TestAuthHandler : AuthenticationHandler + { + public TestAuthHandler( + IOptionsMonitor options, + ILoggerFactory logger, + System.Text.Encodings.Web.UrlEncoder encoder) + : base(options, logger, encoder) { } + + protected override Task HandleAuthenticateAsync() + { + return Task.FromResult(AuthenticateResult.NoResult()); + } + } +}