Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs
T
Joseph Doherty f40409a688
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m16s
ci / java (push) Successful in 2m5s
ci / portable (push) Failing after 5m9s
docs: strip task-tracking IDs from code comments and fill missing XML docs
Tracking IDs are review bookkeeping that rots in shipped source; the rationale they tagged is kept. Comment-only change (CommentChecker 458 -> 16 false positives).
2026-10-07 15:34:32 -04:00

290 lines
14 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>
/// <returns>A task that represents the asynchronous operation.</returns>
[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's browse-scope provider so it wins over the
/// shared library's no-op default. The per-API-key browse-subtree scoping
/// depends on <c>AddSingleton&lt;IGalaxyBrowseScopeProvider, GatewayBrowseScopeProvider&gt;</c>
/// running before <c>AddZbGalaxyRepository</c>, whose <c>TryAddSingleton</c> default
/// (NullGalaxyBrowseScopeProvider → full hierarchy) must lose. If this registration order ever
/// regressed, every metadata-scoped key would silently see the entire Galaxy hierarchy.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Build_RegistersGatewayBrowseScopeProviderOverLibraryDefault()
{
await using WebApplication app = GatewayApplication.Build([]);
ZB.MOM.WW.GalaxyRepository.Grpc.IGalaxyBrowseScopeProvider scopeProvider =
app.Services.GetRequiredService<ZB.MOM.WW.GalaxyRepository.Grpc.IGalaxyBrowseScopeProvider>();
Assert.IsType<ZB.MOM.WW.MxGateway.Server.Security.Authorization.GatewayBrowseScopeProvider>(scopeProvider);
}
/// <summary>Verifies that Build registers the gateway metrics service.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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>
/// <returns>A task that represents the asynchronous operation.</returns>
[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. The auth SQLite
// store path is isolated to a per-process temp file by TestHostEnvironmentInitializer.
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>
/// <returns>A task that represents the asynchronous operation.</returns>
[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>
/// <returns>A task that represents the asynchronous operation.</returns>
[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>
/// <returns>A task that represents the asynchronous operation.</returns>
[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>
/// <returns>A task that represents the asynchronous operation.</returns>
[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}",
// Mounted from the ZB.MOM.WW.Secrets.Ui RCL rather than declared here, so it is the
// one nav destination that a routing regression could remove without touching this
// repo's own pages. The side rail links to it (role-gated), which makes an unmapped
// route a visible dead link rather than a silent absence.
"/admin/secrets",
];
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>
/// <returns>A task that represents the asynchronous operation.</returns>
[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>
/// <returns>A task that represents the asynchronous operation.</returns>
[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. The auth
// store path is isolated by TestHostEnvironmentInitializer, so startup opens a
// writable store and the injected misconfiguration is what fails validation.
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();
}
}