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/"); } }