a856d61510
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
183 lines
6.4 KiB
C#
183 lines
6.4 KiB
C#
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();
|
|
}
|
|
}
|