Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SitesPageTests.cs
T
Joseph Doherty 75b2a7244c feat(artifacts): central ships each site's referenced secrets (ciphertext) and fails closed on a missing one
ArtifactDeploymentService now resolves each site's secret: references (via
the new SiteSecretsCollector, wrapping ISecretStore) into DeployArtifactsCommand.Secrets,
never plaintext. A referenced secret that is absent or tombstoned fails
that site's deploy before anything is sent, with a named error, while the
rest of the batch still deploys; ComputeCurrentArtifactHashesAsync instead
folds a MissingSecrets marker into the hash so such a site reads Out of
date. Central logs what it ships (name@revision) and errors on what's
missing, never material.

Also updates the two other direct ArtifactDeploymentService construction
sites (PendingUpdatesEvaluatorTests, CentralUI SitesPageTests) for the new
constructor parameter.
2026-09-28 18:07:17 -04:00

281 lines
11 KiB
C#

using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates;
using ZB.MOM.WW.Secrets.Abstractions;
using SitesPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Admin.Sites;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Admin;
/// <summary>
/// Regression tests for CentralUI-012. The Sites page loaded all sites and then
/// issued <c>GetDataConnectionsBySiteIdAsync</c> once per site (N+1 database
/// round-trips on every load and post-delete refresh). The fix fetches all
/// data connections in a single <c>GetAllDataConnectionsAsync</c> call and
/// groups them client-side.
/// </summary>
public class SitesPageTests : BunitContext
{
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly IPendingUpdatesCache _pendingUpdates = Substitute.For<IPendingUpdatesCache>();
private void RegisterServices()
{
Services.AddSingleton(_siteRepo);
// Default: an empty snapshot, so the page's badges resolve without a stub.
// Individual tests re-stub GetAsync before rendering.
_pendingUpdates.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult(Snapshot()));
Services.AddSingleton(_pendingUpdates);
var comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
Services.AddSingleton(comms);
var artifactSvc = new ArtifactDeploymentService(
_siteRepo,
Substitute.For<IDeploymentManagerRepository>(),
Substitute.For<ITemplateEngineRepository>(),
Substitute.For<IExternalSystemRepository>(),
Substitute.For<INotificationRepository>(),
comms,
Substitute.For<IAuditService>(),
Options.Create(new DeploymentManagerOptions()),
NullLogger<ArtifactDeploymentService>.Instance,
new ArtifactRevisionHashService(),
// These tests don't exercise secret references, so a store that never has
// anything is never called — CollectAsync only queries a scanned secret: reference.
new SiteSecretsCollector(Substitute.For<ISecretStore>()));
Services.AddSingleton(artifactSvc);
Services.AddSingleton<IDialogService>(Substitute.For<IDialogService>());
var identity = new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Name, "admin") }, "TestCookie");
var authState = new AuthenticationState(new ClaimsPrincipal(identity));
Services.AddSingleton<AuthenticationStateProvider>(
new StubAuthStateProvider(authState));
}
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
public StubAuthStateProvider(AuthenticationState state) => _state = state;
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
private static List<Site> Sites(params int[] ids)
=> ids.Select(id => new Site($"Site{id}", $"SITE-{id}") { Id = id }).ToList();
private static DataConnection Conn(int siteId, string name)
=> new(name, "OpcUa", siteId);
private static PendingUpdatesSnapshot Snapshot(params SitePendingUpdates[] sites)
=> new(new DateTimeOffset(2026, 9, 14, 10, 30, 0, TimeSpan.Zero), sites);
private static SitePendingUpdates Pending(
int siteId,
ArtifactDeploymentState state,
int deployed = 0,
int stale = 0,
int unknown = 0)
=> new(
siteId,
$"SITE-{siteId}",
$"Site{siteId}",
state,
state == ArtifactDeploymentState.NeverDeployed
? null
: new DateTimeOffset(2026, 9, 13, 8, 0, 0, TimeSpan.Zero),
deployed,
stale,
unknown);
[Fact]
public void LoadData_FetchesAllConnectionsInOneQuery_NoPerSiteQueries()
{
RegisterServices();
_siteRepo.GetAllSitesAsync().Returns(Sites(1, 2, 3));
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>
{
Conn(1, "c1"), Conn(2, "c2"), Conn(3, "c3"),
});
Render<SitesPage>();
// Regression: exactly one bulk query, and zero per-site queries.
_siteRepo.Received(1).GetAllDataConnectionsAsync();
_siteRepo.DidNotReceive().GetDataConnectionsBySiteIdAsync(Arg.Any<int>());
}
[Fact]
public void LoadData_GroupsConnectionsBySite_AndRendersThem()
{
RegisterServices();
_siteRepo.GetAllSitesAsync().Returns(Sites(1, 2));
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>
{
Conn(1, "alpha-conn"), Conn(2, "beta-conn"),
});
var cut = Render<SitesPage>();
Assert.Contains("alpha-conn", cut.Markup);
Assert.Contains("beta-conn", cut.Markup);
}
[Fact]
public void ArtifactBadge_ShowsCurrent_OutOfDate_NeverDeployed()
{
RegisterServices();
_siteRepo.GetAllSitesAsync().Returns(Sites(1, 2, 3));
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>());
_pendingUpdates.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult(Snapshot(
Pending(1, ArtifactDeploymentState.Current),
Pending(2, ArtifactDeploymentState.OutOfDate),
Pending(3, ArtifactDeploymentState.NeverDeployed))));
var cut = Render<SitesPage>();
cut.WaitForAssertion(() =>
{
Assert.Contains("aria-label=\"Artifacts: current\"", cut.Markup);
Assert.Contains("Artifacts current", cut.Markup);
Assert.Contains("aria-label=\"Artifacts: out of date\"", cut.Markup);
Assert.Contains("Artifacts out of date", cut.Markup);
Assert.Contains("aria-label=\"Artifacts: never deployed\"", cut.Markup);
Assert.Contains("Artifacts never deployed", cut.Markup);
});
}
[Fact]
public void InstanceLine_ShowsStaleOfDeployed()
{
RegisterServices();
_siteRepo.GetAllSitesAsync().Returns(Sites(1));
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>());
_pendingUpdates.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult(Snapshot(
Pending(1, ArtifactDeploymentState.Current, deployed: 5, stale: 2, unknown: 1))));
var cut = Render<SitesPage>();
cut.WaitForAssertion(() =>
{
Assert.Contains("2 of 5 instances out of date", cut.Markup);
Assert.Contains("1 could not be evaluated", cut.Markup);
Assert.Contains("/deployment/topology", cut.Markup);
});
}
[Fact]
public void InstanceLine_PluralisesTheSingleInstanceCase()
{
RegisterServices();
_siteRepo.GetAllSitesAsync().Returns(Sites(1, 2));
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>());
_pendingUpdates.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult(Snapshot(
Pending(1, ArtifactDeploymentState.Current, deployed: 1, stale: 1),
Pending(2, ArtifactDeploymentState.Current, deployed: 1))));
var cut = Render<SitesPage>();
cut.WaitForAssertion(() =>
{
Assert.Contains("1 of 1 instance out of date", cut.Markup);
Assert.Contains("All 1 instance current", cut.Markup);
Assert.DoesNotContain("1 instances", cut.Markup);
});
}
[Fact]
public void Recheck_ForcesRefresh()
{
RegisterServices();
_siteRepo.GetAllSitesAsync().Returns(Sites(1));
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>());
var cut = Render<SitesPage>();
cut.Find("button[aria-label=\"Re-check pending updates\"]").Click();
cut.WaitForAssertion(() =>
_pendingUpdates.Received().GetAsync(true, Arg.Any<CancellationToken>()));
}
[Fact]
public void SiteLoadFailure_SkipsTheFleetEvaluation()
{
RegisterServices();
_siteRepo.GetAllSitesAsync()
.Returns<Task<IReadOnlyList<Site>>>(_ => throw new InvalidOperationException("db down"));
var cut = Render<SitesPage>();
cut.WaitForAssertion(() => Assert.Contains("Failed to load sites", cut.Markup));
// No cards to badge, so the whole-fleet flatten must not run at all.
_pendingUpdates.DidNotReceive().GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>());
}
[Fact]
public void FailedRecheck_ClearsTheStaleAsOfCaption()
{
RegisterServices();
_siteRepo.GetAllSitesAsync().Returns(Sites(1));
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>());
_pendingUpdates.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult(Snapshot(Pending(1, ArtifactDeploymentState.Current))));
var cut = Render<SitesPage>();
cut.WaitForAssertion(() => Assert.Contains("Updates as of", cut.Markup));
// The re-check now faults: the error wins, so the caption from the earlier good
// evaluation must not stand next to "unavailable" badges.
_pendingUpdates.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns<Task<PendingUpdatesSnapshot>>(_ => throw new InvalidOperationException("evaluator down"));
cut.Find("button[aria-label=\"Re-check pending updates\"]").Click();
cut.WaitForAssertion(() =>
{
Assert.DoesNotContain("Updates as of", cut.Markup);
Assert.Contains("Artifacts: unavailable", cut.Markup);
Assert.Contains("Site1", cut.Markup);
});
}
[Fact]
public void CacheFault_ShowsUnavailable_AndSitesStillRender()
{
RegisterServices();
_siteRepo.GetAllSitesAsync().Returns(Sites(1, 2));
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>());
_pendingUpdates.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns<Task<PendingUpdatesSnapshot>>(_ => throw new InvalidOperationException("evaluator down"));
var cut = Render<SitesPage>();
cut.WaitForAssertion(() =>
{
// The snapshot failing must never take the site list down with it.
Assert.Contains("Site1", cut.Markup);
Assert.Contains("Site2", cut.Markup);
Assert.Contains("Artifacts: unavailable", cut.Markup);
});
}
}