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;
}
}