using System.Net; using System.Reflection; using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using ZB.MOM.WW.MxGateway.Server; using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Dashboard; using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; using AuthenticationOptions = ZB.MOM.WW.MxGateway.Server.Configuration.AuthenticationOptions; namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; /// /// Covers SEC-40: the alarm feed requires a real authenticated dashboard role, so the read-only /// environment bypasses (Authentication:Mode=Disabled, anonymous localhost) no longer /// receive galaxy-wide alarm metadata. /// /// /// The enforcement is a requirement TYPE distinction — /// is an AuthorizationHandler<DashboardAuthorizationRequirement> and therefore never /// runs for — so the tests below assert both /// halves: that both handlers together still deny a bypassed caller, and that the policy the two /// alarm surfaces actually carry is built from that requirement rather than the bypassable one. /// Asserting only the handler would pass even if the hub were left on the old policy. /// public sealed class AlarmFeedAuthorizationTests { /// /// The bypass that satisfies /// — auth disabled AND a loopback request with AllowAnonymousLocalhost on, i.e. both /// arms at once — does not satisfy the alarm requirement, with the real bypass handler present /// in the same evaluation. /// /// A task that represents the asynchronous operation. [Fact] public async Task AlarmRequirement_AnonymousLocalhostAndAuthDisabled_DoesNotSucceed() { AuthorizationHandlerContext context = await EvaluateAsync( new ClaimsPrincipal(new ClaimsIdentity()), AuthenticatedDashboardRoleRequirement.AnyDashboardRole); Assert.False(context.HasSucceeded); } /// /// Control: the SAME evaluation environment satisfies the bypassable requirement. Without this /// pairing the test above could pass because the bypass was misconfigured rather than because /// the requirement type excludes it. /// /// A task that represents the asynchronous operation. [Fact] public async Task ViewerRequirement_AnonymousLocalhostAndAuthDisabled_StillSucceeds() { AuthorizationHandlerContext context = await EvaluateAsync( new ClaimsPrincipal(new ClaimsIdentity()), DashboardAuthorizationRequirement.AnyDashboardRole); Assert.True(context.HasSucceeded); } /// /// An authenticated Viewer — the ordinary LDAP-logged-in dashboard user — does satisfy the /// alarm requirement: SEC-40 excludes the environment bypasses, not Viewers. Alarm metadata /// stays galaxy-wide for them because the monitor is gateway-central and carries no /// per-session provenance to tag-scope by. /// /// The dashboard role the authenticated caller holds. /// A task that represents the asynchronous operation. [Theory] [InlineData(DashboardRoles.Viewer)] [InlineData(DashboardRoles.Admin)] public async Task AlarmRequirement_AuthenticatedDashboardRole_Succeeds(string role) { AuthorizationHandlerContext context = await EvaluateAsync( CreatePrincipal(role), AuthenticatedDashboardRoleRequirement.AnyDashboardRole); Assert.True(context.HasSucceeded); } /// /// A principal carrying a dashboard role claim but no authentication type does not satisfy the /// requirement: "real role claim" means an authenticated identity, matching how the session /// ACL and DashboardSessionAdminService read a role. /// /// A task that represents the asynchronous operation. [Fact] public async Task AlarmRequirement_UnauthenticatedRoleClaim_DoesNotSucceed() { ClaimsPrincipal principal = new(new ClaimsIdentity( [new Claim(ClaimTypes.Role, DashboardRoles.Admin)], authenticationType: null, nameType: ClaimTypes.Name, roleType: ClaimTypes.Role)); AuthorizationHandlerContext context = await EvaluateAsync( principal, AuthenticatedDashboardRoleRequirement.AnyDashboardRole); Assert.False(context.HasSucceeded); } /// /// The registered policy is built from the non-bypassable requirement and from neither /// nor a role list the bypass handler could /// reach, and it still accepts both hub-capable schemes so an external hub client can /// authenticate with a bearer token. /// /// A task that represents the asynchronous operation. [Fact] public async Task AlarmsPolicy_IsRegisteredWithNonBypassableRequirement() { await using WebApplication app = GatewayApplication.Build([]); IAuthorizationPolicyProvider provider = app.Services .GetRequiredService(); AuthorizationPolicy? policy = await provider .GetPolicyAsync(DashboardAuthenticationDefaults.AlarmsPolicy); Assert.NotNull(policy); Assert.Contains(policy.Requirements, r => r is AuthenticatedDashboardRoleRequirement); Assert.DoesNotContain(policy.Requirements, r => r is DashboardAuthorizationRequirement); Assert.Equal( [ DashboardAuthenticationDefaults.AuthenticationScheme, DashboardAuthenticationDefaults.HubAuthenticationScheme, ], policy.AuthenticationSchemes); } /// /// Both readers of the one central monitor carry the alarm policy: the hub (external clients) /// and the /alarms Razor endpoint (the in-page reader). Gating only the hub would leave /// the same metadata one navigation away for a bypassed caller. /// /// A task that represents the asynchronous operation. [Fact] public async Task AlarmsHubAndAlarmsPage_BothCarryTheAlarmsPolicy() { AuthorizeAttribute? hubAttribute = typeof(AlarmsHub) .GetCustomAttribute(inherit: false); Assert.Equal(DashboardAuthenticationDefaults.AlarmsPolicy, hubAttribute?.Policy); await using WebApplication app = GatewayApplication.Build([]); RouteEndpoint alarmsPage = ((IEndpointRouteBuilder)app).DataSources .SelectMany(source => source.Endpoints) .OfType() .Single(endpoint => endpoint.RoutePattern.RawText == "/alarms"); Assert.Contains( alarmsPage.Metadata.OfType(), data => data.Policy == DashboardAuthenticationDefaults.AlarmsPolicy); } /// /// Runs BOTH dashboard authorization handlers over one requirement in the most permissive /// environment the gateway supports (auth disabled, loopback, anonymous localhost allowed), /// mirroring how the authorization service invokes every registered handler. /// /// The caller. /// The single requirement under evaluation. /// The context after both handlers have run. private static async Task EvaluateAsync( ClaimsPrincipal principal, IAuthorizationRequirement requirement) { DefaultHttpContext httpContext = new(); httpContext.Connection.RemoteIpAddress = IPAddress.Loopback; IOptions options = Options.Create(new GatewayOptions { Authentication = new AuthenticationOptions { Mode = AuthenticationMode.Disabled }, Dashboard = new DashboardOptions { AllowAnonymousLocalhost = true }, }); AuthorizationHandlerContext context = new([requirement], principal, httpContext); await new DashboardAuthorizationHandler( new HttpContextAccessor { HttpContext = httpContext }, options).HandleAsync(context); await new AuthenticatedDashboardRoleHandler().HandleAsync(context); return context; } private static ClaimsPrincipal CreatePrincipal(string role) => new(new ClaimsIdentity( [new Claim(ClaimTypes.Role, role)], DashboardAuthenticationDefaults.AuthenticationScheme, ClaimTypes.Name, ClaimTypes.Role)); }