5744aad028
The policy tests shipped at 1c30611 could not detect a deleted gate. They
assert that secrets:manage admits an Administrator and refuses a Viewer —
true, and library behaviour this repo did not author. The wiring is the
only thing that change introduced, and nothing covered it.
The point is sharper for repos where the link already existed before
gating, which includes this one: "an Administrator still sees it" is
identical to the pre-change behaviour, so it cannot distinguish a working
gate from an inert AuthorizeView. Only the negative observation proves a
gate is there at all.
SecretsNavRenderTests renders MainLayout through the framework's static
HtmlRenderer — no component-testing package, because the assertion is
about emitted markup rather than interactivity — and asserts:
- absent for a Viewer, and for an anonymous caller (the load-bearing pair)
- present for an Administrator (the control: without it, a rail that
rendered nothing at all would satisfy both absence assertions and the
suite would report a working gate over a blank page)
- the ungated API Keys sibling still present for a Viewer, so a later
"consistency fix" that hides it fails loudly rather than silently
removing read access
Confirmed non-vacuous by mutation rather than by argument: with the
AuthorizeView removed from the layout, both absence tests go red and all
three original policy tests stay green.
Build 0 warnings / 0 errors; suite 899/899 (895 + 4).
146 lines
6.4 KiB
C#
146 lines
6.4 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Renders <see cref="MainLayout"/> and asserts whether the side rail emits the Secrets link.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The policy-level tests in <c>SecretsNavGateTests</c> are not sufficient on their own, and the
|
|
/// reason is worth stating because it is easy to miss: they would stay green if the
|
|
/// <c><AuthorizeView></c> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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 <c>secrets:manage</c> failing to see the item proves a gate is there at
|
|
/// all — which is why <see cref="Rail_OmitsSecretsLink_ForViewer"/> is the test that matters and the
|
|
/// Administrator case is its control.
|
|
/// </para>
|
|
/// <para>
|
|
/// Uses the framework's static <see cref="HtmlRenderer"/> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class SecretsNavRenderTests
|
|
{
|
|
private const string SecretsHref = "/admin/secrets";
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Rail_OmitsSecretsLink_ForViewer()
|
|
{
|
|
string html = await RenderRailAsync(DashboardRoles.Viewer);
|
|
|
|
Assert.DoesNotContain(SecretsHref, html, StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>Anonymous callers (the read-only localhost path) are likewise not offered it.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Rail_OmitsSecretsLink_ForAnonymous()
|
|
{
|
|
string html = await RenderRailAsync();
|
|
|
|
Assert.DoesNotContain(SecretsHref, html, StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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".
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Rail_StillEmitsApiKeysLink_ForViewer()
|
|
{
|
|
string html = await RenderRailAsync(DashboardRoles.Viewer);
|
|
|
|
Assert.Contains("/apikeys", html, StringComparison.Ordinal);
|
|
}
|
|
|
|
private static async Task<string> RenderRailAsync(params string[] roles)
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddAuthorization(options => options.AddSecretsAuthorization());
|
|
services.AddCascadingAuthenticationState();
|
|
services.AddSingleton<AuthenticationStateProvider>(new StubAuthenticationStateProvider(roles));
|
|
services.AddSingleton<NavigationManager, StubNavigationManager>();
|
|
|
|
await using ServiceProvider provider = services.BuildServiceProvider();
|
|
await using var renderer = new HtmlRenderer(
|
|
provider,
|
|
provider.GetRequiredService<ILoggerFactory>());
|
|
|
|
return await renderer.Dispatcher.InvokeAsync(async () =>
|
|
{
|
|
HtmlRootComponent output = await renderer.RenderComponentAsync<MainLayout>();
|
|
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<AuthenticationState> 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/");
|
|
}
|
|
}
|