using System.Security.Claims; using Bunit; 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. /// 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(); } [Fact] public void Viewer_RendersNothing() { var cut = RenderFor(CacheReturning(Snapshot(SiteRow(1, staleInstances: 3))), Role("Viewer")); Assert.True(string.IsNullOrWhiteSpace(cut.Markup)); } [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); }); } [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); }); } [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']")); }); } [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']")); }); } [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']")); }); } [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); }); } [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); }); } [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(); } }