test(artifacts): cover RetryForSiteAsync secrets reconciliation and DeploySiteSecrets audit row (fix round 1)

This commit is contained in:
Joseph Doherty
2026-09-28 18:28:27 -04:00
parent 00c89aff12
commit 437b02a43c
@@ -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<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(