Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,12 @@ internal static void UseDashboardWithEndpoints<TTimeTicker, TCronTicker>(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);
Expand Down Expand Up @@ -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('/');
Expand All @@ -293,5 +304,62 @@ private static string CombinePathBase(string pathBase, string basePath)
return pathBase + basePath;
}

/// <summary>
/// Like <see cref="MapExtensions.Map(IApplicationBuilder, PathString, Action{IApplicationBuilder})"/>
/// but handles the case where <paramref name="basePath"/> includes the application's PathBase prefix.
/// When <c>UsePathBase("/cool-app")</c> runs before <c>UseTickerQ()</c>, ASP.NET strips the prefix
/// from <c>Request.Path</c>. If the user configured <c>SetBasePath("/cool-app/dashboard")</c>, the
/// standard <c>Map()</c> would never match because the request path is already <c>/dashboard</c>.
/// This method detects and strips the PathBase prefix at request time so routing works regardless
/// of middleware ordering.
/// </summary>
private static void MapPathBaseAware(this IApplicationBuilder app, string basePath, Action<IApplicationBuilder> 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();
}
});
}

}
}
275 changes: 275 additions & 0 deletions tests/TickerQ.Tests/DashboardPathBaseTests.cs
Original file line number Diff line number Diff line change
@@ -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;

Check failure on line 6 in tests/TickerQ.Tests/DashboardPathBaseTests.cs

View workflow job for this annotation

GitHub Actions / PR Build and Test

The type or namespace name 'TestHost' does not exist in the namespace 'Microsoft.AspNetCore' (are you missing an assembly reference?)

Check failure on line 6 in tests/TickerQ.Tests/DashboardPathBaseTests.cs

View workflow job for this annotation

GitHub Actions / PR Build and Test

The type or namespace name 'TestHost' does not exist in the namespace 'Microsoft.AspNetCore' (are you missing an assembly reference?)
using Microsoft.Extensions.Hosting;
using TickerQ.Dashboard.DependencyInjection;

namespace TickerQ.Tests;

/// <summary>
/// Tests for Dashboard PathBase-aware routing (MapPathBaseAware) and CombinePathBase logic.
/// Covers issue #332: Dashboard BasePath not working correctly with UsePathBase.
/// </summary>
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);
}

/// <summary>
/// Creates a test host that uses MapPathBaseAware with the given config.
/// Inside the branch it writes "matched"; outside it writes "fallthrough".
/// </summary>
private static async Task<IHost> 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<IApplicationBuilder>(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
}
Loading