diff --git a/docs/plans/2026-08-11-dashboard-ui-sweeps.md b/docs/plans/2026-08-11-dashboard-ui-sweeps.md index 31fedbe..ed16a7c 100644 --- a/docs/plans/2026-08-11-dashboard-ui-sweeps.md +++ b/docs/plans/2026-08-11-dashboard-ui-sweeps.md @@ -383,7 +383,28 @@ rule is "gate the link when the page denies the role outright", not "gate everyt refused, unauthenticated refused) in `SecretsNavGateTests`, and `/admin/secrets` joins the canonical route list in `GatewayApplicationTests` — it is the one nav destination mounted from an RCL rather than declared here, so a routing regression could remove it without touching this repo's pages. -Not a rendering test: the suite has no component-testing harness, and adding one to assert a single -`AuthorizeView` would be a large dependency for a small claim. -**Verification.** Build 0 warnings / 0 errors; suite **895/895** (892 + 3). +### 8a. Correction: the policy tests could not detect a deleted gate + +The coverage above shipped with a stated rationale — that rendering was disproportionate because the +policy verdict "is the part that can actually be wrong". That rationale was wrong, and a review point +from the OtOpcUa session identified why: the policy is library code this repo did not author, while +the *wiring* is the only thing this change introduced. Worse, the check applies specifically to repos +where the link already existed before gating — "an Administrator still sees it" is identical to the +pre-change behaviour, so it cannot distinguish a working gate from an inert one. **Only the negative +observation proves a gate exists at all.** + +`SecretsNavRenderTests` now renders `MainLayout` through the framework's static `HtmlRenderer` — no +component-testing package needed, since the assertion is about emitted markup, not interactivity — +and asserts the Secrets item is absent for a Viewer and for an anonymous caller, present for an +Administrator, and that the ungated API Keys sibling stays present for a Viewer (so a later +"consistency fix" that hides it fails loudly). + +**Confirmed non-vacuous by mutation**, which is the only thing that makes the absence assertions +worth anything: with the `AuthorizeView` removed from the layout, `Rail_OmitsSecretsLink_ForViewer` +and `Rail_OmitsSecretsLink_ForAnonymous` both go red — **and all three original policy tests stay +green**, demonstrating the gap concretely rather than by argument. The Administrator case is retained +as the control: without it, a rail that rendered no nav at all would satisfy both absence assertions +and the suite would report a working gate over a blank page. + +**Verification.** Build 0 warnings / 0 errors; suite **899/899** (895 + 4). diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/SecretsNavRenderTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/SecretsNavRenderTests.cs new file mode 100644 index 0000000..bcdba9c --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Tests/Dashboard/SecretsNavRenderTests.cs @@ -0,0 +1,145 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.Web.HtmlRendering; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.Auth.AspNetCore; +using ZB.MOM.WW.MxGateway.Server.Dashboard; +using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Layout; +using ZB.MOM.WW.Secrets.Ui; + +namespace ZB.MOM.WW.MxGateway.Tests.Dashboard; + +/// +/// Renders and asserts whether the side rail emits the Secrets link. +/// +/// +/// +/// The policy-level tests in SecretsNavGateTests are not sufficient on their own, and the +/// reason is worth stating because it is easy to miss: they would stay green if the +/// <AuthorizeView> were deleted outright. They prove the policy decides correctly, not +/// that the rail asks it. The wiring is the part this change actually introduced, so it is the part +/// that needs its own evidence. +/// +/// +/// The load-bearing assertion is the NEGATIVE one. "An Administrator sees the link" is identical to +/// the behaviour before the gate existed, so it cannot distinguish a working gate from an inert one. +/// Only a principal without secrets:manage failing to see the item proves a gate is there at +/// all — which is why is the test that matters and the +/// Administrator case is its control. +/// +/// +/// Uses the framework's static rather than a component-testing package: +/// no new dependency, and static rendering is enough because the assertion is about markup the +/// server emits, not about interactivity. +/// +/// +public sealed class SecretsNavRenderTests +{ + private const string SecretsHref = "/admin/secrets"; + + /// + /// The gate's proof. A Viewer holds a real, authenticated identity and still must not be offered + /// the link, because the page would refuse them. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Rail_OmitsSecretsLink_ForViewer() + { + string html = await RenderRailAsync(DashboardRoles.Viewer); + + Assert.DoesNotContain(SecretsHref, html, StringComparison.Ordinal); + } + + /// Anonymous callers (the read-only localhost path) are likewise not offered it. + /// A task that represents the asynchronous operation. + [Fact] + public async Task Rail_OmitsSecretsLink_ForAnonymous() + { + string html = await RenderRailAsync(); + + Assert.DoesNotContain(SecretsHref, html, StringComparison.Ordinal); + } + + /// + /// The control for the two negatives. Without this, a rail that rendered no nav at all — a + /// broken layout, a throwing component swallowed somewhere — would satisfy both absence + /// assertions and the suite would report a working gate over a blank page. The sibling + /// assertions on the always-present items are what make the absence above mean "gated" rather + /// than "nothing rendered". + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Rail_EmitsSecretsLink_ForAdministrator() + { + string html = await RenderRailAsync(DashboardRoles.Admin); + + Assert.Contains(SecretsHref, html, StringComparison.Ordinal); + Assert.Contains("/apikeys", html, StringComparison.Ordinal); + } + + /// + /// Pins the deliberate asymmetry: the ungated sibling stays visible to a Viewer. If someone + /// later "fixes the inconsistency" by wrapping the API Keys item in the same AuthorizeView, + /// this fails — that page renders read-only for Viewers, so hiding its link would remove + /// legitimate access. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task Rail_StillEmitsApiKeysLink_ForViewer() + { + string html = await RenderRailAsync(DashboardRoles.Viewer); + + Assert.Contains("/apikeys", html, StringComparison.Ordinal); + } + + private static async Task RenderRailAsync(params string[] roles) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddAuthorization(options => options.AddSecretsAuthorization()); + services.AddCascadingAuthenticationState(); + services.AddSingleton(new StubAuthenticationStateProvider(roles)); + services.AddSingleton(); + + await using ServiceProvider provider = services.BuildServiceProvider(); + await using var renderer = new HtmlRenderer( + provider, + provider.GetRequiredService()); + + return await renderer.Dispatcher.InvokeAsync(async () => + { + HtmlRootComponent output = await renderer.RenderComponentAsync(); + return output.ToHtmlString(); + }); + } + + // Supplies the authentication state the rail's AuthorizeView reads. An empty role list yields an + // unauthenticated principal; otherwise the identity carries an authentication type, without + // which every policy would fail for the wrong reason and the negative assertions would pass + // vacuously. + private sealed class StubAuthenticationStateProvider(string[] roles) : AuthenticationStateProvider + { + public override Task GetAuthenticationStateAsync() + { + ClaimsIdentity identity = roles.Length == 0 + ? new ClaimsIdentity() + : new ClaimsIdentity( + roles.Select(role => new Claim(ZbClaimTypes.Role, role)), + authenticationType: "Test", + nameType: ZbClaimTypes.Name, + roleType: ZbClaimTypes.Role); + + return Task.FromResult(new AuthenticationState(new ClaimsPrincipal(identity))); + } + } + + // NavLink resolves hrefs against the current URI, so the rail needs a NavigationManager even + // under static rendering. + private sealed class StubNavigationManager : NavigationManager + { + public StubNavigationManager() => Initialize("https://localhost/", "https://localhost/"); + } +}