feat(mqtt): MqttDriverProbe CONNECT handshake

Test-connect probe for the Mqtt driver type: parses MqttDriverOptions
(shared enum-as-name JsonSerializerOptions), opens a bounded MQTT CONNECT
via MqttConnection.BuildClientOptions with a distinct -probe-{guid8}
client-id suffix, and classifies the outcome. Live-probed against
MQTTnet 5.2.0.1603 to confirm the real contract: a broker-rejected
CONNACK is a MqttClientConnectResult.ResultCode, never a thrown
exception, and timeout classification checks the deadline token itself
rather than switching on exception type (which varies unpredictably
between MqttConnectingFailedException and bare OperationCanceledException
for the same frozen-peer shape).

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 16:43:36 -04:00
parent abf3116bd4
commit a856d61510
2 changed files with 426 additions and 0 deletions
@@ -0,0 +1,182 @@
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Tests for <see cref="MqttDriverProbe"/>. Mirrors <c>ModbusDriverProbeTests</c>'
/// shape: a real loopback listener drives server-side behaviour so the probe's CONNECT
/// handshake is exercised against something real rather than mocked.
/// </summary>
[Trait("Category", "Unit")]
public sealed class MqttDriverProbeTests
{
private static readonly TimeSpan QuickTimeout = TimeSpan.FromSeconds(3);
/// <summary>A loopback listener that accepts the TCP connection and then never answers CONNACK —
/// the shape that actually wedges a client. A refused port fails instantly with ECONNREFUSED and
/// would pass whether or not the probe's deadline logic works at all, so the timeout test below
/// needs this rather than a closed port.</summary>
private sealed class BlackholeBroker : IDisposable
{
private readonly TcpListener _listener;
private readonly CancellationTokenSource _cts = new();
private readonly List<TcpClient> _accepted = [];
public BlackholeBroker()
{
_listener = new TcpListener(IPAddress.Loopback, 0);
_listener.Start();
Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
_ = Task.Run(AcceptLoopAsync);
}
public int Port { get; }
private async Task AcceptLoopAsync()
{
try
{
while (!_cts.IsCancellationRequested)
{
var client = await _listener.AcceptTcpClientAsync(_cts.Token).ConfigureAwait(false);
lock (_accepted)
{
_accepted.Add(client);
}
// Accept the TCP handshake and then say nothing — never a CONNACK.
}
}
catch (Exception)
{
// Listener stopped / cancelled — expected at teardown.
}
}
public void Dispose()
{
_cts.Cancel();
_listener.Stop();
lock (_accepted)
{
foreach (var client in _accepted)
{
client.Dispose();
}
}
_cts.Dispose();
}
}
private static TcpListener StartListener()
{
var l = new TcpListener(IPAddress.Loopback, 0);
l.Start();
return l;
}
private static int ListenerPort(TcpListener l) => ((IPEndPoint)l.LocalEndpoint).Port;
[Fact]
public void DriverType_IsCanonicalMqtt() => new MqttDriverProbe().DriverType.ShouldBe("Mqtt");
/// <summary>The plan's own vacuous-pass trap: a closed port fails instantly (ECONNREFUSED)
/// regardless of whether the deadline logic works. Kept as the "refused" leg, not the
/// "deadline" leg — see <see cref="ProbeAsync_BlackholeBroker_FailsWithinDeadline_NoHang"/>
/// for the real timeout-path exercise.</summary>
[Fact]
public async Task ProbeAsync_DeadBroker_ReturnsFailedResult_WithinDeadline()
{
var listener = StartListener();
var port = ListenerPort(listener);
listener.Stop();
var r = await new MqttDriverProbe().ProbeAsync(
$$"""{"host":"127.0.0.1","port":{{port}},"useTls":false,"connectTimeoutSeconds":2}""",
QuickTimeout,
CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNullOrEmpty();
}
/// <summary>The real timeout-path exercise: a broker that completes the TCP handshake and then
/// never answers CONNACK must still fail at the configured deadline rather than hang.</summary>
[Fact]
public async Task ProbeAsync_BlackholeBroker_FailsWithinDeadline_NoHang()
{
using var blackhole = new BlackholeBroker();
var config = $$"""{"host":"127.0.0.1","port":{{blackhole.Port}},"useTls":false,"connectTimeoutSeconds":1}""";
var sw = Stopwatch.StartNew();
var r = await new MqttDriverProbe().ProbeAsync(config, QuickTimeout, CancellationToken.None);
sw.Stop();
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNullOrEmpty();
r.Message.ShouldContain("timed out", Case.Insensitive);
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10));
}
[Fact]
public async Task ProbeAsync_InvalidJson_ReturnsFailedResult_NotThrown()
{
var r = await new MqttDriverProbe().ProbeAsync("not valid json {{", QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNullOrEmpty();
}
[Fact]
public async Task ProbeAsync_NoHost_ReturnsFailedResult()
{
var r = await new MqttDriverProbe().ProbeAsync(
"""{"host":"","port":0}""", QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNullOrEmpty();
}
/// <summary>Never leak a configured password into the probe's result message — it surfaces
/// directly in the AdminUI Test-connect UI.</summary>
[Fact]
public async Task ProbeAsync_NeverLeaksPasswordIntoMessage()
{
var listener = StartListener();
var port = ListenerPort(listener);
listener.Stop();
const string secret = "S3cr3tSquirrel!!";
var config = $$"""
{"host":"127.0.0.1","port":{{port}},"useTls":false,"username":"probe-user","password":"{{secret}}","connectTimeoutSeconds":2}
""";
var r = await new MqttDriverProbe().ProbeAsync(config, QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNullOrEmpty();
r.Message.ShouldNotContain(secret);
}
/// <summary>Enum fields (protocolVersion) must round-trip by name, per the repo's documented
/// enum-serialization trap — a numeric-only seam faults AdminUI-authored configs.</summary>
[Fact]
public async Task ProbeAsync_EnumAsName_DoesNotThrow()
{
var listener = StartListener();
var port = ListenerPort(listener);
listener.Stop();
var config = $$"""
{"host":"127.0.0.1","port":{{port}},"useTls":false,"protocolVersion":"V500","connectTimeoutSeconds":2}
""";
var r = await new MqttDriverProbe().ProbeAsync(config, QuickTimeout, CancellationToken.None);
r.Ok.ShouldBeFalse();
r.Message.ShouldNotBeNullOrEmpty();
}
}