Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/SecretsPageWiringTests.cs
T
Joseph Doherty 5f72ff851d
v2-ci / build (push) Successful in 4m0s
v2-ci / unit-tests (push) Failing after 10m19s
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
2026-07-19 00:22:23 -04:00

68 lines
3.2 KiB
C#

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;
/// <summary>
/// Pins the host-side wiring that makes <c>/admin/secrets</c> interactive (lmxopcua#483).
/// This host's router is static with per-page <c>@rendermode</c> opt-in; routing straight to
/// the Secrets.Ui RCL's routable page rendered it with no circuit — the page displayed but
/// every <c>@onclick</c> 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
/// <c>PageAuthorizationGuardTests</c>; the repo has no bUnit).
/// </summary>
public class SecretsPageWiringTests
{
/// <summary>
/// THE #483 regression pin: without a render mode this host serves the page as static SSR
/// and it silently loses all interactivity.
/// </summary>
[Fact]
public void Wrapper_declares_the_InteractiveServer_render_mode()
{
var mode = typeof(SecretsAdmin).GetCustomAttributes<RenderModeAttribute>(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<InteractiveServerRenderMode>();
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void Wrapper_owns_the_same_route_as_the_RCL_page()
{
string[] wrapper = [.. typeof(SecretsAdmin).GetCustomAttributes<RouteAttribute>(inherit: false).Select(r => r.Template)];
string[] rcl = [.. typeof(SecretsPage).GetCustomAttributes<RouteAttribute>(inherit: false).Select(r => r.Template)];
wrapper.ShouldBe(["/admin/secrets"]);
wrapper.ShouldBe(rcl, "the wrapper must track the RCL page's route exactly");
}
/// <summary>
/// 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.
/// </summary>
[Fact]
public void Wrapper_restates_the_RCL_pages_authorization_policy()
{
var wrapper = typeof(SecretsAdmin).GetCustomAttributes<AuthorizeAttribute>(inherit: false).Single();
var rcl = typeof(SecretsPage).GetCustomAttributes<AuthorizeAttribute>(inherit: false).Single();
wrapper.Policy.ShouldBe(SecretsAuthorization.ManagePolicy);
wrapper.Policy.ShouldBe(rcl.Policy, "the wrapper must enforce exactly what the RCL page declares");
}
}