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); } }