Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs
T
Joseph Doherty f79d13e2d8 feat(mqtt): factory + DriverTypeNames.Mqtt + host factory/probe registration
Task 9. Adds MqttDriverFactoryExtensions (DriverTypeName = DriverTypeNames.Mqtt,
direct deserialization of MqttDriverOptions — no intermediate DTO, mirroring
OpcUaClientDriverFactoryExtensions), the DriverTypeNames.Mqtt constant, and both
host wiring sites: the factory in DriverFactoryBootstrap.Register and the probe in
AddOtOpcUaDriverProbes (the admin-node path Program.cs calls in its hasAdmin
block — a probe wired only on driver nodes makes Test-connect silently dead).

Carried-forward items:

- Converges every MQTT config-parsing seam onto ONE JsonSerializerOptions —
  MqttJson.Options, in .Contracts alongside MqttDriverOptions. It replaces the
  probe's internal JsonOpts and the browser's separate private copy; the factory,
  the probe, MqttDriver.ParseOptions and MqttDriverBrowser now all parse through
  it. .Contracts is the only assembly all four consumers reference, and the
  browser's reference to the runtime .Driver project is a documented layering
  exception scheduled for removal — anchoring the options there would resurrect
  the duplicate the day it goes away.
- Replaces the "Mqtt" literals in MqttDriverProbe, MqttDriverBrowser and
  MqttDriver with the constant (string value unchanged).
- Tightens MqttDriverProbeTests.ProbeAsync_EnumAsName: it asserted only
  Ok == false + non-empty message, which is also exactly what a JSON-parse
  failure produces — so it stayed green under the very regression it names.
  It now asserts the probe got past the parse and reached the network.

Falsifiability: deleting JsonStringEnumConverter from MqttJson.Options reddens 9
tests across 4 suites, including the tightened probe test (message becomes
"Config JSON is invalid: The JSON value could not be converted to
MqttProtocolVersion") — which the pre-fix assertions would have passed.

Also references the MQTT driver from Core.Abstractions.Tests so
DriverTypeNamesGuardTests' reflective bin scan discovers the new factory and the
constant/factory parity check stays honest.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 17:24:13 -04:00

202 lines
7.6 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.
/// <para>
/// <b>The assertions are the whole point.</b> "Ok is false with a non-empty message" is
/// ALSO what a JSON-parse failure produces, so asserting only that would stay green if
/// <c>JsonStringEnumConverter</c> were ever dropped from <see cref="MqttJson.Options"/>
/// — i.e. the exact regression this test exists to catch. So it asserts the probe got
/// PAST the parse (message is not the <c>"Config JSON is invalid"</c> shape) and reached
/// the network (the message names the target endpoint).
/// </para>
/// </summary>
[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}");
}
}