ee193cd2bb
The Secrets management UI (ZB.MOM.WW.Secrets Secrets.Ui, mounted at /admin/secrets) has been linked from the NavMenu Admin section since the Theme adoption, but no NavMenu test asserted it. Add bUnit coverage that the item renders for an Administrator and is absent for a Designer+Deployer principal, matching the existing role-gate test style.
233 lines
9.3 KiB
C#
233 lines
9.3 KiB
C#
using System.Security.Claims;
|
|
using Bunit;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Components;
|
|
using Microsoft.AspNetCore.Components.Authorization;
|
|
using Microsoft.AspNetCore.Components.Rendering;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using ZB.MOM.WW.ScadaBridge.Security;
|
|
using NavMenu = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Layout.NavMenu;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Layout;
|
|
|
|
/// <summary>
|
|
/// bUnit rendering tests for the rail <see cref="NavMenu"/>. After the
|
|
/// ZB.MOM.WW.Theme adoption (Task 2.4) the nav is composed of the kit's
|
|
/// <c>NavRailSection</c> (a native <c><details></c> disclosure, expanded
|
|
/// by default — no JS interop, works in static SSR) and <c>NavRailItem</c>
|
|
/// links. These tests verify the structure (sections render as
|
|
/// <c>details.rail-section</c>, not the old <c>button.nav-section-toggle</c>)
|
|
/// and — the load-bearing security behaviour — that each section's items are
|
|
/// gated by the correct authorization policy. The <c>AuthorizeView Policy=...</c>
|
|
/// blocks evaluate the real policies, which require a claim of type
|
|
/// <see cref="JwtTokenService.RoleClaimType"/> (the canonical
|
|
/// <c>ZbClaimTypes.Role</c> framework URI), so the test principal carries
|
|
/// claims of that exact type.
|
|
/// </summary>
|
|
public class NavMenuTests : BunitContext
|
|
{
|
|
/// <summary>
|
|
/// Renders <see cref="NavMenu"/> under a principal holding the given roles.
|
|
/// <see cref="NavMenu"/>'s top-level <c>AuthorizeView</c> requires the
|
|
/// cascading <see cref="AuthenticationState"/>, so it is rendered inside a
|
|
/// <see cref="CascadingAuthenticationState"/>; the real policies are
|
|
/// registered so the per-item <c>AuthorizeView Policy=...</c> blocks are
|
|
/// genuinely evaluated.
|
|
/// </summary>
|
|
private IRenderedComponent<NavMenu> RenderWithRoles(params string[] roles)
|
|
{
|
|
var claims = new List<Claim> { new(JwtTokenService.UsernameClaimType, "tester") };
|
|
claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r)));
|
|
|
|
// "TestAuth" authentication type → IsAuthenticated == true.
|
|
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
|
return RenderWithPrincipal(user);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renders <see cref="NavMenu"/> with an anonymous (unauthenticated)
|
|
/// principal — a bare <see cref="ClaimsIdentity"/> with no authentication
|
|
/// type, so <c>IsAuthenticated == false</c> and every <c>AuthorizeView</c>
|
|
/// renders its (empty) <c>NotAuthorized</c>/fallthrough branch.
|
|
/// </summary>
|
|
private IRenderedComponent<NavMenu> RenderAnonymous()
|
|
=> RenderWithPrincipal(new ClaimsPrincipal(new ClaimsIdentity()));
|
|
|
|
private IRenderedComponent<NavMenu> RenderWithPrincipal(ClaimsPrincipal user)
|
|
{
|
|
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
|
Services.AddAuthorizationCore();
|
|
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
|
|
// BunitContext pre-registers a placeholder IAuthorizationService that
|
|
// throws when AuthorizeView evaluates a policy. Force the real service
|
|
// so the per-item policy gating is genuinely exercised.
|
|
Services.AddSingleton<IAuthorizationService, DefaultAuthorizationService>();
|
|
|
|
var host = Render<CascadingAuthenticationState>(parameters => parameters
|
|
.Add(p => p.ChildContent, (RenderFragment)(builder =>
|
|
{
|
|
builder.OpenComponent<NavMenu>(0);
|
|
builder.CloseComponent();
|
|
})));
|
|
|
|
return host.FindComponent<NavMenu>();
|
|
}
|
|
|
|
[Fact]
|
|
public void Sections_RenderAsKitDetailsDisclosures_ExpandedByDefault()
|
|
{
|
|
var cut = RenderWithRoles("Administrator", "Designer", "Deployer");
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
// Kit nav: sections are <details class="rail-section"> disclosures,
|
|
// NOT the old interactive button.nav-section-toggle.
|
|
Assert.Empty(cut.FindAll("button.nav-section-toggle"));
|
|
Assert.NotEmpty(cut.FindAll("details.rail-section"));
|
|
|
|
// Section eyebrows still render their titles.
|
|
Assert.Contains(">Notifications<", cut.Markup);
|
|
Assert.Contains(">Deployment<", cut.Markup);
|
|
|
|
// The kit's NavRailSection defaults to open, so items are in the DOM
|
|
// without any toggle/JS — this is the static-SSR-friendly behaviour.
|
|
Assert.Contains("/notifications/smtp", cut.Markup);
|
|
Assert.Contains("/deployment/topology", cut.Markup);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void DeploymentSection_RendersAllItems_ForDeployer()
|
|
{
|
|
var cut = RenderWithRoles("Deployer");
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
Assert.Contains("/deployment/topology", cut.Markup);
|
|
Assert.Contains("/deployment/deployments", cut.Markup);
|
|
Assert.Contains("/deployment/debug-view", cut.Markup);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void NotificationsSection_ShowsAllItems_ForMultiRoleUser()
|
|
{
|
|
var cut = RenderWithRoles("Administrator", "Designer", "Deployer");
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
Assert.Contains("Notifications", cut.Markup);
|
|
Assert.Contains("/notifications/smtp", cut.Markup);
|
|
Assert.Contains("/notifications/lists", cut.Markup);
|
|
Assert.Contains("/notifications/report", cut.Markup);
|
|
Assert.Contains("/notifications/kpis", cut.Markup);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void NotificationsSection_AdminOnlyUser_SeesOnlySmtp()
|
|
{
|
|
var cut = RenderWithRoles("Administrator");
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
Assert.Contains("/notifications/smtp", cut.Markup);
|
|
Assert.DoesNotContain("/notifications/report", cut.Markup);
|
|
Assert.DoesNotContain("/notifications/lists", cut.Markup);
|
|
Assert.DoesNotContain("/notifications/kpis", cut.Markup);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void OldRoutes_AreNoLongerLinked()
|
|
{
|
|
var cut = RenderWithRoles("Administrator", "Designer", "Deployer");
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
Assert.DoesNotContain("/admin/smtp", cut.Markup);
|
|
Assert.DoesNotContain("/monitoring/notification-outbox", cut.Markup);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void AdminSection_SecretsLink_VisibleForAdministrator()
|
|
{
|
|
var cut = RenderWithRoles("Administrator");
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
// Secrets management UI (ZB.MOM.WW.Secrets Secrets.Ui) — Administrator only.
|
|
Assert.Contains("/admin/secrets", cut.Markup);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void AdminSection_SecretsLink_AbsentForNonAdministrator()
|
|
{
|
|
// Designer + Deployer covers every non-admin section without RequireAdmin.
|
|
var cut = RenderWithRoles("Designer", "Deployer");
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
Assert.DoesNotContain("/admin/secrets", cut.Markup);
|
|
});
|
|
}
|
|
|
|
// ── AuthorizeView-gated SECTION visibility (the GAPS open question) ──────
|
|
// A whole NavRailSection wrapped in <AuthorizeView Policy="..."> must SHOW
|
|
// (heading + items) for an authorized principal and be entirely ABSENT for
|
|
// an unauthorized/anonymous one. The Audit section (OperationalAudit policy)
|
|
// is the witness — its heading lives inside the policy block, so a denied
|
|
// user must not even see the eyebrow.
|
|
|
|
[Fact]
|
|
public void PolicyGatedSection_IsVisible_ForAuthorizedPrincipal()
|
|
{
|
|
// OperationalAudit is satisfied by the Administrator role.
|
|
var cut = RenderWithRoles("Administrator");
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
Assert.Contains(">Audit<", cut.Markup); // section heading present
|
|
Assert.Contains("/audit/log", cut.Markup); // gated items present
|
|
Assert.Contains("/audit/configuration", cut.Markup);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void PolicyGatedSection_IsAbsent_ForUnauthorizedPrincipal()
|
|
{
|
|
// Designer satisfies RequireDesign but NOT OperationalAudit.
|
|
var cut = RenderWithRoles("Designer");
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
Assert.DoesNotContain(">Audit<", cut.Markup); // heading hidden, not just items
|
|
Assert.DoesNotContain("/audit/log", cut.Markup);
|
|
Assert.DoesNotContain("/audit/configuration", cut.Markup);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public void PolicyGatedSections_AllAbsent_ForAnonymousPrincipal()
|
|
{
|
|
var cut = RenderAnonymous();
|
|
|
|
cut.WaitForAssertion(() =>
|
|
{
|
|
// The Dashboard link is always-visible (outside every AuthorizeView)...
|
|
Assert.Contains("Dashboard", cut.Markup);
|
|
// ...but no policy-gated section or its items leak to an anonymous user.
|
|
Assert.DoesNotContain(">Admin<", cut.Markup);
|
|
Assert.DoesNotContain(">Design<", cut.Markup);
|
|
Assert.DoesNotContain(">Deployment<", cut.Markup);
|
|
Assert.DoesNotContain(">Notifications<", cut.Markup);
|
|
Assert.DoesNotContain(">Audit<", cut.Markup);
|
|
Assert.DoesNotContain("/admin/sites", cut.Markup);
|
|
Assert.DoesNotContain("/design/templates", cut.Markup);
|
|
Assert.DoesNotContain("/audit/log", cut.Markup);
|
|
});
|
|
}
|
|
}
|