feat(mqtt): MqttConnection connect/TLS/auth under bounded deadline
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="MqttConnection"/>'s two Task-3 responsibilities: the pure
|
||||
/// <see cref="MqttConnection.BuildClientOptions"/> options assembly (TLS / CA pin /
|
||||
/// credentials / protocol knobs) and the bounded <see cref="MqttConnection.ConnectAsync"/>
|
||||
/// deadline.
|
||||
/// </summary>
|
||||
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)
|
||||
// ---------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<Exception>(async () => await conn.ConnectAsync(CancellationToken.None));
|
||||
sw.Stop();
|
||||
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10));
|
||||
conn.IsConnected.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>CreateLinkedTokenSource</c> and this test
|
||||
/// hangs for a minute.
|
||||
/// </summary>
|
||||
[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<OperationCanceledException>(async () => await conn.ConnectAsync(caller.Token));
|
||||
sw.Stop();
|
||||
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The deadline leg is distinguishable from a caller cancellation — a timed-out connect
|
||||
/// surfaces <see cref="TimeoutException"/>, not a bare <see cref="OperationCanceledException"/>,
|
||||
/// so an operator can tell "broker never answered" from "we were asked to stop".
|
||||
/// </summary>
|
||||
[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<TimeoutException>(async () => await conn.ConnectAsync(CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <summary>Never log or throw credentials — the plan's cross-cutting secrets rule.</summary>
|
||||
[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<Exception>(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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<MqttClientTcpOptions>().TlsOptions;
|
||||
|
||||
tls.CertificateValidationHandler.ShouldNotBeNull();
|
||||
tls.CertificateValidationHandler(SelfSignedValidationArgs(built)).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>An untrusted, unpinned server certificate is rejected — the pin actually validates.</summary>
|
||||
[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<MqttClientTcpOptions>().TlsOptions;
|
||||
|
||||
tls.CertificateValidationHandler!(SelfSignedValidationArgs(built)).ShouldBeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(caPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Default posture: OS trust store, no custom handler, escape hatch off.</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>The dev/on-prem escape hatch only opens when explicitly asked for.</summary>
|
||||
[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<MqttClientTcpOptions>();
|
||||
tcp.RemoteEndpoint.ShouldBeOfType<DnsEndPoint>().Host.ShouldBe(Host);
|
||||
tcp.RemoteEndpoint.ShouldBeOfType<DnsEndPoint>().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");
|
||||
}
|
||||
|
||||
/// <summary>The ctor must not touch the network — the universal-browser throwaway-instance pattern.</summary>
|
||||
[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<MqttClientTcpOptions>().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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
private sealed class BlackholeBroker : IDisposable
|
||||
{
|
||||
private readonly List<TcpClient> _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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -21,6 +21,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj"/>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user