using Akka.Actor;
using Akka.Serialization;
using Akka.TestKit.Xunit2;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Protocol;
namespace ZB.MOM.WW.Secrets.Replicator.AkkaDotNet.Tests;
///
/// Wire-contract tests. Secret ciphertext crosses the network as these bytes, so the round-trip is
/// asserted field-by-field rather than by equality alone — a silently dropped crypto field would
/// produce a row that stores fine and fails to decrypt much later, on another node.
///
public sealed class SecretReplicationSerializerTests : TestKit
{
public SecretReplicationSerializerTests()
: base(AkkaSecretsReplication.SerializationConfig)
{
}
private static StoredSecret Row(string name) => new()
{
Name = new SecretName(name),
Description = "a description",
ContentType = SecretContentType.ConnectionString,
Ciphertext = [1, 2, 3, 250],
Nonce = [4, 5, 6],
Tag = [7, 8, 9],
WrappedDek = [10, 11, 12],
WrapNonce = [13, 14, 15],
WrapTag = [16, 17, 18],
KekId = "sha256:abc",
Revision = 42,
IsDeleted = true,
DeletedUtc = new DateTimeOffset(2026, 3, 2, 1, 0, 0, TimeSpan.Zero),
CreatedUtc = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero),
UpdatedUtc = new DateTimeOffset(2026, 2, 1, 0, 0, 0, TimeSpan.Zero),
CreatedBy = "alice",
UpdatedBy = "bob",
};
private object RoundTrip(object message)
{
Serializer serializer = Sys.Serialization.FindSerializerFor(message);
Assert.IsType(serializer);
byte[] bytes = serializer.ToBinary(message);
string manifest = ((SerializerWithStringManifest)serializer).Manifest(message);
return ((SerializerWithStringManifest)serializer).FromBinary(bytes, manifest);
}
[Fact]
public void SecretRowsMessage_round_trips_every_field()
{
StoredSecret original = Row("app/db-conn");
var message = new SecretRowsMessage([SecretRowDto.FromStoredSecret(original)]);
var restored = (SecretRowsMessage)RoundTrip(message);
StoredSecret result = restored.Rows.Single().ToStoredSecret();
Assert.Equal(original.Name.Value, result.Name.Value);
Assert.Equal(original.Description, result.Description);
Assert.Equal(original.ContentType, result.ContentType);
Assert.Equal(original.Ciphertext, result.Ciphertext);
Assert.Equal(original.Nonce, result.Nonce);
Assert.Equal(original.Tag, result.Tag);
Assert.Equal(original.WrappedDek, result.WrappedDek);
Assert.Equal(original.WrapNonce, result.WrapNonce);
Assert.Equal(original.WrapTag, result.WrapTag);
Assert.Equal(original.KekId, result.KekId);
Assert.Equal(original.Revision, result.Revision);
Assert.Equal(original.IsDeleted, result.IsDeleted);
Assert.Equal(original.DeletedUtc, result.DeletedUtc);
Assert.Equal(original.CreatedUtc, result.CreatedUtc);
Assert.Equal(original.UpdatedUtc, result.UpdatedUtc);
Assert.Equal(original.CreatedBy, result.CreatedBy);
Assert.Equal(original.UpdatedBy, result.UpdatedBy);
}
[Fact]
public void Timestamps_survive_the_wire_exactly()
{
// Last-writer-wins compares UpdatedUtc for EQUALITY to break revision ties. A serializer that
// rounded to the millisecond would turn a tie into a spurious "newer" and two nodes would
// then overwrite each other forever, so tick-exactness is a correctness requirement.
StoredSecret original = Row("app/precise") with
{
UpdatedUtc = new DateTimeOffset(2026, 2, 1, 12, 34, 56, TimeSpan.Zero).AddTicks(1234567),
};
var restored = (SecretRowsMessage)RoundTrip(
new SecretRowsMessage([SecretRowDto.FromStoredSecret(original)]));
Assert.Equal(original.UpdatedUtc, restored.Rows.Single().ToStoredSecret().UpdatedUtc);
Assert.Equal(original.UpdatedUtc.Ticks, restored.Rows.Single().ToStoredSecret().UpdatedUtc.Ticks);
}
[Fact]
public void ManifestAnnounce_round_trips()
{
var message = new SecretManifestAnnounce(
[
SecretManifestEntryDto.FromEntry(new SecretManifestEntry
{
Name = new SecretName("a/one"),
Revision = 3,
UpdatedUtc = new DateTimeOffset(2026, 5, 5, 5, 5, 5, TimeSpan.Zero),
IsDeleted = true,
}),
]);
var restored = (SecretManifestAnnounce)RoundTrip(message);
SecretManifestEntry entry = restored.Entries.Single().ToEntry();
Assert.Equal("a/one", entry.Name.Value);
Assert.Equal(3, entry.Revision);
Assert.True(entry.IsDeleted);
Assert.Equal(new DateTimeOffset(2026, 5, 5, 5, 5, 5, TimeSpan.Zero), entry.UpdatedUtc);
}
[Fact]
public void PullRequest_round_trips()
{
var restored = (SecretPullRequest)RoundTrip(new SecretPullRequest(["a/one", "b/two"]));
Assert.Equal(["a/one", "b/two"], restored.Names);
}
[Fact]
public void An_unknown_manifest_fails_loudly_rather_than_guessing()
{
// A mixed-version cluster must fail on an unrecognized payload, not mis-deserialize a secret.
var serializer = (SerializerWithStringManifest)Sys.Serialization
.FindSerializerFor(new SecretPullRequest([]));
Assert.Throws(
() => serializer.FromBinary([1, 2, 3], "zbs-from-the-future-v9"));
}
[Fact]
public void The_wire_contract_carries_no_field_that_could_hold_key_material()
{
// Structural guard on the trust boundary: the DTO's surface is fixed and reviewed. If someone
// adds a property to SecretRowDto, this test fails and forces a deliberate look at whether
// the new field is safe to put on the wire.
string[] properties = [.. typeof(SecretRowDto)
.GetProperties()
.Select(p => p.Name)
.Order(StringComparer.Ordinal)];
Assert.Equal(
[
"Ciphertext", "ContentType", "CreatedBy", "CreatedUtc", "DeletedUtc", "Description",
"IsDeleted", "KekId", "Name", "Nonce", "Revision", "Tag", "UpdatedBy", "UpdatedUtc",
"WrapNonce", "WrapTag", "WrappedDek",
],
properties);
}
}