fix(mqtt): a broker-refused CONNECT no longer reports a Healthy driver
MQTTnet 5 does not throw on an unsuccessful CONNACK — it returns the reason in MqttClientConnectResult.ResultCode and v5 removed v4's ThrowOnNonSuccessfulConnectResponse, so ConnectCoreAsync's unconditional SetState(Connected) sealed a wrong-password deployment green: DriverState.Healthy / HostState.Running on a session that never authenticated. Caught by the Task-13 Mosquitto fixture; 240 offline tests missed it. ConnectCoreAsync now inspects the CONNACK and raises MqttConnectRejectedException. Credentials/identity/protocol/Last-Will refusals are unrecoverable and set Faulted, which stops the reconnect supervisor; transient refusals (ServerUnavailable, ServerBusy, QuotaExceeded, UseAnotherServer, ConnectionRateExceeded, and anything unrecognised) stay Reconnecting under backoff. MqttDriverProbe now shares the rejection wording, so the AdminUI Test-connect button and the running driver can no longer disagree. Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
using System.Text.Json;
|
||||
using MQTTnet;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The operator-visible half of the refused-CONNACK defect. <see cref="MqttConnectionTests"/>
|
||||
/// pins that <c>MqttConnection.ConnectAsync</c> raises the rejection; this pins the thing an
|
||||
/// operator actually sees — that a driver whose broker refused the CONNECT does <b>not</b> seal
|
||||
/// green.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Worth its own suite because the two failures are independent. The connection could classify a
|
||||
/// rejection perfectly and the driver could still report <c>Healthy</c>, simply by not letting the
|
||||
/// failure reach <c>InitializeCoreAsync</c>'s catch — which is exactly the shape the Task-13 live
|
||||
/// fixture found (a wrong broker password produced <c>DriverState.Healthy</c> /
|
||||
/// <c>HostState.Running</c> on a session that never authenticated).
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class MqttDriverConnectRejectionTests
|
||||
{
|
||||
/// <summary>
|
||||
/// THE regression pin. A configured-wrong password must fail the deploy loudly, not produce a
|
||||
/// healthy driver that will never receive a single value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InitializeAsync_BrokerRefusesTheCredentials_IsNotHealthy()
|
||||
{
|
||||
using var broker = new MiniBroker { ConnAckReturnCode = MiniBroker.ReturnCodeNotAuthorized };
|
||||
var options = BrokerOptions(broker.Port);
|
||||
await using var driver = new MqttDriver(options, "d", null);
|
||||
|
||||
var ex = await Should.ThrowAsync<MqttConnectRejectedException>(
|
||||
async () => await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken));
|
||||
|
||||
ex.ResultCode.ShouldBe(MqttClientConnectResultCode.NotAuthorized);
|
||||
|
||||
var health = driver.GetHealth();
|
||||
health.State.ShouldNotBe(DriverState.Healthy, "a driver the broker refused must never seal green");
|
||||
health.State.ShouldBe(DriverState.Faulted);
|
||||
health.LastError.ShouldNotBeNullOrWhiteSpace();
|
||||
health.LastError!.ShouldContain("rejected the credentials");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The transient counterpart: still a failed initialize (the driver has no session), but it must
|
||||
/// not be reported as the same unrecoverable shape — a broker restarting is not a misconfigured
|
||||
/// deployment.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InitializeAsync_BrokerTemporarilyUnavailable_FailsAsTransient()
|
||||
{
|
||||
using var broker = new MiniBroker { ConnAckReturnCode = MiniBroker.ReturnCodeServerUnavailable };
|
||||
var options = BrokerOptions(broker.Port);
|
||||
await using var driver = new MqttDriver(options, "d", null);
|
||||
|
||||
var ex = await Should.ThrowAsync<MqttConnectRejectedException>(
|
||||
async () => await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken));
|
||||
|
||||
ex.IsUnrecoverable.ShouldBeFalse();
|
||||
driver.GetHealth().State.ShouldNotBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The control. Without it, every assertion above would still hold if the driver simply never
|
||||
/// went healthy against this fake broker — the suite would be green and prove nothing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InitializeAsync_BrokerAcceptsTheConnect_IsHealthy()
|
||||
{
|
||||
using var broker = new MiniBroker();
|
||||
var options = BrokerOptions(broker.Port);
|
||||
await using var driver = new MqttDriver(options, "d", null);
|
||||
|
||||
await driver.InitializeAsync(Json(options), TestContext.Current.CancellationToken);
|
||||
|
||||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
private static MqttDriverOptions BrokerOptions(int port) => new()
|
||||
{
|
||||
Mode = MqttMode.Plain,
|
||||
Host = "127.0.0.1",
|
||||
Port = port,
|
||||
UseTls = false,
|
||||
ProtocolVersion = MqttProtocolVersion.V311,
|
||||
Username = "svc",
|
||||
Password = "pw",
|
||||
KeepAliveSeconds = 300,
|
||||
ConnectTimeoutSeconds = 5,
|
||||
ReconnectMinBackoffSeconds = 1,
|
||||
ReconnectMaxBackoffSeconds = 2,
|
||||
};
|
||||
|
||||
private static string Json(MqttDriverOptions options) => JsonSerializer.Serialize(options, MqttJson.Options);
|
||||
}
|
||||
Reference in New Issue
Block a user