af9f14a668
SiteScopeService's instance methods resolve the AUTHORITATIVE state — a fail-closed, DB-backed group-to-role re-mapping. The indicator called them on every 30s tick, and it lives in MainLayout, so that was one or two forced session revalidations per open circuit per page, forever: exactly the per-circuit cost IPendingUpdatesCache exists to avoid. Extracts the pure rule as SiteScopeService.Resolve(ClaimsPrincipal) (no I/O; ResolveAsync now delegates to it, no behaviour change for existing callers). The indicator takes the cascaded AuthenticationState — falling back to a NON-forced GetAuthenticationStateAsync, as Templates/FlowDebugger do — and computes roles plus scope once per auth state, re-running on AuthenticationStateChanged so a mid-session revocation hides the block instead of leaving it stale for the life of the circuit. Also routes the first load through PollGate, adds aria-hidden to the icons, and comments the off-renderer field writes and the InvalidOperationException catch. Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
373 lines
15 KiB
C#
373 lines
15 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());
|
|
}
|
|
}
|
|
}
|