using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
///
/// Tests for . Mirrors ModbusDriverProbeTests'
/// shape: a real loopback listener drives server-side behaviour so the probe's CONNECT
/// handshake is exercised against something real rather than mocked.
///
[Trait("Category", "Unit")]
public sealed class MqttDriverProbeTests
{
private static readonly TimeSpan QuickTimeout = TimeSpan.FromSeconds(3);
/// 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.
private sealed class BlackholeBroker : IDisposable
{
private readonly TcpListener _listener;
private readonly CancellationTokenSource _cts = new();
private readonly List _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");
/// 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
/// for the real timeout-path exercise.
[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();
}
/// 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.
[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();
}
/// Never leak a configured password into the probe's result message — it surfaces
/// directly in the AdminUI Test-connect UI.
[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);
}
/// Enum fields (protocolVersion) must round-trip by name, per the repo's documented
/// enum-serialization trap — a numeric-only seam faults AdminUI-authored configs.
[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();
}
}