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:
+35
-41
@@ -88,34 +88,30 @@ public sealed class PlainMqttLiveTests(MqttFixture fixture)
|
||||
/// <c>allow_anonymous false</c>, so if bad credentials produced a session the fixture would be
|
||||
/// misconfigured and "connected" would prove nothing about auth.
|
||||
/// <para>
|
||||
/// <b>LIVE-GATE FINDING (2026-07-24) — the invariant asserted here is deliberately "no
|
||||
/// session", not "ConnectAsync throws".</b> Against real Mosquitto,
|
||||
/// <see cref="MqttConnection.ConnectAsync"/> <b>returns normally</b> for a CONNECT the
|
||||
/// broker rejects as not-authorized: MQTTnet 5 does not throw on an unsuccessful CONNACK,
|
||||
/// and <c>ConnectCoreAsync</c> then unconditionally publishes
|
||||
/// <see cref="MqttConnectionState.Connected"/> and starts the reconnect supervisor.
|
||||
/// Measured at +300 ms with a wrong password: <c>thrown=<none></c>,
|
||||
/// <c>IsConnected=False</c>, <c>State=Reconnecting</c> (the broker's socket close had
|
||||
/// fired <c>DisconnectedAsync</c> by then). Broker side:
|
||||
/// <c>Client … disconnected, not authorised.</c>
|
||||
/// <b>LIVE-GATE FINDING (2026-07-24), now FIXED — this test was tightened accordingly.</b>
|
||||
/// Against real Mosquitto, <see cref="MqttConnection.ConnectAsync"/> originally
|
||||
/// <b>returned normally</b> for a CONNECT the broker rejected as not-authorized: MQTTnet 5
|
||||
/// does not throw on an unsuccessful CONNACK, and <c>ConnectCoreAsync</c> then
|
||||
/// unconditionally published <see cref="MqttConnectionState.Connected"/> and started the
|
||||
/// reconnect supervisor. Measured at +300 ms with a wrong password:
|
||||
/// <c>thrown=<none></c>, <c>IsConnected=False</c>, <c>State=Reconnecting</c>. Broker
|
||||
/// side: <c>Client … disconnected, not authorised.</c> The operator-visible consequence was
|
||||
/// that <c>InitializeAsync</c> reported <c>DriverState.Healthy</c> / <c>HostState.Running</c>
|
||||
/// off a connect that never authenticated, so a deployment carrying the wrong broker
|
||||
/// password sealed green.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// That is a driver-side defect, not a fixture one — <c>InitializeAsync</c> reports
|
||||
/// <c>DriverState.Healthy</c> / <c>HostState.Running</c> off a connect that never
|
||||
/// authenticated, so a deployment carrying the wrong broker password seals green and the
|
||||
/// AdminUI "Test connect" probe is expected to agree. Fixing it belongs in the driver
|
||||
/// (<c>MqttConnection</c> must inspect the connect result / opt into MQTTnet's
|
||||
/// throw-on-unsuccessful-CONNACK), which is outside this task's file scope; it is reported
|
||||
/// as a live-gate finding.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The test is written to hold either way: it records whether the call threw (as a
|
||||
/// diagnostic, not an assertion) and asserts only the invariant that must be true under
|
||||
/// both behaviours — no session is ever established. A driver fixed to throw still passes.
|
||||
/// <c>ConnectCoreAsync</c> now inspects the CONNACK and raises
|
||||
/// <see cref="MqttConnectRejectedException"/>, so this asserts the strong form: the
|
||||
/// rejection is <b>surfaced</b>, classified as unrecoverable, and lands the connection in
|
||||
/// <see cref="MqttConnectionState.Faulted"/>. The original weak invariant — polled over 5 s
|
||||
/// so the optimistic <c>Connected</c> window cannot pass it for the wrong reason — is kept
|
||||
/// underneath it, because "no session is ever established" is the property that actually
|
||||
/// matters to a consumer and it must hold regardless of how the failure is reported.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Tls_connect_with_wrong_password_never_establishes_a_session()
|
||||
public async Task Tls_connect_with_wrong_password_is_rejected_and_surfaced()
|
||||
{
|
||||
SkipIfUnconfigured();
|
||||
|
||||
@@ -123,34 +119,32 @@ public sealed class PlainMqttLiveTests(MqttFixture fixture)
|
||||
var options = TlsOptions() with { Password = _fx.Password + "-definitely-wrong" };
|
||||
await using var connection = new MqttConnection(options, "mqtt-live-badpass");
|
||||
|
||||
Exception? thrown = null;
|
||||
try
|
||||
{
|
||||
await connection.ConnectAsync(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
thrown = ex;
|
||||
}
|
||||
var rejection = await Should.ThrowAsync<MqttConnectRejectedException>(
|
||||
async () => await connection.ConnectAsync(ct));
|
||||
|
||||
// Poll rather than sample once: the rejection arrives as a broker-initiated socket close a
|
||||
// few milliseconds after ConnectAsync returns, so a single immediate read could catch the
|
||||
// optimistic Connected state and pass for the wrong reason. Every sample must show no
|
||||
// session — a correct password would make IsConnected true within the first sample.
|
||||
// Mosquitto answers bad credentials with NotAuthorized (MQTT 5) / 0x05 (3.1.1); either maps to
|
||||
// the same unrecoverable classification, which is what stops the reconnect supervisor.
|
||||
rejection.IsUnrecoverable.ShouldBeTrue(
|
||||
$"a broker that refused the credentials returned {rejection.ResultCode}, classified as retryable");
|
||||
rejection.Message.ShouldNotContain(options.Password ?? "<unset>", Case.Sensitive);
|
||||
connection.State.ShouldBe(MqttConnectionState.Faulted);
|
||||
|
||||
// Poll rather than sample once: a regression that went back to publishing an optimistic
|
||||
// Connected would still read "not connected" in the first millisecond. Every sample must show
|
||||
// no session — a correct password would make IsConnected true within the first sample.
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
connection.IsConnected.ShouldBeFalse(
|
||||
"a CONNECT the broker rejected as not-authorized must never yield a usable session");
|
||||
connection.State.ShouldNotBe(
|
||||
MqttConnectionState.Connected,
|
||||
"a rejected CONNECT must never advertise a healthy session");
|
||||
await Task.Delay(100, ct);
|
||||
}
|
||||
|
||||
connection.State.ShouldNotBe(
|
||||
MqttConnectionState.Connected,
|
||||
"the settled state after a rejected CONNECT must not advertise a healthy session");
|
||||
|
||||
TestContext.Current.SendDiagnosticMessage(
|
||||
$"wrong-password connect: threw={thrown?.GetType().Name ?? "<nothing — see the LIVE-GATE FINDING in this test's docs>"}, "
|
||||
$"wrong-password connect: {rejection.ResultCode} (unrecoverable={rejection.IsUnrecoverable}), "
|
||||
+ $"IsConnected={connection.IsConnected}, State={connection.State}.");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user