using System.Buffers.Binary; using Google.Protobuf; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess; using ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol; using ZB.MOM.WW.OtOpcUa.Core.Scripting.Protocol.V1; namespace ZB.MOM.WW.OtOpcUa.ScriptWorker.Tests; /// lmxopcua#780 design §13 — frame codec and the CLR ↔ Variant mapping. public sealed class ProtocolTests { public static IEnumerable Values() { object?[] values = [ null, true, false, (sbyte)-5, (byte)250, (short)-3, (ushort)7, -42, 42u, long.MinValue, ulong.MaxValue, 1.5f, double.NaN, 3.25, "text", string.Empty, new DateTime(2026, 11, 9, 1, 2, 3, DateTimeKind.Utc), new[] { 1, 2, 3 }, new short[] { -1, 1 }, new[] { "a", "b" }, new[] { true, false }, Array.Empty(), new[] { new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc) }, (short)0, 0, 0L, 0.0, ]; return values.Select(v => new[] { v }); } /// The exact boxed CLR type survives (design §4.4): (short)ctx.GetTag(x).Value must keep working. [Theory] [MemberData(nameof(Values))] public void Every_type_tag_round_trips_with_its_exact_clr_type(object? value) { VariantCodec.TryEncode(value, out var variant, out var unsupported).ShouldBeTrue(); unsupported.ShouldBeNull(); // Through the wire, not just the object: a payload the serializer drops must still decode. var wire = Variant.Parser.ParseFrom(variant.ToByteArray()); var decoded = VariantCodec.Decode(wire); if (value is null) { decoded.ShouldBeNull(); return; } decoded.ShouldNotBeNull(); decoded.GetType().ShouldBe(value.GetType()); if (value is Array expected) ((Array)decoded).Cast().ShouldBe(expected.Cast()); else if (value is double d && double.IsNaN(d)) double.IsNaN((double)decoded).ShouldBeTrue(); else decoded.ShouldBe(value); } [Fact] public void Unsupported_types_are_refused_not_converted() { foreach (var value in new object[] { new List { 1 }, 1.5m, Guid.NewGuid(), new { A = 1 }, new int[1, 1], new object[] { 1 } }) { VariantCodec.TryEncode(value, out _, out var type).ShouldBeFalse(value.GetType().Name); type.ShouldNotBeNullOrEmpty(); } } [Fact] public void Local_datetime_crosses_as_utc() { var local = new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Local); VariantCodec.TryEncode(local, out var variant, out _).ShouldBeTrue(); var decoded = (DateTime)VariantCodec.Decode(variant)!; decoded.Kind.ShouldBe(DateTimeKind.Utc); decoded.ShouldBe(local.ToUniversalTime()); } [Fact] public void A_payload_that_contradicts_its_tag_is_a_protocol_fault() { Should.Throw(() => VariantCodec.Decode(new Variant { Type = Variant.Types.TypeTag.Int16, S = "x" })); Should.Throw(() => VariantCodec.Decode(new Variant { Type = (Variant.Types.TypeTag)99 })); Should.Throw(() => VariantCodec.Decode(new Variant { Type = Variant.Types.TypeTag.Int16, I = 1 << 20 })); } private static Envelope Sample() => new() { ProtocolVersion = ScriptWorkerFrameCodec.ProtocolVersion, Seq = 7, Heartbeat = new Heartbeat { Inflight = 3, HeapBytes = 1234 }, }; [Fact] public async Task A_frame_round_trips() { using var stream = new MemoryStream(); await ScriptWorkerFrameCodec.WriteAsync(stream, Sample(), 1024, TestContext.Current.CancellationToken); stream.Position = 0; var read = await ScriptWorkerFrameCodec.ReadAsync(stream, 1024, TestContext.Current.CancellationToken); read.ShouldBe(Sample()); (await ScriptWorkerFrameCodec.ReadAsync(stream, 1024, TestContext.Current.CancellationToken)).ShouldBeNull("a clean end of stream at a frame boundary"); } [Fact] public async Task Oversize_frames_are_refused_on_write_and_on_read() { using var stream = new MemoryStream(); await Should.ThrowAsync(async () => await ScriptWorkerFrameCodec.WriteAsync(stream, Sample(), 4, TestContext.Current.CancellationToken)); stream.Length.ShouldBe(0, "nothing is written for a refused frame"); await ScriptWorkerFrameCodec.WriteAsync(stream, Sample(), 1024, TestContext.Current.CancellationToken); stream.Position = 0; await Should.ThrowAsync(async () => await ScriptWorkerFrameCodec.ReadAsync(stream, 4, TestContext.Current.CancellationToken)); } public static TheoryData BadFrames() { var good = Sample().ToByteArray(); var header = new byte[4]; BinaryPrimitives.WriteInt32LittleEndian(header, good.Length); var wrongVersion = new Envelope { ProtocolVersion = 99, Heartbeat = new Heartbeat() }.ToByteArray(); var noBody = new Envelope { ProtocolVersion = 1, Seq = 1 }.ToByteArray(); return new TheoryData { { "zero length", new byte[] { 0, 0, 0, 0 } }, { "negative length", new byte[] { 0xFF, 0xFF, 0xFF, 0xFF } }, { "truncated header", new byte[] { 5, 0 } }, { "truncated body", [.. header, .. good[..^1]] }, { "garbage body", [5, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF] }, { "wrong protocol version", Frame(wrongVersion) }, { "no body", Frame(noBody) }, }; } private static byte[] Frame(byte[] body) { var header = new byte[4]; BinaryPrimitives.WriteInt32LittleEndian(header, body.Length); return [.. header, .. body]; } [Theory] [MemberData(nameof(BadFrames))] public async Task Malformed_frames_are_protocol_faults(string why, byte[] bytes) { using var stream = new MemoryStream(bytes); await Should.ThrowAsync(async () => await ScriptWorkerFrameCodec.ReadAsync(stream, 1024, TestContext.Current.CancellationToken), why); } /// Random bytes never escape as anything but a protocol fault (or a clean end of stream). [Fact] public async Task Fuzzed_input_only_ever_yields_a_protocol_fault() { var random = new Random(780); for (var i = 0; i < 3000; i++) { var bytes = new byte[random.Next(0, 64)]; random.NextBytes(bytes); if (bytes.Length >= 4 && random.Next(2) == 0) BinaryPrimitives.WriteInt32LittleEndian(bytes, random.Next(1, bytes.Length)); using var stream = new MemoryStream(bytes); try { while (await ScriptWorkerFrameCodec.ReadAsync(stream, 1024, TestContext.Current.CancellationToken) is { } envelope) { // A frame that parses must still decode its variants without an unexpected exception type. if (envelope.BodyCase == Envelope.BodyOneofCase.Results) foreach (var r in envelope.Results.Items) try { VariantCodec.Decode(r.Value); } catch (Exception ex) when (ex is ScriptWorkerProtocolException or OverflowException) { } } } catch (ScriptWorkerProtocolException) { } } } [Fact] public void Backoff_doubles_from_250ms_to_a_30s_cap_with_20_percent_jitter() { WorkerShard.NextBackoff(1, 0.5).ShouldBe(TimeSpan.FromMilliseconds(250)); WorkerShard.NextBackoff(2, 0.5).ShouldBe(TimeSpan.FromMilliseconds(500)); WorkerShard.NextBackoff(3, 0.5).ShouldBe(TimeSpan.FromSeconds(1)); WorkerShard.NextBackoff(20, 0.5).ShouldBe(TimeSpan.FromSeconds(30)); WorkerShard.NextBackoff(1, 0).ShouldBe(TimeSpan.FromMilliseconds(200)); WorkerShard.NextBackoff(1, 0.9999999).TotalMilliseconds.ShouldBe(300, tolerance: 0.01); WorkerShard.NextBackoff(40, 1).ShouldBe(TimeSpan.FromSeconds(36)); } [Fact] public void Shard_placement_is_stable_and_in_range() { // FNV-1a, not string.GetHashCode (randomised per process): a script stays on its shard across restarts. OutOfProcessScriptRunner.ShardIndex("1:vt-1", 2).ShouldBe(OutOfProcessScriptRunner.ShardIndex("1:vt-1", 2)); var spread = Enumerable.Range(0, 400).Select(i => OutOfProcessScriptRunner.ShardIndex($"1:vt-{i}", 4)).ToArray(); spread.ShouldAllBe(i => i >= 0 && i < 4); spread.Distinct().Count().ShouldBe(4); OutOfProcessScriptRunner.ShardIndex("abc", 1000).ShouldBe((int)(0x1A47E90Bu % 1000)); } }