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 Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests; /// /// Tests for . Each scenario binds a /// loopback on 127.0.0.1:0, accepts on a background task, /// and drives the client factory against it — proving a plaintext stream round-trips a byte, /// a TLS connection succeeds when the pinned thumbprint matches, and fails when it does not. /// public sealed class TcpConnectFactoryTests { // Generous timeout so the deterministic tests never hang CI if a side stalls. private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); /// 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. var pfx = ephemeral.Export(X509ContentType.Pfx, "pw"); return X509CertificateLoader.LoadPkcs12(pfx, "pw", X509KeyStorageFlags.Exportable); } /// Plaintext: the factory returns a connected stream; a byte written server-side reads back client-side. [Fact] public async Task Plaintext_ReturnsConnectedStream_ByteRoundTrips() { using var cts = new CancellationTokenSource(Timeout); var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var boundPort = ((IPEndPoint)listener.LocalEndpoint).Port; // Accept one client and push a single byte from the server side. var serverTask = Task.Run(async () => { using var server = await listener.AcceptTcpClientAsync(cts.Token); var serverStream = server.GetStream(); await serverStream.WriteAsync(new byte[] { 0x7A }, cts.Token); await serverStream.FlushAsync(cts.Token); // Hold the connection open until the client has read. await Task.Delay(TimeSpan.FromMilliseconds(200), cts.Token); }, cts.Token); var opts = new WonderwareHistorianClientOptions("127.0.0.1", boundPort, "secret") { UseTls = false, }; await using var clientStream = await FrameChannel.DefaultTcpConnectFactory(opts, cts.Token); var buffer = new byte[1]; var read = await clientStream.ReadAsync(buffer, cts.Token); read.ShouldBe(1); buffer[0].ShouldBe((byte)0x7A); await serverTask; listener.Stop(); } /// TLS pin match: a self-signed cert pinned by thumbprint authenticates successfully. [Fact] public async Task Tls_PinnedThumbprintMatches_ConnectsSuccessfully() { using var cts = new CancellationTokenSource(Timeout); using var cert = MakeSelfSignedCert(); var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var boundPort = ((IPEndPoint)listener.LocalEndpoint).Port; var serverTask = Task.Run(async () => { using var server = await listener.AcceptTcpClientAsync(cts.Token); var ssl = new SslStream(server.GetStream(), leaveInnerStreamOpen: false); await ssl.AuthenticateAsServerAsync(cert, clientCertificateRequired: false, enabledSslProtocols: SslProtocols.Tls12, checkCertificateRevocation: false); // Hold open until the client finished its handshake. await Task.Delay(TimeSpan.FromMilliseconds(200), cts.Token); ssl.Dispose(); }, cts.Token); var opts = new WonderwareHistorianClientOptions("127.0.0.1", boundPort, "secret") { UseTls = true, ServerCertThumbprint = cert.GetCertHashString(), }; await using var stream = await FrameChannel.DefaultTcpConnectFactory(opts, cts.Token); stream.ShouldBeOfType(); ((SslStream)stream).IsAuthenticated.ShouldBeTrue(); await serverTask; listener.Stop(); } /// TLS wrong thumbprint: the pin check fails the validation callback → AuthenticationException. [Fact] public async Task Tls_WrongThumbprint_ThrowsAuthenticationException() { using var cts = new CancellationTokenSource(Timeout); using var cert = MakeSelfSignedCert(); var listener = new TcpListener(IPAddress.Loopback, 0); listener.Start(); var boundPort = ((IPEndPoint)listener.LocalEndpoint).Port; // The server still attempts its handshake; it will fault when the client aborts. Swallow. var serverTask = Task.Run(async () => { try { using var server = await listener.AcceptTcpClientAsync(cts.Token); var ssl = new SslStream(server.GetStream(), leaveInnerStreamOpen: false); await ssl.AuthenticateAsServerAsync(cert, clientCertificateRequired: false, enabledSslProtocols: SslProtocols.Tls12, checkCertificateRevocation: false); ssl.Dispose(); } catch { // Expected — the client rejects the cert and tears the connection down. } }, cts.Token); var opts = new WonderwareHistorianClientOptions("127.0.0.1", boundPort, "secret") { UseTls = true, ServerCertThumbprint = "00112233445566778899AABBCCDDEEFF00112233", // bogus }; await Should.ThrowAsync( async () => await FrameChannel.DefaultTcpConnectFactory(opts, cts.Token)); try { await serverTask; } catch { /* ignore */ } listener.Stop(); } }