247 lines
11 KiB
C#
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();
|
|
}
|
|
}
|