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:
Joseph Doherty
2026-07-24 18:02:00 -04:00
parent 033b3700c4
commit 2409d05a28
6 changed files with 646 additions and 204 deletions
@@ -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=&lt;none&gt;</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=&lt;none&gt;</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}.");
}