Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs
T
Joseph Doherty 96471d2345 refactor(adminui): B2-WP8 DriverTypeNames rewire + retire routed driver-authoring flow
Part 1 — rewire the three DriverType dispatch maps from hand-authored string
literals to the canonical DriverTypeNames constants (single source of truth):
- TagConfigEditorMap, TagConfigValidator (AdminUI)
- EquipmentTagConfigInspector (ControlPlane)
This FIXES the live drift ("TwinCat"->TwinCAT, "Focas"->FOCAS). The maps are
OrdinalIgnoreCase so behavior is identical; the value is no future drift.
Calculation is not added to the inspector (no CalculationTagDefinitionFactory.Inspect).
Added TagConfigDriverTypeNameGuardTests pinning the maps to the constants.

Part 2 — retire the routed /clusters/{id}/drivers driver-authoring flow (/raw
Waves A-C now cover driver/device/tag authoring end-to-end):
- Deleted DriverTypePicker, DriverEditRouter, the 8 *DriverPage shells, and the
  ClusterDrivers list page.
- Removed the "Drivers" tab from ClusterNav. Routes /clusters/{id}/drivers* now
  simply do not exist -> clean 404.
- The Forms/DeviceForms bodies, shared driver sections, and address pickers used
  by the /raw modals are untouched.

Part 3 — updated PageAuthorizationGuardTests census: removed the 10 deleted
ConfigEditor pages + ClusterDrivers, updated group-count comments (ConfigEditor
21->11, AuthenticatedRead 17->16). Removed the now-dead Clusters.Drivers usings
from the 4 *DriverPageFormSerializationTests + the guard test.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
2026-07-16 04:39:22 -04:00

180 lines
8.8 KiB
C#

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.Uns;
using ZB.MOM.WW.OtOpcUa.Security.Auth;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Authorization;
/// <summary>
/// Reflection guard over every routable AdminUI page's class-level authorization metadata.
/// This is the substitute for bUnit (the repo has no bUnit; razor <c>@attribute</c>/<c>@page</c>
/// 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 = <c>typeof(Routes).Assembly</c> — the exact assembly Routes.razor hands
/// <c>&lt;Router AppAssembly=…&gt;</c>, so the guard sees precisely the router's page set.
/// </summary>
public class PageAuthorizationGuardTests
{
/// <summary>
/// Target classification: every routable page → its required <see cref="AdminUiPolicies"/> policy.
/// This is the 38-page census from the R2-05 plan. <see cref="AnonymousPages"/> holds the sole
/// <c>[AllowAnonymous]</c> page. Together they must exactly cover the router's page set.
/// </summary>
private static readonly IReadOnlyDictionary<Type, string> ExpectedPolicy = new Dictionary<Type, string>
{
// ── ConfigEditor (11): the config-authoring surface (incl. live ResilienceConfig).
// The routed /clusters/{id}/drivers* driver-authoring pages (DriverTypePicker,
// DriverEditRouter, + the 8 per-type *DriverPage shells) retired in B2-WP8 — driver
// authoring now flows through the /raw tree, so those routes no longer exist. ──
[typeof(GlobalUns)] = AdminUiPolicies.ConfigEditor,
[typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Raw.GlobalRaw)] = 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(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) + the dev ContextMenu demo ──
[typeof(Home)] = AdminUiPolicies.AuthenticatedRead,
[typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Dev.ContextMenuDemo)] = 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(ClusterRedundancy)] = AdminUiPolicies.AuthenticatedRead,
};
/// <summary>The only page that must remain anonymously reachable (Admin-001 — the login form).</summary>
private static readonly IReadOnlySet<Type> AnonymousPages = new HashSet<Type> { typeof(Login) };
private static readonly string[] KnownPolicyNames =
[
AdminUiPolicies.FleetAdmin,
AdminUiPolicies.DriverOperator,
AdminUiPolicies.ConfigEditor,
AdminUiPolicies.AuthenticatedRead,
];
private static IReadOnlyList<Type> RoutablePages() =>
[.. typeof(Routes).Assembly.GetTypes()
.Where(t => t.GetCustomAttributes<RouteAttribute>(inherit: false).Any())
.OrderBy(t => t.FullName, StringComparer.Ordinal)];
private static string RouteOf(Type t) =>
string.Join(", ", t.GetCustomAttributes<RouteAttribute>(inherit: false).Select(r => r.Template));
private static AuthorizeAttribute? Authorize(Type t) =>
t.GetCustomAttributes<AuthorizeAttribute>(inherit: false).SingleOrDefault();
private static bool IsAnonymous(Type t) =>
t.GetCustomAttributes<AllowAnonymousAttribute>(inherit: false).Any();
/// <summary>
/// Rule 1 — no bare gates. Every routable page must carry either <c>[AllowAnonymous]</c> or an
/// <c>[Authorize]</c> with a non-empty Policy and null Roles. Bans bare <c>[Authorize]</c>, the
/// <c>Roles="…"</c> idiom, and attribute-less pages.
/// </summary>
[Fact]
public void EveryRoutablePage_carriesAnExplicitNamedPolicyOrAllowAnonymous()
{
var offenders = new List<string>();
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));
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void EveryRoutablePage_isClassifiedAndMatchesExpectedPolicy()
{
var routable = RoutablePages().ToHashSet();
var classified = new HashSet<Type>(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<string>();
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 ?? "<none>"}'");
}
mismatches.ShouldBeEmpty(
"Pages whose actual policy differs from the expected classification:\n" +
string.Join("\n", mismatches));
}
/// <summary>Rule 3 — every Policy value in use is one of the four known <see cref="AdminUiPolicies"/> constants.</summary>
[Fact]
public void EveryPolicyNameInUse_isAKnownAdminUiPolicy()
{
var unknown = new List<string>();
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));
}
}