75b2a7244c
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.
420 lines
19 KiB
C#
420 lines
19 KiB
C#
using System.Text.Json;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using NSubstitute;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
|
|
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.Commons.Interfaces.Transport;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
|
|
using ZB.MOM.WW.ScadaBridge.Communication;
|
|
using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates;
|
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
|
|
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
|
|
using ZB.MOM.WW.Secrets.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
|
|
|
|
/// <summary>
|
|
/// Coverage for <see cref="PendingUpdatesEvaluator"/> — the single fleet-wide read
|
|
/// behind the Central UI rail indicator and the Sites page badges.
|
|
///
|
|
/// <para>
|
|
/// The evaluator is built over the REAL <see cref="ArtifactDeploymentService"/> and
|
|
/// <see cref="DeploymentService"/> (substituting only their repository and flattening
|
|
/// seams, as the sibling suites do), because the two properties worth pinning are
|
|
/// exactly the ones a mocked collaborator would paper over: that the artifact state
|
|
/// is decided by comparing the site's STORED hash against a hash recomputed from the
|
|
/// current definitions, and that only instances the operator has actually deployed
|
|
/// are ever handed to the staleness batch.
|
|
/// </para>
|
|
/// </summary>
|
|
public class PendingUpdatesEvaluatorTests
|
|
{
|
|
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
|
private readonly ITemplateEngineRepository _templateRepo = Substitute.For<ITemplateEngineRepository>();
|
|
private readonly IExternalSystemRepository _externalSystemRepo = Substitute.For<IExternalSystemRepository>();
|
|
private readonly INotificationRepository _notificationRepo = Substitute.For<INotificationRepository>();
|
|
private readonly IDeploymentManagerRepository _deploymentRepo = Substitute.For<IDeploymentManagerRepository>();
|
|
private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
|
|
private readonly IAuditService _audit = Substitute.For<IAuditService>();
|
|
private readonly RevisionHashService _hasher = new();
|
|
private readonly ArtifactRevisionHashService _artifactHasher = new();
|
|
|
|
private readonly ArtifactDeploymentService _artifactService;
|
|
private readonly DeploymentService _deploymentService;
|
|
private readonly PendingUpdatesEvaluator _evaluator;
|
|
|
|
/// <summary>
|
|
/// Builds the evaluator over the real services; every repository and the
|
|
/// flattening pipeline are substituted.
|
|
/// </summary>
|
|
public PendingUpdatesEvaluatorTests()
|
|
{
|
|
_pipeline.CreateSession().Returns(new FlattenSession(new TemplateGraphWatermark()));
|
|
|
|
var comms = new CommunicationService(
|
|
Options.Create(new CommunicationOptions()),
|
|
NullLogger<CommunicationService>.Instance);
|
|
|
|
_artifactService = new ArtifactDeploymentService(
|
|
_siteRepo, _deploymentRepo, _templateRepo, _externalSystemRepo, _notificationRepo,
|
|
comms, _audit,
|
|
Options.Create(new DeploymentManagerOptions()),
|
|
NullLogger<ArtifactDeploymentService>.Instance,
|
|
_artifactHasher,
|
|
// None of these tests reference secrets, so a store that never has anything is
|
|
// never exercised — CollectAsync only calls GetAsync for a scanned secret: reference.
|
|
new SiteSecretsCollector(Substitute.For<ISecretStore>()));
|
|
|
|
_deploymentService = new DeploymentService(
|
|
_deploymentRepo,
|
|
_siteRepo,
|
|
_pipeline,
|
|
comms,
|
|
new OperationLockManager(),
|
|
_audit,
|
|
new DiffService(),
|
|
_hasher,
|
|
new DeploymentStatusNotifier(NullLogger<DeploymentStatusNotifier>.Instance),
|
|
Substitute.For<IStaleInstanceProbe>(),
|
|
Options.Create(new DeploymentManagerOptions()),
|
|
Options.Create(new CommunicationOptions()),
|
|
NullLogger<DeploymentService>.Instance);
|
|
|
|
_evaluator = new PendingUpdatesEvaluator(
|
|
_siteRepo, _templateRepo, _artifactService, _deploymentService,
|
|
NullLogger<PendingUpdatesEvaluator>.Instance);
|
|
}
|
|
|
|
// ── arrange helpers ──────────────────────────────────────────────────────
|
|
|
|
/// <summary>Stubs the site list read.</summary>
|
|
private void ArrangeSites(params Site[] sites) =>
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(sites);
|
|
|
|
/// <summary>Stubs the instance list read.</summary>
|
|
private void ArrangeInstances(params Instance[] instances) =>
|
|
_templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>()).Returns(instances);
|
|
|
|
/// <summary>Builds an instance in the given lifecycle state at the given site.</summary>
|
|
private static Instance MakeInstance(int id, int siteId, InstanceState state) =>
|
|
new($"Inst{id}") { Id = id, SiteId = siteId, State = state };
|
|
|
|
/// <summary>Builds a one-attribute flattened config with the given scalar value.</summary>
|
|
private static FlattenedConfiguration ScalarConfig(string value) => new()
|
|
{
|
|
InstanceUniqueName = "TestInst",
|
|
Attributes = [new ResolvedAttribute { CanonicalName = "Setpoint", Value = value, DataType = "Int32" }]
|
|
};
|
|
|
|
/// <summary>Stubs a successful flatten of <paramref name="instanceId"/> at the given hash.</summary>
|
|
private void ArrangeFlatten(int instanceId, FlattenedConfiguration config, string hash) =>
|
|
_pipeline.FlattenAndValidateAsync(
|
|
instanceId, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>())
|
|
.Returns(Result<FlatteningPipelineResult>.Success(
|
|
new FlatteningPipelineResult(config, hash, ValidationResult.Success())));
|
|
|
|
/// <summary>Stubs a FAILED flatten of <paramref name="instanceId"/> — the "unknown" leg.</summary>
|
|
private void ArrangeFlattenFailure(int instanceId) =>
|
|
_pipeline.FlattenAndValidateAsync(
|
|
instanceId, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>())
|
|
.Returns(Result<FlatteningPipelineResult>.Failure("template missing"));
|
|
|
|
/// <summary>Stubs the (InstanceId, RevisionHash) projection read.</summary>
|
|
private void ArrangeDeployedHashes(params (int InstanceId, string Hash)[] rows) =>
|
|
_deploymentRepo.GetDeployedRevisionHashesAsync(Arg.Any<CancellationToken>())
|
|
.Returns(rows.ToDictionary(r => r.InstanceId, r => r.Hash));
|
|
|
|
/// <summary>Stubs the fat snapshot read for <paramref name="instanceId"/>.</summary>
|
|
private void ArrangeSnapshot(int instanceId, FlattenedConfiguration config, string frozenHash) =>
|
|
_deploymentRepo.GetDeployedSnapshotByInstanceIdAsync(instanceId, Arg.Any<CancellationToken>())
|
|
.Returns(new DeployedConfigSnapshot("dep1", frozenHash, JsonSerializer.Serialize(config))
|
|
{
|
|
InstanceId = instanceId,
|
|
DeployedAt = DateTimeOffset.UtcNow
|
|
});
|
|
|
|
/// <summary>
|
|
/// Asks the artifact service what the site's hash WOULD be right now — the value a
|
|
/// successful deploy would have stamped, i.e. what makes a site read Current.
|
|
/// </summary>
|
|
private async Task<string> CurrentArtifactHashAsync(Site site) =>
|
|
(await _artifactService.ComputeCurrentArtifactHashesAsync([site]))[site.Id];
|
|
|
|
// ── artifact state ───────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task EvaluateAsync_NullStoredHash_IsNeverDeployedAndNotAPendingUpdate()
|
|
{
|
|
var site = new Site("Alpha", "site-a") { Id = 1 };
|
|
ArrangeSites(site);
|
|
ArrangeInstances();
|
|
|
|
var snapshot = await _evaluator.EvaluateAsync();
|
|
|
|
var row = Assert.Single(snapshot.Sites);
|
|
Assert.Equal(ArtifactDeploymentState.NeverDeployed, row.ArtifactState);
|
|
Assert.Null(row.ArtifactsDeployedAt);
|
|
// NeverDeployed is the operator not having deployed yet, not a pending
|
|
// update: it is surfaced on the Sites page but never counted or nagged about.
|
|
Assert.Equal(0, snapshot.StaleArtifactSiteCount);
|
|
Assert.False(snapshot.HasPendingUpdates);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EvaluateAsync_StoredHashMatchesCurrentDefinitions_IsCurrent()
|
|
{
|
|
var site = new Site("Alpha", "site-a") { Id = 1 };
|
|
var deployedAt = DateTimeOffset.UtcNow.AddHours(-2);
|
|
site.ArtifactRevisionHash = await CurrentArtifactHashAsync(site);
|
|
site.ArtifactsDeployedAt = deployedAt;
|
|
ArrangeSites(site);
|
|
ArrangeInstances();
|
|
|
|
var snapshot = await _evaluator.EvaluateAsync();
|
|
|
|
var row = Assert.Single(snapshot.Sites);
|
|
Assert.Equal(ArtifactDeploymentState.Current, row.ArtifactState);
|
|
Assert.Equal(deployedAt, row.ArtifactsDeployedAt);
|
|
Assert.Equal(0, snapshot.StaleArtifactSiteCount);
|
|
Assert.False(snapshot.HasPendingUpdates);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EvaluateAsync_StoredHashDiffersFromCurrentDefinitions_IsOutOfDateAndCounted()
|
|
{
|
|
var site = new Site("Alpha", "site-a") { Id = 1 };
|
|
site.ArtifactRevisionHash = "a-hash-from-an-older-artifact-set";
|
|
site.ArtifactsDeployedAt = DateTimeOffset.UtcNow.AddDays(-3);
|
|
ArrangeSites(site);
|
|
ArrangeInstances();
|
|
|
|
var snapshot = await _evaluator.EvaluateAsync();
|
|
|
|
var row = Assert.Single(snapshot.Sites);
|
|
Assert.Equal(ArtifactDeploymentState.OutOfDate, row.ArtifactState);
|
|
Assert.Equal(1, snapshot.StaleArtifactSiteCount);
|
|
Assert.True(snapshot.HasPendingUpdates);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EvaluateAsync_CarriesSiteIdentityOntoTheRow()
|
|
{
|
|
var site = new Site("Alpha Plant", "site-a") { Id = 7 };
|
|
ArrangeSites(site);
|
|
ArrangeInstances();
|
|
|
|
var snapshot = await _evaluator.EvaluateAsync();
|
|
|
|
var row = Assert.Single(snapshot.Sites);
|
|
Assert.Equal(7, row.SiteId);
|
|
Assert.Equal("site-a", row.SiteIdentifier);
|
|
Assert.Equal("Alpha Plant", row.SiteName);
|
|
}
|
|
|
|
// ── instance counts ──────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task EvaluateAsync_CountsDeployedStaleAndUnknownInstancesPerSite()
|
|
{
|
|
var site = new Site("Alpha", "site-a") { Id = 1 };
|
|
ArrangeSites(site);
|
|
// 1 = deployed + stale, 2 = deployed but un-evaluatable, 3 = never deployed.
|
|
ArrangeInstances(
|
|
MakeInstance(1, 1, InstanceState.Enabled),
|
|
MakeInstance(2, 1, InstanceState.Disabled),
|
|
MakeInstance(3, 1, InstanceState.NotDeployed));
|
|
|
|
var deployedConfig = ScalarConfig("5");
|
|
var deployedHash = _hasher.ComputeHash(deployedConfig);
|
|
ArrangeDeployedHashes((1, deployedHash), (2, deployedHash));
|
|
ArrangeFlatten(1, ScalarConfig("9"), _hasher.ComputeHash(ScalarConfig("9")));
|
|
ArrangeSnapshot(1, deployedConfig, deployedHash);
|
|
ArrangeFlattenFailure(2);
|
|
|
|
var snapshot = await _evaluator.EvaluateAsync();
|
|
|
|
var row = Assert.Single(snapshot.Sites);
|
|
Assert.Equal(2, row.DeployedInstanceCount);
|
|
Assert.Equal(1, row.StaleInstanceCount);
|
|
Assert.Equal(1, row.UnknownInstanceCount);
|
|
Assert.Equal(1, snapshot.StaleInstanceCount);
|
|
Assert.True(snapshot.HasPendingUpdates);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EvaluateAsync_NotDeployedInstancesAreNeverHandedToTheStalenessBatch()
|
|
{
|
|
var site = new Site("Alpha", "site-a") { Id = 1 };
|
|
ArrangeSites(site);
|
|
ArrangeInstances(
|
|
MakeInstance(1, 1, InstanceState.Enabled),
|
|
MakeInstance(2, 1, InstanceState.NotDeployed));
|
|
|
|
var config = ScalarConfig("5");
|
|
var hash = _hasher.ComputeHash(config);
|
|
// Instance 2 deliberately HAS a leftover deployed-hash row (an undeploy that
|
|
// left the snapshot behind), so the staleness batch would happily evaluate it
|
|
// if its id were passed — the filter under test is the State gate, not the
|
|
// batch's own omission of instances with no snapshot.
|
|
ArrangeDeployedHashes((1, hash), (2, hash));
|
|
ArrangeFlatten(1, config, hash);
|
|
ArrangeFlatten(2, config, hash);
|
|
|
|
var snapshot = await _evaluator.EvaluateAsync();
|
|
|
|
// A NotDeployed instance has nothing to be stale against; flattening it would
|
|
// be pure cost on every TTL expiry, so it must never reach the batch.
|
|
await _pipeline.Received(1).FlattenAndValidateAsync(
|
|
1, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>());
|
|
await _pipeline.DidNotReceive().FlattenAndValidateAsync(
|
|
2, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession?>());
|
|
Assert.Equal(1, snapshot.Sites.Single().DeployedInstanceCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EvaluateAsync_AttributesInstanceCountsToTheirOwnSite()
|
|
{
|
|
var alpha = new Site("Alpha", "site-a") { Id = 1 };
|
|
var bravo = new Site("Bravo", "site-b") { Id = 2 };
|
|
ArrangeSites(alpha, bravo);
|
|
ArrangeInstances(
|
|
MakeInstance(1, 1, InstanceState.Enabled),
|
|
MakeInstance(2, 2, InstanceState.Enabled));
|
|
|
|
var deployedConfig = ScalarConfig("5");
|
|
var deployedHash = _hasher.ComputeHash(deployedConfig);
|
|
ArrangeDeployedHashes((1, deployedHash), (2, deployedHash));
|
|
ArrangeFlatten(1, deployedConfig, deployedHash);
|
|
ArrangeFlatten(2, ScalarConfig("9"), _hasher.ComputeHash(ScalarConfig("9")));
|
|
ArrangeSnapshot(2, deployedConfig, deployedHash);
|
|
|
|
var snapshot = await _evaluator.EvaluateAsync();
|
|
|
|
Assert.Equal(0, snapshot.Sites.Single(s => s.SiteId == 1).StaleInstanceCount);
|
|
Assert.Equal(1, snapshot.Sites.Single(s => s.SiteId == 2).StaleInstanceCount);
|
|
Assert.Equal(1, snapshot.StaleInstanceCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EvaluateAsync_NothingStale_HasNoPendingUpdates()
|
|
{
|
|
var site = new Site("Alpha", "site-a") { Id = 1 };
|
|
site.ArtifactRevisionHash = await CurrentArtifactHashAsync(site);
|
|
ArrangeSites(site);
|
|
ArrangeInstances(MakeInstance(1, 1, InstanceState.Enabled));
|
|
|
|
var config = ScalarConfig("5");
|
|
var hash = _hasher.ComputeHash(config);
|
|
ArrangeDeployedHashes((1, hash));
|
|
ArrangeFlatten(1, config, hash);
|
|
|
|
var snapshot = await _evaluator.EvaluateAsync();
|
|
|
|
Assert.Equal(1, snapshot.Sites.Single().DeployedInstanceCount);
|
|
Assert.Equal(0, snapshot.StaleInstanceCount);
|
|
Assert.Equal(0, snapshot.StaleArtifactSiteCount);
|
|
Assert.False(snapshot.HasPendingUpdates);
|
|
}
|
|
|
|
// ── ordering, ComputedAt, ForSites ───────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task EvaluateAsync_OrdersSitesByNameIgnoringCase()
|
|
{
|
|
ArrangeSites(
|
|
new Site("charlie", "site-c") { Id = 3 },
|
|
new Site("Alpha", "site-a") { Id = 1 },
|
|
new Site("bravo", "site-b") { Id = 2 });
|
|
ArrangeInstances();
|
|
|
|
var snapshot = await _evaluator.EvaluateAsync();
|
|
|
|
Assert.Equal(["Alpha", "bravo", "charlie"], snapshot.Sites.Select(s => s.SiteName));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EvaluateAsync_StampsComputedAt()
|
|
{
|
|
var before = DateTimeOffset.UtcNow;
|
|
ArrangeSites(new Site("Alpha", "site-a") { Id = 1 });
|
|
ArrangeInstances();
|
|
|
|
var snapshot = await _evaluator.EvaluateAsync();
|
|
|
|
Assert.InRange(snapshot.ComputedAt, before, DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EvaluateAsync_ExceptionsPropagate()
|
|
{
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
|
|
.Returns<IReadOnlyList<Site>>(_ => throw new InvalidOperationException("db down"));
|
|
|
|
// The caller's single-flight cache discards a faulted flight and the UI
|
|
// renders "unavailable"; swallowing here would make a broken fleet read
|
|
// "up to date", which is the one answer that must never be invented.
|
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => _evaluator.EvaluateAsync());
|
|
Assert.Equal("db down", ex.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void ForSites_KeepsOnlyTheNamedSitesAndTheSameComputedAt()
|
|
{
|
|
var computedAt = DateTimeOffset.UtcNow.AddMinutes(-1);
|
|
var snapshot = new PendingUpdatesSnapshot(computedAt,
|
|
[
|
|
new SitePendingUpdates(1, "site-a", "Alpha", ArtifactDeploymentState.OutOfDate, null, 2, 2, 0),
|
|
new SitePendingUpdates(2, "site-b", "Bravo", ArtifactDeploymentState.Current, null, 1, 1, 0),
|
|
new SitePendingUpdates(3, "site-c", "Charlie", ArtifactDeploymentState.OutOfDate, null, 0, 0, 0)
|
|
]);
|
|
|
|
var scoped = snapshot.ForSites(new HashSet<int> { 2 });
|
|
|
|
Assert.Equal(computedAt, scoped.ComputedAt);
|
|
var row = Assert.Single(scoped.Sites);
|
|
Assert.Equal(2, row.SiteId);
|
|
// The totals are recomputed over the kept sites, so a site-scoped user is
|
|
// never shown a count that includes a site they cannot see.
|
|
Assert.Equal(1, scoped.StaleInstanceCount);
|
|
Assert.Equal(0, scoped.StaleArtifactSiteCount);
|
|
Assert.True(scoped.HasPendingUpdates);
|
|
}
|
|
|
|
[Fact]
|
|
public void ForSites_EmptyScope_YieldsNoPendingUpdates()
|
|
{
|
|
var snapshot = new PendingUpdatesSnapshot(DateTimeOffset.UtcNow,
|
|
[
|
|
new SitePendingUpdates(1, "site-a", "Alpha", ArtifactDeploymentState.OutOfDate, null, 2, 2, 0)
|
|
]);
|
|
|
|
var scoped = snapshot.ForSites(new HashSet<int>());
|
|
|
|
Assert.Empty(scoped.Sites);
|
|
Assert.False(scoped.HasPendingUpdates);
|
|
}
|
|
|
|
[Fact]
|
|
public void Snapshot_Totals_SumAcrossSites()
|
|
{
|
|
var snapshot = new PendingUpdatesSnapshot(DateTimeOffset.UtcNow,
|
|
[
|
|
new SitePendingUpdates(1, "site-a", "Alpha", ArtifactDeploymentState.OutOfDate, null, 4, 2, 1),
|
|
new SitePendingUpdates(2, "site-b", "Bravo", ArtifactDeploymentState.NeverDeployed, null, 0, 0, 0),
|
|
new SitePendingUpdates(3, "site-c", "Charlie", ArtifactDeploymentState.Current, null, 3, 3, 0)
|
|
]);
|
|
|
|
Assert.Equal(5, snapshot.StaleInstanceCount);
|
|
// NeverDeployed is deliberately not counted alongside OutOfDate.
|
|
Assert.Equal(1, snapshot.StaleArtifactSiteCount);
|
|
Assert.True(snapshot.HasPendingUpdates);
|
|
}
|
|
}
|