using System.Reflection; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.AdminUI.Components; using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages; using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters; using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters.Drivers; using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Uns; using ZB.MOM.WW.OtOpcUa.Security.Auth; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Authorization; /// /// Reflection guard over every routable AdminUI page's class-level authorization metadata. /// This is the substitute for bUnit (the repo has no bUnit; razor @attribute/@page /// compile to class-level attributes, so a metadata scan is authoritative and needs no rendering). /// It is the anti-drift anchor: a new page fails until the author consciously classifies it. /// Guard universe = typeof(Routes).Assembly — the exact assembly Routes.razor hands /// <Router AppAssembly=…>, so the guard sees precisely the router's page set. /// public class PageAuthorizationGuardTests { /// /// Target classification: every routable page → its required policy. /// This is the 38-page census from the R2-05 plan. holds the sole /// [AllowAnonymous] page. Together they must exactly cover the router's page set. /// private static readonly IReadOnlyDictionary ExpectedPolicy = new Dictionary { // ── ConfigEditor (20): the config-authoring surface (incl. live ResilienceConfig) ── [typeof(GlobalUns)] = AdminUiPolicies.ConfigEditor, [typeof(EquipmentPage)] = AdminUiPolicies.ConfigEditor, [typeof(NewCluster)] = AdminUiPolicies.ConfigEditor, [typeof(ClusterEdit)] = AdminUiPolicies.ConfigEditor, [typeof(NodeEdit)] = AdminUiPolicies.ConfigEditor, [typeof(NamespaceEdit)] = AdminUiPolicies.ConfigEditor, [typeof(AclEdit)] = AdminUiPolicies.ConfigEditor, [typeof(DriverTypePicker)] = AdminUiPolicies.ConfigEditor, [typeof(DriverEditRouter)] = AdminUiPolicies.ConfigEditor, [typeof(ModbusDriverPage)] = AdminUiPolicies.ConfigEditor, [typeof(S7DriverPage)] = AdminUiPolicies.ConfigEditor, [typeof(AbCipDriverPage)] = AdminUiPolicies.ConfigEditor, [typeof(AbLegacyDriverPage)] = AdminUiPolicies.ConfigEditor, [typeof(TwinCATDriverPage)] = AdminUiPolicies.ConfigEditor, [typeof(FocasDriverPage)] = AdminUiPolicies.ConfigEditor, [typeof(OpcUaClientDriverPage)] = AdminUiPolicies.ConfigEditor, [typeof(GalaxyDriverPage)] = AdminUiPolicies.ConfigEditor, [typeof(Deployments)] = AdminUiPolicies.ConfigEditor, [typeof(Scripts)] = AdminUiPolicies.ConfigEditor, [typeof(ScriptEdit)] = AdminUiPolicies.ConfigEditor, // ── FleetAdmin (1) ── [typeof(RoleGrants)] = AdminUiPolicies.FleetAdmin, // ── AuthenticatedRead (16): dashboards, lists, live tails (read-only) ── [typeof(Home)] = AdminUiPolicies.AuthenticatedRead, [typeof(Fleet)] = AdminUiPolicies.AuthenticatedRead, [typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Hosts)] = AdminUiPolicies.AuthenticatedRead, [typeof(Alerts)] = AdminUiPolicies.AuthenticatedRead, [typeof(ScriptLog)] = AdminUiPolicies.AuthenticatedRead, [typeof(AlarmsHistorian)] = AdminUiPolicies.AuthenticatedRead, [typeof(Account)] = AdminUiPolicies.AuthenticatedRead, [typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Certificates)] = AdminUiPolicies.AuthenticatedRead, [typeof(Reservations)] = AdminUiPolicies.AuthenticatedRead, [typeof(ClustersList)] = AdminUiPolicies.AuthenticatedRead, [typeof(ClusterOverview)] = AdminUiPolicies.AuthenticatedRead, [typeof(ClusterAudit)] = AdminUiPolicies.AuthenticatedRead, [typeof(ClusterAcls)] = AdminUiPolicies.AuthenticatedRead, [typeof(ClusterNamespaces)] = AdminUiPolicies.AuthenticatedRead, [typeof(ClusterDrivers)] = AdminUiPolicies.AuthenticatedRead, [typeof(ClusterRedundancy)] = AdminUiPolicies.AuthenticatedRead, }; /// The only page that must remain anonymously reachable (Admin-001 — the login form). private static readonly IReadOnlySet AnonymousPages = new HashSet { typeof(Login) }; private static readonly string[] KnownPolicyNames = [ AdminUiPolicies.FleetAdmin, AdminUiPolicies.DriverOperator, AdminUiPolicies.ConfigEditor, AdminUiPolicies.AuthenticatedRead, ]; private static IReadOnlyList RoutablePages() => [.. typeof(Routes).Assembly.GetTypes() .Where(t => t.GetCustomAttributes(inherit: false).Any()) .OrderBy(t => t.FullName, StringComparer.Ordinal)]; private static string RouteOf(Type t) => string.Join(", ", t.GetCustomAttributes(inherit: false).Select(r => r.Template)); private static AuthorizeAttribute? Authorize(Type t) => t.GetCustomAttributes(inherit: false).SingleOrDefault(); private static bool IsAnonymous(Type t) => t.GetCustomAttributes(inherit: false).Any(); /// /// Rule 1 — no bare gates. Every routable page must carry either [AllowAnonymous] or an /// [Authorize] with a non-empty Policy and null Roles. Bans bare [Authorize], the /// Roles="…" idiom, and attribute-less pages. /// [Fact] public void EveryRoutablePage_carriesAnExplicitNamedPolicyOrAllowAnonymous() { var offenders = new List(); foreach (var page in RoutablePages()) { if (IsAnonymous(page)) continue; var auth = Authorize(page); if (auth is null) { offenders.Add($"{page.FullName} ({RouteOf(page)}): no [Authorize]/[AllowAnonymous] attribute"); continue; } if (string.IsNullOrEmpty(auth.Policy)) offenders.Add($"{page.FullName} ({RouteOf(page)}): bare [Authorize] — no Policy"); if (!string.IsNullOrEmpty(auth.Roles)) offenders.Add($"{page.FullName} ({RouteOf(page)}): uses Roles=\"{auth.Roles}\" idiom — convert to a named policy"); } offenders.ShouldBeEmpty( "Pages must carry an explicit named policy (or [AllowAnonymous]). Offenders:\n" + string.Join("\n", offenders)); } /// /// Rule 2 — exhaustive classification. The router's page set must equal the expected map plus the /// anonymous allowlist, and every page's actual policy must match the expected one. A brand-new page /// fails here until the author classifies it. /// [Fact] public void EveryRoutablePage_isClassifiedAndMatchesExpectedPolicy() { var routable = RoutablePages().ToHashSet(); var classified = new HashSet(ExpectedPolicy.Keys); classified.UnionWith(AnonymousPages); var unclassified = routable.Except(classified) .Select(t => $"{t.FullName} ({RouteOf(t)})").OrderBy(s => s).ToList(); unclassified.ShouldBeEmpty( "Routable pages missing from the guard's expected map — classify each in ExpectedPolicy or AnonymousPages:\n" + string.Join("\n", unclassified)); var stale = classified.Except(routable) .Select(t => t.FullName ?? t.Name).OrderBy(s => s).ToList(); stale.ShouldBeEmpty( "Guard expected-map entries that are no longer routable pages — remove them:\n" + string.Join("\n", stale)); var mismatches = new List(); foreach (var (page, expected) in ExpectedPolicy) { var actual = Authorize(page)?.Policy; if (actual != expected) mismatches.Add($"{page.FullName} ({RouteOf(page)}): expected policy '{expected}', got '{actual ?? ""}'"); } mismatches.ShouldBeEmpty( "Pages whose actual policy differs from the expected classification:\n" + string.Join("\n", mismatches)); } /// Rule 3 — every Policy value in use is one of the four known constants. [Fact] public void EveryPolicyNameInUse_isAKnownAdminUiPolicy() { var unknown = new List(); foreach (var page in RoutablePages()) { var policy = Authorize(page)?.Policy; if (!string.IsNullOrEmpty(policy) && !KnownPolicyNames.Contains(policy)) unknown.Add($"{page.FullName} ({RouteOf(page)}): unknown policy '{policy}'"); } unknown.ShouldBeEmpty( "Pages referencing a policy name not defined in AdminUiPolicies:\n" + string.Join("\n", unknown)); } }