/* ======================================================================== * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Buffers.Binary; using System.Collections; using System.Collections.Generic; using System.Reflection; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging.Abstractions; using NUnit.Framework; using Opc.Ua.Bindings; using Opc.Ua.Pcap.Dissection; using Opc.Ua.Pcap.Frame; namespace Opc.Ua.Pcap.Tests.Dissection { /// /// Tests for the public surface — /// input validation, control-message paths, and asymmetric /// OpenSecureChannel reporting (none of which require loaded key /// material). /// [TestFixture] public sealed class ServiceCallReassemblerTests { [Test] public void ConstructorRejectsNullLoggerFactory() { Assert.That( () => new ServiceCallReassembler(loggerFactory: null!), Throws.TypeOf() .With.Property("ParamName").EqualTo("loggerFactory")); } [Test] public void DefaultConstructorDoesNotThrow() { ServiceCallReassembler reassembler = new(); // Brand-new reassembler reports no completed calls. IReadOnlyList empty = reassembler.DrainCompleted(); Assert.That(empty, Is.Empty); } [Test] public void LoadKeyMaterialRejectsNull() { var reassembler = new ServiceCallReassembler(NullLoggerFactory.Instance); Assert.That( () => reassembler.LoadKeyMaterial(material: null!), Throws.TypeOf() .With.Property("ParamName").EqualTo("material")); } [Test] public void ProcessAllRejectsNullFrames() { var reassembler = new ServiceCallReassembler(NullLoggerFactory.Instance); Assert.That( () => reassembler.ProcessAll(frames: null!), Throws.TypeOf() .With.Property("ParamName").EqualTo("frames")); } [Test] public void ProcessAllAsyncRejectsNullFrames() { var reassembler = new ServiceCallReassembler(NullLoggerFactory.Instance); Assert.That( async () => await reassembler.ProcessAllAsync( frames: null!, CancellationToken.None).ConfigureAwait(false), Throws.TypeOf() .With.Property("ParamName").EqualTo("frames")); } [Test] public void PushIgnoresFrameShorterThanFourBytes() { var reassembler = new ServiceCallReassembler(NullLoggerFactory.Instance); var frame = new CaptureFrame( DateTimeOffset.UtcNow, CaptureFrameDirection.ClientToServer, "client:1", "server:1", new byte[] { 1, 2, 3 }); reassembler.Push(frame); Assert.That(reassembler.DrainCompleted(), Is.Empty, "A frame shorter than the 4-byte message-type marker must be skipped."); } [Test] public void PushIgnoresFrameWithInvalidMessageType() { var reassembler = new ServiceCallReassembler(NullLoggerFactory.Instance); byte[] payload = new byte[24]; BinaryPrimitives.WriteUInt32LittleEndian(payload, 0xDEADBEEF); var frame = new CaptureFrame( DateTimeOffset.UtcNow, CaptureFrameDirection.ClientToServer, "client:1", "server:1", payload); reassembler.Push(frame); Assert.That(reassembler.DrainCompleted(), Is.Empty, "Frames whose first 4 bytes do not encode a recognised TcpMessageType must be ignored."); } [Test] public void PushIgnoresFrameWithUnknownDirection() { var reassembler = new ServiceCallReassembler(NullLoggerFactory.Instance); byte[] payload = BuildOpnChunk(channelId: 1, tokenId: 2, size: 64); var frame = new CaptureFrame( DateTimeOffset.UtcNow, CaptureFrameDirection.Unknown, clientEndpoint: string.Empty, serverEndpoint: string.Empty, payload); reassembler.Push(frame); Assert.That(reassembler.DrainCompleted(), Is.Empty, "Frames with Unknown direction must be skipped — we cannot decide " + "client vs server keys."); } [Test] public void PushOpenChunkFromClientProducesOpenSecureChannelRequestCall() { var reassembler = new ServiceCallReassembler(NullLoggerFactory.Instance); byte[] payload = BuildOpnChunk(channelId: 0x0A0B0C0D, tokenId: 0x12345678, size: 64); var timestamp = new DateTimeOffset(2026, 3, 4, 5, 6, 7, TimeSpan.Zero); var frame = new CaptureFrame( timestamp, CaptureFrameDirection.ClientToServer, clientEndpoint: "10.0.0.1:55000", serverEndpoint: "10.0.0.2:4840", payload); reassembler.Push(frame); IReadOnlyList completed = reassembler.DrainCompleted(); Assert.That(completed, Has.Count.EqualTo(1)); DecodedServiceCall call = completed[0]; Assert.That(call.RequestName, Is.EqualTo("OpenSecureChannelRequest")); Assert.That(call.ChannelId, Is.EqualTo(0x0A0B0C0DU)); Assert.That(call.TokenId, Is.EqualTo(0x12345678U)); Assert.That(call.RequestTimestamp, Is.EqualTo(timestamp)); Assert.That(call.RequestBodySize, Is.EqualTo(payload.Length)); Assert.That(call.RequestSummary, Is.EqualTo("(asymmetric OpenSecureChannel chunk)")); } [Test] public void PushOpenChunkFromServerProducesOpenSecureChannelResponseCall() { var reassembler = new ServiceCallReassembler(NullLoggerFactory.Instance); byte[] payload = BuildOpnChunk(channelId: 0x99999999, tokenId: 0x77777777, size: 64); var frame = new CaptureFrame( DateTimeOffset.UtcNow, CaptureFrameDirection.ServerToClient, string.Empty, string.Empty, payload); reassembler.Push(frame); IReadOnlyList completed = reassembler.DrainCompleted(); Assert.That(completed, Has.Count.EqualTo(1)); Assert.That(completed[0].RequestName, Is.EqualTo("OpenSecureChannelResponse")); } [Test] public void DrainCompletedClearsInternalList() { var reassembler = new ServiceCallReassembler(NullLoggerFactory.Instance); byte[] payload = BuildOpnChunk(channelId: 1, tokenId: 2, size: 64); var frame = new CaptureFrame( DateTimeOffset.UtcNow, CaptureFrameDirection.ClientToServer, string.Empty, string.Empty, payload); reassembler.Push(frame); IReadOnlyList first = reassembler.DrainCompleted(); IReadOnlyList second = reassembler.DrainCompleted(); Assert.That(first, Has.Count.EqualTo(1)); Assert.That(second, Is.Empty, "DrainCompleted must clear the internal list so the next call sees " + "only freshly completed entries."); } [Test] public void ProcessAllFeedsEveryFrameThenReturnsCompleted() { var reassembler = new ServiceCallReassembler(NullLoggerFactory.Instance); var frames = new List { MakeOpenFrame(channelId: 1, tokenId: 10, fromClient: true), MakeOpenFrame(channelId: 2, tokenId: 20, fromClient: false) }; IReadOnlyList calls = reassembler.ProcessAll(frames); Assert.That(calls, Has.Count.EqualTo(2)); Assert.That(calls[0].ChannelId, Is.EqualTo(1U)); Assert.That(calls[0].TokenId, Is.EqualTo(10U)); Assert.That(calls[0].RequestName, Is.EqualTo("OpenSecureChannelRequest")); Assert.That(calls[1].ChannelId, Is.EqualTo(2U)); Assert.That(calls[1].TokenId, Is.EqualTo(20U)); Assert.That(calls[1].RequestName, Is.EqualTo("OpenSecureChannelResponse")); } [Test] public async Task ProcessAllAsyncFeedsEveryFrameThenReturnsCompleted() { var reassembler = new ServiceCallReassembler(NullLoggerFactory.Instance); var frames = new List { MakeOpenFrame(channelId: 5, tokenId: 6, fromClient: true), MakeOpenFrame(channelId: 7, tokenId: 8, fromClient: true) }; IReadOnlyList calls = await reassembler.ProcessAllAsync( AsAsyncEnumerable(frames), CancellationToken.None).ConfigureAwait(false); Assert.That(calls, Has.Count.EqualTo(2)); Assert.That(calls[0].ChannelId, Is.EqualTo(5U)); Assert.That(calls[1].ChannelId, Is.EqualTo(7U)); } [Test] public void PushUnknownMessageTypeBaseDoesNotThrow() { // A 4-byte type that passes IsValid but whose masked base is none // of HEL/ACK/ERR/RHE/OPN/MSG/CLO — defensively ensure nothing // crashes. The Final-flag (0x46000000) combined with a fabricated // 3-byte tail "ZZZ" yields 0x465A5A5A which IsValid rejects, so // build a recognized-but-unhandled type instead: Hello. // NOTE: due to the bug in Push() (mask vs case label mismatch), // Hello control messages produce no DecodedServiceCall today. // The test pins the current behaviour so we can spot regressions // — if the production bug is fixed, this assertion needs to flip // to expect a completed control-message entry. var reassembler = new ServiceCallReassembler(NullLoggerFactory.Instance); byte[] payload = new byte[32]; // Hello = 0x464C4548 ("HELF" with little-endian read). BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x464C4548U); var frame = new CaptureFrame( DateTimeOffset.UtcNow, CaptureFrameDirection.ClientToServer, string.Empty, string.Empty, payload); Assert.That(() => reassembler.Push(frame), Throws.Nothing); Assert.That(reassembler.DrainCompleted(), Is.Empty, "Current production code drops Hello/Ack/Error/ReverseHello because " + "ServiceCallReassembler.Push masks the type with MessageTypeMask but " + "compares against the un-masked case labels. Document the behaviour."); } [Test] public void PrivateAddControlMessageProducesCompletedEntry() { var reassembler = new ServiceCallReassembler(NullLoggerFactory.Instance); var timestamp = new DateTimeOffset(2026, 4, 5, 6, 7, 8, TimeSpan.Zero); CaptureFrame frame = MakeControlFrame(TcpMessageType.Hello, timestamp); InvokeInstance(reassembler, "AddControlMessage", frame, "Hello"); IReadOnlyList completed = reassembler.DrainCompleted(); Assert.That(completed, Has.Count.EqualTo(1)); Assert.That(completed[0].RequestName, Is.EqualTo("Hello")); Assert.That(completed[0].RequestTimestamp, Is.EqualTo(timestamp)); Assert.That(completed[0].RequestBodySize, Is.EqualTo(8)); } [Test] public void PushMessageWithoutKeyMaterialIsSkipped() { var reassembler = new ServiceCallReassembler(NullLoggerFactory.Instance); byte[] chunk = BuildMsgChunk(channelId: 0x12345678, tokenId: 0x42, body: [1, 2, 3]); Assert.That( () => reassembler.Push( new CaptureFrame( DateTimeOffset.UtcNow, CaptureFrameDirection.ClientToServer, string.Empty, string.Empty, chunk)), Throws.Nothing); Assert.That(reassembler.DrainCompleted(), Is.Empty); } /// /// Verifies request summaries include service-specific counts and use the fallback body size for unknown kinds. /// [Test] [SetCulture("en-US")] public void PrivateRequestSummaryFormatsKnownRequestKinds() { Assert.That( InvokeCreateRequestSummary( new ReadRequest { RequestHeader = new RequestHeader { RequestHandle = 10, AuditEntryId = "audit" }, NodesToRead = [new ReadValueId { NodeId = ObjectIds.Server }], MaxAge = 12.5 }, "ReadRequest", 100), Is.EqualTo("handle=10 audit=audit 1 nodes, maxAge=12.5")); Assert.That( InvokeCreateRequestSummary( new BrowseRequest { View = new ViewDescription(), NodesToBrowse = [new BrowseDescription { NodeId = ObjectIds.ObjectsFolder }] }, "BrowseRequest", 101), Is.EqualTo("handle=0 audit= 1 nodes, view=default")); Assert.That( InvokeCreateRequestSummary( new WriteRequest { NodesToWrite = [new WriteValue()] }, "WriteRequest", 102), Is.EqualTo("handle=0 audit= 1 nodes")); Assert.That( InvokeCreateRequestSummary( new CallRequest { MethodsToCall = [new CallMethodRequest()] }, "CallRequest", 103), Is.EqualTo("handle=0 audit= 1 methods")); Assert.That( InvokeCreateRequestSummary( new HistoryReadRequest { NodesToRead = [new HistoryReadValueId()] }, "HistoryReadRequest", 104), Is.EqualTo("handle=0 audit= 1 nodes")); Assert.That( InvokeCreateRequestSummary( new CreateMonitoredItemsRequest { ItemsToCreate = [new MonitoredItemCreateRequest()] }, "CreateMonitoredItemsRequest", 105), Is.EqualTo("handle=0 audit= 1 items")); Assert.That( InvokeCreateRequestSummary( new DeleteMonitoredItemsRequest { MonitoredItemIds = [1U, 2U] }, "DeleteMonitoredItemsRequest", 106), Is.EqualTo("handle=0 audit= 2 items")); Assert.That( InvokeCreateRequestSummary( new PublishRequest { SubscriptionAcknowledgements = [new SubscriptionAcknowledgement()] }, "PublishRequest", 107), Is.EqualTo("handle=0 audit= 1 acks")); Assert.That( InvokeCreateRequestSummary(new RegisterNodesRequest(), "RegisterNodesRequest", 108), Is.EqualTo("handle=0 audit= RegisterNodesRequest body=108B")); } /// /// Verifies request summary display values use the current culture's decimal separator. /// [Test] [SetCulture("de-DE")] public void RequestSummaryFormatsNumericDisplayValuesUsingCurrentCulture() { Assert.That( InvokeCreateRequestSummary( new ReadRequest { RequestHeader = new RequestHeader { RequestHandle = 10, AuditEntryId = "audit" }, NodesToRead = [new ReadValueId { NodeId = ObjectIds.Server }], MaxAge = 12.5 }, "ReadRequest", 100), Is.EqualTo("handle=10 audit=audit 1 nodes, maxAge=12,5")); } [Test] public void PrivateRequestSummaryFormatsNonDefaultBrowseView() { Assert.That( InvokeCreateRequestSummary( new BrowseRequest { View = new ViewDescription { ViewId = new NodeId(1234, 2) }, NodesToBrowse = [new BrowseDescription { NodeId = ObjectIds.ObjectsFolder }] }, "BrowseRequest", 101), Is.EqualTo("handle=0 audit= 1 nodes, view=ns=2;i=1234")); } [Test] public void PrivateFormattingHelpersHandleCollectionsAndMissingProperties() { var encodeable = new EncodeableWithCollections(); Assert.That( InvokeStatic("FormatCount", encodeable, "Items", "items"), Is.EqualTo("2 items")); Assert.That( InvokeStatic("FormatCount", encodeable, "Missing", "items"), Is.EqualTo(string.Empty)); Assert.That( InvokeStatic("FormatCount", encodeable, "Value", "items"), Is.EqualTo(string.Empty)); Assert.That( InvokeStatic("FormatCount", encodeable, "ConvertibleItems", "items"), Is.EqualTo("3 items")); Assert.That( InvokeStatic("FormatCountAndValue", encodeable, "Items", "items", "Value", "value"), Is.EqualTo("2 items, value=42")); Assert.That( InvokeStatic("FormatCountAndValue", encodeable, "Items", "items", "NullValue", "value"), Is.EqualTo("2 items, value=null")); Assert.That( InvokeStatic("FormatCountAndValue", encodeable, "Missing", "items", "Value", "value"), Is.EqualTo(string.Empty)); } [Test] public void PrivateDecodedMessageHelpersCoverResponsesAndAnnotations() { var reassembler = new ServiceCallReassembler(NullLoggerFactory.Instance); var response = new ReadResponse { ResponseHeader = new ResponseHeader { ServiceResult = StatusCodes.BadUnexpectedError, Timestamp = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc) } }; object decodedResponse = InvokeInstance( reassembler, "CreateDecodedMessage", response, 10); object decodedEncodeable = InvokeInstance( reassembler, "CreateDecodedMessage", new Argument { Name = "Input" }, 11); var annotations = new Dictionary(); InvokeStatic( "AddRequestAnnotations", annotations, new ReadRequest { RequestHeader = new RequestHeader { RequestHandle = 123, AuditEntryId = "audit" } }); InvokeStatic("AddRequestAnnotations", annotations, new Argument()); Assert.That(GetProperty(decodedResponse, "Name"), Is.EqualTo("ReadResponse")); Assert.That(GetProperty(decodedResponse, "ResponseStatus"), Is.EqualTo(StatusCodes.BadUnexpectedError)); Assert.That(GetProperty(decodedResponse, "Summary"), Does.Contain("status=BadUnexpectedError")); Assert.That(GetProperty(decodedEncodeable, "Name"), Is.EqualTo("Argument")); Assert.That(GetProperty(decodedEncodeable, "Summary"), Is.EqualTo("Argument body=11B")); Assert.That(annotations["RequestHandle"], Is.EqualTo("123")); Assert.That(annotations["AuditEntryId"], Is.EqualTo("audit")); Assert.That(annotations, Has.Count.EqualTo(2)); } [Test] public void PrivateDecodeMessageReturnsUndecodedForMalformedBody() { var reassembler = new ServiceCallReassembler(NullLoggerFactory.Instance); var chunks = new List { new byte[] { 0xFF, 0x00 }, new byte[] { 0xAA } }; object decoded = InvokeInstance(reassembler, "DecodeMessage", chunks); Assert.That(GetProperty(decoded, "Name"), Is.EqualTo("3B")); Assert.That(GetProperty(decoded, "Summary"), Is.EqualTo("(undecoded: 3 bytes)")); } // ---- helpers ---- private static CaptureFrame MakeControlFrame(uint messageType, DateTimeOffset timestamp) { byte[] payload = new byte[8]; BinaryPrimitives.WriteUInt32LittleEndian(payload, messageType); BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)payload.Length); return new CaptureFrame( timestamp, CaptureFrameDirection.ClientToServer, string.Empty, string.Empty, payload); } private static byte[] BuildMsgChunk(uint channelId, uint tokenId, byte[] body) { int size = 24 + body.Length; byte[] chunk = new byte[size]; BinaryPrimitives.WriteUInt32LittleEndian(chunk, TcpMessageType.MessageFinal); BinaryPrimitives.WriteUInt32LittleEndian(chunk.AsSpan(4), (uint)size); BinaryPrimitives.WriteUInt32LittleEndian(chunk.AsSpan(8), channelId); BinaryPrimitives.WriteUInt32LittleEndian(chunk.AsSpan(12), tokenId); BinaryPrimitives.WriteUInt32LittleEndian(chunk.AsSpan(16), 1); BinaryPrimitives.WriteUInt32LittleEndian(chunk.AsSpan(20), 2); body.CopyTo(chunk.AsSpan(24)); return chunk; } private static string InvokeCreateRequestSummary(IServiceRequest request, string typeName, int byteCount) { return InvokeStatic("CreateRequestSummary", request, typeName, byteCount); } private static T InvokeInstance(ServiceCallReassembler reassembler, string methodName, params object?[] parameters) { MethodInfo method = typeof(ServiceCallReassembler).GetMethod( methodName, BindingFlags.NonPublic | BindingFlags.Instance)!; return (T)method.Invoke(reassembler, parameters)!; } private static T InvokeStatic(string methodName, params object?[] parameters) { MethodInfo method = typeof(ServiceCallReassembler).GetMethod( methodName, BindingFlags.NonPublic | BindingFlags.Static)!; return (T)method.Invoke(null, parameters)!; } private static T GetProperty(object value, string name) { return (T)value.GetType().GetProperty(name, BindingFlags.Public | BindingFlags.Instance)!.GetValue(value)!; } private sealed class EncodeableWithCollections : IEncodeable { public ExpandedNodeId TypeId => ExpandedNodeId.Null; public ExpandedNodeId BinaryEncodingId => ExpandedNodeId.Null; public ExpandedNodeId XmlEncodingId => ExpandedNodeId.Null; public ICollection Items { get; } = new ArrayList { 1, 2 }; public IConvertableToArray ConvertibleItems { get; } = new ConvertableCollection(); public int Value => 42; public string? NullValue => null; public void Encode(IEncoder encoder) { } public void Decode(IDecoder decoder) { } public bool IsEqual(IEncodeable? encodeable) { return ReferenceEquals(this, encodeable); } public object Clone() { return new EncodeableWithCollections(); } } private sealed class ConvertableCollection : IConvertableToArray { public int Count => 3; public Array? ToArray() { throw new InvalidOperationException("Collection count must not allocate an array."); } } private static byte[] BuildOpnChunk(uint channelId, uint tokenId, int size) { if (size < 16) { throw new ArgumentOutOfRangeException(nameof(size)); } byte[] data = new byte[size]; // "OPNF" little-endian = 0x464E504F (Open + Final flag). BinaryPrimitives.WriteUInt32LittleEndian(data, 0x464E504FU); BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(4), (uint)size); BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(8), channelId); BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(12), tokenId); return data; } private static CaptureFrame MakeOpenFrame(uint channelId, uint tokenId, bool fromClient) { return new CaptureFrame( DateTimeOffset.UtcNow, fromClient ? CaptureFrameDirection.ClientToServer : CaptureFrameDirection.ServerToClient, string.Empty, string.Empty, BuildOpnChunk(channelId, tokenId, size: 32)); } private static async IAsyncEnumerable AsAsyncEnumerable(IEnumerable source) { foreach (CaptureFrame frame in source) { yield return frame; await Task.Yield(); } } } }