513fa1aa16
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.
442 lines
19 KiB
C#
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;
|
|
}
|
|
}
|