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. /// /// The assertions are the whole point. "Ok is false with a non-empty message" is /// ALSO what a JSON-parse failure produces, so asserting only that would stay green if /// JsonStringEnumConverter were ever dropped from /// — i.e. the exact regression this test exists to catch. So it asserts the probe got /// PAST the parse (message is not the "Config JSON is invalid" shape) and reached /// the network (the message names the target endpoint). /// /// [Fact] public async Task ProbeAsync_EnumAsName_ParsesAndReachesTheNetwork() { 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(); r.Message.ShouldNotStartWith( "Config JSON is invalid", Case.Insensitive, "the probe never parsed the config — 'V500' was rejected, so the shared JSON options " + "lost their JsonStringEnumConverter"); r.Message.ShouldNotStartWith("Config JSON deserialized to null"); r.Message.ShouldNotStartWith("Config has no host/port"); // A network-level marker: the probe got as far as dialling the (closed) loopback port. r.Message.ShouldContain($"127.0.0.1:{port}"); } }