e0a3fbf35b
The themed Blazor <LoginCard> page (Components/Pages/Login.razor, @page "/login")
registers a Razor Components endpoint that matches ALL HTTP methods. The credential
form POSTed to /login, where MapPost("/login") also matched — so every POST /login
threw Microsoft.AspNetCore.Routing.Matching.AmbiguousMatchException (HTTP 500),
breaking dashboard login for every user. It was latent because the dashboard was only
ever reached via the AllowAnonymousLocalhost bypass on the host box.
Move the credential POST to a distinct /auth/login route (mirroring ScadaBridge, which
never collided because it posts to /auth/login). GET /login stays the Blazor page; the
cookie LoginPath stays /login. Adds a registration assertion pinning DashboardLoginPost
to /auth/login as the regression guard.
Files: Login.razor (LoginCard Action), DashboardEndpointRouteBuilderExtensions (MapPost
route), GatewayApplicationTests (route assertion).
252 lines
11 KiB
C#
252 lines
11 KiB
C#
using System.Net;
|
|
using System.Net.Http;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Routing;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.MxGateway.Server;
|
|
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
|
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Tests.Gateway;
|
|
|
|
public sealed class GatewayApplicationTests
|
|
{
|
|
/// <summary>Verifies that Build maps the canonical three health tiers.</summary>
|
|
[Fact]
|
|
public async Task Build_MapsCanonicalHealthEndpoints()
|
|
{
|
|
await using WebApplication app = GatewayApplication.Build([]);
|
|
|
|
var paths = ((IEndpointRouteBuilder)app).DataSources
|
|
.SelectMany(dataSource => dataSource.Endpoints)
|
|
.OfType<RouteEndpoint>()
|
|
.Select(e => e.RoutePattern.RawText)
|
|
.ToHashSet();
|
|
|
|
Assert.Contains("/health/ready", paths);
|
|
Assert.Contains("/health/active", paths);
|
|
Assert.Contains("/healthz", paths);
|
|
Assert.DoesNotContain("/health/live", paths);
|
|
}
|
|
|
|
/// <summary>Verifies that Build registers Serilog as the host logging provider.</summary>
|
|
[Fact]
|
|
public void Build_UsesSerilogLoggerProvider()
|
|
{
|
|
using var app = GatewayApplication.Build([]);
|
|
var factory = app.Services.GetRequiredService<ILoggerFactory>();
|
|
Assert.Equal("SerilogLoggerFactory", factory.GetType().Name);
|
|
}
|
|
|
|
/// <summary>Verifies that Build registers the gateway metrics service.</summary>
|
|
[Fact]
|
|
public async Task Build_RegistersGatewayMetrics()
|
|
{
|
|
await using WebApplication app = GatewayApplication.Build([]);
|
|
|
|
GatewayMetrics metrics = app.Services.GetRequiredService<GatewayMetrics>();
|
|
|
|
Assert.NotNull(metrics);
|
|
}
|
|
|
|
/// <summary>Verifies that Build mounts the Prometheus /metrics scrape endpoint.</summary>
|
|
[Fact]
|
|
public async Task Build_MapsMetricsEndpoint()
|
|
{
|
|
// Bind an ephemeral port (:0) — xUnit runs test collections in parallel, so any
|
|
// started-host test must avoid a fixed port to prevent a bind collision.
|
|
await using WebApplication app = GatewayApplication.Build(["--urls=http://127.0.0.1:0"]);
|
|
await app.StartAsync();
|
|
try
|
|
{
|
|
using var client = new HttpClient { BaseAddress = new Uri(app.Urls.First()) };
|
|
|
|
using HttpResponseMessage response = await client.GetAsync("/metrics");
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
}
|
|
finally
|
|
{
|
|
await app.StopAsync();
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that Build maps dashboard and authentication endpoints when the dashboard is enabled.</summary>
|
|
[Fact]
|
|
public async Task Build_WhenDashboardEnabled_MapsBlazorDashboardAndAuthEndpoints()
|
|
{
|
|
await using WebApplication app = GatewayApplication.Build([]);
|
|
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
|
|
|
|
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/");
|
|
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/sessions");
|
|
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/workers");
|
|
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/events");
|
|
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/settings");
|
|
|
|
// GET /login is served by the [AllowAnonymous] Blazor <Login> component
|
|
// (Components/Pages/Login.razor → @page "/login"), not a named minimal-API
|
|
// endpoint. The credential POST goes to the DashboardLoginPost endpoint at
|
|
// /auth/login — a DISTINCT route. The Blazor component endpoint matches all HTTP
|
|
// methods, so sharing the "/login" route with MapPost previously made POST /login
|
|
// ambiguous (AmbiguousMatchException → HTTP 500). Pinning the POST to /auth/login
|
|
// is the regression guard for that fix.
|
|
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/login"
|
|
&& endpoint.Metadata.GetMetadata<Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata>() is not null);
|
|
Assert.Contains(endpoints, endpoint =>
|
|
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLoginPost"
|
|
&& endpoint.RoutePattern.RawText == "/auth/login");
|
|
Assert.Contains(endpoints, endpoint =>
|
|
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == "DashboardLogout");
|
|
}
|
|
|
|
/// <summary>Verifies that the dashboard login, logout, and denied endpoints allow anonymous access.</summary>
|
|
[Fact]
|
|
public async Task Build_WhenDashboardEnabled_AuthEndpointsAllowAnonymousAccess()
|
|
{
|
|
await using WebApplication app = GatewayApplication.Build([]);
|
|
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
|
|
|
|
string[] anonymousEndpointNames =
|
|
["DashboardLoginPost", "DashboardLogout", "DashboardLogoutGet", "DashboardAccessDenied"];
|
|
foreach (string endpointName in anonymousEndpointNames)
|
|
{
|
|
RouteEndpoint endpoint = Assert.Single(
|
|
endpoints,
|
|
candidate => candidate.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName == endpointName);
|
|
|
|
Assert.NotNull(endpoint.Metadata.GetMetadata<IAllowAnonymous>());
|
|
}
|
|
|
|
// GET /login is the [AllowAnonymous] Blazor <Login> component route. Its
|
|
// [AllowAnonymous] attribute overrides the RequireAuthorization(ViewerPolicy)
|
|
// that MapRazorComponents<App>() applies, so the LoginPath="/login" redirect
|
|
// resolves for unauthenticated users instead of looping the cookie challenge.
|
|
RouteEndpoint loginComponent = Assert.Single(
|
|
endpoints,
|
|
candidate => candidate.RoutePattern.RawText == "/login"
|
|
&& candidate.Metadata.GetMetadata<Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata>() is not null);
|
|
Assert.NotNull(loginComponent.Metadata.GetMetadata<IAllowAnonymous>());
|
|
}
|
|
|
|
/// <summary>Verifies that dashboard Razor component routes require the dashboard viewer policy.</summary>
|
|
[Fact]
|
|
public async Task Build_WhenDashboardEnabled_ComponentRoutesRequireAuthorization()
|
|
{
|
|
await using WebApplication app = GatewayApplication.Build([]);
|
|
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
|
|
|
|
// Razor-component endpoints are distinguished from minimal-API
|
|
// endpoints registered at the same path by the presence of
|
|
// ComponentTypeMetadata. Filter to those before checking auth.
|
|
string[] componentRoutes = ["/", "/sessions", "/workers", "/events", "/settings"];
|
|
foreach (string route in componentRoutes)
|
|
{
|
|
RouteEndpoint[] matches = endpoints
|
|
.Where(endpoint => endpoint.RoutePattern.RawText == route
|
|
&& endpoint.Metadata.GetMetadata<Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata>() is not null)
|
|
.ToArray();
|
|
|
|
Assert.NotEmpty(matches);
|
|
Assert.All(matches, endpoint =>
|
|
{
|
|
IAuthorizeData? authorize = endpoint.Metadata.GetMetadata<IAuthorizeData>();
|
|
Assert.NotNull(authorize);
|
|
Assert.Equal(DashboardAuthenticationDefaults.ViewerPolicy, authorize.Policy);
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that dashboard routes are registered at root when enabled.</summary>
|
|
[Fact]
|
|
public async Task Build_WhenDashboardEnabled_RegistersDashboardRoutesAtRoot()
|
|
{
|
|
await using WebApplication app = GatewayApplication.Build([]);
|
|
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
|
|
|
|
string[] canonicalRoutes =
|
|
[
|
|
"/",
|
|
"/sessions",
|
|
"/workers",
|
|
"/events",
|
|
"/settings",
|
|
"/galaxy",
|
|
"/apikeys",
|
|
"/sessions/{SessionId}",
|
|
];
|
|
foreach (string canonical in canonicalRoutes)
|
|
{
|
|
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == canonical
|
|
&& endpoint.Metadata.GetMetadata<Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata>() is not null);
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that dashboard routes are not mapped when disabled.</summary>
|
|
[Fact]
|
|
public async Task Build_WhenDashboardDisabled_DoesNotMapDashboardRoutes()
|
|
{
|
|
await using WebApplication app = GatewayApplication.Build(["--MxGateway:Dashboard:Enabled=false"]);
|
|
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
|
|
|
|
Assert.DoesNotContain(endpoints, endpoint =>
|
|
endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName?.StartsWith(
|
|
"Dashboard",
|
|
StringComparison.Ordinal) == true);
|
|
}
|
|
|
|
/// <summary>Verifies that StartAsync fails when gateway configuration is invalid.</summary>
|
|
/// <param name="key">Configuration key to override.</param>
|
|
/// <param name="value">Invalid configuration value.</param>
|
|
/// <param name="expectedFailure">Expected validation error message.</param>
|
|
[Theory]
|
|
[InlineData(
|
|
"MxGateway:Worker:ExecutablePath",
|
|
"worker.dll",
|
|
"MxGateway:Worker:ExecutablePath must point to a .exe file.")]
|
|
[InlineData(
|
|
"MxGateway:Events:QueueCapacity",
|
|
"0",
|
|
"MxGateway:Events:QueueCapacity must be greater than zero.")]
|
|
[InlineData(
|
|
"MxGateway:Authentication:PepperSecretName",
|
|
"",
|
|
"MxGateway:Authentication:PepperSecretName is required")]
|
|
[InlineData(
|
|
"MxGateway:Dashboard:GroupToRole:GwAdmin",
|
|
"BogusRole",
|
|
"MxGateway:Dashboard:GroupToRole['GwAdmin'] must be 'Administrator' or 'Viewer'.")]
|
|
[InlineData(
|
|
"MxGateway:Ldap:AllowInsecure",
|
|
"false",
|
|
"MxGateway:Ldap:AllowInsecure must be true when Transport is None (plaintext).")]
|
|
public async Task StartAsync_InvalidGatewayConfiguration_FailsStartup(
|
|
string key,
|
|
string value,
|
|
string expectedFailure)
|
|
{
|
|
// Bind an ephemeral port (:0) — xUnit runs test collections in parallel, so any
|
|
// WebApplication-building test must avoid a fixed port to prevent a bind collision.
|
|
await using WebApplication app = GatewayApplication.Build(
|
|
[$"--{key}={value}", "--urls=http://127.0.0.1:0"]);
|
|
|
|
OptionsValidationException exception = await Assert.ThrowsAsync<OptionsValidationException>(
|
|
() => app.StartAsync());
|
|
|
|
Assert.Contains(
|
|
exception.Failures,
|
|
failure => failure.Contains(expectedFailure, StringComparison.Ordinal));
|
|
}
|
|
|
|
private static IReadOnlyList<RouteEndpoint> GetRouteEndpoints(WebApplication app)
|
|
{
|
|
return ((IEndpointRouteBuilder)app).DataSources
|
|
.SelectMany(dataSource => dataSource.Endpoints)
|
|
.OfType<RouteEndpoint>()
|
|
.ToArray();
|
|
}
|
|
}
|