From 5f72ff851d6016d1d16242ca469760a9ba6f86ac Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 19 Jul 2026 00:22:23 -0400 Subject: [PATCH] fix(adminui): make /admin/secrets interactive - wrap the Secrets.Ui page with a render mode (#483) The Secrets.Ui RCL's routable page was routed directly, but this host's router is deliberately static (cookie SignInAsync needs SSR) with per-page @rendermode opt-in - a directive the RCL page cannot carry, because the other three family hosts render it under a globally interactive router where a nested render mode throws. Routing straight to the RCL page therefore rendered /admin/secrets with no circuit: it displayed, but every @onclick was silently dead. Fix: a host-side wrapper page (Pages/SecretsAdmin.razor) now owns /admin/secrets, carries the standard per-page InteractiveServer render mode, re-states the RCL page's own authorization policy (the router only enforces [Authorize] on the routed component, which is now the wrapper), and renders the RCL page as an ordinary child. The RCL assembly is de-registered from both AdditionalAssemblies sites (router + endpoint) so the route is unambiguous. Guards: SecretsPageWiringTests pins the render mode (the #483 regression), route parity with the RCL page, and policy parity; the page census in PageAuthorizationGuardTests classifies the new page and admits the secrets:manage policy. AdminUI.Tests 665/665. Live-verified on the rebuilt docker-dev rig with Playwright: /_blazor circuit negotiated on /admin/secrets and Add secret opens the editor (before the fix: zero interactive markers, no circuit, dead click). Closes #483. Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts --- .../Components/App.razor | 6 +- .../Components/Pages/SecretsAdmin.razor | 20 ++++++ .../EndpointRouteBuilderExtensions.cs | 13 ++-- .../PageAuthorizationGuardTests.cs | 8 +++ .../SecretsPageWiringTests.cs | 67 +++++++++++++++++++ 5 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/SecretsAdmin.razor create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/SecretsPageWiringTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor index 4697fe81..07b3d1ac 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor @@ -19,7 +19,11 @@ - + @* No AdditionalAssemblies: the Secrets.Ui RCL's routable page is NOT routed directly in this + host — Pages/Secrets.razor wraps it so the route can carry this host's per-page + @rendermode (lmxopcua#483); registering the RCL routes too would make /admin/secrets + ambiguous. *@ + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/SecretsAdmin.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/SecretsAdmin.razor new file mode 100644 index 00000000..cba83c86 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/SecretsAdmin.razor @@ -0,0 +1,20 @@ +@page "/admin/secrets" +@attribute [Authorize(Policy = ZB.MOM.WW.Secrets.Ui.SecretsAuthorization.ManagePolicy)] +@rendermode RenderMode.InteractiveServer +@using ZB.MOM.WW.Secrets.Ui + +@* Host-side wrapper for the ZB.MOM.WW.Secrets.Ui secrets page (lmxopcua#483). + + This host's is deliberately static (cookie SignInAsync needs SSR), so every + interactive page opts in per-page with @rendermode — a directive the RCL's own routable + SecretsPage cannot carry, because the other three family hosts render it under a globally + interactive router where a nested render mode throws. Routing straight to the RCL page + therefore produced a statically-rendered, dead page here: no circuit, @onclick inert. + + So this wrapper owns the /admin/secrets route in THIS host (the RCL assembly is no longer + passed to either AdditionalAssemblies registration — doing both would make the route + ambiguous), carries the standard per-page render mode, re-states the RCL page's own + authorization policy (the router only enforces [Authorize] on the routed page itself, + which is now this one), and renders the RCL page as an ordinary child component. *@ + + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs index 8bff8987..11d629c5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/EndpointRouteBuilderExtensions.cs @@ -30,13 +30,14 @@ public static class EndpointRouteBuilderExtensions // Razor class library static assets (_content/ZB.MOM.WW.OtOpcUa.AdminUI/**) are // served via the Host's app.UseStaticFiles() middleware which must run BEFORE // UseAuthentication() — see Program.cs. - // The /admin/secrets management page lives in the external ZB.MOM.WW.Secrets.Ui RCL, so its - // routable component must be discovered at the endpoint layer (AddAdditionalAssemblies) — - // the interactive Router's AdditionalAssemblies (App.razor) alone is not enough for SSR - // endpoint discovery. Both registrations are required or /admin/secrets 404s. + // The Secrets.Ui RCL's routable page is deliberately NOT registered here (lmxopcua#483): + // this host's router is static with per-page @rendermode opt-in, so routing straight to + // the RCL page rendered it without a circuit — it displayed but every @onclick was dead. + // Pages/Secrets.razor in THIS assembly owns /admin/secrets instead (wrapping the RCL + // page with the render mode); registering the RCL routes too would make the route + // ambiguous. app.MapRazorComponents() - .AddInteractiveServerRenderMode() - .AddAdditionalAssemblies(typeof(ZB.MOM.WW.Secrets.Ui.SecretsPage).Assembly); + .AddInteractiveServerRenderMode(); return app; } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs index 27d01ceb..4a21b837 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs @@ -47,6 +47,11 @@ public class PageAuthorizationGuardTests // ── FleetAdmin (1) ── [typeof(RoleGrants)] = AdminUiPolicies.FleetAdmin, + // ── Secrets (1): host wrapper for the ZB.MOM.WW.Secrets.Ui RCL page (lmxopcua#483) — + // carries the RCL page's own policy, registered additively by AddAdminUI via + // AddSecretsAuthorization(). ── + [typeof(SecretsAdmin)] = global::ZB.MOM.WW.Secrets.Ui.SecretsAuthorization.ManagePolicy, + // ── 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, @@ -75,6 +80,9 @@ public class PageAuthorizationGuardTests AdminUiPolicies.DriverOperator, AdminUiPolicies.ConfigEditor, AdminUiPolicies.AuthenticatedRead, + // Not an AdminUiPolicies constant: the /admin/secrets wrapper re-states the Secrets.Ui + // RCL page's own policy, which AddAdminUI registers via AddSecretsAuthorization(). + global::ZB.MOM.WW.Secrets.Ui.SecretsAuthorization.ManagePolicy, ]; private static IReadOnlyList RoutablePages() => diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/SecretsPageWiringTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/SecretsPageWiringTests.cs new file mode 100644 index 00000000..f1be1c71 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/SecretsPageWiringTests.cs @@ -0,0 +1,67 @@ +using System.Reflection; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages; +using ZB.MOM.WW.Secrets.Ui; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; + +/// +/// Pins the host-side wiring that makes /admin/secrets interactive (lmxopcua#483). +/// This host's router is static with per-page @rendermode opt-in; routing straight to +/// the Secrets.Ui RCL's routable page rendered it with no circuit — the page displayed but +/// every @onclick was dead, and nothing failed. The fix is a wrapper page in this +/// assembly that owns the route and carries the render mode. Razor directives compile to +/// class-level attributes, so a metadata scan is authoritative (same technique as +/// PageAuthorizationGuardTests; the repo has no bUnit). +/// +public class SecretsPageWiringTests +{ + /// + /// THE #483 regression pin: without a render mode this host serves the page as static SSR + /// and it silently loses all interactivity. + /// + [Fact] + public void Wrapper_declares_the_InteractiveServer_render_mode() + { + var mode = typeof(SecretsAdmin).GetCustomAttributes(inherit: false) + .SingleOrDefault(); + + mode.ShouldNotBeNull( + "/admin/secrets lost its @rendermode — in this host's static router that renders a " + + "dead page (displays, but no circuit and every @onclick inert), exactly lmxopcua#483"); + mode.Mode.ShouldBeOfType(); + } + + /// + /// The wrapper must shadow the RCL page's route exactly — if Secrets.Ui ever moves its + /// page, the wrapper must move with it or the nav rail and bookmarks split from the RCL. + /// + [Fact] + public void Wrapper_owns_the_same_route_as_the_RCL_page() + { + string[] wrapper = [.. typeof(SecretsAdmin).GetCustomAttributes(inherit: false).Select(r => r.Template)]; + string[] rcl = [.. typeof(SecretsPage).GetCustomAttributes(inherit: false).Select(r => r.Template)]; + + wrapper.ShouldBe(["/admin/secrets"]); + wrapper.ShouldBe(rcl, "the wrapper must track the RCL page's route exactly"); + } + + /// + /// The router enforces [Authorize] only on the routed component — which is now the wrapper, + /// with the RCL page rendered as a plain child whose own attribute is inert. The wrapper + /// must therefore re-state the RCL page's policy verbatim, or the fix silently widens access. + /// + [Fact] + public void Wrapper_restates_the_RCL_pages_authorization_policy() + { + var wrapper = typeof(SecretsAdmin).GetCustomAttributes(inherit: false).Single(); + var rcl = typeof(SecretsPage).GetCustomAttributes(inherit: false).Single(); + + wrapper.Policy.ShouldBe(SecretsAuthorization.ManagePolicy); + wrapper.Policy.ShouldBe(rcl.Policy, "the wrapper must enforce exactly what the RCL page declares"); + } +}