Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Layout/PendingUpdatesIndicatorTests.cs
T
Joseph Doherty 513fa1aa16 docs(tests): fix CommentChecker findings across tests/ — 13,392 → 0 (repo-wide scan clean)
Comment/XML-doc changes only:
- add summaries to ~10k test methods, fixtures, fakes and helpers
  (+ <param> on theories, <returns> on async Task methods,
  <inheritdoc/> on interface implementations the checker can resolve)
- strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N,
  #NN, Component-0NN) from comments, keeping the explanatory text;
  reword false positives in comments only
- real docs on fakes implementing BCL/library interfaces
  (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime)
- reword the last 5 src/ false positives (CWE-1236, failures-1,
  year-0001, M365)

Only non-comment edits: four one-line nested classes split across lines
so members can carry docs. Build 0 errors / 0 warnings.
2026-10-07 15:05:49 -04:00

442 lines
19 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;
/// <summary>Initializes a new instance of the <c>CountingAuthStateProvider</c> class.</summary>
/// <param name="user">The user whose claims the provider exposes.</param>
public CountingAuthStateProvider(ClaimsPrincipal user) => _user = user;
/// <summary>Gets the calls recorded by this fake.</summary>
public int Calls { get; private set; }
/// <inheritdoc />
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>();
}
/// <summary>Verifies that a user with only the Viewer role sees no pending-updates indicator.</summary>
[Fact]
public void Viewer_RendersNothing()
{
var cut = RenderFor(CacheReturning(Snapshot(SiteRow(1, staleInstances: 3))), Role("Viewer"));
Assert.True(string.IsNullOrWhiteSpace(cut.Markup));
}
/// <summary>Verifies that a Deployer with nothing pending sees the up-to-date state.</summary>
[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);
});
}
/// <summary>Verifies that the snapshot is captioned with the instant at which it was computed.</summary>
[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);
});
}
/// <summary>Verifies that a user who is both Deployer and Administrator sees the total pending count and both links.</summary>
[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']"));
});
}
/// <summary>Verifies that a Deployer without the Administrator role sees the site count as plain text.</summary>
[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']"));
});
}
/// <summary>Verifies that an Administrator without the Deployer role sees the instance count as plain text.</summary>
[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']"));
});
}
/// <summary>Verifies that a cache fault shows an unavailable state with the fault message as a tooltip.</summary>
[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);
});
}
/// <summary>Verifies that a site-scoped Deployer is shown counts for the permitted sites only.</summary>
[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);
});
}
/// <summary>Verifies that disposing the component after it has rendered does not throw.</summary>
[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();
}
/// <summary>Verifies that a cascaded authentication state is used instead of asking the authentication state provider.</summary>
[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);
}
/// <summary>Verifies that polling does not resolve any further authentication state.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>Verifies that an authentication state change hides the indicator when the role is revoked.</summary>
[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;
/// <summary>Initializes a new instance of the <c>MutableAuthStateProvider</c> class.</summary>
/// <param name="user">The user whose claims the provider exposes.</param>
public MutableAuthStateProvider(ClaimsPrincipal user) => _user = user;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(new AuthenticationState(_user));
/// <summary>Replaces the user returned by the provider and notifies listeners of the authentication state change.</summary>
/// <param name="user">The user whose claims the provider exposes.</param>
public void SetUser(ClaimsPrincipal user)
{
_user = user;
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
}
/// <summary>Verifies that polling starts once an asynchronous authentication resolve completes.</summary>
[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;
/// <summary>Initializes a new instance of the <c>DeferredAuthStateProvider</c> class.</summary>
/// <param name="state">The authentication state the provider returns.</param>
public DeferredAuthStateProvider(Task<AuthenticationState> state) => _state = state;
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync() => _state;
}
}