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 ScadaLink.Security; using NavMenu = ScadaLink.CentralUI.Components.Layout.NavMenu; namespace ScadaLink.CentralUI.Tests.Layout; /// /// bUnit rendering tests for the sidebar . They verify the /// collapsible section behaviour (sections collapsed by default, a toggle /// reveals a section's items and persists state to a cookie) and that the /// Notifications section's items are gated per-policy. The /// AuthorizeView Policy=... blocks evaluate the real policies, which /// require a claim of type ("Role"), /// so the test principal carries claims of that exact type. /// public class NavMenuTests : BunitContext { public NavMenuTests() { // NavMenu reads the nav-collapse cookie via the navState.get JS interop // call in OnAfterRenderAsync and writes it via navState.set on toggle. // Loose mode lets navState.get no-op (returns null) so the sidebar // renders collapsed, and still records navState.set invocations. JSInterop.Mode = JSRuntimeMode.Loose; } /// /// Renders under a principal holding the given roles. /// 's top-level AuthorizeView requires the /// cascading , so it is rendered inside a /// ; the real policies are /// registered so the per-item AuthorizeView Policy=... blocks are /// genuinely evaluated. /// private IRenderedComponent RenderWithRoles(params string[] roles) { var claims = new List { new("Username", "tester") }; claims.AddRange(roles.Select(r => new Claim(JwtTokenService.RoleClaimType, r))); var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(user)); Services.AddAuthorizationCore(); AuthorizationPolicies.AddScadaLinkAuthorization(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(); var host = Render(parameters => parameters .Add(p => p.ChildContent, (RenderFragment)(builder => { builder.OpenComponent(0); builder.CloseComponent(); }))); return host.FindComponent(); } /// /// Clicks the collapsible section header whose title matches, expanding it. /// private static void ExpandSection(IRenderedComponent cut, string title) { var toggle = cut.FindAll("button.nav-section-toggle") .Single(b => b.TextContent.Contains(title, StringComparison.Ordinal)); toggle.Click(); } [Fact] public void Sections_AreCollapsedByDefault() { var cut = RenderWithRoles("Admin", "Design", "Deployment"); cut.WaitForAssertion(() => { // Section headers render unconditionally... Assert.Contains(">Notifications<", cut.Markup); Assert.Contains(">Deployment<", cut.Markup); // ...but their items stay out of the DOM until the section opens. Assert.DoesNotContain("/notifications/smtp", cut.Markup); Assert.DoesNotContain("/deployment/topology", cut.Markup); }); } [Fact] public void TogglingSection_RevealsItsItems() { var cut = RenderWithRoles("Deployment"); Assert.DoesNotContain("/deployment/topology", cut.Markup); ExpandSection(cut, "Deployment"); Assert.Contains("/deployment/topology", cut.Markup); Assert.Contains("/deployment/deployments", cut.Markup); Assert.Contains("/deployment/debug-view", cut.Markup); } [Fact] public void TogglingSection_PersistsStateToCookie() { var cut = RenderWithRoles("Deployment"); ExpandSection(cut, "Deployment"); // Expanding wrote the cookie through the navState.set JS interop call. var invocation = JSInterop.Invocations.Last(i => i.Identifier == "navState.set"); Assert.Equal("deployment", invocation.Arguments[0]); } [Fact] public void NotificationsSection_ShowsAllItems_ForMultiRoleUser() { var cut = RenderWithRoles("Admin", "Design", "Deployment"); ExpandSection(cut, "Notifications"); 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("Admin"); ExpandSection(cut, "Notifications"); 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("Admin", "Design", "Deployment"); cut.WaitForAssertion(() => { Assert.DoesNotContain("/admin/smtp", cut.Markup); Assert.DoesNotContain("/monitoring/notification-outbox", cut.Markup); }); } }