diff --git a/src/TickerQ.Dashboard/DependencyInjection/ServiceCollectionExtensions.cs b/src/TickerQ.Dashboard/DependencyInjection/ServiceCollectionExtensions.cs index bed1ac56..f7de31f1 100644 --- a/src/TickerQ.Dashboard/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/TickerQ.Dashboard/DependencyInjection/ServiceCollectionExtensions.cs @@ -103,8 +103,12 @@ internal static void UseDashboardWithEndpoints(this IA } } - // Map a branch for the basePath to properly isolate dashboard - app.Map(basePath, dashboardApp => + // Map a branch for the basePath with PathBase-aware routing. + // Standard app.Map() fails when UsePathBase() runs before UseTickerQ() and the user + // includes the PathBase prefix in SetBasePath() (e.g. SetBasePath("/cool-app/dashboard") + // with UsePathBase("/cool-app")), because PathBase is already stripped from Request.Path. + // This also handles the normal case where SetBasePath() contains only the dashboard segment. + app.MapPathBaseAware(basePath, dashboardApp => { // Execute pre-dashboard middleware config.PreDashboardMiddleware?.Invoke(dashboardApp); @@ -279,12 +283,19 @@ private static string CombinePathBase(string pathBase, string basePath) if (string.IsNullOrEmpty(pathBase)) return basePath; - // If basePath already includes the pathBase prefix, treat it as the full frontend path + // If basePath already includes the pathBase prefix, treat it as the full frontend path. // This prevents /cool-app/cool-app/... and similar double-prefix issues when users // configure BasePath with the full URL segment. if (basePath.StartsWith(pathBase, StringComparison.OrdinalIgnoreCase)) return basePath; + // Inside a Map() branch, ASP.NET adds the matched segment to PathBase automatically. + // So PathBase already ends with basePath (e.g. PathBase="/cool-app/dashboard", + // basePath="/dashboard"). In this case, just return PathBase — it already is the + // full frontend path. Without this check, we'd produce "/cool-app/dashboard/dashboard". + if (pathBase.EndsWith(basePath, StringComparison.OrdinalIgnoreCase)) + return pathBase; + // Normalize to avoid double slashes if (pathBase.EndsWith("/")) pathBase = pathBase.TrimEnd('/'); @@ -293,5 +304,62 @@ private static string CombinePathBase(string pathBase, string basePath) return pathBase + basePath; } + /// + /// Like + /// but handles the case where includes the application's PathBase prefix. + /// When UsePathBase("/cool-app") runs before UseTickerQ(), ASP.NET strips the prefix + /// from Request.Path. If the user configured SetBasePath("/cool-app/dashboard"), the + /// standard Map() would never match because the request path is already /dashboard. + /// This method detects and strips the PathBase prefix at request time so routing works regardless + /// of middleware ordering. + /// + private static void MapPathBaseAware(this IApplicationBuilder app, string basePath, Action configuration) + { + var branchBuilder = app.New(); + configuration(branchBuilder); + var branch = branchBuilder.Build(); + + app.Use(async (context, next) => + { + var routePath = basePath; + + // If basePath includes the current PathBase prefix, strip it for route matching. + // Example: basePath="/cool-app/dashboard", PathBase="/cool-app" → routePath="/dashboard" + if (context.Request.PathBase.HasValue) + { + var pathBaseValue = context.Request.PathBase.Value; + if (routePath.StartsWith(pathBaseValue, StringComparison.OrdinalIgnoreCase) + && routePath.Length > pathBaseValue.Length) + { + routePath = routePath.Substring(pathBaseValue.Length); + } + } + + if (context.Request.Path.StartsWithSegments(routePath, out var matchedPath, out var remainingPath)) + { + var originalPath = context.Request.Path; + var originalPathBase = context.Request.PathBase; + + // Mirror Map() behavior: move the matched segment from Path to PathBase + context.Request.PathBase = originalPathBase.Add(matchedPath); + context.Request.Path = remainingPath; + + try + { + await branch(context); + } + finally + { + context.Request.PathBase = originalPathBase; + context.Request.Path = originalPath; + } + } + else + { + await next(); + } + }); + } + } } diff --git a/tests/TickerQ.Tests/DashboardPathBaseTests.cs b/tests/TickerQ.Tests/DashboardPathBaseTests.cs new file mode 100644 index 00000000..46a0fc09 --- /dev/null +++ b/tests/TickerQ.Tests/DashboardPathBaseTests.cs @@ -0,0 +1,275 @@ +using System.Net; +using System.Reflection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Hosting; +using TickerQ.Dashboard.DependencyInjection; + +namespace TickerQ.Tests; + +/// +/// Tests for Dashboard PathBase-aware routing (MapPathBaseAware) and CombinePathBase logic. +/// Covers issue #332: Dashboard BasePath not working correctly with UsePathBase. +/// +public class DashboardPathBaseTests +{ + #region CombinePathBase — via reflection (private static method) + + private static readonly MethodInfo CombinePathBaseMethod = + typeof(ServiceCollectionExtensions).GetMethod("CombinePathBase", BindingFlags.NonPublic | BindingFlags.Static)!; + + private static string CombinePathBase(string pathBase, string basePath) + => (string)CombinePathBaseMethod.Invoke(null, new object[] { pathBase, basePath })!; + + [Fact] + public void CombinePathBase_NoPathBase_ReturnsBasePath() + { + var result = CombinePathBase("", "/tickerq/dashboard"); + Assert.Equal("/tickerq/dashboard", result); + } + + [Fact] + public void CombinePathBase_NullPathBase_ReturnsBasePath() + { + var result = CombinePathBase(null!, "/tickerq/dashboard"); + Assert.Equal("/tickerq/dashboard", result); + } + + [Fact] + public void CombinePathBase_RootBasePath_ReturnsPathBase() + { + var result = CombinePathBase("/cool-app", "/"); + Assert.Equal("/cool-app", result); + } + + [Fact] + public void CombinePathBase_EmptyBasePath_ReturnsRoot() + { + var result = CombinePathBase("", "/"); + Assert.Equal("/", result); + } + + [Fact] + public void CombinePathBase_BasePathIncludesPathBase_ReturnsBasePath() + { + // User sets SetBasePath("/cool-app/dashboard") with UsePathBase("/cool-app") + var result = CombinePathBase("/cool-app", "/cool-app/dashboard"); + Assert.Equal("/cool-app/dashboard", result); + } + + [Fact] + public void CombinePathBase_PathBaseEndsWithBasePath_ReturnsPathBase() + { + // Inside Map() branch: ASP.NET adds matched segment to PathBase + // PathBase="/cool-app/dashboard", basePath="/dashboard" + var result = CombinePathBase("/cool-app/dashboard", "/dashboard"); + Assert.Equal("/cool-app/dashboard", result); + } + + [Fact] + public void CombinePathBase_PathBaseEqualsBasePath_ReturnsBasePath() + { + // Inside Map() with no external PathBase: PathBase="/dashboard", basePath="/dashboard" + var result = CombinePathBase("/dashboard", "/dashboard"); + Assert.Equal("/dashboard", result); + } + + [Fact] + public void CombinePathBase_DisjointPaths_Concatenates() + { + // PathBase="/api" and basePath="/dashboard" — no overlap + var result = CombinePathBase("/api", "/dashboard"); + Assert.Equal("/api/dashboard", result); + } + + [Fact] + public void CombinePathBase_PathBaseWithTrailingSlash_NormalizesAndConcatenates() + { + var result = CombinePathBase("/api/", "/dashboard"); + Assert.Equal("/api/dashboard", result); + } + + #endregion + + #region MapPathBaseAware — integration tests with TestHost + + [Fact] + public async Task MapPathBaseAware_NoPathBase_MatchesBasePath() + { + // Standard case: no UsePathBase, SetBasePath("/dashboard") + using var host = await CreateTestHost( + basePath: "/dashboard", + usePathBase: null); + + var client = host.GetTestClient(); + var response = await client.GetAsync("/dashboard/test"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal("matched", body); + } + + [Fact] + public async Task MapPathBaseAware_NoPathBase_NonMatchingPath_PassesThrough() + { + using var host = await CreateTestHost( + basePath: "/dashboard", + usePathBase: null); + + var client = host.GetTestClient(); + var response = await client.GetAsync("/other/path"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal("fallthrough", body); + } + + [Fact] + public async Task MapPathBaseAware_WithPathBase_NormalBasePath_Matches() + { + // Normal case: UsePathBase("/cool-app"), SetBasePath("/dashboard") + using var host = await CreateTestHost( + basePath: "/dashboard", + usePathBase: "/cool-app"); + + var client = host.GetTestClient(); + var response = await client.GetAsync("/cool-app/dashboard/test"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal("matched", body); + } + + [Fact] + public async Task MapPathBaseAware_WithPathBase_BasePathIncludesPrefix_Matches() + { + // Issue #332 scenario: UsePathBase("/cool-app"), SetBasePath("/cool-app/dashboard") + // Standard Map() would fail here because path is "/dashboard" after PathBase stripping + using var host = await CreateTestHost( + basePath: "/cool-app/dashboard", + usePathBase: "/cool-app"); + + var client = host.GetTestClient(); + var response = await client.GetAsync("/cool-app/dashboard/test"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal("matched", body); + } + + [Fact] + public async Task MapPathBaseAware_WithPathBase_BasePathIncludesPrefix_SetsCorrectPathBase() + { + // Verify that PathBase is correctly set inside the branch + using var host = await CreateTestHost( + basePath: "/cool-app/dashboard", + usePathBase: "/cool-app", + writePathBase: true); + + var client = host.GetTestClient(); + var response = await client.GetAsync("/cool-app/dashboard/test"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal("/cool-app/dashboard", body); + } + + [Fact] + public async Task MapPathBaseAware_WithPathBase_NormalBasePath_SetsCorrectPathBase() + { + using var host = await CreateTestHost( + basePath: "/dashboard", + usePathBase: "/cool-app", + writePathBase: true); + + var client = host.GetTestClient(); + var response = await client.GetAsync("/cool-app/dashboard/test"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal("/cool-app/dashboard", body); + } + + [Fact] + public async Task MapPathBaseAware_WithPathBase_RootPath_PassesThrough() + { + using var host = await CreateTestHost( + basePath: "/cool-app/dashboard", + usePathBase: "/cool-app"); + + var client = host.GetTestClient(); + var response = await client.GetAsync("/cool-app/other"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal("fallthrough", body); + } + + [Fact] + public async Task MapPathBaseAware_ExactBasePathMatch_WithNoTrailingSegment() + { + using var host = await CreateTestHost( + basePath: "/dashboard", + usePathBase: null); + + var client = host.GetTestClient(); + var response = await client.GetAsync("/dashboard"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(); + Assert.Equal("matched", body); + } + + /// + /// Creates a test host that uses MapPathBaseAware with the given config. + /// Inside the branch it writes "matched"; outside it writes "fallthrough". + /// + private static async Task CreateTestHost( + string basePath, + string? usePathBase, + bool writePathBase = false) + { + // Access the private MapPathBaseAware extension method via reflection + var mapMethod = typeof(ServiceCollectionExtensions).GetMethod( + "MapPathBaseAware", + BindingFlags.NonPublic | BindingFlags.Static)!; + + var host = await new HostBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder.UseTestServer(); + webBuilder.Configure(app => + { + if (usePathBase != null) + app.UsePathBase(usePathBase); + + // Call private MapPathBaseAware via reflection + var configuration = new Action(branch => + { + branch.Run(async context => + { + if (writePathBase) + await context.Response.WriteAsync(context.Request.PathBase.Value ?? ""); + else + await context.Response.WriteAsync("matched"); + }); + }); + + mapMethod.Invoke(null, new object[] { app, basePath, configuration }); + + // Fallthrough + app.Run(async context => + { + await context.Response.WriteAsync("fallthrough"); + }); + }); + }) + .StartAsync(); + + return host; + } + + #endregion +}