feat(artifacts): never stamp a site current unless it applied every secret; DeploySiteSecrets audit row

This commit is contained in:
Joseph Doherty
2026-09-28 18:20:54 -04:00
parent 75b2a7244c
commit 00c89aff12
2 changed files with 334 additions and 9 deletions
@@ -600,7 +600,11 @@ public class ArtifactDeploymentServiceTests : TestKit
.Returns(Task.FromResult<StoredSecret?>(resolvedRow));
var recorder = new ArtifactProbeRecorder();
var probe = Sys.ActorOf(Props.Create(() => new ArtifactProbeActor(recorder)));
// 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);
@@ -621,6 +625,182 @@ public class ArtifactDeploymentServiceTests : TestKit
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 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);
}
private ArtifactDeploymentService CreateService(ILogger<ArtifactDeploymentService>? logger = null)
{
var comms = new CommunicationService(
@@ -726,4 +906,29 @@ public class ArtifactDeploymentServiceTests : TestKit
});
}
}
/// <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>
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)));
}
});
}
}
}