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.
}
}
}
}