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)); } /// /// Task 4's reconnect loop retries connect on the SAME instance, reusing the /// IMqttClient held across failures. A failed attempt must therefore leave the /// connection usable rather than poisoned. /// [Fact] public async Task ConnectAsync_CanBeRetriedOnTheSameInstanceAfterAFailedAttempt() { 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)); // Second attempt reaches the broker again and fails the same way — not ObjectDisposed, // not a null client, not a silently-swallowed no-op. await Should.ThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None)); conn.IsConnected.ShouldBeFalse(); } /// /// Connecting a disposed instance is an — the same /// classification the in-flight dispose race folds into. Dispose is idempotent. /// [Fact] public async Task ConnectAsync_AfterDispose_ThrowsObjectDisposed_AndDisposeIsIdempotent() { var opts = new MqttDriverOptions { Host = "127.0.0.1", Port = 1, UseTls = false, ConnectTimeoutSeconds = 1 }; var conn = new MqttConnection(opts, driverId: "t", logger: null); await conn.DisposeAsync(); await conn.DisposeAsync(); 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(); using var stranger = SelfSignedLeaf(Host); tls.CertificateValidationHandler(ValidationArgs(built, stranger)).ShouldBeFalse(); } /// An untrusted, unpinned server certificate is rejected — the pin actually validates. [Fact] public void BuildClientOptions_CaPin_RejectsCertificateNotIssuedByThePinnedCa() { using var ca = IssueCa(issuer: null, "otopcua-test-ca", validDays: 30); var caPath = WritePem(ca); 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; using var stranger = SelfSignedLeaf(Host); tls.CertificateValidationHandler!(ValidationArgs(built, stranger)).ShouldBeFalse(); } finally { File.Delete(caPath); } } /// /// The accept path. Without this every certificate test asserts rejection, so deleting the /// return true — or getting TrustMode / CustomTrustStore wrong — would /// leave the suite green while no broker could ever connect. /// [Fact] public void BuildClientOptions_CaPin_AcceptsLeafIssuedByThePinnedCa() { using var ca = IssueCa(issuer: null, "otopcua-test-ca", validDays: 30); using var leaf = IssueLeaf(ca, Host, validDays: 20); var caPath = WritePem(ca); 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!(ValidationArgs(built, leaf)).ShouldBeTrue(); } finally { File.Delete(caPath); } } /// /// The common real-world broker topology: the leaf is signed by an intermediate that chains /// to the pinned root, and the intermediate is delivered in the handshake rather than being /// installed locally. Only the pinned ROOT is written to the CA file — the intermediate must /// be picked up from what the broker presented. /// [Fact] public void BuildClientOptions_CaPin_AcceptsLeafBehindAnIntermediateThatTheBrokerPresented() { using var root = IssueCa(issuer: null, "otopcua-test-root", validDays: 30); using var intermediate = IssueCa(root, "otopcua-test-intermediate", validDays: 25); using var leaf = IssueLeaf(intermediate, Host, validDays: 20); var caPath = WritePem(root); 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!(ValidationArgs(built, leaf, intermediate)).ShouldBeTrue(); } 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 readonly DateTimeOffset NotBefore = DateTimeOffset.UtcNow.AddDays(-1); /// A CA certificate, self-signed when is null. private static X509Certificate2 IssueCa(X509Certificate2? issuer, string commonName, int validDays) { using var key = RSA.Create(2048); var request = new CertificateRequest($"CN={commonName}", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true)); request.CertificateExtensions.Add( new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true)); request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); if (issuer is null) { return request.CreateSelfSigned(NotBefore, NotBefore.AddDays(validDays)); } // Create() drops the private key; an intermediate needs it back to sign its own children. using var issued = request.Create(issuer, NotBefore, NotBefore.AddDays(validDays), NextSerial()); return issued.CopyWithPrivateKey(key); } /// A server (end-entity) certificate signed by . private static X509Certificate2 IssueLeaf(X509Certificate2 issuer, string commonName, int validDays) { using var key = RSA.Create(2048); var request = new CertificateRequest($"CN={commonName}", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true)); request.CertificateExtensions.Add( new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, true)); request.CertificateExtensions.Add( new X509EnhancedKeyUsageExtension([new Oid("1.3.6.1.5.5.7.3.1")], false)); // serverAuth request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); return request.Create(issuer, NotBefore, NotBefore.AddDays(validDays), NextSerial()); } /// A self-signed leaf that chains to nothing — the "stranger" a pin must reject. private static X509Certificate2 SelfSignedLeaf(string commonName) { using var key = RSA.Create(2048); var request = new CertificateRequest($"CN={commonName}", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); return request.CreateSelfSigned(NotBefore, NotBefore.AddDays(20)); } private static byte[] NextSerial() => RandomNumberGenerator.GetBytes(8); private static string WritePem(params X509Certificate2[] certificates) { var path = Path.Combine(Path.GetTempPath(), $"otopcua-mqtt-ca-{Guid.NewGuid():N}.pem"); File.WriteAllText(path, string.Join(Environment.NewLine, certificates.Select(c => c.ExportCertificatePem()))); return path; } /// /// Mimics what SslStream hands a validation callback: the presented leaf plus a chain /// carrying whatever intermediates the peer supplied. The chain is built (and allowed to /// fail) purely so ChainElements is populated the way the platform would populate it. /// private static MqttClientCertificateValidationEventArgs ValidationArgs( MqttClientOptions built, X509Certificate2 leaf, params X509Certificate2[] presentedIntermediates) { var chain = new X509Chain(); chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllFlags; foreach (var intermediate in presentedIntermediates) { chain.ChainPolicy.ExtraStore.Add(intermediate); } chain.Build(leaf); return new MqttClientCertificateValidationEventArgs( leaf, chain, SslPolicyErrors.RemoteCertificateChainErrors, built.ChannelOptions); } /// /// 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. } } } }