test(artifacts): cover RetryForSiteAsync secrets reconciliation and DeploySiteSecrets audit row (fix round 1)
This commit is contained in:
+164
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user