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 { /// Verifies that Build maps the canonical three health tiers. [Fact] public async Task Build_MapsCanonicalHealthEndpoints() { await using WebApplication app = GatewayApplication.Build([]); var paths = ((IEndpointRouteBuilder)app).DataSources .SelectMany(dataSource => dataSource.Endpoints) .OfType() .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); } /// Verifies that Build registers Serilog as the host logging provider. [Fact] public void Build_UsesSerilogLoggerProvider() { using var app = GatewayApplication.Build([]); var factory = app.Services.GetRequiredService(); Assert.Equal("SerilogLoggerFactory", factory.GetType().Name); } /// Verifies that Build registers the gateway metrics service. [Fact] public async Task Build_RegistersGatewayMetrics() { await using WebApplication app = GatewayApplication.Build([]); GatewayMetrics metrics = app.Services.GetRequiredService(); Assert.NotNull(metrics); } /// Verifies that Build mounts the Prometheus /metrics scrape endpoint. [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(); } } /// Verifies that Build maps dashboard and authentication endpoints when the dashboard is enabled. [Fact] public async Task Build_WhenDashboardEnabled_MapsBlazorDashboardAndAuthEndpoints() { await using WebApplication app = GatewayApplication.Build([]); IReadOnlyList 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 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() is not null); Assert.Contains(endpoints, endpoint => endpoint.Metadata.GetMetadata()?.EndpointName == "DashboardLoginPost"); Assert.Contains(endpoints, endpoint => endpoint.Metadata.GetMetadata()?.EndpointName == "DashboardLogout"); } /// Verifies that the dashboard login, logout, and denied endpoints allow anonymous access. [Fact] public async Task Build_WhenDashboardEnabled_AuthEndpointsAllowAnonymousAccess() { await using WebApplication app = GatewayApplication.Build([]); IReadOnlyList endpoints = GetRouteEndpoints(app); string[] anonymousEndpointNames = ["DashboardLoginPost", "DashboardLogout", "DashboardLogoutGet", "DashboardAccessDenied"]; foreach (string endpointName in anonymousEndpointNames) { RouteEndpoint endpoint = Assert.Single( endpoints, candidate => candidate.Metadata.GetMetadata()?.EndpointName == endpointName); Assert.NotNull(endpoint.Metadata.GetMetadata()); } // GET /login is the [AllowAnonymous] Blazor component route. Its // [AllowAnonymous] attribute overrides the RequireAuthorization(ViewerPolicy) // that MapRazorComponents() 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() is not null); Assert.NotNull(loginComponent.Metadata.GetMetadata()); } /// Verifies that dashboard Razor component routes require the dashboard viewer policy. [Fact] public async Task Build_WhenDashboardEnabled_ComponentRoutesRequireAuthorization() { await using WebApplication app = GatewayApplication.Build([]); IReadOnlyList 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() is not null) .ToArray(); Assert.NotEmpty(matches); Assert.All(matches, endpoint => { IAuthorizeData? authorize = endpoint.Metadata.GetMetadata(); Assert.NotNull(authorize); Assert.Equal(DashboardAuthenticationDefaults.ViewerPolicy, authorize.Policy); }); } } /// Verifies that dashboard routes are registered at root when enabled. [Fact] public async Task Build_WhenDashboardEnabled_RegistersDashboardRoutesAtRoot() { await using WebApplication app = GatewayApplication.Build([]); IReadOnlyList 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() is not null); } } /// Verifies that dashboard routes are not mapped when disabled. [Fact] public async Task Build_WhenDashboardDisabled_DoesNotMapDashboardRoutes() { await using WebApplication app = GatewayApplication.Build(["--MxGateway:Dashboard:Enabled=false"]); IReadOnlyList endpoints = GetRouteEndpoints(app); Assert.DoesNotContain(endpoints, endpoint => endpoint.Metadata.GetMetadata()?.EndpointName?.StartsWith( "Dashboard", StringComparison.Ordinal) == true); } /// Verifies that StartAsync fails when gateway configuration is invalid. /// Configuration key to override. /// Invalid configuration value. /// Expected validation error message. [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( () => app.StartAsync()); Assert.Contains( exception.Failures, failure => failure.Contains(expectedFailure, StringComparison.Ordinal)); } private static IReadOnlyList GetRouteEndpoints(WebApplication app) { return ((IEndpointRouteBuilder)app).DataSources .SelectMany(dataSource => dataSource.Endpoints) .OfType() .ToArray(); } }