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));
}