29049c4ef6
Secret-store outage (final review M5): SiteSecretsCollector returns a store read failure as SiteSecretsCollection.StoreError instead of throwing. A deploy now fails only the sites that reference a secret (nothing sent, not stamped, DeploySiteSecrets row with the error), and ComputeCurrentArtifactHashesAsync omits only those sites (they read Out of date) instead of the exception aborting the whole sweep and the fleet-wide pending-updates check. Removal-only deploys: the site reply gains SecretsRemoved (proto field 7, names only) - the deployed_secrets rows the deploy soft-removed. Central carries it on SiteArtifactResult and writes the DeploySiteSecrets row with a removed list whenever it is non-empty, so dropping a site's last secret: reference is audited centrally rather than only in site logs.
1355 lines
63 KiB
C#
1355 lines
63 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Text.Json;
|
|
using Akka.Actor;
|
|
using Akka.TestKit.Xunit2;
|
|
using Microsoft.Extensions.Logging;
|
|
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.Sites;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
|
|
using ZB.MOM.WW.ScadaBridge.Communication;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
|
|
using ZB.MOM.WW.Secrets.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests;
|
|
|
|
/// <summary>
|
|
/// WP-7: Tests for system-wide artifact deployment.
|
|
/// </summary>
|
|
public class ArtifactDeploymentServiceTests : TestKit
|
|
{
|
|
private readonly ISiteRepository _siteRepo;
|
|
private readonly IDeploymentManagerRepository _deploymentRepo;
|
|
private readonly ITemplateEngineRepository _templateRepo;
|
|
private readonly IExternalSystemRepository _externalSystemRepo;
|
|
private readonly INotificationRepository _notificationRepo;
|
|
private readonly IAuditService _audit;
|
|
private readonly ArtifactRevisionHashService _hashService = new();
|
|
private readonly ISecretStore _secretStore;
|
|
|
|
public ArtifactDeploymentServiceTests()
|
|
{
|
|
_siteRepo = Substitute.For<ISiteRepository>();
|
|
_deploymentRepo = Substitute.For<IDeploymentManagerRepository>();
|
|
_templateRepo = Substitute.For<ITemplateEngineRepository>();
|
|
_externalSystemRepo = Substitute.For<IExternalSystemRepository>();
|
|
// DeploymentManager-025/-027: the notification repo is retained only so the
|
|
// tests can assert the artifact path NEVER touches it (it is no longer a
|
|
// constructor dependency of ArtifactDeploymentService).
|
|
_notificationRepo = Substitute.For<INotificationRepository>();
|
|
_audit = Substitute.For<IAuditService>();
|
|
// Unconfigured GetAsync returns null (NSubstitute default), i.e. "not in the store" —
|
|
// exactly the fail-closed posture a test wants unless it explicitly seeds a row.
|
|
_secretStore = Substitute.For<ISecretStore>();
|
|
}
|
|
|
|
private static StoredSecret SecretRow(string name, long revision, string? description = null) => new()
|
|
{
|
|
Name = new SecretName(name),
|
|
Description = description,
|
|
ContentType = SecretContentType.Text,
|
|
Ciphertext = [1, 2, 3],
|
|
Nonce = [4, 5],
|
|
Tag = [6, 7],
|
|
WrappedDek = [8, 9],
|
|
WrapNonce = [10, 11],
|
|
WrapTag = [12, 13],
|
|
KekId = "kek-1",
|
|
Revision = revision,
|
|
CreatedUtc = DateTimeOffset.Parse("2026-01-01T00:00:00Z"),
|
|
UpdatedUtc = DateTimeOffset.Parse("2026-01-02T00:00:00Z"),
|
|
};
|
|
|
|
[Fact]
|
|
public async Task DeployToAllSitesAsync_NoSites_ReturnsFailure()
|
|
{
|
|
_siteRepo.GetAllSitesAsync().Returns(new List<Site>());
|
|
|
|
var service = CreateService();
|
|
|
|
var result = await service.DeployToAllSitesAsync("admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("No sites", result.Error);
|
|
}
|
|
|
|
[Fact]
|
|
public void SiteArtifactResult_ContainsSiteInfo()
|
|
{
|
|
var result = new SiteArtifactResult("site1", "Site One", true, null);
|
|
Assert.Equal("site1", result.SiteId);
|
|
Assert.Equal("Site One", result.SiteName);
|
|
Assert.True(result.Success);
|
|
Assert.Null(result.ErrorMessage);
|
|
}
|
|
|
|
[Fact]
|
|
public void ArtifactDeploymentSummary_CountsCorrectly()
|
|
{
|
|
var results = new List<SiteArtifactResult>
|
|
{
|
|
new("s1", "Site1", true, null),
|
|
new("s2", "Site2", false, "error"),
|
|
new("s3", "Site3", true, null)
|
|
};
|
|
var summary = new ArtifactDeploymentSummary("dep1", results, 2, 1);
|
|
|
|
Assert.Equal(2, summary.SuccessCount);
|
|
Assert.Equal(1, summary.FailureCount);
|
|
Assert.Equal(3, summary.SiteResults.Count);
|
|
}
|
|
|
|
// ── DeploymentManager-010: one logical deployment id across all per-site commands ──
|
|
|
|
[Fact]
|
|
public async Task DeployToAllSitesAsync_AllPerSiteCommandsShareTheSummaryDeploymentId()
|
|
{
|
|
// DeploymentManager-010: previously each per-site DeployArtifactsCommand
|
|
// minted its own GUID, so one logical deployment produced N+1 unrelated
|
|
// ids. Every per-site command must now carry the SAME id, equal to the
|
|
// id reported in the summary and audit log.
|
|
var sites = new List<Site>
|
|
{
|
|
new("Site One", "site-1") { Id = 1 },
|
|
new("Site Two", "site-2") { Id = 2 }
|
|
};
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(sites);
|
|
var recorder = new ArtifactProbeRecorder();
|
|
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
|
|
var result = await service.DeployToAllSitesAsync("admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
var commands = recorder.Received;
|
|
Assert.Equal(2, commands.Count);
|
|
|
|
// All per-site commands carry one shared id, equal to the summary id.
|
|
var distinctIds = commands.Select(c => c.DeploymentId).Distinct().ToList();
|
|
Assert.Single(distinctIds);
|
|
Assert.Equal(result.Value.DeploymentId, distinctIds[0]);
|
|
|
|
// The persisted record embeds the same logical deployment id.
|
|
await _deploymentRepo.Received().AddSystemArtifactDeploymentAsync(
|
|
Arg.Do<SystemArtifactDeploymentRecord>(r =>
|
|
{
|
|
using var doc = JsonDocument.Parse(r.PerSiteStatus!);
|
|
Assert.Equal(result.Value.DeploymentId,
|
|
doc.RootElement.GetProperty("DeploymentId").GetString());
|
|
}),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
// ── DeploymentManager-014: real per-site success/failure coverage ──
|
|
|
|
[Fact]
|
|
public async Task DeployToAllSitesAsync_PartialFailure_ReportsPerSiteMatrix()
|
|
{
|
|
// Site one succeeds, site two fails -> the summary counts must reflect
|
|
// the per-site matrix.
|
|
var sites = new List<Site>
|
|
{
|
|
new("Site One", "ok-site") { Id = 1 },
|
|
new("Site Two", "fail-site") { Id = 2 }
|
|
};
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(sites);
|
|
var recorder = new ArtifactProbeRecorder();
|
|
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder, "fail-site")));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
|
|
var result = await service.DeployToAllSitesAsync("admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(1, result.Value.SuccessCount);
|
|
Assert.Equal(1, result.Value.FailureCount);
|
|
Assert.Contains(result.Value.SiteResults, r => r.SiteId == "ok-site" && r.Success);
|
|
Assert.Contains(result.Value.SiteResults, r => r.SiteId == "fail-site" && !r.Success);
|
|
}
|
|
|
|
// ── DeploymentManager-023: global artifact queries hoisted out of the per-site loop ──
|
|
|
|
[Fact]
|
|
public async Task DeployToAllSitesAsync_HoistsGlobalArtifactQueriesOutOfPerSiteLoop()
|
|
{
|
|
// DeploymentManager-023: previously each per-site iteration of the deploy-many
|
|
// loop re-issued the global artifact queries (shared scripts, external systems,
|
|
// DB connections) — a textbook N+1 over the global sets. With three sites the
|
|
// queries must now be issued ONCE in total, regardless of site count.
|
|
var sites = new List<Site>
|
|
{
|
|
new("Site One", "site-1") { Id = 1 },
|
|
new("Site Two", "site-2") { Id = 2 },
|
|
new("Site Three", "site-3") { Id = 3 },
|
|
};
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(sites);
|
|
var recorder = new ArtifactProbeRecorder();
|
|
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
|
|
var result = await service.DeployToAllSitesAsync("admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
// Each global query must be called EXACTLY ONCE for the whole multi-site sweep.
|
|
await _templateRepo.Received(1).GetAllSharedScriptsAsync(Arg.Any<CancellationToken>());
|
|
await _externalSystemRepo.Received(1).GetAllExternalSystemsAsync(Arg.Any<CancellationToken>());
|
|
await _externalSystemRepo.Received(1).GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>());
|
|
// DeploymentManager-025/-027: notification lists and SMTP configuration are
|
|
// central-only — the artifact path must NEVER fetch them, and the per-site
|
|
// command must NEVER carry them.
|
|
await _notificationRepo.DidNotReceive().GetAllNotificationListsAsync(Arg.Any<CancellationToken>());
|
|
await _notificationRepo.DidNotReceive().GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>());
|
|
Assert.All(recorder.Received, cmd =>
|
|
{
|
|
Assert.Null(cmd.NotificationLists);
|
|
Assert.Null(cmd.SmtpConfigurations);
|
|
});
|
|
// The per-site query (data connections) DOES vary per site and must still run
|
|
// once per site.
|
|
await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>());
|
|
await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>());
|
|
await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(3, Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deferred-work row 27(a): the per-system retry policy must be projected onto the
|
|
/// artifact that ships to sites. Before the fix <c>ExternalSystemArtifact</c> had no such
|
|
/// fields, so a centrally-configured policy never left central and every site cached call
|
|
/// silently used the engine default (50 x 30 s) — while the sibling
|
|
/// <c>DatabaseConnectionArtifact</c> had always carried its equivalents.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task DeployedExternalSystemArtifact_CarriesRetryPolicy()
|
|
{
|
|
_externalSystemRepo.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>()).Returns(
|
|
[
|
|
new ExternalSystemDefinition("Mes", "https://mes", "ApiKey")
|
|
{
|
|
Id = 1,
|
|
MaxRetries = 6,
|
|
RetryDelay = TimeSpan.FromSeconds(120),
|
|
},
|
|
]);
|
|
|
|
var recorder = new ArtifactProbeRecorder();
|
|
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
|
|
var result = await service.RetryForSiteAsync(1, "retry-site", "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
var artifact = Assert.Single(Assert.Single(recorder.Received).ExternalSystems!);
|
|
Assert.Equal(6, artifact.MaxRetries);
|
|
Assert.Equal(TimeSpan.FromSeconds(120), artifact.RetryDelay);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetryForSiteAsync_SingleSitePath_StillRunsTheGlobalQueriesOnce()
|
|
{
|
|
// DeploymentManager-023: the single-site convenience overload still owns its
|
|
// own global-fetch (it cannot inherit from a sweep), so for one site every
|
|
// global query is issued exactly once. Pin this so a future refactor cannot
|
|
// accidentally route RetryForSiteAsync through the multi-site loop and lose
|
|
// the audit row's deploymentId guarantee.
|
|
var recorder = new ArtifactProbeRecorder();
|
|
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
|
|
var result = await service.RetryForSiteAsync(1, "retry-site", "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
await _templateRepo.Received(1).GetAllSharedScriptsAsync(Arg.Any<CancellationToken>());
|
|
await _externalSystemRepo.Received(1).GetAllExternalSystemsAsync(Arg.Any<CancellationToken>());
|
|
await _externalSystemRepo.Received(1).GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>());
|
|
// DeploymentManager-025/-027: central-only — never fetched, never shipped.
|
|
await _notificationRepo.DidNotReceive().GetAllNotificationListsAsync(Arg.Any<CancellationToken>());
|
|
await _notificationRepo.DidNotReceive().GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>());
|
|
Assert.All(recorder.Received, cmd =>
|
|
{
|
|
Assert.Null(cmd.NotificationLists);
|
|
Assert.Null(cmd.SmtpConfigurations);
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetryForSiteAsync_SiteSucceeds_ReturnsSuccessAndAudits()
|
|
{
|
|
var recorder = new ArtifactProbeRecorder();
|
|
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
|
|
var result = await service.RetryForSiteAsync(1, "retry-site", "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal("retry-site", result.Value.SiteId);
|
|
await _audit.Received().LogAsync(
|
|
"admin", "RetryArtifactDeployment", "SystemArtifact",
|
|
Arg.Any<string>(), "retry-site", Arg.Any<object>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
// ── PLAN-07 Task 4: single-site artifact deployment ──
|
|
|
|
[Fact]
|
|
public async Task DeployToSiteAsync_DeploysOnlyToTargetSite()
|
|
{
|
|
// Two sites exist; deploying to site 1 must build/send exactly one per-site
|
|
// command (for SITE1) and never touch SITE2's per-site data.
|
|
var site1 = new Site("Site One", "SITE1") { Id = 1 };
|
|
_siteRepo.GetSiteByIdAsync(1, Arg.Any<CancellationToken>()).Returns(site1);
|
|
var recorder = new ArtifactProbeRecorder();
|
|
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
|
|
var result = await service.DeployToSiteAsync(1, "tester");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Single(result.Value.SiteResults);
|
|
Assert.Equal("SITE1", result.Value.SiteResults[0].SiteId);
|
|
Assert.Single(recorder.Received);
|
|
await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>());
|
|
await _siteRepo.DidNotReceive().GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeployToSiteAsync_UnknownSite_ReturnsFailure()
|
|
{
|
|
_siteRepo.GetSiteByIdAsync(999, Arg.Any<CancellationToken>()).Returns((Site?)null);
|
|
var service = CreateService();
|
|
|
|
var result = await service.DeployToSiteAsync(999, "tester");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("999", result.Error);
|
|
}
|
|
|
|
// ── Site artifact revision stamp (pending-updates §3.1) ──
|
|
|
|
[Fact]
|
|
public async Task DeployToAllSitesAsync_SuccessfulSite_StampsHashAndTimestampAndSaves()
|
|
{
|
|
var site = new Site("Site One", "site-1") { Id = 1 };
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site> { site });
|
|
var recorder = new ArtifactProbeRecorder();
|
|
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
var before = DateTimeOffset.UtcNow;
|
|
|
|
var result = await service.DeployToAllSitesAsync("admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
var expected = _hashService.ComputeHash(Assert.Single(recorder.Received));
|
|
Assert.Equal(expected, site.ArtifactRevisionHash);
|
|
Assert.NotNull(site.ArtifactsDeployedAt);
|
|
Assert.InRange(site.ArtifactsDeployedAt!.Value, before, DateTimeOffset.UtcNow);
|
|
await _siteRepo.Received(1).UpdateSiteAsync(site, Arg.Any<CancellationToken>());
|
|
await _siteRepo.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeployToAllSitesAsync_FailedSite_KeepsItsPreviousStamp()
|
|
{
|
|
// A failed site must keep reading "out of date": it keeps the stamp of whatever
|
|
// artifact set it last received, never the one it just refused.
|
|
var previousStamp = DateTimeOffset.UtcNow.AddDays(-2);
|
|
var ok = new Site("Site One", "ok-site") { Id = 1 };
|
|
var failed = new Site("Site Two", "fail-site")
|
|
{
|
|
Id = 2,
|
|
ArtifactRevisionHash = "sha256:previous",
|
|
ArtifactsDeployedAt = previousStamp
|
|
};
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site> { ok, failed });
|
|
var recorder = new ArtifactProbeRecorder();
|
|
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder, "fail-site")));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
|
|
var result = await service.DeployToAllSitesAsync("admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal("sha256:previous", failed.ArtifactRevisionHash);
|
|
Assert.Equal(previousStamp, failed.ArtifactsDeployedAt);
|
|
Assert.NotNull(ok.ArtifactRevisionHash);
|
|
await _siteRepo.DidNotReceive().UpdateSiteAsync(failed, Arg.Any<CancellationToken>());
|
|
await _siteRepo.Received(1).UpdateSiteAsync(ok, Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeployToAllSitesAsync_StampSaveThrows_DeploymentRecordStillPersistedAndSuccessReported()
|
|
{
|
|
// ORDERING RULE: audit row first, best-effort stamp after. Both repositories are
|
|
// scoped over ONE DbContext, so a failed stamp save leaves the Site entries tracked
|
|
// as Modified — stamping first would let that failure rethrow out of the deployment
|
|
// record's own (uncaught) SaveChangesAsync and lose the audit row for a deploy where
|
|
// every site succeeded. The record must therefore be saved BEFORE the stamp is tried.
|
|
var site = new Site("Site One", "site-1") { Id = 1 };
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site> { site });
|
|
_siteRepo.SaveChangesAsync(Arg.Any<CancellationToken>())
|
|
.Returns<int>(_ => throw new InvalidOperationException("db down"));
|
|
var recorder = new ArtifactProbeRecorder();
|
|
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
|
|
var result = await service.DeployToAllSitesAsync("admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(1, result.Value.SuccessCount);
|
|
await _deploymentRepo.Received(1).AddSystemArtifactDeploymentAsync(
|
|
Arg.Any<SystemArtifactDeploymentRecord>(), Arg.Any<CancellationToken>());
|
|
await _deploymentRepo.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
|
|
Received.InOrder(() =>
|
|
{
|
|
_deploymentRepo.SaveChangesAsync(Arg.Any<CancellationToken>());
|
|
_siteRepo.UpdateSiteAsync(site, Arg.Any<CancellationToken>());
|
|
});
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetryForSiteAsync_Success_StampsTheSite()
|
|
{
|
|
var site = new Site("Retry Site", "retry-site") { Id = 7 };
|
|
_siteRepo.GetSiteByIdAsync(7, Arg.Any<CancellationToken>()).Returns(site);
|
|
var recorder = new ArtifactProbeRecorder();
|
|
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
|
|
var result = await service.RetryForSiteAsync(7, "retry-site", "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(
|
|
_hashService.ComputeHash(Assert.Single(recorder.Received)),
|
|
site.ArtifactRevisionHash);
|
|
Assert.NotNull(site.ArtifactsDeployedAt);
|
|
await _siteRepo.Received(1).UpdateSiteAsync(site, Arg.Any<CancellationToken>());
|
|
await _siteRepo.Received(1).SaveChangesAsync(Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetryForSiteAsync_Failure_DoesNotStampTheSite()
|
|
{
|
|
var site = new Site("Retry Site", "fail-site") { Id = 7 };
|
|
_siteRepo.GetSiteByIdAsync(7, Arg.Any<CancellationToken>()).Returns(site);
|
|
var recorder = new ArtifactProbeRecorder();
|
|
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder, "fail-site")));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
|
|
var result = await service.RetryForSiteAsync(7, "fail-site", "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Null(site.ArtifactRevisionHash);
|
|
Assert.Null(site.ArtifactsDeployedAt);
|
|
await _siteRepo.DidNotReceive().UpdateSiteAsync(Arg.Any<Site>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ComputeCurrentArtifactHashesAsync_MatchesTheHashADeployStamps()
|
|
{
|
|
// The whole comparison rests on this equality: a "what would it be now" hash of
|
|
// unchanged definitions must equal the hash the last successful deploy stamped.
|
|
var sites = new List<Site>
|
|
{
|
|
new("Site One", "site-1") { Id = 1 },
|
|
new("Site Two", "site-2") { Id = 2 }
|
|
};
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(sites);
|
|
var recorder = new ArtifactProbeRecorder();
|
|
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
|
|
var deployResult = await service.DeployToAllSitesAsync("admin");
|
|
Assert.True(deployResult.IsSuccess);
|
|
|
|
var current = await service.ComputeCurrentArtifactHashesAsync(sites);
|
|
|
|
Assert.Equal(2, current.Count);
|
|
foreach (var site in sites)
|
|
{
|
|
Assert.Equal(site.ArtifactRevisionHash, current[site.Id]);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ComputeCurrentArtifactHashesAsync_MatchesTheHashADeployStamps_WithSecrets()
|
|
{
|
|
// Final review M7: the parity above runs with no data connections. Pin it for a site
|
|
// whose connections reference secrets, so the stamped hash (built from a command that
|
|
// carries a non-empty Secrets list) equals the "what would it be now" hash — a
|
|
// successful secrets deploy must read Current, not Out of date.
|
|
var site = new Site("Site One", "site-1") { Id = 1 };
|
|
var sites = new List<Site> { site };
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(sites);
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
|
.Returns(new List<DataConnection>
|
|
{
|
|
new("GW1", "MxGateway", 1)
|
|
{
|
|
PrimaryConfiguration = """{"ApiKey":"secret:key1","Password":"secret:key2"}"""
|
|
}
|
|
});
|
|
_secretStore.GetAsync(Arg.Is<SecretName>(n => n.Value == "key1"), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<StoredSecret?>(SecretRow("key1", 3)));
|
|
_secretStore.GetAsync(Arg.Is<SecretName>(n => n.Value == "key2"), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<StoredSecret?>(SecretRow("key2", 7)));
|
|
|
|
var recorder = new ArtifactProbeRecorder();
|
|
var probe = Sys.ActorOf(Props.Create(() => new SecretsAckProbeActor(
|
|
recorder, cmd => cmd.Secrets == null ? (int?)null : cmd.Secrets.Count)));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
|
|
var deployResult = await service.DeployToAllSitesAsync("admin");
|
|
Assert.True(deployResult.IsSuccess);
|
|
Assert.Equal(1, deployResult.Value.SuccessCount);
|
|
var shipped = Assert.Single(recorder.Received);
|
|
Assert.Equal(2, shipped.Secrets!.Count);
|
|
Assert.NotNull(site.ArtifactRevisionHash);
|
|
|
|
var current = await service.ComputeCurrentArtifactHashesAsync(sites);
|
|
|
|
Assert.Equal(site.ArtifactRevisionHash, current[site.Id]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ComputeCurrentArtifactHashesAsync_HoistsTheGlobalQueriesOnce()
|
|
{
|
|
var sites = new List<Site>
|
|
{
|
|
new("Site One", "site-1") { Id = 1 },
|
|
new("Site Two", "site-2") { Id = 2 },
|
|
new("Site Three", "site-3") { Id = 3 }
|
|
};
|
|
var service = CreateService();
|
|
|
|
var current = await service.ComputeCurrentArtifactHashesAsync(sites);
|
|
|
|
Assert.Equal(3, current.Count);
|
|
await _templateRepo.Received(1).GetAllSharedScriptsAsync(Arg.Any<CancellationToken>());
|
|
await _externalSystemRepo.Received(1).GetAllExternalSystemsAsync(Arg.Any<CancellationToken>());
|
|
await _externalSystemRepo.Received(1).GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>());
|
|
await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>());
|
|
await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(3, Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ComputeCurrentArtifactHashesAsync_NoSites_ReturnsEmptyWithoutQuerying()
|
|
{
|
|
var service = CreateService();
|
|
|
|
var current = await service.ComputeCurrentArtifactHashesAsync([]);
|
|
|
|
Assert.Empty(current);
|
|
await _templateRepo.DidNotReceive().GetAllSharedScriptsAsync(Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
// ── Secrets ride the artifact deploy (design §3.1/§3.3) ──
|
|
|
|
[Fact]
|
|
public async Task DeployToAllSites_SiteWithMissingSecret_FailsThatSiteOnly_AndSendsNothingToIt()
|
|
{
|
|
var sites = new List<Site>
|
|
{
|
|
new("Site One", "ok-site") { Id = 1 },
|
|
new("Site Two", "bad-site") { Id = 2 }
|
|
};
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(sites);
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
|
.Returns(new List<DataConnection>());
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
|
|
.Returns(new List<DataConnection>
|
|
{
|
|
new("GW1", "MxGateway", 2) { PrimaryConfiguration = """{"ApiKey":"secret:missing-key"}""" }
|
|
});
|
|
// _secretStore is unconfigured for "missing-key" — GetAsync returns null (absent).
|
|
|
|
var recorder = new ArtifactProbeRecorder();
|
|
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
|
|
var result = await service.DeployToAllSitesAsync("admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(1, result.Value.SuccessCount);
|
|
Assert.Equal(1, result.Value.FailureCount);
|
|
Assert.Contains(result.Value.SiteResults, r => r.SiteId == "ok-site" && r.Success);
|
|
var badResult = Assert.Single(result.Value.SiteResults, r => r.SiteId == "bad-site");
|
|
Assert.False(badResult.Success);
|
|
Assert.Equal(
|
|
"Secret 'missing-key' referenced by data connection(s) GW1 is not in the central secret store",
|
|
badResult.ErrorMessage);
|
|
|
|
// Nothing was sent to the bad site: the probe only ever saw ok-site's command.
|
|
var sentCommand = Assert.Single(recorder.Received);
|
|
Assert.DoesNotContain(
|
|
sentCommand.DataConnections ?? [],
|
|
dc => dc.Name == "GW1");
|
|
|
|
// The bad site keeps no stamp — it still reads "out of date" / "never deployed".
|
|
var badSite = sites.Single(s => s.SiteIdentifier == "bad-site");
|
|
Assert.Null(badSite.ArtifactRevisionHash);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ComputeCurrentHashes_MissingSecret_UsesMissingMarker()
|
|
{
|
|
var site = new Site("Site One", "site-1") { Id = 1 };
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
|
.Returns(new List<DataConnection>
|
|
{
|
|
new("GW1", "MxGateway", 1) { PrimaryConfiguration = """{"ApiKey":"secret:the-key"}""" }
|
|
});
|
|
var service = CreateService();
|
|
|
|
// The reference does not resolve yet: ComputeCurrentArtifactHashesAsync must not
|
|
// fail the call — it folds a Missing marker into the hash instead.
|
|
var beforeCreated = await service.ComputeCurrentArtifactHashesAsync([site]);
|
|
|
|
// The secret now exists in the store.
|
|
_secretStore.GetAsync(Arg.Is<SecretName>(n => n.Value == "the-key"), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<StoredSecret?>(SecretRow("the-key", 1)));
|
|
var afterCreated = await service.ComputeCurrentArtifactHashesAsync([site]);
|
|
|
|
Assert.NotEqual(beforeCreated[1], afterCreated[1]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DeployLogs_NeverContainSecretMaterial()
|
|
{
|
|
var ciphertext = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF, 0x13, 0x37 };
|
|
var resolvedRow = SecretRow("present-key", 5) with { Ciphertext = ciphertext };
|
|
var ciphertextBase64 = Convert.ToBase64String(ciphertext);
|
|
var ciphertextHex = Convert.ToHexString(ciphertext);
|
|
|
|
var sites = new List<Site>
|
|
{
|
|
new("Site One", "ok-site") { Id = 1 },
|
|
new("Site Two", "bad-site") { Id = 2 }
|
|
};
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(sites);
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
|
.Returns(new List<DataConnection>
|
|
{
|
|
new("GW1", "MxGateway", 1) { PrimaryConfiguration = """{"ApiKey":"secret:present-key"}""" }
|
|
});
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
|
|
.Returns(new List<DataConnection>
|
|
{
|
|
new("GW2", "MxGateway", 2) { PrimaryConfiguration = """{"ApiKey":"secret:missing-key"}""" }
|
|
});
|
|
_secretStore.GetAsync(Arg.Is<SecretName>(n => n.Value == "present-key"), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<StoredSecret?>(resolvedRow));
|
|
|
|
var recorder = new ArtifactProbeRecorder();
|
|
// Task 7: ok-site's command ships one secret, so the probe must ack it (SecretsApplied
|
|
// = shipped count) for the site to reconcile as a success — an unset SecretsApplied
|
|
// now reads as an old site (see the dedicated reconciliation tests below).
|
|
var probe = Sys.ActorOf(Props.Create(() =>
|
|
new SecretsAckProbeActor(recorder, cmd => cmd.Secrets == null ? (int?)null : cmd.Secrets.Count)));
|
|
var capturingLogger = new CapturingLogger<ArtifactDeploymentService>();
|
|
var service = CreateServiceWithCommActor(probe, capturingLogger);
|
|
|
|
var result = await service.DeployToAllSitesAsync("admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(1, result.Value.SuccessCount);
|
|
Assert.Equal(1, result.Value.FailureCount);
|
|
|
|
// The Information/Error lines still name what shipped and what didn't ...
|
|
Assert.Contains(capturingLogger.Messages, m => m.Contains("present-key@r5"));
|
|
Assert.Contains(capturingLogger.Messages, m => m.Contains("missing-key"));
|
|
Assert.Contains(capturingLogger.Messages, m => m.Contains("not in the central secret store"));
|
|
|
|
// ... but never the ciphertext, in any rendering, in any log message or exception text.
|
|
Assert.DoesNotContain(capturingLogger.Messages, m => m.Contains(ciphertextBase64));
|
|
Assert.DoesNotContain(capturingLogger.Messages, m => m.Contains(ciphertextHex));
|
|
Assert.DoesNotContain(capturingLogger.Messages, m => m.Contains(ciphertextHex, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
// ── Task 7: stamp gating on secret application + DeploySiteSecrets audit row ──
|
|
|
|
[Fact]
|
|
public async Task OldSite_NullSecretsApplied_WithSecrets_FailsAndDoesNotStamp()
|
|
{
|
|
// The site's build predates the secrets feature: it acks the artifact deploy as a
|
|
// whole (Success=true) but never populates SecretsApplied, silently ignoring the new
|
|
// proto field. That must not read as "the secret was applied".
|
|
var site = new Site("Site One", "site-1") { Id = 1 };
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site> { site });
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
|
.Returns(new List<DataConnection>
|
|
{
|
|
new("GW1", "MxGateway", 1) { PrimaryConfiguration = """{"ApiKey":"secret:the-key"}""" }
|
|
});
|
|
_secretStore.GetAsync(Arg.Is<SecretName>(n => n.Value == "the-key"), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<StoredSecret?>(SecretRow("the-key", 1)));
|
|
|
|
var recorder = new ArtifactProbeRecorder();
|
|
var probe = Sys.ActorOf(Props.Create(() => new SecretsAckProbeActor(recorder, _ => null)));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
|
|
var result = await service.DeployToAllSitesAsync("admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(0, result.Value.SuccessCount);
|
|
Assert.Equal(1, result.Value.FailureCount);
|
|
var siteResult = Assert.Single(result.Value.SiteResults);
|
|
Assert.False(siteResult.Success);
|
|
Assert.Equal(
|
|
"Site build predates secret deployment — upgrade the site before deploying secrets",
|
|
siteResult.ErrorMessage);
|
|
Assert.Null(siteResult.SecretsDeployed);
|
|
Assert.Null(site.ArtifactRevisionHash);
|
|
Assert.Null(site.ArtifactsDeployedAt);
|
|
await _siteRepo.DidNotReceive().UpdateSiteAsync(site, Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task PartialApply_FailsAndDoesNotStamp()
|
|
{
|
|
// The site applied 1 of the 2 shipped secrets (a partial local failure). Success=true
|
|
// on the wire response is not enough — the count must match exactly.
|
|
var site = new Site("Site One", "site-1") { Id = 1 };
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site> { site });
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
|
.Returns(new List<DataConnection>
|
|
{
|
|
new("GW1", "MxGateway", 1)
|
|
{
|
|
PrimaryConfiguration = """{"ApiKey":"secret:key1","Password":"secret:key2"}"""
|
|
}
|
|
});
|
|
_secretStore.GetAsync(Arg.Is<SecretName>(n => n.Value == "key1"), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<StoredSecret?>(SecretRow("key1", 1)));
|
|
_secretStore.GetAsync(Arg.Is<SecretName>(n => n.Value == "key2"), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<StoredSecret?>(SecretRow("key2", 1)));
|
|
|
|
var recorder = new ArtifactProbeRecorder();
|
|
var probe = Sys.ActorOf(Props.Create(() => new SecretsAckProbeActor(recorder, _ => 1)));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
|
|
var result = await service.DeployToAllSitesAsync("admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(0, result.Value.SuccessCount);
|
|
Assert.Equal(1, result.Value.FailureCount);
|
|
var siteResult = Assert.Single(result.Value.SiteResults);
|
|
Assert.False(siteResult.Success);
|
|
Assert.Equal("Site applied 1 of 2 secrets", siteResult.ErrorMessage);
|
|
Assert.Equal(1, siteResult.SecretsDeployed);
|
|
Assert.Null(site.ArtifactRevisionHash);
|
|
await _siteRepo.DidNotReceive().UpdateSiteAsync(site, Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OldSite_NoSecrets_StillStamps()
|
|
{
|
|
// No data connection references a secret at all, so the command ships zero secrets.
|
|
// An old site's SecretsApplied=null reply must NOT gate the stamp in this case —
|
|
// unchanged behaviour.
|
|
var site = new Site("Site One", "site-1") { Id = 1 };
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site> { site });
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
|
.Returns(new List<DataConnection>());
|
|
|
|
var recorder = new ArtifactProbeRecorder();
|
|
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
|
|
var result = await service.DeployToAllSitesAsync("admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(1, result.Value.SuccessCount);
|
|
var siteResult = Assert.Single(result.Value.SiteResults);
|
|
Assert.True(siteResult.Success);
|
|
Assert.Null(siteResult.SecretsDeployed);
|
|
Assert.NotNull(site.ArtifactRevisionHash);
|
|
await _siteRepo.Received(1).UpdateSiteAsync(site, Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NoSecretReferences_ShipsEmptySecretsList_SoTheSiteRemovesPreviouslyDeployedOnes()
|
|
{
|
|
// The site applies DeployArtifactsCommand.Secrets as the FULL set: rows absent from it
|
|
// are marked removed and the mirror deletes them, while a NULL Secrets means "central
|
|
// shipped no secrets" and touches nothing. Dropping a site's LAST secret reference must
|
|
// therefore ship an empty list, not null — otherwise the previously deployed secret stays
|
|
// live on both site nodes forever (Task 13 live gate, check 5).
|
|
var site = new Site("Site One", "site-1") { Id = 1 };
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site> { site });
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
|
.Returns(new List<DataConnection>
|
|
{
|
|
new("GW1", "MxGateway", 1) { PrimaryConfiguration = """{"ApiKey":"a-literal-key"}""" }
|
|
});
|
|
|
|
var recorder = new ArtifactProbeRecorder();
|
|
var probe = Sys.ActorOf(Props.Create(() => new SecretsAckProbeActor(recorder, _ => 0)));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
|
|
var result = await service.DeployToAllSitesAsync("admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
var cmd = Assert.Single(recorder.Received);
|
|
Assert.NotNull(cmd.Secrets);
|
|
Assert.Empty(cmd.Secrets!);
|
|
Assert.Null(cmd.MissingSecrets);
|
|
var siteResult = Assert.Single(result.Value.SiteResults);
|
|
Assert.True(siteResult.Success);
|
|
// Nothing shipped => nothing to confirm; SecretsDeployed stays null, and no
|
|
// DeploySiteSecrets audit row is written for a deploy that carried no secrets.
|
|
Assert.Null(siteResult.SecretsDeployed);
|
|
Assert.NotNull(site.ArtifactRevisionHash);
|
|
await _audit.DidNotReceive().LogAsync(
|
|
Arg.Any<string>(), "DeploySiteSecrets", Arg.Any<string>(), Arg.Any<string>(),
|
|
Arg.Any<string>(), Arg.Any<object?>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SecretDeploy_WritesOneAuditRowPerSite_WithoutMaterial()
|
|
{
|
|
var ciphertext = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF, 0x13, 0x37 };
|
|
var resolvedRow = SecretRow("present-key", 3) with { Ciphertext = ciphertext };
|
|
var ciphertextBase64 = Convert.ToBase64String(ciphertext);
|
|
var ciphertextHex = Convert.ToHexString(ciphertext);
|
|
|
|
var secretSite = new Site("Secret Site", "sec-site") { Id = 1 };
|
|
var plainSite = new Site("Plain Site", "plain-site") { Id = 2 };
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site> { secretSite, plainSite });
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
|
.Returns(new List<DataConnection>
|
|
{
|
|
new("GW1", "MxGateway", 1) { PrimaryConfiguration = """{"ApiKey":"secret:present-key"}""" }
|
|
});
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
|
|
.Returns(new List<DataConnection>());
|
|
_secretStore.GetAsync(Arg.Is<SecretName>(n => n.Value == "present-key"), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<StoredSecret?>(resolvedRow));
|
|
|
|
var recorder = new ArtifactProbeRecorder();
|
|
// Correctly acks whatever it received, so this test exercises only the audit-row
|
|
// requirement, not the reconciliation-failure paths covered above.
|
|
var probe = Sys.ActorOf(Props.Create(() =>
|
|
new SecretsAckProbeActor(recorder, cmd => cmd.Secrets == null ? (int?)null : cmd.Secrets.Count)));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
|
|
object? captured = null;
|
|
var callCount = 0;
|
|
_audit.When(x => x.LogAsync(
|
|
Arg.Any<string>(), "DeploySiteSecrets", Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
|
Arg.Any<object>(), Arg.Any<CancellationToken>()))
|
|
.Do(ci =>
|
|
{
|
|
callCount++;
|
|
if ((string)ci[3] == "sec-site")
|
|
{
|
|
Assert.Equal("admin", ci[0]);
|
|
Assert.Equal("SiteSecrets", ci[2]);
|
|
Assert.Equal("Secret Site", ci[4]);
|
|
captured = ci[5];
|
|
}
|
|
else
|
|
{
|
|
Assert.Fail($"Unexpected DeploySiteSecrets row for site '{ci[3]}' — only sec-site has secret refs.");
|
|
}
|
|
});
|
|
|
|
var result = await service.DeployToAllSitesAsync("admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(2, result.Value.SuccessCount);
|
|
|
|
// Exactly one DeploySiteSecrets row — for sec-site only; plain-site has no secret
|
|
// references at all and gets none.
|
|
Assert.Equal(1, callCount);
|
|
Assert.NotNull(captured);
|
|
var json = JsonSerializer.Serialize(captured);
|
|
Assert.Contains("present-key", json);
|
|
Assert.Contains("\"revision\":3", json);
|
|
Assert.Contains("\"success\":true", json);
|
|
|
|
// ... but never any secret material, in any rendering.
|
|
Assert.DoesNotContain(ciphertextBase64, json);
|
|
Assert.DoesNotContain(ciphertextHex, json);
|
|
Assert.DoesNotContain(ciphertextHex, json, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("Ciphertext", json, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("Nonce", json, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("WrappedDek", json, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("WrapNonce", json, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("WrapTag", json, StringComparison.OrdinalIgnoreCase);
|
|
Assert.DoesNotContain("\"tag\"", json, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
// ── Task 7 fix round 1: the same gate + audit row on the RetryForSiteAsync path ──
|
|
// (review gap: only the DeployToAllSitesAsync/sweep path had coverage above)
|
|
|
|
[Fact]
|
|
public async Task RetryForSiteAsync_OldSite_NullSecretsApplied_WithSecrets_FailsAndDoesNotStamp()
|
|
{
|
|
var site = new Site("Retry Site", "retry-site") { Id = 7 };
|
|
_siteRepo.GetSiteByIdAsync(7, Arg.Any<CancellationToken>()).Returns(site);
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any<CancellationToken>())
|
|
.Returns(new List<DataConnection>
|
|
{
|
|
new("GW1", "MxGateway", 7) { PrimaryConfiguration = """{"ApiKey":"secret:the-key"}""" }
|
|
});
|
|
_secretStore.GetAsync(Arg.Is<SecretName>(n => n.Value == "the-key"), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<StoredSecret?>(SecretRow("the-key", 1)));
|
|
|
|
var recorder = new ArtifactProbeRecorder();
|
|
var probe = Sys.ActorOf(Props.Create(() => new SecretsAckProbeActor(recorder, _ => null)));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
|
|
var result = await service.RetryForSiteAsync(7, "retry-site", "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Equal(
|
|
"Site build predates secret deployment — upgrade the site before deploying secrets",
|
|
result.Error);
|
|
Assert.Null(site.ArtifactRevisionHash);
|
|
Assert.Null(site.ArtifactsDeployedAt);
|
|
await _siteRepo.DidNotReceive().UpdateSiteAsync(Arg.Any<Site>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetryForSiteAsync_PartialApply_FailsAndDoesNotStamp()
|
|
{
|
|
var site = new Site("Retry Site", "retry-site") { Id = 7 };
|
|
_siteRepo.GetSiteByIdAsync(7, Arg.Any<CancellationToken>()).Returns(site);
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any<CancellationToken>())
|
|
.Returns(new List<DataConnection>
|
|
{
|
|
new("GW1", "MxGateway", 7)
|
|
{
|
|
PrimaryConfiguration = """{"ApiKey":"secret:key1","Password":"secret:key2"}"""
|
|
}
|
|
});
|
|
_secretStore.GetAsync(Arg.Is<SecretName>(n => n.Value == "key1"), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<StoredSecret?>(SecretRow("key1", 1)));
|
|
_secretStore.GetAsync(Arg.Is<SecretName>(n => n.Value == "key2"), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<StoredSecret?>(SecretRow("key2", 1)));
|
|
|
|
var recorder = new ArtifactProbeRecorder();
|
|
var probe = Sys.ActorOf(Props.Create(() => new SecretsAckProbeActor(recorder, _ => 1)));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
|
|
var result = await service.RetryForSiteAsync(7, "retry-site", "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Equal("Site applied 1 of 2 secrets", result.Error);
|
|
Assert.Null(site.ArtifactRevisionHash);
|
|
await _siteRepo.DidNotReceive().UpdateSiteAsync(Arg.Any<Site>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetryForSiteAsync_MissingSecret_FailsClosed_SendsNothing_WritesOneAuditRow()
|
|
{
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any<CancellationToken>())
|
|
.Returns(new List<DataConnection>
|
|
{
|
|
new("GW1", "MxGateway", 7) { PrimaryConfiguration = """{"ApiKey":"secret:missing-key"}""" }
|
|
});
|
|
// _secretStore is unconfigured for "missing-key" — GetAsync returns null (absent).
|
|
|
|
var recorder = new ArtifactProbeRecorder();
|
|
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
|
|
object? captured = null;
|
|
var callCount = 0;
|
|
_audit.When(x => x.LogAsync(
|
|
Arg.Any<string>(), "DeploySiteSecrets", Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
|
Arg.Any<object>(), Arg.Any<CancellationToken>()))
|
|
.Do(ci =>
|
|
{
|
|
callCount++;
|
|
Assert.Equal("admin", ci[0]);
|
|
Assert.Equal("SiteSecrets", ci[2]);
|
|
Assert.Equal("retry-site", ci[3]);
|
|
Assert.Equal("retry-site", ci[4]);
|
|
captured = ci[5];
|
|
});
|
|
|
|
var result = await service.RetryForSiteAsync(7, "retry-site", "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Equal(
|
|
"Secret 'missing-key' referenced by data connection(s) GW1 is not in the central secret store",
|
|
result.Error);
|
|
|
|
// Nothing was sent to the site at all.
|
|
Assert.Empty(recorder.Received);
|
|
|
|
// Exactly one DeploySiteSecrets row, naming the missing secret, success=false.
|
|
Assert.Equal(1, callCount);
|
|
Assert.NotNull(captured);
|
|
var json = JsonSerializer.Serialize(captured);
|
|
Assert.Contains("missing-key", json);
|
|
Assert.Contains("\"success\":false", json);
|
|
Assert.Contains("\"secrets\":[]", json);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetryForSiteAsync_HappyPath_WithSecrets_StampsAndWritesOneAuditRow()
|
|
{
|
|
var ciphertext = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF };
|
|
var resolvedRow = SecretRow("present-key", 5) with { Ciphertext = ciphertext };
|
|
var ciphertextBase64 = Convert.ToBase64String(ciphertext);
|
|
|
|
var site = new Site("Retry Site", "retry-site") { Id = 7 };
|
|
_siteRepo.GetSiteByIdAsync(7, Arg.Any<CancellationToken>()).Returns(site);
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any<CancellationToken>())
|
|
.Returns(new List<DataConnection>
|
|
{
|
|
new("GW1", "MxGateway", 7) { PrimaryConfiguration = """{"ApiKey":"secret:present-key"}""" }
|
|
});
|
|
_secretStore.GetAsync(Arg.Is<SecretName>(n => n.Value == "present-key"), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<StoredSecret?>(resolvedRow));
|
|
|
|
var recorder = new ArtifactProbeRecorder();
|
|
var probe = Sys.ActorOf(Props.Create(() =>
|
|
new SecretsAckProbeActor(recorder, cmd => cmd.Secrets == null ? (int?)null : cmd.Secrets.Count)));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
|
|
object? captured = null;
|
|
var callCount = 0;
|
|
_audit.When(x => x.LogAsync(
|
|
Arg.Any<string>(), "DeploySiteSecrets", Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
|
|
Arg.Any<object>(), Arg.Any<CancellationToken>()))
|
|
.Do(ci =>
|
|
{
|
|
callCount++;
|
|
Assert.Equal("retry-site", ci[3]);
|
|
captured = ci[5];
|
|
});
|
|
|
|
var result = await service.RetryForSiteAsync(7, "retry-site", "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(1, result.Value.SecretsDeployed);
|
|
Assert.Equal(
|
|
_hashService.ComputeHash(Assert.Single(recorder.Received)),
|
|
site.ArtifactRevisionHash);
|
|
Assert.NotNull(site.ArtifactsDeployedAt);
|
|
await _siteRepo.Received(1).UpdateSiteAsync(site, Arg.Any<CancellationToken>());
|
|
|
|
Assert.Equal(1, callCount);
|
|
Assert.NotNull(captured);
|
|
var json = JsonSerializer.Serialize(captured);
|
|
Assert.Contains("present-key", json);
|
|
Assert.Contains("\"revision\":5", json);
|
|
Assert.Contains("\"success\":true", json);
|
|
Assert.Contains("\"missing\":[]", json);
|
|
Assert.DoesNotContain(ciphertextBase64, json);
|
|
Assert.DoesNotContain("Ciphertext", json, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private ArtifactDeploymentService CreateService(ILogger<ArtifactDeploymentService>? logger = null)
|
|
{
|
|
var comms = new CommunicationService(
|
|
Options.Create(new CommunicationOptions()),
|
|
NullLogger<CommunicationService>.Instance);
|
|
|
|
return new ArtifactDeploymentService(
|
|
_siteRepo, _deploymentRepo, _templateRepo, _externalSystemRepo, _notificationRepo,
|
|
comms, _audit,
|
|
Options.Create(new DeploymentManagerOptions()),
|
|
logger ?? NullLogger<ArtifactDeploymentService>.Instance,
|
|
_hashService,
|
|
new SiteSecretsCollector(_secretStore));
|
|
}
|
|
|
|
private ArtifactDeploymentService CreateServiceWithCommActor(
|
|
IActorRef commActor, ILogger<ArtifactDeploymentService>? logger = null)
|
|
{
|
|
var comms = new CommunicationService(
|
|
Options.Create(new CommunicationOptions
|
|
{
|
|
ArtifactDeploymentTimeout = TimeSpan.FromSeconds(5)
|
|
}),
|
|
NullLogger<CommunicationService>.Instance);
|
|
comms.SetCommunicationActor(commActor);
|
|
|
|
return new ArtifactDeploymentService(
|
|
_siteRepo, _deploymentRepo, _templateRepo, _externalSystemRepo, _notificationRepo,
|
|
comms, _audit,
|
|
Options.Create(new DeploymentManagerOptions
|
|
{
|
|
ArtifactDeploymentTimeoutPerSite = TimeSpan.FromSeconds(5)
|
|
}),
|
|
logger ?? NullLogger<ArtifactDeploymentService>.Instance,
|
|
_hashService,
|
|
new SiteSecretsCollector(_secretStore));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Captures every formatted log message (and, for entries logged with an exception, the
|
|
/// exception's <see cref="Exception.ToString"/>) so a test can assert on log CONTENT —
|
|
/// specifically, that secret material never appears in it.
|
|
/// </summary>
|
|
private sealed class CapturingLogger<T> : ILogger<T>
|
|
{
|
|
public List<string> Messages { get; } = [];
|
|
|
|
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
|
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
|
|
public void Log<TState>(
|
|
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
|
Func<TState, Exception?, string> formatter)
|
|
{
|
|
Messages.Add(formatter(state, exception));
|
|
if (exception is not null)
|
|
{
|
|
Messages.Add(exception.ToString());
|
|
}
|
|
}
|
|
|
|
private sealed class NullScope : IDisposable
|
|
{
|
|
public static readonly NullScope Instance = new();
|
|
public void Dispose() { }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Per-test recorder for <see cref="ArtifactProbeActor"/>. DeploymentManager-024:
|
|
/// each test owns its own instance, passed into the actor's constructor, so
|
|
/// the received-command list is no longer shared static state that races
|
|
/// under parallel test execution.
|
|
/// </summary>
|
|
private sealed class ArtifactProbeRecorder
|
|
{
|
|
public readonly ConcurrentBag<DeployArtifactsCommand> Received = new();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stand-in CentralCommunicationActor for artifact deployment. Records every
|
|
/// <see cref="DeployArtifactsCommand"/> it receives into the per-test
|
|
/// <see cref="ArtifactProbeRecorder"/> and replies success unless the target
|
|
/// site id is in the configured failure set.
|
|
/// </summary>
|
|
private class ArtifactProbeActor : ReceiveActor
|
|
{
|
|
public ArtifactProbeActor(ArtifactProbeRecorder recorder, params string[] failingSites)
|
|
{
|
|
var failSet = new HashSet<string>(failingSites);
|
|
|
|
Receive<SiteEnvelope>(env =>
|
|
{
|
|
if (env.Message is DeployArtifactsCommand cmd)
|
|
{
|
|
recorder.Received.Add(cmd);
|
|
var success = !failSet.Contains(env.SiteId);
|
|
Sender.Tell(new ArtifactDeploymentResponse(
|
|
cmd.DeploymentId, env.SiteId, success,
|
|
success ? null : "site rejected artifacts", DateTimeOffset.UtcNow));
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Task 7: stand-in CentralCommunicationActor that always reports the deploy itself as
|
|
/// successful but lets a test control exactly what <see cref="ArtifactDeploymentResponse.SecretsApplied"/>
|
|
/// comes back for a given command — an old site (<c>null</c>), a partial apply (a count
|
|
/// short of what was shipped), or a correct ack (the shipped count) — so the secrets-
|
|
/// reconciliation gate can be exercised independently of the wire-level success/failure
|
|
/// the shared <see cref="ArtifactProbeActor"/> models.
|
|
/// </summary>
|
|
// ── Final review M5: a central secret-store outage is contained to the sites that need it ──
|
|
|
|
[Fact]
|
|
public async Task StoreOutage_FailsOnlyTheSecretReferencingSite_AndAuditsIt()
|
|
{
|
|
var secretSite = new Site("Secret Site", "sec-site") { Id = 1 };
|
|
var plainSite = new Site("Plain Site", "plain-site") { Id = 2 };
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site> { secretSite, plainSite });
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
|
.Returns(new List<DataConnection>
|
|
{
|
|
new("GW1", "MxGateway", 1) { PrimaryConfiguration = """{"ApiKey":"secret:the-key"}""" }
|
|
});
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
|
|
.Returns(new List<DataConnection>());
|
|
_secretStore.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>())
|
|
.Returns<Task<StoredSecret?>>(_ => throw new InvalidOperationException("SQL Server unreachable"));
|
|
|
|
var recorder = new ArtifactProbeRecorder();
|
|
var probe = Sys.ActorOf(Props.Create(() =>
|
|
new SecretsAckProbeActor(recorder, cmd => cmd.Secrets == null ? (int?)null : cmd.Secrets.Count)));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
|
|
object? audited = null;
|
|
_audit.When(x => x.LogAsync(
|
|
Arg.Any<string>(), "DeploySiteSecrets", Arg.Any<string>(), "sec-site", Arg.Any<string>(),
|
|
Arg.Any<object>(), Arg.Any<CancellationToken>()))
|
|
.Do(ci => audited = ci[5]);
|
|
|
|
// Before M5 the store exception escaped the build loop and the whole sweep threw.
|
|
var result = await service.DeployToAllSitesAsync("admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.Equal(1, result.Value.SuccessCount);
|
|
Assert.Equal(1, result.Value.FailureCount);
|
|
var failed = Assert.Single(result.Value.SiteResults, r => r.SiteId == "sec-site");
|
|
Assert.False(failed.Success);
|
|
Assert.Contains("Central secret store could not be read", failed.ErrorMessage);
|
|
Assert.Contains("SQL Server unreachable", failed.ErrorMessage);
|
|
Assert.Contains(result.Value.SiteResults, r => r.SiteId == "plain-site" && r.Success);
|
|
|
|
// Nothing was sent to the failing site, and it keeps no stamp.
|
|
var sent = Assert.Single(recorder.Received);
|
|
Assert.Empty(sent.DataConnections ?? []);
|
|
Assert.Null(secretSite.ArtifactRevisionHash);
|
|
Assert.NotNull(plainSite.ArtifactRevisionHash);
|
|
|
|
Assert.NotNull(audited);
|
|
var json = JsonSerializer.Serialize(audited);
|
|
Assert.Contains("\"success\":false", json);
|
|
Assert.Contains("Central secret store could not be read", json);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ComputeCurrentHashes_StoreOutage_OmitsOnlyTheSecretReferencingSite()
|
|
{
|
|
var secretSite = new Site("Secret Site", "sec-site") { Id = 1 };
|
|
var plainSite = new Site("Plain Site", "plain-site") { Id = 2 };
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
|
.Returns(new List<DataConnection>
|
|
{
|
|
new("GW1", "MxGateway", 1) { PrimaryConfiguration = """{"ApiKey":"secret:the-key"}""" }
|
|
});
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
|
|
.Returns(new List<DataConnection>());
|
|
_secretStore.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>())
|
|
.Returns<Task<StoredSecret?>>(_ => throw new InvalidOperationException("SQL Server unreachable"));
|
|
var service = CreateService();
|
|
|
|
var hashes = await service.ComputeCurrentArtifactHashesAsync([secretSite, plainSite]);
|
|
|
|
// The secret-referencing site is left out (the evaluator reads that as Out of date);
|
|
// the plain site still gets its hash instead of the whole fleet check failing.
|
|
Assert.False(hashes.ContainsKey(1));
|
|
Assert.True(hashes.ContainsKey(2));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetryForSite_StoreOutage_FailsWithoutSending()
|
|
{
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
|
.Returns(new List<DataConnection>
|
|
{
|
|
new("GW1", "MxGateway", 1) { PrimaryConfiguration = """{"ApiKey":"secret:the-key"}""" }
|
|
});
|
|
_secretStore.GetAsync(Arg.Any<SecretName>(), Arg.Any<CancellationToken>())
|
|
.Returns<Task<StoredSecret?>>(_ => throw new InvalidOperationException("SQL Server unreachable"));
|
|
|
|
var recorder = new ArtifactProbeRecorder();
|
|
var probe = Sys.ActorOf(Props.Create(() => new SecretsAckProbeActor(recorder, _ => 1)));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
|
|
var result = await service.RetryForSiteAsync(1, "sec-site", "admin");
|
|
|
|
Assert.True(result.IsFailure);
|
|
Assert.Contains("Central secret store could not be read", result.Error);
|
|
Assert.Empty(recorder.Received);
|
|
await _audit.Received(1).LogAsync(
|
|
"admin", "DeploySiteSecrets", "SiteSecrets", "sec-site", Arg.Any<string>(),
|
|
Arg.Any<object>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
// ── Removal-only deploys are audited (the site reports the names it retired) ──
|
|
|
|
[Fact]
|
|
public async Task RemovalOnlyDeploy_WritesAuditRowNamingTheRemovedSecrets()
|
|
{
|
|
var site = new Site("Site One", "site-1") { Id = 1 };
|
|
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site> { site });
|
|
// The site's last secret reference is gone: nothing ships, the site retires "old-key".
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
|
.Returns(new List<DataConnection>());
|
|
|
|
var recorder = new ArtifactProbeRecorder();
|
|
var probe = Sys.ActorOf(Props.Create(() => new RemovingProbeActor(recorder, new[] { "old-key" })));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
|
|
object? audited = null;
|
|
_audit.When(x => x.LogAsync(
|
|
Arg.Any<string>(), "DeploySiteSecrets", Arg.Any<string>(), "site-1", Arg.Any<string>(),
|
|
Arg.Any<object>(), Arg.Any<CancellationToken>()))
|
|
.Do(ci => audited = ci[5]);
|
|
|
|
var result = await service.DeployToAllSitesAsync("admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
var siteResult = Assert.Single(result.Value.SiteResults);
|
|
Assert.True(siteResult.Success);
|
|
Assert.Equal(["old-key"], siteResult.SecretsRemoved!);
|
|
|
|
Assert.NotNull(audited);
|
|
var json = JsonSerializer.Serialize(audited);
|
|
Assert.Contains("\"removed\":[\"old-key\"]", json);
|
|
Assert.Contains("\"secrets\":[]", json);
|
|
Assert.Contains("\"success\":true", json);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RetryForSite_RemovalOnly_WritesAuditRowNamingTheRemovedSecrets()
|
|
{
|
|
_siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
|
.Returns(new List<DataConnection>());
|
|
|
|
var recorder = new ArtifactProbeRecorder();
|
|
var probe = Sys.ActorOf(Props.Create(() => new RemovingProbeActor(recorder, new[] { "old-key" })));
|
|
var service = CreateServiceWithCommActor(probe);
|
|
|
|
object? audited = null;
|
|
_audit.When(x => x.LogAsync(
|
|
Arg.Any<string>(), "DeploySiteSecrets", Arg.Any<string>(), "site-1", Arg.Any<string>(),
|
|
Arg.Any<object>(), Arg.Any<CancellationToken>()))
|
|
.Do(ci => audited = ci[5]);
|
|
|
|
var result = await service.RetryForSiteAsync(1, "site-1", "admin");
|
|
|
|
Assert.True(result.IsSuccess);
|
|
Assert.NotNull(audited);
|
|
Assert.Contains("\"removed\":[\"old-key\"]", JsonSerializer.Serialize(audited));
|
|
}
|
|
|
|
/// <summary>Acks every command as applied (0 shipped secrets) and reports the given names as retired.</summary>
|
|
private class RemovingProbeActor : ReceiveActor
|
|
{
|
|
public RemovingProbeActor(ArtifactProbeRecorder recorder, IReadOnlyList<string> removed)
|
|
{
|
|
Receive<SiteEnvelope>(env =>
|
|
{
|
|
if (env.Message is DeployArtifactsCommand cmd)
|
|
{
|
|
recorder.Received.Add(cmd);
|
|
Sender.Tell(new ArtifactDeploymentResponse(
|
|
cmd.DeploymentId, env.SiteId, true, null, DateTimeOffset.UtcNow,
|
|
cmd.Secrets?.Count, removed));
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
private class SecretsAckProbeActor : ReceiveActor
|
|
{
|
|
public SecretsAckProbeActor(ArtifactProbeRecorder recorder, Func<DeployArtifactsCommand, int?> secretsApplied)
|
|
{
|
|
Receive<SiteEnvelope>(env =>
|
|
{
|
|
if (env.Message is DeployArtifactsCommand cmd)
|
|
{
|
|
recorder.Received.Add(cmd);
|
|
Sender.Tell(new ArtifactDeploymentResponse(
|
|
cmd.DeploymentId, env.SiteId, true, null, DateTimeOffset.UtcNow,
|
|
secretsApplied(cmd)));
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|