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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user