using System.Security.Claims; using Bunit; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Layout; using ZB.MOM.WW.ScadaBridge.CentralUI.Services; using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates; using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Layout; /// /// bUnit rendering tests for , the rail-footer /// "is anything waiting to be deployed?" block. Three behaviours are load-bearing and /// pinned here: it is invisible to anyone who is neither Deployer nor Administrator /// (it advertises fleet state), its two breakdown links are gated independently by the /// role that can actually act on each (Topology ⇒ Deployer, Sites ⇒ Administrator), and /// a site-scoped operator's totals count only their permitted sites. /// /// A fourth property is pinned for cost rather than correctness: because this block /// lives in MainLayout, roles and scope must be resolved once per authentication /// state, NEVER once per 30s poll — a per-poll resolve would be a forced session /// revalidation on every page of every circuit. /// /// public class PendingUpdatesIndicatorTests : BunitContext { private static Claim Role(string role) => new(JwtTokenService.RoleClaimType, role); private static Claim SiteClaim(int siteId) => new(JwtTokenService.SiteIdClaimType, siteId.ToString()); private static SitePendingUpdates SiteRow( int siteId, int staleInstances, ArtifactDeploymentState artifacts = ArtifactDeploymentState.Current) => new( siteId, $"SITE-{siteId}", $"Site {siteId}", artifacts, DateTimeOffset.UtcNow, DeployedInstanceCount: 10, StaleInstanceCount: staleInstances, UnknownInstanceCount: 0); private static PendingUpdatesSnapshot Snapshot(params SitePendingUpdates[] sites) => new(DateTimeOffset.UtcNow, sites); /// Builds a cache substitute answering every GetAsync with the given snapshot. private static IPendingUpdatesCache CacheReturning(PendingUpdatesSnapshot snapshot) { var cache = Substitute.For(); cache.GetAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(snapshot)); return cache; } /// Builds a cache substitute whose every GetAsync faults. private static IPendingUpdatesCache CacheFaulting(string message) { var cache = Substitute.For(); cache.GetAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromException(new InvalidOperationException(message))); return cache; } private IRenderedComponent RenderFor(IPendingUpdatesCache cache, params Claim[] claims) { // "TestAuth" authentication type => IsAuthenticated == true, so SiteScopeService // collects the SiteId claims instead of short-circuiting to "no scope". var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); Services.AddSingleton(new TestAuthStateProvider(user)); Services.AddScoped(); Services.AddSingleton(cache); Services.AddSingleton>(NullLogger.Instance); return Render(); } /// An auth provider that records how many times its state was asked for. private sealed class CountingAuthStateProvider : AuthenticationStateProvider { private readonly ClaimsPrincipal _user; /// Initializes a new instance of the CountingAuthStateProvider class. /// The user whose claims the provider exposes. public CountingAuthStateProvider(ClaimsPrincipal user) => _user = user; /// Gets the calls recorded by this fake. public int Calls { get; private set; } /// public override Task GetAuthenticationStateAsync() { Calls++; return Task.FromResult(new AuthenticationState(_user)); } } /// /// Renders the indicator under an explicit cascaded — /// the same cascade CascadingAuthenticationState supplies in the real app. /// private IRenderedComponent RenderCascaded( IPendingUpdatesCache cache, AuthenticationStateProvider provider, ClaimsPrincipal cascadedUser) { Services.AddSingleton(provider); Services.AddScoped(); Services.AddSingleton(cache); Services.AddSingleton>(NullLogger.Instance); var host = Render>>(parameters => parameters .Add(p => p.Value, Task.FromResult(new AuthenticationState(cascadedUser))) .Add(p => p.ChildContent, (RenderFragment)(builder => { builder.OpenComponent(0); builder.CloseComponent(); }))); return host.FindComponent(); } /// Verifies that a user with only the Viewer role sees no pending-updates indicator. [Fact] public void Viewer_RendersNothing() { var cut = RenderFor(CacheReturning(Snapshot(SiteRow(1, staleInstances: 3))), Role("Viewer")); Assert.True(string.IsNullOrWhiteSpace(cut.Markup)); } /// Verifies that a Deployer with nothing pending sees the up-to-date state. [Fact] public void Deployer_WithNothingPending_ShowsUpToDate() { var cut = RenderFor( CacheReturning(Snapshot(SiteRow(1, staleInstances: 0))), Role(Roles.Deployer)); cut.WaitForAssertion(() => { Assert.Contains("Up to date", cut.Markup); Assert.DoesNotContain("Pending updates", cut.Markup); Assert.DoesNotContain("Checking for updates", cut.Markup); }); } /// Verifies that the snapshot is captioned with the instant at which it was computed. [Fact] public void Snapshot_IsCaptionedWithItsComputedInstant() { var cut = RenderFor( CacheReturning(Snapshot(SiteRow(1, staleInstances: 0))), Role(Roles.Deployer)); cut.WaitForAssertion(() => { // The block's tooltip is the snapshot's as-of time, not "now". Assert.Contains("as of ", cut.Markup); Assert.Contains(" UTC", cut.Markup); }); } /// Verifies that a user who is both Deployer and Administrator sees the total pending count and both links. [Fact] public void DeployerAndAdministrator_WithPendingWork_ShowsTotalAndBothLinks() { // 3 stale instances at site 1 + site 2 running out-of-date artifacts => total 4. var cut = RenderFor( CacheReturning(Snapshot( SiteRow(1, staleInstances: 3), SiteRow(2, staleInstances: 0, ArtifactDeploymentState.OutOfDate))), Role(Roles.Deployer), Role(Roles.Administrator)); cut.WaitForAssertion(() => { Assert.Contains("Pending updates", cut.Markup); var badge = cut.Find("span.badge"); Assert.Equal("4", badge.TextContent.Trim()); Assert.Contains("3 instances", cut.Markup); Assert.Contains("1 site", cut.Markup); Assert.DoesNotContain("1 sites", cut.Markup); // Both halves present => they are separated, not run together. Assert.Contains("·", cut.Find("span.rail-updates-detail").TextContent); Assert.NotNull(cut.Find("a[href='/deployment/topology']")); Assert.NotNull(cut.Find("a[href='/admin/sites']")); }); } /// Verifies that a Deployer without the Administrator role sees the site count as plain text. [Fact] public void DeployerWithoutAdministrator_ShowsSiteCountAsPlainText() { var cut = RenderFor( CacheReturning(Snapshot( SiteRow(1, staleInstances: 1), SiteRow(2, staleInstances: 0, ArtifactDeploymentState.OutOfDate))), Role(Roles.Deployer)); cut.WaitForAssertion(() => { // Singular noun for a count of one, and the instance half still links. Assert.Contains("1 instance", cut.Markup); Assert.DoesNotContain("1 instances", cut.Markup); Assert.NotNull(cut.Find("a[href='/deployment/topology']")); // Administrator-only destination is not offered to a plain Deployer. Assert.Contains("1 site", cut.Markup); Assert.Empty(cut.FindAll("a[href='/admin/sites']")); }); } /// Verifies that an Administrator without the Deployer role sees the instance count as plain text. [Fact] public void AdministratorWithoutDeployer_ShowsInstanceCountAsPlainText() { var cut = RenderFor( CacheReturning(Snapshot( SiteRow(1, staleInstances: 2), SiteRow(2, staleInstances: 0, ArtifactDeploymentState.OutOfDate))), Role(Roles.Administrator)); cut.WaitForAssertion(() => { Assert.Contains("2 instances", cut.Markup); Assert.Empty(cut.FindAll("a[href='/deployment/topology']")); Assert.NotNull(cut.Find("a[href='/admin/sites']")); }); } /// Verifies that a cache fault shows an unavailable state with the fault message as a tooltip. [Fact] public void CacheFault_ShowsUnavailableWithTheMessageAsTooltip() { var cut = RenderFor(CacheFaulting("evaluator exploded"), Role(Roles.Deployer)); cut.WaitForAssertion(() => { Assert.Contains("Updates unavailable", cut.Markup); Assert.Contains("evaluator exploded", cut.Markup); // A failed read must never be rendered as a clean bill of health. Assert.DoesNotContain("Up to date", cut.Markup); }); } /// Verifies that a site-scoped Deployer is shown counts for the permitted sites only. [Fact] public void SiteScopedDeployer_CountsOnlyPermittedSites() { // Permitted site 1 has 3 stale instances; site 2 (out of scope) has 5 and // out-of-date artifacts. Only site 1's tally may reach the badge. var cut = RenderFor( CacheReturning(Snapshot( SiteRow(1, staleInstances: 3), SiteRow(2, staleInstances: 5, ArtifactDeploymentState.OutOfDate))), Role(Roles.Deployer), SiteClaim(1)); cut.WaitForAssertion(() => { var badge = cut.Find("span.badge"); Assert.Equal("3", badge.TextContent.Trim()); Assert.Contains("3 instances", cut.Markup); // Unscoped, the same snapshot would read "8 instances · 1 site". Assert.DoesNotContain("8 instances", cut.Markup); Assert.DoesNotContain("1 site", cut.Markup); }); } /// Verifies that disposing the component after it has rendered does not throw. [Fact] public void Dispose_AfterRender_DoesNotThrow() { var cut = RenderFor( CacheReturning(Snapshot(SiteRow(1, staleInstances: 0))), Role(Roles.Deployer)); cut.WaitForAssertion(() => Assert.Contains("Up to date", cut.Markup)); var instance = cut.Instance; instance.Dispose(); instance.Dispose(); } /// Verifies that a cascaded authentication state is used instead of asking the authentication state provider. [Fact] public void CascadedAuthenticationState_IsUsedInsteadOfAskingTheProvider() { // The provider would report an ANONYMOUS user, so the block can only render // if the component read the cascaded principal — which is what the app's // CascadingAuthenticationState supplies. var anonymous = new CountingAuthStateProvider(new ClaimsPrincipal(new ClaimsIdentity())); var deployer = new ClaimsPrincipal(new ClaimsIdentity([Role(Roles.Deployer)], "TestAuth")); var cut = RenderCascaded( CacheReturning(Snapshot(SiteRow(1, staleInstances: 0))), anonymous, deployer); cut.WaitForAssertion(() => Assert.Contains("Up to date", cut.Markup)); Assert.Equal(0, anonymous.Calls); } /// Verifies that polling does not resolve any further authentication state. /// A task that represents the asynchronous operation. [Fact] public async Task Polling_ResolvesNoFurtherAuthenticationState() { // The regression this pins: SiteScopeService's instance methods resolve the // AUTHORITATIVE state (a fail-closed, DB-backed group→role re-mapping). Doing // that per tick, from a component that sits in the layout, would cost one or // two forced session revalidations per circuit every 30s on every page. var provider = new CountingAuthStateProvider( new ClaimsPrincipal(new ClaimsIdentity([Role(Roles.Deployer), SiteClaim(1)], "TestAuth"))); var cache = CacheReturning(Snapshot(SiteRow(1, staleInstances: 2))); Services.AddSingleton(provider); Services.AddScoped(); Services.AddSingleton(cache); Services.AddSingleton>(NullLogger.Instance); var cut = Render(); cut.WaitForAssertion(() => Assert.Contains("2 instances", cut.Markup)); var afterFirstLoad = provider.Calls; // Three more poll cycles, exactly as the timer drives them. for (var i = 0; i < 3; i++) { await cut.Instance.PollOnceAsync(); } // Each poll read the fleet cache; none of them re-resolved the principal. _ = cache.Received(4).GetAsync(Arg.Any(), Arg.Any()); Assert.Equal(afterFirstLoad, provider.Calls); // Scope narrowing still applies on every poll — it reads the resolved fields. Assert.Contains("2 instances", cut.Markup); } /// Verifies that an authentication state change hides the indicator when the role is revoked. [Fact] public void AuthenticationStateChange_HidesTheBlockWhenTheRoleIsRevoked() { var cache = CacheReturning(Snapshot(SiteRow(1, staleInstances: 0))); var provider = new MutableAuthStateProvider( new ClaimsPrincipal(new ClaimsIdentity([Role(Roles.Deployer)], "TestAuth"))); Services.AddSingleton(provider); Services.AddScoped(); Services.AddSingleton(cache); Services.AddSingleton>(NullLogger.Instance); var cut = Render(); cut.WaitForAssertion(() => Assert.Contains("Up to date", cut.Markup)); // Role revoked mid-session: the block must go, not linger for the life of // the circuit showing what the previous principal could see. provider.SetUser(new ClaimsPrincipal(new ClaimsIdentity([Role(Roles.Viewer)], "TestAuth"))); cut.WaitForAssertion(() => Assert.True(string.IsNullOrWhiteSpace(cut.Markup))); } /// An auth provider whose principal can be swapped mid-test, raising the change event. private sealed class MutableAuthStateProvider : AuthenticationStateProvider { private ClaimsPrincipal _user; /// Initializes a new instance of the MutableAuthStateProvider class. /// The user whose claims the provider exposes. public MutableAuthStateProvider(ClaimsPrincipal user) => _user = user; /// public override Task GetAuthenticationStateAsync() => Task.FromResult(new AuthenticationState(_user)); /// Replaces the user returned by the provider and notifies listeners of the authentication state change. /// The user whose claims the provider exposes. public void SetUser(ClaimsPrincipal user) { _user = user; NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); } } /// Verifies that polling starts once an asynchronous authentication resolve completes. [Fact] public void Polling_StartsAfterAnAsynchronousAuthResolveCompletes() { // Regression: OnParametersSetAsync awaits the principal. On a real cookie // session that await suspends, so the FIRST render happens with _visible // still false and OnAfterRenderAsync(firstRender: true) skips starting the // poll; the render that follows the resolve is firstRender: false. Without a // start from OnParametersSetAsync the block sits on "Checking for updates…" // for the life of the circuit. var gate = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); var cache = CacheReturning(Snapshot(SiteRow(1, staleInstances: 4))); Services.AddSingleton(new DeferredAuthStateProvider(gate.Task)); Services.AddScoped(); Services.AddSingleton(cache); Services.AddSingleton>(NullLogger.Instance); var cut = Render(); // Nothing resolved yet: no principal, so nothing rendered and nothing polled. Assert.True(string.IsNullOrWhiteSpace(cut.Markup)); gate.SetResult(new AuthenticationState( new ClaimsPrincipal(new ClaimsIdentity([Role(Roles.Deployer)], "TestAuth")))); cut.WaitForAssertion(() => { _ = cache.Received().GetAsync(Arg.Any(), Arg.Any()); Assert.Contains("4 instances", cut.Markup); }); } /// An auth provider whose state resolves only when the supplied task completes. private sealed class DeferredAuthStateProvider : AuthenticationStateProvider { private readonly Task _state; /// Initializes a new instance of the DeferredAuthStateProvider class. /// The authentication state the provider returns. public DeferredAuthStateProvider(Task state) => _state = state; /// public override Task GetAuthenticationStateAsync() => _state; } }