feat(central-ui): rail-footer pending-updates indicator
Adds PendingUpdatesIndicator to MainLayout's RailFooter, above .rail-user. Self-gating on the Deployer/Administrator role claims, it polls the process-wide single-flight IPendingUpdatesCache every 30s through PollGate and renders four states: checking / up to date / pending (warning pill plus an 'N instances · M sites' breakdown) / unavailable. Each half of the breakdown links only for the role that can act on it (Topology => Deployer, Sites => Administrator), and a site-scoped operator's totals are narrowed through SiteScopeService so they never count a site they cannot see. Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit rendering tests for <see cref="PendingUpdatesIndicator"/>, 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.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>Builds a cache substitute answering every <c>GetAsync</c> with the given snapshot.</summary>
|
||||
private static IPendingUpdatesCache CacheReturning(PendingUpdatesSnapshot snapshot)
|
||||
{
|
||||
var cache = Substitute.For<IPendingUpdatesCache>();
|
||||
cache.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>()).Returns(Task.FromResult(snapshot));
|
||||
return cache;
|
||||
}
|
||||
|
||||
/// <summary>Builds a cache substitute whose every <c>GetAsync</c> faults.</summary>
|
||||
private static IPendingUpdatesCache CacheFaulting(string message)
|
||||
{
|
||||
var cache = Substitute.For<IPendingUpdatesCache>();
|
||||
cache.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromException<PendingUpdatesSnapshot>(new InvalidOperationException(message)));
|
||||
return cache;
|
||||
}
|
||||
|
||||
private IRenderedComponent<PendingUpdatesIndicator> 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<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddScoped<SiteScopeService>();
|
||||
Services.AddSingleton(cache);
|
||||
Services.AddSingleton<ILogger<PendingUpdatesIndicator>>(NullLogger<PendingUpdatesIndicator>.Instance);
|
||||
|
||||
return Render<PendingUpdatesIndicator>();
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user