From 437b02a43c94a23aebf38b03f4a217c6aa04228e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 28 Sep 2026 18:28:27 -0400 Subject: [PATCH] test(artifacts): cover RetryForSiteAsync secrets reconciliation and DeploySiteSecrets audit row (fix round 1) --- .../ArtifactDeploymentServiceTests.cs | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactDeploymentServiceTests.cs b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactDeploymentServiceTests.cs index 859a0492..adcc3009 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactDeploymentServiceTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.DeploymentManager.Tests/ArtifactDeploymentServiceTests.cs @@ -801,6 +801,170 @@ public class ArtifactDeploymentServiceTests : TestKit 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()).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()); + } + + [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()); + } + + [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); + } + + [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(