29049c4ef6
Secret-store outage (final review M5): SiteSecretsCollector returns a store read failure as SiteSecretsCollection.StoreError instead of throwing. A deploy now fails only the sites that reference a secret (nothing sent, not stamped, DeploySiteSecrets row with the error), and ComputeCurrentArtifactHashesAsync omits only those sites (they read Out of date) instead of the exception aborting the whole sweep and the fleet-wide pending-updates check. Removal-only deploys: the site reply gains SecretsRemoved (proto field 7, names only) - the deployed_secrets rows the deploy soft-removed. Central carries it on SiteArtifactResult and writes the DeploySiteSecrets row with a removed list whenever it is non-empty, so dropping a site's last secret: reference is audited centrally rather than only in site logs.
105 lines
4.7 KiB
C#
105 lines
4.7 KiB
C#
using Google.Protobuf.WellKnownTypes;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
|
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
|
|
|
/// <summary>
|
|
/// Round-trip tests for the additive <c>Secrets</c> / <c>SecretsApplied</c> wire contract
|
|
/// on <see cref="DeployArtifactsCommand"/> / <see cref="ArtifactDeploymentResponse"/>
|
|
/// (proto field 9 / field 6). Site secrets design §3.3, task-2 of
|
|
/// docs/plans/2026-09-28-site-secrets-artifact-deploy-design.md.
|
|
/// </summary>
|
|
public class SiteCommandDtoMapperSecretsTests
|
|
{
|
|
private static SecretArtifact Sample(string name) => new(name, "text/plain",
|
|
[1, 2, 3], [4], [5], [6, 7], [8], [9], "kek-A", 7,
|
|
DateTimeOffset.Parse("2026-09-01T00:00:00Z"), DateTimeOffset.Parse("2026-09-02T00:00:00Z"), "d");
|
|
|
|
[Fact]
|
|
public void DeployArtifacts_Secrets_RoundTrip_Verbatim()
|
|
{
|
|
var cmd = new DeployArtifactsCommand("dep", null, null, null, null, null, null,
|
|
DateTimeOffset.UtcNow, [Sample("A"), Sample("B")]);
|
|
var back = (DeployArtifactsCommand)SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(cmd));
|
|
Assert.Equal(2, back.Secrets!.Count);
|
|
Assert.Equal(cmd.Secrets![0].Ciphertext, back.Secrets[0].Ciphertext);
|
|
Assert.Equal(cmd.Secrets[0].Nonce, back.Secrets[0].Nonce);
|
|
Assert.Equal(cmd.Secrets[0].Tag, back.Secrets[0].Tag);
|
|
Assert.Equal(cmd.Secrets[0].WrappedDek, back.Secrets[0].WrappedDek);
|
|
Assert.Equal(cmd.Secrets[0].WrapNonce, back.Secrets[0].WrapNonce);
|
|
Assert.Equal(cmd.Secrets[0].WrapTag, back.Secrets[0].WrapTag);
|
|
Assert.Equal(cmd.Secrets[0].ContentType, back.Secrets[0].ContentType);
|
|
Assert.Equal(cmd.Secrets[0].CreatedUtc, back.Secrets[0].CreatedUtc);
|
|
Assert.Equal(cmd.Secrets[0].UpdatedUtc, back.Secrets[0].UpdatedUtc);
|
|
Assert.Equal(cmd.Secrets[0].Description, back.Secrets[0].Description);
|
|
Assert.Equal("kek-A", back.Secrets[1].KekId);
|
|
Assert.Equal(7, back.Secrets[1].Revision);
|
|
}
|
|
|
|
[Fact]
|
|
public void DeployArtifacts_NullSecrets_StaysNull()
|
|
{
|
|
var cmd = new DeployArtifactsCommand("dep", null, null, null, null, null, null, DateTimeOffset.UtcNow);
|
|
var dto = SiteCommandDtoMapper.ToProto(cmd);
|
|
Assert.Null(dto.Secrets);
|
|
Assert.Null(((DeployArtifactsCommand)SiteCommandDtoMapper.FromProto(dto)).Secrets);
|
|
}
|
|
|
|
[Fact]
|
|
public void DeployArtifacts_EmptySecrets_StaysEmpty_NotNull()
|
|
{
|
|
var cmd = new DeployArtifactsCommand("dep", null, null, null, null, null, null,
|
|
DateTimeOffset.UtcNow, []);
|
|
var back = (DeployArtifactsCommand)SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(cmd));
|
|
Assert.NotNull(back.Secrets);
|
|
Assert.Empty(back.Secrets!);
|
|
}
|
|
|
|
[Fact]
|
|
public void DeployArtifacts_CommandDtoWithoutField9_MapsToNullSecrets()
|
|
{
|
|
// Simulates an OLD CENTRAL that never set field 9 — the DTO here carries no
|
|
// Secrets wrapper at all, exactly as a pre-upgrade sender's wire bytes would
|
|
// decode. FromProto must treat that absence as null, not an empty list.
|
|
var dto = new DeployArtifactsCommandDto
|
|
{
|
|
DeploymentId = "dep",
|
|
Timestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow)
|
|
};
|
|
|
|
Assert.Null(dto.Secrets);
|
|
|
|
var back = SiteCommandDtoMapper.FromProto(dto);
|
|
Assert.Null(back.Secrets);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData(3)]
|
|
public void ArtifactResponse_SecretsApplied_RoundTrips(int? applied)
|
|
{
|
|
var r = new ArtifactDeploymentResponse("dep", "site-a", true, null, DateTimeOffset.UtcNow, applied);
|
|
Assert.Equal(applied, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(r)).SecretsApplied);
|
|
}
|
|
|
|
[Fact]
|
|
public void ArtifactResponse_SecretsRemoved_RoundTrips()
|
|
{
|
|
var r = new ArtifactDeploymentResponse(
|
|
"dep", "site-a", true, null, DateTimeOffset.UtcNow, 0, ["old-1", "old-2"]);
|
|
Assert.Equal(["old-1", "old-2"], SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(r)).SecretsRemoved!);
|
|
}
|
|
|
|
[Fact]
|
|
public void ArtifactResponse_NoSecretsRemoved_IsNullOnBothSides()
|
|
{
|
|
// Absent on the wire (an old site, or nothing removed) must read back as null.
|
|
var none = new ArtifactDeploymentResponse("dep", "site-a", true, null, DateTimeOffset.UtcNow, 1);
|
|
Assert.Null(SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(none)).SecretsRemoved);
|
|
|
|
var empty = none with { SecretsRemoved = [] };
|
|
Assert.Null(SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(empty)).SecretsRemoved);
|
|
}
|
|
}
|