f40409a688
Tracking IDs are review bookkeeping that rots in shipped source; the rationale they tagged is kept. Comment-only change (CommentChecker 458 -> 16 false positives).
189 lines
8.4 KiB
C#
189 lines
8.4 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
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 which policy-gated links the side rail emits —
|
|
/// Secrets (<c>secrets:manage</c>) and Alarms
|
|
/// (<see cref="DashboardAuthenticationDefaults.AlarmsPolicy"/>).
|
|
/// </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";
|
|
private const string AlarmsHref = "/alarms";
|
|
|
|
/// <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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The nav half: an anonymous caller — the read-only localhost path — is not offered the
|
|
/// Alarms link, because the page and hub now refuse them. Same reasoning as the Secrets
|
|
/// negative above: this is the assertion that distinguishes a gate from an inert one.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Rail_OmitsAlarmsLink_ForAnonymous()
|
|
{
|
|
string html = await RenderRailAsync();
|
|
|
|
Assert.DoesNotContain(AlarmsHref, html, StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The control for that negative: an authenticated Viewer still gets the Alarms link. The Alarms gate
|
|
/// excludes the environment bypasses, not Viewers — alarm metadata stays galaxy-wide for a
|
|
/// real dashboard role because the central monitor carries no per-session provenance.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Rail_EmitsAlarmsLink_ForViewer()
|
|
{
|
|
string html = await RenderRailAsync(DashboardRoles.Viewer);
|
|
|
|
Assert.Contains(AlarmsHref, html, StringComparison.Ordinal);
|
|
}
|
|
|
|
private static async Task<string> RenderRailAsync(params string[] roles)
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging();
|
|
services.AddAuthorization(options =>
|
|
{
|
|
options.AddSecretsAuthorization();
|
|
// The rail's Alarms AuthorizeView resolves this by name; an unregistered policy throws
|
|
// rather than denying, so it must be present for any rail render.
|
|
options.AddPolicy(
|
|
DashboardAuthenticationDefaults.AlarmsPolicy,
|
|
policy => policy.AddRequirements(AuthenticatedDashboardRoleRequirement.AnyDashboardRole));
|
|
});
|
|
services.AddSingleton<IAuthorizationHandler, AuthenticatedDashboardRoleHandler>();
|
|
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
|
|
{
|
|
/// <inheritdoc />
|
|
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
|
|
{
|
|
/// <summary>Initializes the stub with a fixed localhost base URI.</summary>
|
|
public StubNavigationManager() => Initialize("https://localhost/", "https://localhost/");
|
|
}
|
|
}
|