using System; using System.IO; using System.Net; using System.Net.Security; using System.Net.Sockets; using System.Security.Authentication; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; using MessagePack; using Serilog; using Serilog.Core; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Ipc; /// /// Round-trip tests for added with the TCP transport. Each /// scenario binds the server on 127.0.0.1:0, connects a real , /// performs the Hello handshake, and exercises a request/reply over the wire framing — both /// plaintext and over TLS. These target net48 and run on Windows in CI; on the macOS dev box /// they only compile. /// public sealed class TcpRoundTripTests { private static readonly ILogger Quiet = Logger.None; // Generous timeout so the deterministic tests don't hang CI if the server misbehaves. private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); /// /// Fake handler that echoes a fixed when it sees a /// , mirroring the client correlation id. /// private sealed class EchoHandler : IFrameHandler { public Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct) { if (kind != MessageKind.ReadRawRequest) return Task.CompletedTask; var request = MessagePackSerializer.Deserialize(body); var reply = new ReadRawReply { CorrelationId = request.CorrelationId, Success = true, Samples = new[] { new HistorianSampleDto { ValueBytes = MessagePackSerializer.Serialize(42.0), Quality = 192, TimestampUtcTicks = new DateTime(2026, 6, 12, 0, 0, 0, DateTimeKind.Utc).Ticks, }, }, }; return writer.WriteAsync(MessageKind.ReadRawReply, reply, ct); } } /// Generates an in-memory self-signed RSA cert with a serverAuth EKU and a private key. private static X509Certificate2 MakeSelfSignedCert() { using var rsa = RSA.Create(2048); var req = new CertificateRequest("CN=otopcua-historian-sidecar-test", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); req.CertificateExtensions.Add( new X509EnhancedKeyUsageExtension( new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") /* serverAuth */ }, critical: false)); using var ephemeral = req.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(1)); // Round-trip through a PFX so the returned cert carries an exportable private key on net48. var pfx = ephemeral.Export(X509ContentType.Pfx, "pw"); return new X509Certificate2(pfx, "pw", X509KeyStorageFlags.Exportable); } /// Performs the Hello handshake on the given stream and returns the deserialized ack. private static async Task HelloAsync(Stream stream, string secret, CancellationToken ct) { using var writer = new FrameWriter(stream, leaveOpen: true); using var reader = new FrameReader(stream, leaveOpen: true); await writer.WriteAsync(MessageKind.Hello, new Hello { ProtocolMajor = Hello.CurrentMajor, PeerName = "test-client", SharedSecret = secret }, ct); var ackFrame = await reader.ReadFrameAsync(ct); ackFrame.ShouldNotBeNull(); ackFrame!.Value.Kind.ShouldBe(MessageKind.HelloAck); return MessagePackSerializer.Deserialize(ackFrame.Value.Body); } /// Wraps a connected client socket stream in an SslStream that pins the server cert thumbprint. private static async Task ClientTlsAsync(NetworkStream inner, string expectedThumbprint, CancellationToken ct) { var ssl = new SslStream(inner, leaveInnerStreamOpen: false, userCertificateValidationCallback: (_, cert, _, _) => cert is not null && string.Equals( cert.GetCertHashString(), expectedThumbprint, StringComparison.OrdinalIgnoreCase)); await ssl.AuthenticateAsClientAsync("otopcua-historian-sidecar-test", clientCertificates: null, enabledSslProtocols: SslProtocols.Tls12, checkCertificateRevocation: false); return ssl; } /// Plaintext: Hello (good secret) is accepted and a ReadRaw request is echoed back. [Fact] public async Task Plaintext_RoundTrip_HelloAcceptedAndRequestEchoed() { using var cts = new CancellationTokenSource(Timeout); using var server = new TcpFrameServer(IPAddress.Loopback, 0, "shh", tlsCert: null, Quiet); var serverTask = server.RunOneConnectionAsync(new EchoHandler(), cts.Token); using var client = new TcpClient(); await client.ConnectAsync(IPAddress.Loopback, server.BoundPort); var stream = client.GetStream(); var ack = await HelloAsync(stream, "shh", cts.Token); ack.Accepted.ShouldBeTrue(); using var writer = new FrameWriter(stream, leaveOpen: true); using var reader = new FrameReader(stream, leaveOpen: true); await writer.WriteAsync(MessageKind.ReadRawRequest, new ReadRawRequest { TagName = "Tank.Level", MaxValues = 10, CorrelationId = "corr-1" }, cts.Token); var replyFrame = await reader.ReadFrameAsync(cts.Token); replyFrame.ShouldNotBeNull(); replyFrame!.Value.Kind.ShouldBe(MessageKind.ReadRawReply); var reply = MessagePackSerializer.Deserialize(replyFrame.Value.Body); reply.Success.ShouldBeTrue(); reply.CorrelationId.ShouldBe("corr-1"); reply.Samples.Length.ShouldBe(1); MessagePackSerializer.Deserialize(reply.Samples[0].ValueBytes!).ShouldBe(42.0); client.Close(); await serverTask; } /// TLS: a self-signed server cert; the client pins its thumbprint; same exchange succeeds. [Fact] public async Task Tls_RoundTrip_HelloAcceptedAndRequestEchoed() { using var cts = new CancellationTokenSource(Timeout); using var cert = MakeSelfSignedCert(); using var server = new TcpFrameServer(IPAddress.Loopback, 0, "shh", tlsCert: cert, Quiet); var serverTask = server.RunOneConnectionAsync(new EchoHandler(), cts.Token); using var client = new TcpClient(); await client.ConnectAsync(IPAddress.Loopback, server.BoundPort); using var ssl = await ClientTlsAsync(client.GetStream(), cert.Thumbprint, cts.Token); var ack = await HelloAsync(ssl, "shh", cts.Token); ack.Accepted.ShouldBeTrue(); using var writer = new FrameWriter(ssl, leaveOpen: true); using var reader = new FrameReader(ssl, leaveOpen: true); await writer.WriteAsync(MessageKind.ReadRawRequest, new ReadRawRequest { TagName = "Tank.Level", MaxValues = 10, CorrelationId = "tls-1" }, cts.Token); var replyFrame = await reader.ReadFrameAsync(cts.Token); replyFrame.ShouldNotBeNull(); replyFrame!.Value.Kind.ShouldBe(MessageKind.ReadRawReply); var reply = MessagePackSerializer.Deserialize(replyFrame.Value.Body); reply.Success.ShouldBeTrue(); reply.CorrelationId.ShouldBe("tls-1"); client.Close(); await serverTask; } /// /// TLS: when the client pins a wrong thumbprint the validation callback returns false, /// causing to throw /// before any Hello is exchanged. /// [Fact] public async Task Tls_BadThumbprint_AuthenticationFails() { using var cts = new CancellationTokenSource(Timeout); using var cert = MakeSelfSignedCert(); using var server = new TcpFrameServer(IPAddress.Loopback, 0, "shh", tlsCert: cert, Quiet); var serverTask = server.RunOneConnectionAsync(new EchoHandler(), cts.Token); using var client = new TcpClient(); await client.ConnectAsync(IPAddress.Loopback, server.BoundPort); // Deliberately pin the wrong thumbprint — all zeros. const string wrongThumbprint = "0000000000000000000000000000000000000000"; var ssl = new SslStream(client.GetStream(), leaveInnerStreamOpen: false, userCertificateValidationCallback: (_, serverCert, _, _) => serverCert is not null && string.Equals(serverCert.GetCertHashString(), wrongThumbprint, StringComparison.OrdinalIgnoreCase)); await Should.ThrowAsync(async () => await ssl.AuthenticateAsClientAsync("otopcua-historian-sidecar-test", clientCertificates: null, enabledSslProtocols: SslProtocols.Tls12, checkCertificateRevocation: false)); ssl.Dispose(); // Server will see the broken TLS handshake and end the connection; let it finish. try { await serverTask; } catch { /* server may throw on the aborted TLS */ } } /// Bad secret: Hello is rejected with Accepted=false and the shared-secret-mismatch reason. [Fact] public async Task BadSecret_HelloRejected() { using var cts = new CancellationTokenSource(Timeout); using var server = new TcpFrameServer(IPAddress.Loopback, 0, "right-secret", tlsCert: null, Quiet); var serverTask = server.RunOneConnectionAsync(new EchoHandler(), cts.Token); using var client = new TcpClient(); await client.ConnectAsync(IPAddress.Loopback, server.BoundPort); var ack = await HelloAsync(client.GetStream(), "wrong-secret", cts.Token); ack.Accepted.ShouldBeFalse(); ack.RejectReason.ShouldBe("shared-secret-mismatch"); client.Close(); await serverTask; } /// /// Single-active serial accept: while client A is connected (Hello done), client B's /// Hello does not complete until A disconnects. The server only accepts one connection /// per , so B's handshake is served by /// the second loop iteration that runs only after A's connection ends. /// [Fact] public async Task SingleActive_SecondClientHelloCompletesOnlyAfterFirstCloses() { using var cts = new CancellationTokenSource(Timeout); using var server = new TcpFrameServer(IPAddress.Loopback, 0, "shh", tlsCert: null, Quiet); // Run the server loop: it accepts one connection at a time, serially. var serverLoop = server.RunAsync(new EchoHandler(), cts.Token); // Client A connects and completes its Hello — it now owns the single active slot. using var clientA = new TcpClient(); await clientA.ConnectAsync(IPAddress.Loopback, server.BoundPort); var ackA = await HelloAsync(clientA.GetStream(), "shh", cts.Token); ackA.Accepted.ShouldBeTrue(); // Client B connects. The TCP connect may complete (OS backlog) but the server is still // busy with A, so B's Hello round-trip must NOT complete yet. using var clientB = new TcpClient(); await clientB.ConnectAsync(IPAddress.Loopback, server.BoundPort); var bHelloTask = HelloAsync(clientB.GetStream(), "shh", cts.Token); // Give B a chance to (wrongly) complete — it must remain pending while A is connected. var earlyWinner = await Task.WhenAny(bHelloTask, Task.Delay(TimeSpan.FromMilliseconds(500), cts.Token)); earlyWinner.ShouldNotBe(bHelloTask, "client B's Hello completed while client A was still connected"); // Now disconnect A. The server's next loop iteration accepts B and serves its Hello. clientA.Close(); var ackB = await bHelloTask; ackB.Accepted.ShouldBeTrue(); // Tear down: cancel the loop and let it unwind. cts.Cancel(); try { await serverLoop; } catch (OperationCanceledException) { /* expected */ } } [Fact] public async Task BindFailure_SurfacesBindError_NotPermanentNotListening() { // Regression (live-caught 2026-06-12): when TcpFrameServer's listener bind fails (port in a // Windows excluded range → WSAEACCES, or already in use), the failure must surface as the // bind SocketException on EVERY accept attempt — NOT a one-time bind error followed by a // permanent InvalidOperationException "Not listening". The latter is the assign-before-Start // wedge: a non-null-but-unstarted listener that EnsureListening's guard never re-Starts, // which crash-looped the live sidecar on the reserved port 32569. using var cts = new CancellationTokenSource(Timeout); // Occupy a loopback port exclusively so the server's Start() bind is forbidden. var blocker = new TcpListener(IPAddress.Loopback, 0) { ExclusiveAddressUse = true }; blocker.Start(); try { var takenPort = ((IPEndPoint)blocker.LocalEndpoint).Port; using var server = new TcpFrameServer(IPAddress.Loopback, takenPort, "shh", tlsCert: null, Quiet); // First accept attempt: the bind fails with a SocketException. await Should.ThrowAsync(() => server.RunOneConnectionAsync(new EchoHandler(), cts.Token)); // Second attempt MUST also be the bind SocketException — not InvalidOperationException // "Not listening". This is the assertion that fails against the assign-before-Start bug. var second = await Should.ThrowAsync(() => server.RunOneConnectionAsync(new EchoHandler(), cts.Token)); second.ShouldBeOfType(); } finally { blocker.Stop(); } } }