Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Layout/PendingUpdatesIndicatorTests.cs
T
Joseph Doherty 3a21cfc2f5 fix(central-ui): pending-updates indicator starts polling once the async auth resolve completes
OnParametersSetAsync awaits the principal. On a real cookie session that await
suspends, so the FIRST render ran with _visible still false and
OnAfterRenderAsync(firstRender: true) skipped EnsurePollingStarted(); the
render that followed the resolve was firstRender: false, and nothing else
started the poll unless AuthenticationStateChanged happened to fire. The block
therefore rendered and sat on 'Checking for updates…' for the life of the
circuit. OnParametersSetAsync now starts polling itself when visible (the
start is idempotent and the OnAfterRender call is kept).

Also corrects the comments that called the non-forced
GetAuthenticationStateAsync cheap: on CookieAuthenticationStateProvider both
entry points revalidate against the database and differ only in failClosed, so
the saving is not asking the provider on a tick and reusing the cascaded state
the router already resolved. The provider fallback is a real refresh, is only
taken when no cascade is present (AddCascadingAuthenticationState is
registered, so it is dead in production), and is now resolved at most once per
auth state rather than on every parameter set.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 14:29:36 -04:00

416 lines
17 KiB
C#

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;
/// <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.
/// <para>
/// 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.
/// </para>
/// </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>();
}
/// <summary>An auth provider that records how many times its state was asked for.</summary>
private sealed class CountingAuthStateProvider : AuthenticationStateProvider
{
private readonly ClaimsPrincipal _user;
public CountingAuthStateProvider(ClaimsPrincipal user) => _user = user;
public int Calls { get; private set; }
public override Task<AuthenticationState> GetAuthenticationStateAsync()
{
Calls++;
return Task.FromResult(new AuthenticationState(_user));
}
}
/// <summary>
/// Renders the indicator under an explicit cascaded <see cref="AuthenticationState"/> —
/// the same cascade <c>CascadingAuthenticationState</c> supplies in the real app.
/// </summary>
private IRenderedComponent<PendingUpdatesIndicator> RenderCascaded(
IPendingUpdatesCache cache,
AuthenticationStateProvider provider,
ClaimsPrincipal cascadedUser)
{
Services.AddSingleton(provider);
Services.AddScoped<SiteScopeService>();
Services.AddSingleton(cache);
Services.AddSingleton<ILogger<PendingUpdatesIndicator>>(NullLogger<PendingUpdatesIndicator>.Instance);
var host = Render<CascadingValue<Task<AuthenticationState>>>(parameters => parameters
.Add(p => p.Value, Task.FromResult(new AuthenticationState(cascadedUser)))
.Add(p => p.ChildContent, (RenderFragment)(builder =>
{
builder.OpenComponent<PendingUpdatesIndicator>(0);
builder.CloseComponent();
})));
return host.FindComponent<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();
}
[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);
}
[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<AuthenticationStateProvider>(provider);
Services.AddScoped<SiteScopeService>();
Services.AddSingleton(cache);
Services.AddSingleton<ILogger<PendingUpdatesIndicator>>(NullLogger<PendingUpdatesIndicator>.Instance);
var cut = Render<PendingUpdatesIndicator>();
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<bool>(), Arg.Any<CancellationToken>());
Assert.Equal(afterFirstLoad, provider.Calls);
// Scope narrowing still applies on every poll — it reads the resolved fields.
Assert.Contains("2 instances", cut.Markup);
}
[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<AuthenticationStateProvider>(provider);
Services.AddScoped<SiteScopeService>();
Services.AddSingleton(cache);
Services.AddSingleton<ILogger<PendingUpdatesIndicator>>(NullLogger<PendingUpdatesIndicator>.Instance);
var cut = Render<PendingUpdatesIndicator>();
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)));
}
/// <summary>An auth provider whose principal can be swapped mid-test, raising the change event.</summary>
private sealed class MutableAuthStateProvider : AuthenticationStateProvider
{
private ClaimsPrincipal _user;
public MutableAuthStateProvider(ClaimsPrincipal user) => _user = user;
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(new AuthenticationState(_user));
public void SetUser(ClaimsPrincipal user)
{
_user = user;
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
}
[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<AuthenticationState>(
TaskCreationOptions.RunContinuationsAsynchronously);
var cache = CacheReturning(Snapshot(SiteRow(1, staleInstances: 4)));
Services.AddSingleton<AuthenticationStateProvider>(new DeferredAuthStateProvider(gate.Task));
Services.AddScoped<SiteScopeService>();
Services.AddSingleton(cache);
Services.AddSingleton<ILogger<PendingUpdatesIndicator>>(NullLogger<PendingUpdatesIndicator>.Instance);
var cut = Render<PendingUpdatesIndicator>();
// 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<bool>(), Arg.Any<CancellationToken>());
Assert.Contains("4 instances", cut.Markup);
});
}
/// <summary>An auth provider whose state resolves only when the supplied task completes.</summary>
private sealed class DeferredAuthStateProvider : AuthenticationStateProvider
{
private readonly Task<AuthenticationState> _state;
public DeferredAuthStateProvider(Task<AuthenticationState> state) => _state = state;
public override Task<AuthenticationState> GetAuthenticationStateAsync() => _state;
}
}