Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Tests/Gateway/GatewayApplicationTests.cs
T

247 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 now served by the [AllowAnonymous] Blazor <Login> component
// (Components/Pages/Login.razor → @page "/login"), not a named minimal-API
// endpoint. The form still POSTs to the minimal-API DashboardLoginPost endpoint.
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");
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();
}
}