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; /// /// Tests for system-wide artifact deployment. /// 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; /// Initializes the test class with substitute repositories and an audit service. public ArtifactDeploymentServiceTests() { _siteRepo = Substitute.For(); _deploymentRepo = Substitute.For(); _templateRepo = Substitute.For(); _externalSystemRepo = Substitute.For(); // 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(); _audit = Substitute.For(); // 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(); } 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"), }; /// Verifies that deploying to all sites when none are configured returns a failure. /// A task that represents the asynchronous operation. [Fact] public async Task DeployToAllSitesAsync_NoSites_ReturnsFailure() { _siteRepo.GetAllSitesAsync().Returns(new List()); var service = CreateService(); var result = await service.DeployToAllSitesAsync("admin"); Assert.True(result.IsFailure); Assert.Contains("No sites", result.Error); } /// Verifies that a per-site artifact result carries the site's identifying information. [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); } /// Verifies that the artifact deployment summary counts successes and failures correctly. [Fact] public void ArtifactDeploymentSummary_CountsCorrectly() { var results = new List { 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); } // ── One logical deployment id across all per-site commands ── /// Verifies that every per-site command carries the same deployment id as the summary. /// A task that represents the asynchronous operation. [Fact] public async Task DeployToAllSitesAsync_AllPerSiteCommandsShareTheSummaryDeploymentId() { // 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 { new("Site One", "site-1") { Id = 1 }, new("Site Two", "site-2") { Id = 2 } }; _siteRepo.GetAllSitesAsync(Arg.Any()).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(r => { using var doc = JsonDocument.Parse(r.PerSiteStatus!); Assert.Equal(result.Value.DeploymentId, doc.RootElement.GetProperty("DeploymentId").GetString()); }), Arg.Any()); } // ── Real per-site success/failure coverage ── /// Verifies that a partial failure is reported as a per-site success and failure matrix. /// A task that represents the asynchronous operation. [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 { new("Site One", "ok-site") { Id = 1 }, new("Site Two", "fail-site") { Id = 2 } }; _siteRepo.GetAllSitesAsync(Arg.Any()).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); } // ── Global artifact queries hoisted out of the per-site loop ── /// Verifies that the global artifact queries run once in total rather than once per site. /// A task that represents the asynchronous operation. [Fact] public async Task DeployToAllSitesAsync_HoistsGlobalArtifactQueriesOutOfPerSiteLoop() { // 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 { 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()).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()); await _externalSystemRepo.Received(1).GetAllExternalSystemsAsync(Arg.Any()); await _externalSystemRepo.Received(1).GetAllDatabaseConnectionsAsync(Arg.Any()); // 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()); await _notificationRepo.DidNotReceive().GetAllSmtpConfigurationsAsync(Arg.Any()); 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()); await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(2, Arg.Any()); await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(3, Arg.Any()); } /// /// Deferred-work row 27(a): the per-system retry policy must be projected onto the /// artifact that ships to sites. Before the fix ExternalSystemArtifact 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 /// DatabaseConnectionArtifact had always carried its equivalents. /// /// A task that represents the asynchronous operation. [Fact] public async Task DeployedExternalSystemArtifact_CarriesRetryPolicy() { _externalSystemRepo.GetAllExternalSystemsAsync(Arg.Any()).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); } /// Verifies that retrying a single site still runs the global artifact queries exactly once. /// A task that represents the asynchronous operation. [Fact] public async Task RetryForSiteAsync_SingleSitePath_StillRunsTheGlobalQueriesOnce() { // 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()); await _externalSystemRepo.Received(1).GetAllExternalSystemsAsync(Arg.Any()); await _externalSystemRepo.Received(1).GetAllDatabaseConnectionsAsync(Arg.Any()); // Central-only — never fetched, never shipped. await _notificationRepo.DidNotReceive().GetAllNotificationListsAsync(Arg.Any()); await _notificationRepo.DidNotReceive().GetAllSmtpConfigurationsAsync(Arg.Any()); Assert.All(recorder.Received, cmd => { Assert.Null(cmd.NotificationLists); Assert.Null(cmd.SmtpConfigurations); }); } /// Verifies that a successful retry for a site returns success and writes an audit entry. /// A task that represents the asynchronous operation. [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(), "retry-site", Arg.Any(), Arg.Any()); } // ── Single-site artifact deployment ── /// Verifies that a single-site deploy sends artifacts only to the target site. /// A task that represents the asynchronous operation. [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()).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()); await _siteRepo.DidNotReceive().GetDataConnectionsBySiteIdAsync(2, Arg.Any()); } /// Verifies that a single-site deploy to an unknown site returns a failure. /// A task that represents the asynchronous operation. [Fact] public async Task DeployToSiteAsync_UnknownSite_ReturnsFailure() { _siteRepo.GetSiteByIdAsync(999, Arg.Any()).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) ── /// Verifies that a successful deploy stamps the site's artifact hash and timestamp and saves. /// A task that represents the asynchronous operation. [Fact] public async Task DeployToAllSitesAsync_SuccessfulSite_StampsHashAndTimestampAndSaves() { var site = new Site("Site One", "site-1") { Id = 1 }; _siteRepo.GetAllSitesAsync(Arg.Any()).Returns(new List { 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()); await _siteRepo.Received(1).SaveChangesAsync(Arg.Any()); } /// Verifies that a failed site keeps its previous artifact stamp. /// A task that represents the asynchronous operation. [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()).Returns(new List { 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()); await _siteRepo.Received(1).UpdateSiteAsync(ok, Arg.Any()); } /// Verifies that the deployment record is still persisted and success reported when saving the stamp throws. /// A task that represents the asynchronous operation. [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()).Returns(new List { site }); _siteRepo.SaveChangesAsync(Arg.Any()) .Returns(_ => 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(), Arg.Any()); await _deploymentRepo.Received(1).SaveChangesAsync(Arg.Any()); Received.InOrder(() => { _deploymentRepo.SaveChangesAsync(Arg.Any()); _siteRepo.UpdateSiteAsync(site, Arg.Any()); }); } /// Verifies that a successful retry stamps the site. /// A task that represents the asynchronous operation. [Fact] public async Task RetryForSiteAsync_Success_StampsTheSite() { var site = new Site("Retry Site", "retry-site") { Id = 7 }; _siteRepo.GetSiteByIdAsync(7, Arg.Any()).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()); await _siteRepo.Received(1).SaveChangesAsync(Arg.Any()); } /// Verifies that a failed retry does not stamp the site. /// A task that represents the asynchronous operation. [Fact] public async Task RetryForSiteAsync_Failure_DoesNotStampTheSite() { var site = new Site("Retry Site", "fail-site") { Id = 7 }; _siteRepo.GetSiteByIdAsync(7, Arg.Any()).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(), Arg.Any()); } /// Verifies that the computed current artifact hashes match the hash a deploy stamps. /// A task that represents the asynchronous operation. [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 { new("Site One", "site-1") { Id = 1 }, new("Site Two", "site-2") { Id = 2 } }; _siteRepo.GetAllSitesAsync(Arg.Any()).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]); } } /// Verifies that the computed current artifact hashes match the stamped hash when data connections reference secrets. /// A task that represents the asynchronous operation. [Fact] public async Task ComputeCurrentArtifactHashesAsync_MatchesTheHashADeployStamps_WithSecrets() { // 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 }; _siteRepo.GetAllSitesAsync(Arg.Any()).Returns(sites); _siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any()) .Returns(new List { new("GW1", "MxGateway", 1) { PrimaryConfiguration = """{"ApiKey":"secret:key1","Password":"secret:key2"}""" } }); _secretStore.GetAsync(Arg.Is(n => n.Value == "key1"), Arg.Any()) .Returns(Task.FromResult(SecretRow("key1", 3))); _secretStore.GetAsync(Arg.Is(n => n.Value == "key2"), Arg.Any()) .Returns(Task.FromResult(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]); } /// Verifies that computing the current artifact hashes runs the global queries once. /// A task that represents the asynchronous operation. [Fact] public async Task ComputeCurrentArtifactHashesAsync_HoistsTheGlobalQueriesOnce() { var sites = new List { 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()); await _externalSystemRepo.Received(1).GetAllExternalSystemsAsync(Arg.Any()); await _externalSystemRepo.Received(1).GetAllDatabaseConnectionsAsync(Arg.Any()); await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(1, Arg.Any()); await _siteRepo.Received(1).GetDataConnectionsBySiteIdAsync(3, Arg.Any()); } /// Verifies that computing the current hashes with no sites returns an empty result without querying. /// A task that represents the asynchronous operation. [Fact] public async Task ComputeCurrentArtifactHashesAsync_NoSites_ReturnsEmptyWithoutQuerying() { var service = CreateService(); var current = await service.ComputeCurrentArtifactHashesAsync([]); Assert.Empty(current); await _templateRepo.DidNotReceive().GetAllSharedScriptsAsync(Arg.Any()); } // ── Secrets ride the artifact deploy (design §3.1/§3.3) ── /// Verifies that a site with a missing secret fails alone and nothing is sent to it. /// A task that represents the asynchronous operation. [Fact] public async Task DeployToAllSites_SiteWithMissingSecret_FailsThatSiteOnly_AndSendsNothingToIt() { var sites = new List { new("Site One", "ok-site") { Id = 1 }, new("Site Two", "bad-site") { Id = 2 } }; _siteRepo.GetAllSitesAsync(Arg.Any()).Returns(sites); _siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any()) .Returns(new List()); _siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any()) .Returns(new List { 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); } /// Verifies that a missing secret is represented by the missing marker in the computed hash. /// A task that represents the asynchronous operation. [Fact] public async Task ComputeCurrentHashes_MissingSecret_UsesMissingMarker() { var site = new Site("Site One", "site-1") { Id = 1 }; _siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any()) .Returns(new List { 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(n => n.Value == "the-key"), Arg.Any()) .Returns(Task.FromResult(SecretRow("the-key", 1))); var afterCreated = await service.ComputeCurrentArtifactHashesAsync([site]); Assert.NotEqual(beforeCreated[1], afterCreated[1]); } /// Verifies that deploy logs never contain secret material. /// A task that represents the asynchronous operation. [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 { new("Site One", "ok-site") { Id = 1 }, new("Site Two", "bad-site") { Id = 2 } }; _siteRepo.GetAllSitesAsync(Arg.Any()).Returns(sites); _siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any()) .Returns(new List { new("GW1", "MxGateway", 1) { PrimaryConfiguration = """{"ApiKey":"secret:present-key"}""" } }); _siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any()) .Returns(new List { new("GW2", "MxGateway", 2) { PrimaryConfiguration = """{"ApiKey":"secret:missing-key"}""" } }); _secretStore.GetAsync(Arg.Is(n => n.Value == "present-key"), Arg.Any()) .Returns(Task.FromResult(resolvedRow)); var recorder = new ArtifactProbeRecorder(); // 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(); 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)); } // ── Stamp gating on secret application + DeploySiteSecrets audit row ── /// Verifies that an old site that reports no applied secrets fails and is not stamped when secrets were shipped. /// A task that represents the asynchronous operation. [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()).Returns(new List { site }); _siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any()) .Returns(new List { new("GW1", "MxGateway", 1) { PrimaryConfiguration = """{"ApiKey":"secret:the-key"}""" } }); _secretStore.GetAsync(Arg.Is(n => n.Value == "the-key"), Arg.Any()) .Returns(Task.FromResult(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()); } /// Verifies that a partial secrets apply fails the deploy and does not stamp the site. /// A task that represents the asynchronous operation. [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()).Returns(new List { site }); _siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any()) .Returns(new List { new("GW1", "MxGateway", 1) { PrimaryConfiguration = """{"ApiKey":"secret:key1","Password":"secret:key2"}""" } }); _secretStore.GetAsync(Arg.Is(n => n.Value == "key1"), Arg.Any()) .Returns(Task.FromResult(SecretRow("key1", 1))); _secretStore.GetAsync(Arg.Is(n => n.Value == "key2"), Arg.Any()) .Returns(Task.FromResult(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()); } /// Verifies that an old site is still stamped when no secrets were shipped. /// A task that represents the asynchronous operation. [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()).Returns(new List { site }); _siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any()) .Returns(new List()); 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()); } /// Verifies that a site with no secret references receives an empty secrets list so it removes previously deployed ones. /// A task that represents the asynchronous operation. [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. var site = new Site("Site One", "site-1") { Id = 1 }; _siteRepo.GetAllSitesAsync(Arg.Any()).Returns(new List { site }); _siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any()) .Returns(new List { 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(), "DeploySiteSecrets", Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } /// Verifies that a secret deploy writes one audit row per site without any secret material. /// A task that represents the asynchronous operation. [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()).Returns(new List { secretSite, plainSite }); _siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any()) .Returns(new List { new("GW1", "MxGateway", 1) { PrimaryConfiguration = """{"ApiKey":"secret:present-key"}""" } }); _siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any()) .Returns(new List()); _secretStore.GetAsync(Arg.Is(n => n.Value == "present-key"), Arg.Any()) .Returns(Task.FromResult(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(), "DeploySiteSecrets", Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())) .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); } // ── The same gate + audit row on the RetryForSiteAsync path ── // (review gap: only the DeployToAllSitesAsync/sweep path had coverage above) /// Verifies that a retry for an old site that reports no applied secrets fails and is not stamped when secrets were shipped. /// A task that represents the asynchronous operation. [Fact] public async Task RetryForSiteAsync_OldSite_NullSecretsApplied_WithSecrets_FailsAndDoesNotStamp() { var site = new Site("Retry Site", "retry-site") { Id = 7 }; _siteRepo.GetSiteByIdAsync(7, Arg.Any()).Returns(site); _siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any()) .Returns(new List { new("GW1", "MxGateway", 7) { PrimaryConfiguration = """{"ApiKey":"secret:the-key"}""" } }); _secretStore.GetAsync(Arg.Is(n => n.Value == "the-key"), Arg.Any()) .Returns(Task.FromResult(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(), Arg.Any()); } /// Verifies that a partial secrets apply on retry fails and does not stamp the site. /// A task that represents the asynchronous operation. [Fact] public async Task RetryForSiteAsync_PartialApply_FailsAndDoesNotStamp() { var site = new Site("Retry Site", "retry-site") { Id = 7 }; _siteRepo.GetSiteByIdAsync(7, Arg.Any()).Returns(site); _siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any()) .Returns(new List { new("GW1", "MxGateway", 7) { PrimaryConfiguration = """{"ApiKey":"secret:key1","Password":"secret:key2"}""" } }); _secretStore.GetAsync(Arg.Is(n => n.Value == "key1"), Arg.Any()) .Returns(Task.FromResult(SecretRow("key1", 1))); _secretStore.GetAsync(Arg.Is(n => n.Value == "key2"), Arg.Any()) .Returns(Task.FromResult(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(), Arg.Any()); } /// Verifies that a retry with a missing secret fails closed, sends nothing and writes one audit row. /// A task that represents the asynchronous operation. [Fact] public async Task RetryForSiteAsync_MissingSecret_FailsClosed_SendsNothing_WritesOneAuditRow() { _siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any()) .Returns(new List { 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(), "DeploySiteSecrets", Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())) .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); } /// Verifies that a successful retry with secrets stamps the site and writes one audit row. /// A task that represents the asynchronous operation. [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()).Returns(site); _siteRepo.GetDataConnectionsBySiteIdAsync(7, Arg.Any()) .Returns(new List { new("GW1", "MxGateway", 7) { PrimaryConfiguration = """{"ApiKey":"secret:present-key"}""" } }); _secretStore.GetAsync(Arg.Is(n => n.Value == "present-key"), Arg.Any()) .Returns(Task.FromResult(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(), "DeploySiteSecrets", Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())) .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()); 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? logger = null) { var comms = new CommunicationService( Options.Create(new CommunicationOptions()), NullLogger.Instance); return new ArtifactDeploymentService( _siteRepo, _deploymentRepo, _templateRepo, _externalSystemRepo, _notificationRepo, comms, _audit, Options.Create(new DeploymentManagerOptions()), logger ?? NullLogger.Instance, _hashService, new SiteSecretsCollector(_secretStore)); } private ArtifactDeploymentService CreateServiceWithCommActor( IActorRef commActor, ILogger? logger = null) { var comms = new CommunicationService( Options.Create(new CommunicationOptions { ArtifactDeploymentTimeout = TimeSpan.FromSeconds(5) }), NullLogger.Instance); comms.SetCommunicationActor(commActor); return new ArtifactDeploymentService( _siteRepo, _deploymentRepo, _templateRepo, _externalSystemRepo, _notificationRepo, comms, _audit, Options.Create(new DeploymentManagerOptions { ArtifactDeploymentTimeoutPerSite = TimeSpan.FromSeconds(5) }), logger ?? NullLogger.Instance, _hashService, new SiteSecretsCollector(_secretStore)); } /// /// Captures every formatted log message (and, for entries logged with an exception, the /// exception's ) so a test can assert on log CONTENT — /// specifically, that secret material never appears in it. /// private sealed class CapturingLogger : ILogger { /// Gets the formatted log messages captured so far. public List Messages { get; } = []; /// Returns no scope because the fake does not track scopes. /// The type of the state object. /// The scope or log state. /// A no-op scope. public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance; /// Reports whether logging is enabled for the given level. /// The log level. /// True if the level is enabled; otherwise false. public bool IsEnabled(LogLevel logLevel) => true; /// Records the log entry for later assertions. /// The type of the state object. /// The log level. /// The event id. /// The scope or log state. /// The exception associated with the entry, if any. /// Formats the state and exception into the message text. public void Log( LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func 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(); /// Does nothing; there is nothing to release. public void Dispose() { } } } /// /// Per-test recorder for . /// 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. /// private sealed class ArtifactProbeRecorder { public readonly ConcurrentBag Received = new(); } /// /// Stand-in CentralCommunicationActor for artifact deployment. Records every /// it receives into the per-test /// and replies success unless the target /// site id is in the configured failure set. /// private class ArtifactProbeActor : ReceiveActor { /// Initializes the actor with the per-test recorder and the site ids that should fail. /// The recorder that receives every deploy command. /// The site ids for which the actor replies with a failure. public ArtifactProbeActor(ArtifactProbeRecorder recorder, params string[] failingSites) { var failSet = new HashSet(failingSites); Receive(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)); } }); } } // ── A central secret-store outage is contained to the sites that need it ── /// Verifies that a central secret-store outage fails only the secret-referencing site and audits it. /// A task that represents the asynchronous operation. [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()).Returns(new List { secretSite, plainSite }); _siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any()) .Returns(new List { new("GW1", "MxGateway", 1) { PrimaryConfiguration = """{"ApiKey":"secret:the-key"}""" } }); _siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any()) .Returns(new List()); _secretStore.GetAsync(Arg.Any(), Arg.Any()) .Returns>(_ => 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(), "DeploySiteSecrets", Arg.Any(), "sec-site", Arg.Any(), Arg.Any(), Arg.Any())) .Do(ci => audited = ci[5]); // Previously 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); } /// Verifies that a secret-store outage omits only the secret-referencing site from the computed hashes. /// A task that represents the asynchronous operation. [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()) .Returns(new List { new("GW1", "MxGateway", 1) { PrimaryConfiguration = """{"ApiKey":"secret:the-key"}""" } }); _siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any()) .Returns(new List()); _secretStore.GetAsync(Arg.Any(), Arg.Any()) .Returns>(_ => 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)); } /// Verifies that a secret-store outage on retry fails the site without sending anything. /// A task that represents the asynchronous operation. [Fact] public async Task RetryForSite_StoreOutage_FailsWithoutSending() { _siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any()) .Returns(new List { new("GW1", "MxGateway", 1) { PrimaryConfiguration = """{"ApiKey":"secret:the-key"}""" } }); _secretStore.GetAsync(Arg.Any(), Arg.Any()) .Returns>(_ => 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(), Arg.Any(), Arg.Any()); } // ── Removal-only deploys are audited (the site reports the names it retired) ── /// Verifies that a removal-only deploy writes an audit row naming the removed secrets. /// A task that represents the asynchronous operation. [Fact] public async Task RemovalOnlyDeploy_WritesAuditRowNamingTheRemovedSecrets() { var site = new Site("Site One", "site-1") { Id = 1 }; _siteRepo.GetAllSitesAsync(Arg.Any()).Returns(new List { site }); // The site's last secret reference is gone: nothing ships, the site retires "old-key". _siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any()) .Returns(new List()); 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(), "DeploySiteSecrets", Arg.Any(), "site-1", Arg.Any(), Arg.Any(), Arg.Any())) .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); } /// Verifies that a removal-only retry writes an audit row naming the removed secrets. /// A task that represents the asynchronous operation. [Fact] public async Task RetryForSite_RemovalOnly_WritesAuditRowNamingTheRemovedSecrets() { _siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any()) .Returns(new List()); 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(), "DeploySiteSecrets", Arg.Any(), "site-1", Arg.Any(), Arg.Any(), Arg.Any())) .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)); } /// Acks every command as applied (0 shipped secrets) and reports the given names as retired. private class RemovingProbeActor : ReceiveActor { /// Initializes the actor with the recorder and the secret names to report as retired. /// The recorder that receives every deploy command. /// The secret names reported as removed by the site. public RemovingProbeActor(ArtifactProbeRecorder recorder, IReadOnlyList removed) { Receive(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)); } }); } } /// Stand-in CentralCommunicationActor that always reports the deploy itself as successful but lets a test control exactly what comes back for a given command: an old site (null), a partial apply, or a correct ack, so the secrets-reconciliation gate can be exercised independently of the wire-level success or failure that models. private class SecretsAckProbeActor : ReceiveActor { /// Initializes the actor with the recorder and the function that decides the applied-secrets count. /// The recorder that receives every deploy command. /// Computes the applied-secrets count to acknowledge for a command. public SecretsAckProbeActor(ArtifactProbeRecorder recorder, Func secretsApplied) { Receive(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))); } }); } } }