using System.Diagnostics; using System.Net; using System.Net.Security; using System.Net.Sockets; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using MQTTnet; using Shouldly; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; /// /// Covers 's two Task-3 responsibilities: the pure /// options assembly (TLS / CA pin / /// credentials / protocol knobs) and the bounded /// deadline. /// public sealed class MqttConnectionTests { private const string Host = "broker.example.test"; // --------------------------------------------------------------------------------- // Bounded connect (the R2-01 frozen-peer lesson: no unbounded wait on a dead broker) // --------------------------------------------------------------------------------- /// /// A broker that completes the TCP handshake but never answers CONNACK is the shape that /// actually wedges a client — a refused port returns ECONNREFUSED instantly and would pass /// whether or not any deadline logic existed. /// [Fact] public async Task ConnectAsync_BlackholeBroker_FailsWithinDeadline_NoHang() { using var blackhole = new BlackholeBroker(); var opts = new MqttDriverOptions { Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 2, }; await using var conn = new MqttConnection(opts, driverId: "t", logger: null); var sw = Stopwatch.StartNew(); await Should.ThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); sw.Stop(); sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10)); conn.IsConnected.ShouldBeFalse(); } /// /// Falsifiability control for the linked-CTS wiring specifically: the configured connect /// deadline is 60 s, so the only thing that can end this call inside 10 s is the caller's /// token actually reaching MQTTnet. Drop the CreateLinkedTokenSource and this test /// hangs for a minute. /// [Fact] public async Task ConnectAsync_CallerCancellation_ObservedLongBeforeConnectDeadline() { using var blackhole = new BlackholeBroker(); var opts = new MqttDriverOptions { Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 60, }; await using var conn = new MqttConnection(opts, driverId: "t", logger: null); using var caller = new CancellationTokenSource(TimeSpan.FromMilliseconds(300)); var sw = Stopwatch.StartNew(); await Should.ThrowAsync(async () => await conn.ConnectAsync(caller.Token)); sw.Stop(); sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10)); } /// /// The deadline leg is distinguishable from a caller cancellation — a timed-out connect /// surfaces , not a bare , /// so an operator can tell "broker never answered" from "we were asked to stop". /// [Fact] public async Task ConnectAsync_DeadlineElapsed_ThrowsTimeoutException() { using var blackhole = new BlackholeBroker(); var opts = new MqttDriverOptions { Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 1, }; await using var conn = new MqttConnection(opts, driverId: "t", logger: null); await Should.ThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); } /// Never log or throw credentials — the plan's cross-cutting secrets rule. [Fact] public async Task ConnectAsync_Failure_DoesNotLeakPasswordIntoExceptionMessage() { const string secret = "sup3r-s3cret-broker-pw"; using var blackhole = new BlackholeBroker(); var opts = new MqttDriverOptions { Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 1, Username = "svc", Password = secret, }; await using var conn = new MqttConnection(opts, driverId: "t", logger: null); var ex = await Should.ThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); ex.ToString().ShouldNotContain(secret); } // --------------------------------------------------------------------------------- // BuildClientOptions — pure assembly, no I/O // --------------------------------------------------------------------------------- [Fact] public void BuildClientOptions_UseTlsWithCaPin_SetsTlsAndInstallsChainValidator() { var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, AllowUntrustedServerCertificate = false, CaCertificatePath = "/tmp/ca.pem", }; var tls = BuildTls(opts); tls.UseTls.ShouldBeTrue(); tls.TargetHost.ShouldBe(Host); tls.AllowUntrustedCertificates.ShouldBeFalse(); tls.IgnoreCertificateChainErrors.ShouldBeFalse(); tls.CertificateValidationHandler.ShouldNotBeNull(); } /// /// The CA file is read lazily inside the validation callback, never by the builder — so a /// path that does not exist yet must not throw at build time, and must fail the *connection* /// closed when the handshake finally asks. /// [Fact] public void BuildClientOptions_CaPinPathMissing_BuildsWithoutIo_AndValidatorFailsClosed() { var missing = Path.Combine(Path.GetTempPath(), $"otopcua-mqtt-no-such-ca-{Guid.NewGuid():N}.pem"); File.Exists(missing).ShouldBeFalse(); var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, CaCertificatePath = missing }; var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null); var tls = built.ChannelOptions.ShouldBeOfType().TlsOptions; tls.CertificateValidationHandler.ShouldNotBeNull(); tls.CertificateValidationHandler(SelfSignedValidationArgs(built)).ShouldBeFalse(); } /// An untrusted, unpinned server certificate is rejected — the pin actually validates. [Fact] public void BuildClientOptions_CaPin_RejectsCertificateNotIssuedByThePinnedCa() { var caPath = WriteTempPemCa(); try { var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, CaCertificatePath = caPath }; var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null); var tls = built.ChannelOptions.ShouldBeOfType().TlsOptions; tls.CertificateValidationHandler!(SelfSignedValidationArgs(built)).ShouldBeFalse(); } finally { File.Delete(caPath); } } /// Default posture: OS trust store, no custom handler, escape hatch off. [Fact] public void BuildClientOptions_TlsWithoutCaPin_LeavesOsTrustStoreValidationInPlace() { var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true }; var tls = BuildTls(opts); tls.UseTls.ShouldBeTrue(); tls.CertificateValidationHandler.ShouldBeNull(); tls.AllowUntrustedCertificates.ShouldBeFalse(); tls.IgnoreCertificateChainErrors.ShouldBeFalse(); tls.IgnoreCertificateRevocationErrors.ShouldBeFalse(); } /// The dev/on-prem escape hatch only opens when explicitly asked for. [Fact] public void BuildClientOptions_AllowUntrustedServerCertificate_OpensEscapeHatch() { var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, AllowUntrustedServerCertificate = true, }; var tls = BuildTls(opts); tls.AllowUntrustedCertificates.ShouldBeTrue(); tls.IgnoreCertificateChainErrors.ShouldBeTrue(); tls.CertificateValidationHandler.ShouldNotBeNull(); } [Fact] public void BuildClientOptions_UseTlsFalse_KeepsTlsOffAndInstallsNoValidator() { var opts = new MqttDriverOptions { Host = Host, Port = 1883, UseTls = false, AllowUntrustedServerCertificate = true, CaCertificatePath = "/tmp/ca.pem", }; var tls = BuildTls(opts); tls.UseTls.ShouldBeFalse(); tls.CertificateValidationHandler.ShouldBeNull(); tls.AllowUntrustedCertificates.ShouldBeFalse(); } [Fact] public void BuildClientOptions_MapsProtocolAndSessionKnobs() { var opts = new MqttDriverOptions { Host = Host, Port = 8883, ClientId = "otopcua-node-1", ProtocolVersion = MqttProtocolVersion.V311, CleanSession = false, KeepAliveSeconds = 45, ConnectTimeoutSeconds = 7, }; var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null); built.ProtocolVersion.ShouldBe(MQTTnet.Formatter.MqttProtocolVersion.V311); built.ClientId.ShouldBe("otopcua-node-1"); built.CleanSession.ShouldBeFalse(); built.KeepAlivePeriod.ShouldBe(TimeSpan.FromSeconds(45)); built.Timeout.ShouldBe(TimeSpan.FromSeconds(7)); var tcp = built.ChannelOptions.ShouldBeOfType(); tcp.RemoteEndpoint.ShouldBeOfType().Host.ShouldBe(Host); tcp.RemoteEndpoint.ShouldBeOfType().Port.ShouldBe(8883); } [Fact] public void BuildClientOptions_ClientIdSuffix_IsAppended() { var opts = new MqttDriverOptions { Host = Host, ClientId = "otopcua" }; MqttConnection.BuildClientOptions(opts, clientIdSuffix: "-browse").ClientId.ShouldBe("otopcua-browse"); } [Fact] public void BuildClientOptions_NoUsername_SendsNoCredentials() => MqttConnection.BuildClientOptions(new MqttDriverOptions { Host = Host }, clientIdSuffix: null) .Credentials.ShouldBeNull(); [Fact] public void BuildClientOptions_Username_SendsCredentials() { var opts = new MqttDriverOptions { Host = Host, Username = "svc", Password = "pw" }; var built = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null); built.Credentials.ShouldNotBeNull(); built.Credentials.GetUserName(built).ShouldBe("svc"); } /// The ctor must not touch the network — the universal-browser throwaway-instance pattern. [Fact] public void Ctor_IsConnectionFree() { var opts = new MqttDriverOptions { Host = "127.0.0.1", Port = 1, UseTls = false }; var conn = new MqttConnection(opts, driverId: "t", logger: null); conn.IsConnected.ShouldBeFalse(); } // --------------------------------------------------------------------------------- // helpers // --------------------------------------------------------------------------------- private static MqttClientTlsOptions BuildTls(MqttDriverOptions opts) => MqttConnection.BuildClientOptions(opts, clientIdSuffix: null) .ChannelOptions.ShouldBeOfType().TlsOptions; private static MqttClientCertificateValidationEventArgs SelfSignedValidationArgs(MqttClientOptions built) { using var rsa = RSA.Create(2048); var request = new CertificateRequest($"CN={Host}", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); var cert = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); return new MqttClientCertificateValidationEventArgs( cert, new X509Chain(), SslPolicyErrors.RemoteCertificateChainErrors, built.ChannelOptions); } private static string WriteTempPemCa() { using var rsa = RSA.Create(2048); var request = new CertificateRequest("CN=otopcua-test-ca", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); using var ca = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)); var path = Path.Combine(Path.GetTempPath(), $"otopcua-mqtt-ca-{Guid.NewGuid():N}.pem"); File.WriteAllText(path, ca.ExportCertificatePem()); return path; } /// /// Accepts the TCP connection and then says nothing — the client sends CONNECT and waits /// forever for a CONNACK that never comes. This is the frozen-peer shape; a closed port is /// not (it refuses instantly and proves nothing about a deadline). /// private sealed class BlackholeBroker : IDisposable { private readonly List _accepted = []; private readonly CancellationTokenSource _cts = new(); private readonly TcpListener _listener; public BlackholeBroker() { _listener = new TcpListener(IPAddress.Loopback, 0); _listener.Start(); Port = ((IPEndPoint)_listener.LocalEndpoint).Port; _ = Task.Run(AcceptLoopAsync); } public int Port { get; } public void Dispose() { _cts.Cancel(); _listener.Stop(); lock (_accepted) { foreach (var client in _accepted) { client.Dispose(); } _accepted.Clear(); } _cts.Dispose(); } private async Task AcceptLoopAsync() { try { while (!_cts.IsCancellationRequested) { var client = await _listener.AcceptTcpClientAsync(_cts.Token); lock (_accepted) { _accepted.Add(client); } } } catch (Exception) { // Listener stopped / token cancelled — the fixture is going away. } } } }