5d4dd17d7f
Driver.Mqtt.Transport-002 (Low, correctness). Credentials were attached only when Username was non-empty, so a config carrying a Password and no Username connected anonymously with the secret silently discarded — the deployment reads as authenticated and is not, which is the direction an operator cannot see from the config. Warned rather than sent. MQTT 3.1.1 may not carry a password without a username, so the password-only CONNECT is not available on every protocol version this factory serves, and synthesizing one under V500 only would change the wire behaviour of deployments that connect happily today. The configured values stay authoritative; the drop just stops being silent. The secret is never formatted into the message. Tests: the warning fires and does NOT contain the password, plus the falsifying control that an ordinary anonymous config (neither credential set) stays silent — without it the fix could warn on every connect and still pass.
1353 lines
60 KiB
C#
1353 lines
60 KiB
C#
using System.Diagnostics;
|
|
using System.Net;
|
|
using System.Net.Security;
|
|
using System.Net.Sockets;
|
|
using System.Security.Cryptography;
|
|
using System.Security.Cryptography.X509Certificates;
|
|
using Microsoft.Extensions.Logging;
|
|
using MQTTnet;
|
|
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
|
|
|
/// <summary>
|
|
/// Covers <see cref="MqttConnection"/>'s two Task-3 responsibilities: the pure
|
|
/// <see cref="MqttClientOptionsFactory.BuildClientOptions"/> options assembly (TLS / CA pin /
|
|
/// credentials / protocol knobs) and the bounded <see cref="MqttConnection.ConnectAsync"/>
|
|
/// deadline.
|
|
/// </summary>
|
|
public sealed class MqttConnectionTests
|
|
{
|
|
private const string Host = "broker.example.test";
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// Bounded connect (the R2-01 frozen-peer lesson: no unbounded wait on a dead broker)
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// A broker that completes the TCP handshake but never answers CONNACK is the shape that
|
|
/// actually wedges a client — a refused port returns ECONNREFUSED instantly and would pass
|
|
/// whether or not any deadline logic existed.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ConnectAsync_BlackholeBroker_FailsWithinDeadline_NoHang()
|
|
{
|
|
using var blackhole = new BlackholeBroker();
|
|
var opts = new MqttDriverOptions
|
|
{
|
|
Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 2,
|
|
};
|
|
await using var conn = new MqttConnection(opts, driverId: "t", logger: null);
|
|
|
|
var sw = Stopwatch.StartNew();
|
|
await Should.ThrowAsync<Exception>(async () => await conn.ConnectAsync(CancellationToken.None));
|
|
sw.Stop();
|
|
|
|
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10));
|
|
conn.IsConnected.ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Falsifiability control for the linked-CTS wiring specifically: the configured connect
|
|
/// deadline is 60 s, so the only thing that can end this call inside 10 s is the caller's
|
|
/// token actually reaching MQTTnet. Drop the <c>CreateLinkedTokenSource</c> and this test
|
|
/// hangs for a minute.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ConnectAsync_CallerCancellation_ObservedLongBeforeConnectDeadline()
|
|
{
|
|
using var blackhole = new BlackholeBroker();
|
|
var opts = new MqttDriverOptions
|
|
{
|
|
Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 60,
|
|
};
|
|
await using var conn = new MqttConnection(opts, driverId: "t", logger: null);
|
|
using var caller = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
|
|
|
|
var sw = Stopwatch.StartNew();
|
|
await Should.ThrowAsync<OperationCanceledException>(async () => await conn.ConnectAsync(caller.Token));
|
|
sw.Stop();
|
|
|
|
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The deadline leg is distinguishable from a caller cancellation — a timed-out connect
|
|
/// surfaces <see cref="TimeoutException"/>, not a bare <see cref="OperationCanceledException"/>,
|
|
/// so an operator can tell "broker never answered" from "we were asked to stop".
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ConnectAsync_DeadlineElapsed_ThrowsTimeoutException()
|
|
{
|
|
using var blackhole = new BlackholeBroker();
|
|
var opts = new MqttDriverOptions
|
|
{
|
|
Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 1,
|
|
};
|
|
await using var conn = new MqttConnection(opts, driverId: "t", logger: null);
|
|
|
|
await Should.ThrowAsync<TimeoutException>(async () => await conn.ConnectAsync(CancellationToken.None));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Task 4's reconnect loop retries connect on the SAME instance, reusing the
|
|
/// <c>IMqttClient</c> held across failures. A failed attempt must therefore leave the
|
|
/// connection usable rather than poisoned.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ConnectAsync_CanBeRetriedOnTheSameInstanceAfterAFailedAttempt()
|
|
{
|
|
using var blackhole = new BlackholeBroker();
|
|
var opts = new MqttDriverOptions
|
|
{
|
|
Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 1,
|
|
};
|
|
await using var conn = new MqttConnection(opts, driverId: "t", logger: null);
|
|
|
|
await Should.ThrowAsync<TimeoutException>(async () => await conn.ConnectAsync(CancellationToken.None));
|
|
|
|
// Second attempt reaches the broker again and fails the same way — not ObjectDisposed,
|
|
// not a null client, not a silently-swallowed no-op.
|
|
await Should.ThrowAsync<TimeoutException>(async () => await conn.ConnectAsync(CancellationToken.None));
|
|
conn.IsConnected.ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Connecting a disposed instance is an <see cref="ObjectDisposedException"/> — the same
|
|
/// classification the in-flight dispose race folds into. Dispose is idempotent.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ConnectAsync_AfterDispose_ThrowsObjectDisposed_AndDisposeIsIdempotent()
|
|
{
|
|
var opts = new MqttDriverOptions { Host = "127.0.0.1", Port = 1, UseTls = false, ConnectTimeoutSeconds = 1 };
|
|
var conn = new MqttConnection(opts, driverId: "t", logger: null);
|
|
|
|
await conn.DisposeAsync();
|
|
await conn.DisposeAsync();
|
|
|
|
await Should.ThrowAsync<ObjectDisposedException>(async () => await conn.ConnectAsync(CancellationToken.None));
|
|
}
|
|
|
|
/// <summary>Never log or throw credentials — the plan's cross-cutting secrets rule.</summary>
|
|
[Fact]
|
|
public async Task ConnectAsync_Failure_DoesNotLeakPasswordIntoExceptionMessage()
|
|
{
|
|
const string secret = "sup3r-s3cret-broker-pw";
|
|
using var blackhole = new BlackholeBroker();
|
|
var opts = new MqttDriverOptions
|
|
{
|
|
Host = "127.0.0.1",
|
|
Port = blackhole.Port,
|
|
UseTls = false,
|
|
ConnectTimeoutSeconds = 1,
|
|
Username = "svc",
|
|
Password = secret,
|
|
};
|
|
await using var conn = new MqttConnection(opts, driverId: "t", logger: null);
|
|
|
|
var ex = await Should.ThrowAsync<Exception>(async () => await conn.ConnectAsync(CancellationToken.None));
|
|
|
|
ex.ToString().ShouldNotContain(secret);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// BuildClientOptions — pure assembly, no I/O
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void BuildClientOptions_UseTlsWithCaPin_SetsTlsAndInstallsChainValidator()
|
|
{
|
|
var opts = new MqttDriverOptions
|
|
{
|
|
Host = Host,
|
|
Port = 8883,
|
|
UseTls = true,
|
|
AllowUntrustedServerCertificate = false,
|
|
CaCertificatePath = "/tmp/ca.pem",
|
|
};
|
|
|
|
var tls = BuildTls(opts);
|
|
|
|
tls.UseTls.ShouldBeTrue();
|
|
tls.TargetHost.ShouldBe(Host);
|
|
tls.AllowUntrustedCertificates.ShouldBeFalse();
|
|
tls.IgnoreCertificateChainErrors.ShouldBeFalse();
|
|
tls.CertificateValidationHandler.ShouldNotBeNull();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The CA file is read lazily inside the validation callback, never by the builder — so a
|
|
/// path that does not exist yet must not throw at build time, and must fail the *connection*
|
|
/// closed when the handshake finally asks.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildClientOptions_CaPinPathMissing_BuildsWithoutIo_AndValidatorFailsClosed()
|
|
{
|
|
var missing = Path.Combine(Path.GetTempPath(), $"otopcua-mqtt-no-such-ca-{Guid.NewGuid():N}.pem");
|
|
File.Exists(missing).ShouldBeFalse();
|
|
|
|
var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, CaCertificatePath = missing };
|
|
|
|
var built = MqttClientOptionsFactory.BuildClientOptions(opts, clientIdSuffix: null);
|
|
var tls = built.ChannelOptions.ShouldBeOfType<MqttClientTcpOptions>().TlsOptions;
|
|
|
|
tls.CertificateValidationHandler.ShouldNotBeNull();
|
|
using var stranger = SelfSignedLeaf(Host);
|
|
tls.CertificateValidationHandler(ValidationArgs(built, stranger)).ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>An untrusted, unpinned server certificate is rejected — the pin actually validates.</summary>
|
|
[Fact]
|
|
public void BuildClientOptions_CaPin_RejectsCertificateNotIssuedByThePinnedCa()
|
|
{
|
|
using var ca = IssueCa(issuer: null, "otopcua-test-ca", validDays: 30);
|
|
var caPath = WritePem(ca);
|
|
try
|
|
{
|
|
var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, CaCertificatePath = caPath };
|
|
var built = MqttClientOptionsFactory.BuildClientOptions(opts, clientIdSuffix: null);
|
|
var tls = built.ChannelOptions.ShouldBeOfType<MqttClientTcpOptions>().TlsOptions;
|
|
|
|
using var stranger = SelfSignedLeaf(Host);
|
|
tls.CertificateValidationHandler!(ValidationArgs(built, stranger)).ShouldBeFalse();
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(caPath);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The accept path. Without this every certificate test asserts rejection, so deleting the
|
|
/// <c>return true</c> — or getting <c>TrustMode</c> / <c>CustomTrustStore</c> wrong — would
|
|
/// leave the suite green while no broker could ever connect.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildClientOptions_CaPin_AcceptsLeafIssuedByThePinnedCa()
|
|
{
|
|
using var ca = IssueCa(issuer: null, "otopcua-test-ca", validDays: 30);
|
|
using var leaf = IssueLeaf(ca, Host, validDays: 20);
|
|
var caPath = WritePem(ca);
|
|
try
|
|
{
|
|
var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, CaCertificatePath = caPath };
|
|
var built = MqttClientOptionsFactory.BuildClientOptions(opts, clientIdSuffix: null);
|
|
var tls = built.ChannelOptions.ShouldBeOfType<MqttClientTcpOptions>().TlsOptions;
|
|
|
|
tls.CertificateValidationHandler!(ValidationArgs(built, leaf)).ShouldBeTrue();
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(caPath);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The common real-world broker topology: the leaf is signed by an intermediate that chains
|
|
/// to the pinned root, and the intermediate is delivered in the handshake rather than being
|
|
/// installed locally. Only the pinned ROOT is written to the CA file — the intermediate must
|
|
/// be picked up from what the broker presented.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildClientOptions_CaPin_AcceptsLeafBehindAnIntermediateThatTheBrokerPresented()
|
|
{
|
|
using var root = IssueCa(issuer: null, "otopcua-test-root", validDays: 30);
|
|
using var intermediate = IssueCa(root, "otopcua-test-intermediate", validDays: 25);
|
|
using var leaf = IssueLeaf(intermediate, Host, validDays: 20);
|
|
var caPath = WritePem(root);
|
|
try
|
|
{
|
|
var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true, CaCertificatePath = caPath };
|
|
var built = MqttClientOptionsFactory.BuildClientOptions(opts, clientIdSuffix: null);
|
|
var tls = built.ChannelOptions.ShouldBeOfType<MqttClientTcpOptions>().TlsOptions;
|
|
|
|
tls.CertificateValidationHandler!(ValidationArgs(built, leaf, intermediate)).ShouldBeTrue();
|
|
}
|
|
finally
|
|
{
|
|
File.Delete(caPath);
|
|
}
|
|
}
|
|
|
|
/// <summary>Default posture: OS trust store, no custom handler, escape hatch off.</summary>
|
|
[Fact]
|
|
public void BuildClientOptions_TlsWithoutCaPin_LeavesOsTrustStoreValidationInPlace()
|
|
{
|
|
var opts = new MqttDriverOptions { Host = Host, Port = 8883, UseTls = true };
|
|
|
|
var tls = BuildTls(opts);
|
|
|
|
tls.UseTls.ShouldBeTrue();
|
|
tls.CertificateValidationHandler.ShouldBeNull();
|
|
tls.AllowUntrustedCertificates.ShouldBeFalse();
|
|
tls.IgnoreCertificateChainErrors.ShouldBeFalse();
|
|
tls.IgnoreCertificateRevocationErrors.ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>The dev/on-prem escape hatch only opens when explicitly asked for.</summary>
|
|
[Fact]
|
|
public void BuildClientOptions_AllowUntrustedServerCertificate_OpensEscapeHatch()
|
|
{
|
|
var opts = new MqttDriverOptions
|
|
{
|
|
Host = Host, Port = 8883, UseTls = true, AllowUntrustedServerCertificate = true,
|
|
};
|
|
|
|
var tls = BuildTls(opts);
|
|
|
|
tls.AllowUntrustedCertificates.ShouldBeTrue();
|
|
tls.IgnoreCertificateChainErrors.ShouldBeTrue();
|
|
tls.CertificateValidationHandler.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildClientOptions_UseTlsFalse_KeepsTlsOffAndInstallsNoValidator()
|
|
{
|
|
var opts = new MqttDriverOptions
|
|
{
|
|
Host = Host, Port = 1883, UseTls = false, AllowUntrustedServerCertificate = true, CaCertificatePath = "/tmp/ca.pem",
|
|
};
|
|
|
|
var tls = BuildTls(opts);
|
|
|
|
tls.UseTls.ShouldBeFalse();
|
|
tls.CertificateValidationHandler.ShouldBeNull();
|
|
tls.AllowUntrustedCertificates.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildClientOptions_MapsProtocolAndSessionKnobs()
|
|
{
|
|
var opts = new MqttDriverOptions
|
|
{
|
|
Host = Host,
|
|
Port = 8883,
|
|
ClientId = "otopcua-node-1",
|
|
ProtocolVersion = MqttProtocolVersion.V311,
|
|
CleanSession = false,
|
|
KeepAliveSeconds = 45,
|
|
ConnectTimeoutSeconds = 7,
|
|
};
|
|
|
|
var built = MqttClientOptionsFactory.BuildClientOptions(opts, clientIdSuffix: null);
|
|
|
|
built.ProtocolVersion.ShouldBe(MQTTnet.Formatter.MqttProtocolVersion.V311);
|
|
built.ClientId.ShouldBe("otopcua-node-1");
|
|
built.CleanSession.ShouldBeFalse();
|
|
built.KeepAlivePeriod.ShouldBe(TimeSpan.FromSeconds(45));
|
|
built.Timeout.ShouldBe(TimeSpan.FromSeconds(7));
|
|
var tcp = built.ChannelOptions.ShouldBeOfType<MqttClientTcpOptions>();
|
|
tcp.RemoteEndpoint.ShouldBeOfType<DnsEndPoint>().Host.ShouldBe(Host);
|
|
tcp.RemoteEndpoint.ShouldBeOfType<DnsEndPoint>().Port.ShouldBe(8883);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildClientOptions_ClientIdSuffix_IsAppended()
|
|
{
|
|
var opts = new MqttDriverOptions { Host = Host, ClientId = "otopcua" };
|
|
|
|
MqttClientOptionsFactory.BuildClientOptions(opts, clientIdSuffix: "-browse").ClientId.ShouldBe("otopcua-browse");
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildClientOptions_NoUsername_SendsNoCredentials()
|
|
=> MqttClientOptionsFactory.BuildClientOptions(new MqttDriverOptions { Host = Host }, clientIdSuffix: null)
|
|
.Credentials.ShouldBeNull();
|
|
|
|
[Fact]
|
|
public void BuildClientOptions_Username_SendsCredentials()
|
|
{
|
|
var opts = new MqttDriverOptions { Host = Host, Username = "svc", Password = "pw" };
|
|
|
|
var built = MqttClientOptionsFactory.BuildClientOptions(opts, clientIdSuffix: null);
|
|
|
|
built.Credentials.ShouldNotBeNull();
|
|
built.Credentials.GetUserName(built).ShouldBe("svc");
|
|
}
|
|
|
|
/// <summary>
|
|
/// <b>Driver.Mqtt.Transport-002.</b> A configured password with no username is dropped — MQTT
|
|
/// 3.1.1 may not carry one without the other, and this factory does not synthesize the
|
|
/// password-only CONNECT 5.0 would permit. What must not happen is the drop being silent: the
|
|
/// deployment then believes it authenticates and does not, which is the one direction an
|
|
/// operator cannot see from the config. The secret itself stays out of the log.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildClientOptions_PasswordWithoutUsername_IsDroppedLoudlyAndWithoutLeakingIt()
|
|
{
|
|
const string secret = "s3cr3t-that-must-not-be-logged";
|
|
var log = new CapturingLogger();
|
|
|
|
var built = MqttClientOptionsFactory.BuildClientOptions(
|
|
new MqttDriverOptions { Host = Host, Password = secret }, clientIdSuffix: null, logger: log);
|
|
|
|
built.Credentials.ShouldBeNull();
|
|
log.CountContaining("password").ShouldBe(1);
|
|
log.CountContaining(secret).ShouldBe(0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The falsifying control for the test above: an ordinary anonymous deployment configures
|
|
/// neither credential and must stay silent, or every such driver warns on every connect.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BuildClientOptions_NoCredentialsAtAll_SaysNothing()
|
|
{
|
|
var log = new CapturingLogger();
|
|
|
|
MqttClientOptionsFactory.BuildClientOptions(
|
|
new MqttDriverOptions { Host = Host }, clientIdSuffix: null, logger: log)
|
|
.Credentials.ShouldBeNull();
|
|
|
|
log.CountContaining("password").ShouldBe(0);
|
|
}
|
|
|
|
/// <summary>The ctor must not touch the network — the universal-browser throwaway-instance pattern.</summary>
|
|
[Fact]
|
|
public void Ctor_IsConnectionFree()
|
|
{
|
|
var opts = new MqttDriverOptions { Host = "127.0.0.1", Port = 1, UseTls = false };
|
|
|
|
var conn = new MqttConnection(opts, driverId: "t", logger: null);
|
|
|
|
conn.IsConnected.ShouldBeFalse();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// Task 4 — NextBackoff (pure): bounded, monotone, cannot hot-loop, cannot overflow
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
[Theory]
|
|
[InlineData(0, 1)]
|
|
[InlineData(1, 2)]
|
|
[InlineData(2, 4)]
|
|
[InlineData(3, 8)]
|
|
[InlineData(4, 16)]
|
|
[InlineData(5, 30)] // 32 → clamped
|
|
[InlineData(10, 30)]
|
|
public void NextBackoff_IsExponentialClampedToMax(int attempt, int expectedSeconds)
|
|
=> MqttConnection.NextBackoff(attempt, minSeconds: 1, maxSeconds: 30)
|
|
.ShouldBe(TimeSpan.FromSeconds(expectedSeconds));
|
|
|
|
/// <summary>
|
|
/// A shift-based implementation (<c>min << attempt</c>) wraps: <c>1 << 32</c> is
|
|
/// <c>1</c>, not 4294967296, so a broker that has been down for half an hour would suddenly
|
|
/// be hammered once a second. The calculator must saturate, not wrap.
|
|
/// </summary>
|
|
[Fact]
|
|
public void NextBackoff_HugeAttemptCount_SaturatesAtMax_NeverWrapsBackToMin()
|
|
{
|
|
foreach (var attempt in new[] { 31, 32, 33, 40, 62, 63, 64, 1_000, int.MaxValue })
|
|
{
|
|
MqttConnection.NextBackoff(attempt, minSeconds: 1, maxSeconds: 30)
|
|
.ShouldBe(TimeSpan.FromSeconds(30), $"attempt {attempt}");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void NextBackoff_IsMonotoneNonDecreasing_AndAlwaysWithinMinMax()
|
|
{
|
|
var previous = TimeSpan.Zero;
|
|
for (var attempt = 0; attempt <= 64; attempt++)
|
|
{
|
|
var backoff = MqttConnection.NextBackoff(attempt, minSeconds: 2, maxSeconds: 30);
|
|
|
|
backoff.ShouldBeGreaterThanOrEqualTo(previous, $"attempt {attempt} went backwards");
|
|
backoff.ShouldBeGreaterThanOrEqualTo(TimeSpan.FromSeconds(2), $"attempt {attempt} below min");
|
|
backoff.ShouldBeLessThanOrEqualTo(TimeSpan.FromSeconds(30), $"attempt {attempt} above max");
|
|
previous = backoff;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A misconfigured <c>max < min</c> must not collapse the floor — the floor is the only
|
|
/// thing standing between a dead broker and a hot reconnect loop.
|
|
/// </summary>
|
|
[Fact]
|
|
public void NextBackoff_MaxBelowMin_HonoursMin_SoTheLoopCannotGoHot()
|
|
=> MqttConnection.NextBackoff(0, minSeconds: 10, maxSeconds: 5).ShouldBe(TimeSpan.FromSeconds(10));
|
|
|
|
[Fact]
|
|
public void NextBackoff_NonPositiveAttempt_IsTheFirstAttemptDelay()
|
|
{
|
|
MqttConnection.NextBackoff(0, minSeconds: 3, maxSeconds: 30).ShouldBe(TimeSpan.FromSeconds(3));
|
|
MqttConnection.NextBackoff(-5, minSeconds: 3, maxSeconds: 30).ShouldBe(TimeSpan.FromSeconds(3));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// Task 4 — the Reconnected callback (the load-bearing re-subscribe hook)
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task FireReconnected_InvokesEverySubscribedCallback()
|
|
{
|
|
await using var conn = new MqttConnection(LoopbackOpts(), driverId: "t", logger: null);
|
|
var first = 0;
|
|
var second = 0;
|
|
conn.Reconnected += _ => { Interlocked.Increment(ref first); return Task.CompletedTask; };
|
|
conn.Reconnected += _ => { Interlocked.Increment(ref second); return Task.CompletedTask; };
|
|
|
|
await conn.FireReconnectedAsync(TestContext.Current.CancellationToken);
|
|
|
|
first.ShouldBe(1);
|
|
second.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Awaiting a multicast <c>Func<Task></c> directly returns only the LAST handler's task
|
|
/// and abandons the earlier ones — so one throwing subscriber would silently skip every
|
|
/// subscriber behind it. The fan-out must walk the invocation list and still surface the
|
|
/// failure, because a re-subscribe that failed is a connection that has gone dark.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task FireReconnected_OneHandlerThrows_StillInvokesTheRest_AndSurfacesTheFailure()
|
|
{
|
|
await using var conn = new MqttConnection(LoopbackOpts(), driverId: "t", logger: null);
|
|
var behindTheThrower = 0;
|
|
conn.Reconnected += _ => throw new InvalidOperationException("SUBACK refused");
|
|
conn.Reconnected += _ => { Interlocked.Increment(ref behindTheThrower); return Task.CompletedTask; };
|
|
|
|
await Should.ThrowAsync<Exception>(async () => await conn.FireReconnectedAsync(TestContext.Current.CancellationToken));
|
|
|
|
behindTheThrower.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FireReconnected_NoSubscribers_IsANoOp()
|
|
{
|
|
await using var conn = new MqttConnection(LoopbackOpts(), driverId: "t", logger: null);
|
|
|
|
await Should.NotThrowAsync(async () => await conn.FireReconnectedAsync(TestContext.Current.CancellationToken));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// Task 4 — State + LastMessage
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void State_AfterCtor_IsDisconnected()
|
|
=> new MqttConnection(LoopbackOpts(), driverId: "t", logger: null)
|
|
.State.ShouldBe(MqttConnectionState.Disconnected);
|
|
|
|
[Fact]
|
|
public async Task State_AfterDispose_IsDisposed()
|
|
{
|
|
var conn = new MqttConnection(LoopbackOpts(), driverId: "t", logger: null);
|
|
|
|
await conn.DisposeAsync();
|
|
|
|
conn.State.ShouldBe(MqttConnectionState.Disposed);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Faulted is reserved for config that cannot succeed no matter how long we retry. A broker
|
|
/// that is merely down is <see cref="MqttConnectionState.Reconnecting"/>, indefinitely.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task State_UnrecoverableProtocolConfig_IsFaulted()
|
|
{
|
|
var opts = new MqttDriverOptions
|
|
{
|
|
Host = "127.0.0.1",
|
|
Port = 1,
|
|
UseTls = false,
|
|
ConnectTimeoutSeconds = 1,
|
|
ProtocolVersion = (MqttProtocolVersion)99,
|
|
};
|
|
await using var conn = new MqttConnection(opts, driverId: "t", logger: null);
|
|
|
|
await Should.ThrowAsync<ArgumentOutOfRangeException>(async () => await conn.ConnectAsync(CancellationToken.None));
|
|
|
|
conn.State.ShouldBe(MqttConnectionState.Faulted);
|
|
}
|
|
|
|
/// <summary>A broker that is simply unreachable is retryable, never <c>Faulted</c>.</summary>
|
|
[Fact]
|
|
public async Task State_UnreachableBroker_IsNotFaulted()
|
|
{
|
|
using var blackhole = new BlackholeBroker();
|
|
var opts = new MqttDriverOptions
|
|
{
|
|
Host = "127.0.0.1", Port = blackhole.Port, UseTls = false, ConnectTimeoutSeconds = 1,
|
|
};
|
|
await using var conn = new MqttConnection(opts, driverId: "t", logger: null);
|
|
|
|
await Should.ThrowAsync<TimeoutException>(async () => await conn.ConnectAsync(CancellationToken.None));
|
|
|
|
conn.State.ShouldNotBe(MqttConnectionState.Faulted);
|
|
}
|
|
|
|
[Fact]
|
|
public void LastMessage_BeforeAnyTraffic_IsNull()
|
|
{
|
|
var conn = new MqttConnection(LoopbackOpts(), driverId: "t", logger: null);
|
|
|
|
conn.LastMessageUtc.ShouldBeNull();
|
|
conn.LastMessageAge.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>
|
|
/// A failed FIRST connect is the caller's problem (driver-host resilience retries
|
|
/// <c>InitializeAsync</c>) — it must not silently leave a background reconnect worker
|
|
/// hammering an endpoint the caller has already given up on.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ConnectAsync_InitialAttemptFails_LeavesNoBackgroundReconnectStorm()
|
|
{
|
|
using var blackhole = new BlackholeBroker();
|
|
var opts = new MqttDriverOptions
|
|
{
|
|
Host = "127.0.0.1",
|
|
Port = blackhole.Port,
|
|
UseTls = false,
|
|
ConnectTimeoutSeconds = 1,
|
|
ReconnectMinBackoffSeconds = 1,
|
|
ReconnectMaxBackoffSeconds = 1,
|
|
};
|
|
await using var conn = new MqttConnection(opts, driverId: "t", logger: null);
|
|
|
|
await Should.ThrowAsync<TimeoutException>(async () => await conn.ConnectAsync(CancellationToken.None));
|
|
var acceptedAfterTheAttempt = blackhole.AcceptedCount;
|
|
|
|
await Task.Delay(TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
|
|
|
|
blackhole.AcceptedCount.ShouldBe(acceptedAfterTheAttempt);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// Task 4 — the reconnect supervisor, against a real (minimal) broker
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// THE load-bearing invariant. MQTT subscriptions do not survive a clean session, so a
|
|
/// reconnect that completes without firing <c>Reconnected</c> leaves a healthy-looking
|
|
/// connection that receives nothing, forever, with no error to show for it. Driven through
|
|
/// the real socket → real MQTTnet <c>DisconnectedAsync</c> → real re-CONNECT path, not a
|
|
/// test seam.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task BrokerDropsTheConnection_ReconnectsAndFiresReconnected()
|
|
{
|
|
using var broker = new MiniBroker();
|
|
await using var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null);
|
|
var resubscribes = 0;
|
|
conn.Reconnected += _ => { Interlocked.Increment(ref resubscribes); return Task.CompletedTask; };
|
|
|
|
await conn.ConnectAsync(CancellationToken.None);
|
|
|
|
conn.State.ShouldBe(MqttConnectionState.Connected);
|
|
conn.IsConnected.ShouldBeTrue();
|
|
Volatile.Read(ref resubscribes).ShouldBe(0, "the initial connect is not a reconnect");
|
|
|
|
broker.DropAll();
|
|
|
|
(await WaitUntilAsync(() => Volatile.Read(ref resubscribes) >= 1, TimeSpan.FromSeconds(30)))
|
|
.ShouldBeTrue("the reconnect never fired Reconnected — subscriptions would be silently gone");
|
|
(await WaitUntilAsync(() => conn.IsConnected, TimeSpan.FromSeconds(10))).ShouldBeTrue();
|
|
conn.State.ShouldBe(MqttConnectionState.Connected);
|
|
broker.AcceptedCount.ShouldBeGreaterThanOrEqualTo(2);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A broker that stays down keeps the supervisor in <c>Reconnecting</c> indefinitely (never
|
|
/// <c>Faulted</c>) — and the backoff keeps the retry rate low enough that a handful of
|
|
/// attempts, not hundreds, land in the observation window.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task BrokerStaysDown_RetriesUnderBackoff_WithoutHotLooping()
|
|
{
|
|
using var broker = new MiniBroker();
|
|
var opts = BrokerOpts(broker.Port) with
|
|
{
|
|
ConnectTimeoutSeconds = 1, ReconnectMinBackoffSeconds = 1, ReconnectMaxBackoffSeconds = 2,
|
|
};
|
|
await using var conn = new MqttConnection(opts, driverId: "t", logger: null);
|
|
|
|
await conn.ConnectAsync(CancellationToken.None);
|
|
var acceptedWhileHealthy = broker.AcceptedCount;
|
|
|
|
broker.Blackhole = true; // accepts TCP, never answers CONNACK again
|
|
broker.DropAll();
|
|
|
|
(await WaitUntilAsync(() => conn.State == MqttConnectionState.Reconnecting, TimeSpan.FromSeconds(10)))
|
|
.ShouldBeTrue();
|
|
await Task.Delay(TimeSpan.FromSeconds(8), TestContext.Current.CancellationToken);
|
|
|
|
conn.State.ShouldBe(MqttConnectionState.Reconnecting, "a down broker is retryable, not Faulted");
|
|
var retries = broker.AcceptedCount - acceptedWhileHealthy;
|
|
retries.ShouldBeGreaterThanOrEqualTo(1, "the supervisor gave up on a down broker");
|
|
retries.ShouldBeLessThan(15, $"backoff is not bounding the retry rate ({retries} attempts in ~8s)");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Dispose during a reconnect backoff must stop the supervisor dead — no leaked task, no
|
|
/// later attempt resurrecting a connection the caller has torn down.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Dispose_DuringReconnectBackoff_StopsTheSupervisor_NoFurtherAttempts()
|
|
{
|
|
using var broker = new MiniBroker();
|
|
var opts = BrokerOpts(broker.Port) with
|
|
{
|
|
ConnectTimeoutSeconds = 1, ReconnectMinBackoffSeconds = 1, ReconnectMaxBackoffSeconds = 2,
|
|
};
|
|
var conn = new MqttConnection(opts, driverId: "t", logger: null);
|
|
|
|
await conn.ConnectAsync(CancellationToken.None);
|
|
broker.Blackhole = true;
|
|
broker.DropAll();
|
|
(await WaitUntilAsync(() => conn.State == MqttConnectionState.Reconnecting, TimeSpan.FromSeconds(10)))
|
|
.ShouldBeTrue();
|
|
|
|
await conn.DisposeAsync();
|
|
var acceptedAtDispose = broker.AcceptedCount;
|
|
|
|
conn.State.ShouldBe(MqttConnectionState.Disposed);
|
|
conn.IsConnected.ShouldBeFalse();
|
|
await Task.Delay(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
|
broker.AcceptedCount.ShouldBe(acceptedAtDispose, "the supervisor outlived DisposeAsync");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The T3 connect/dispose leak, reproduced at the exact interleaving the reviewer traced:
|
|
/// the client has already CONNECTED when dispose flags the instance and blocks on the
|
|
/// lifecycle gate this connect is holding — so dispose disposes nothing. Without the
|
|
/// post-connect re-check the client stays live with an open socket that no later
|
|
/// <c>DisposeAsync</c> can ever reach; the broker sees the leak as a connection that never
|
|
/// closes.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task DisposeRacingAnInFlightConnect_LeavesNoLiveBrokerConnection()
|
|
{
|
|
using var broker = new MiniBroker();
|
|
var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null);
|
|
|
|
// No assertions inside the hook: it runs inside the production try, so a Shouldly throw would
|
|
// be laundered through Classify and resurface as a confusing TimeoutException. Record, assert
|
|
// outside.
|
|
var raceReproduced = false;
|
|
conn.AfterConnectHookForTests = async () =>
|
|
{
|
|
_ = Task.Run(async () => await conn.DisposeAsync());
|
|
raceReproduced = await WaitUntilAsync(
|
|
() => conn.State == MqttConnectionState.Disposed,
|
|
TimeSpan.FromSeconds(5));
|
|
};
|
|
|
|
await Should.ThrowAsync<ObjectDisposedException>(async () => await conn.ConnectAsync(CancellationToken.None));
|
|
|
|
raceReproduced.ShouldBeTrue("dispose never flagged the instance — the race was not reproduced");
|
|
(await WaitUntilAsync(() => broker.LiveConnections == 0, TimeSpan.FromSeconds(15)))
|
|
.ShouldBeTrue($"a live broker connection leaked ({broker.LiveConnections} still open)");
|
|
conn.IsConnected.ShouldBeFalse();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// C1 — connect-vs-connect: the supervisor and ConnectAsync must not corrupt each other
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Direction (b), the simplest form: MQTTnet throws
|
|
/// <c>InvalidOperationException: It is not allowed to connect with a server after the
|
|
/// connection is established</c> for a connect-while-connected. That is outside this type's
|
|
/// documented contract, so a caller re-running <c>InitializeAsync</c> against a healthy
|
|
/// session would look like a hard driver failure.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ConnectAsync_OnAnAlreadyEstablishedSession_IsANoOp_NotAnInvalidOperation()
|
|
{
|
|
using var broker = new MiniBroker();
|
|
await using var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null);
|
|
|
|
await conn.ConnectAsync(CancellationToken.None);
|
|
await Should.NotThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None));
|
|
|
|
conn.IsConnected.ShouldBeTrue();
|
|
conn.State.ShouldBe(MqttConnectionState.Connected);
|
|
broker.AcceptedCount.ShouldBe(1, "the second connect opened a second socket instead of no-opping");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Direction (b) through the supervisor: the supervisor restores the session, then the driver
|
|
/// host re-runs <c>InitializeAsync</c> — exactly the sequence this design relies on.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ConnectAsync_AfterTheSupervisorAlreadyReconnected_IsANoOp()
|
|
{
|
|
using var broker = new MiniBroker();
|
|
await using var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null);
|
|
var resubscribes = 0;
|
|
conn.Reconnected += _ => { Interlocked.Increment(ref resubscribes); return Task.CompletedTask; };
|
|
|
|
await conn.ConnectAsync(CancellationToken.None);
|
|
broker.DropAll();
|
|
(await WaitUntilAsync(
|
|
() => Volatile.Read(ref resubscribes) >= 1 && conn.State == MqttConnectionState.Connected,
|
|
TimeSpan.FromSeconds(30)))
|
|
.ShouldBeTrue();
|
|
|
|
await Should.NotThrowAsync(async () => await conn.ConnectAsync(CancellationToken.None));
|
|
|
|
conn.IsConnected.ShouldBeTrue();
|
|
conn.State.ShouldBe(MqttConnectionState.Connected);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Direction (a): a caller reconnects while the supervisor is asleep in backoff. The
|
|
/// supervisor must find the restored session and stand down — <b>silently</b>, without firing
|
|
/// <c>Reconnected</c> (the caller owns its own re-subscribe). Left unfixed it fails forever
|
|
/// against a perfectly healthy connection, at Debug level, inflating <c>attempt</c> so the
|
|
/// NEXT genuine outage waits max backoff instead of min.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task CallerReconnectsWhileTheSupervisorIsInBackoff_SupervisorStandsDownSilently()
|
|
{
|
|
using var broker = new MiniBroker();
|
|
var log = new CapturingLogger();
|
|
var opts = BrokerOpts(broker.Port) with { ReconnectMinBackoffSeconds = 3, ReconnectMaxBackoffSeconds = 3 };
|
|
await using var conn = new MqttConnection(opts, driverId: "t", logger: log);
|
|
var resubscribes = 0;
|
|
conn.Reconnected += _ => { Interlocked.Increment(ref resubscribes); return Task.CompletedTask; };
|
|
|
|
await conn.ConnectAsync(CancellationToken.None);
|
|
broker.DropAll();
|
|
(await WaitUntilAsync(() => conn.State == MqttConnectionState.Reconnecting, TimeSpan.FromSeconds(10)))
|
|
.ShouldBeTrue();
|
|
|
|
// The caller wins the race while the supervisor sleeps off its 3 s backoff.
|
|
await conn.ConnectAsync(CancellationToken.None);
|
|
conn.IsConnected.ShouldBeTrue();
|
|
var failuresBefore = log.CountContaining("reconnect attempt");
|
|
|
|
// Three more supervisor wake points would land in this window if it were still looping.
|
|
await Task.Delay(TimeSpan.FromSeconds(9), TestContext.Current.CancellationToken);
|
|
|
|
log.CountContaining("reconnect attempt")
|
|
.ShouldBe(failuresBefore, "the supervisor kept failing against a healthy connection");
|
|
conn.State.ShouldBe(MqttConnectionState.Connected);
|
|
conn.IsConnected.ShouldBeTrue();
|
|
Volatile.Read(ref resubscribes).ShouldBe(0, "the caller owns its own re-subscribe; Reconnected must not fire");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The operational consequence of direction (a), measured rather than argued: after a caller
|
|
/// has won a reconnect race, the supervisor must be parked at its outer wait with its attempt
|
|
/// counter reset, so the NEXT genuine outage recovers at MIN backoff.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The timings are load-bearing, not arbitrary. A supervisor still flailing against the healthy
|
|
/// connection would have burned attempts 0 and 1 (at t≈2 s and t≈6 s) and be asleep in an 8 s
|
|
/// backoff running to t≈14 s, so the outage induced at t≈7 s cannot be answered before then.
|
|
/// A stood-down supervisor answers it one min-backoff (2 s) later. The 5 s window separates the
|
|
/// two by a clear margin; widening the burn only widens the separation.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task AfterACallerWinsTheRace_TheNextGenuineOutageStillRecoversAtMinBackoff()
|
|
{
|
|
using var broker = new MiniBroker();
|
|
var opts = BrokerOpts(broker.Port) with { ReconnectMinBackoffSeconds = 2, ReconnectMaxBackoffSeconds = 30 };
|
|
await using var conn = new MqttConnection(opts, driverId: "t", logger: null);
|
|
var resubscribes = 0;
|
|
conn.Reconnected += _ => { Interlocked.Increment(ref resubscribes); return Task.CompletedTask; };
|
|
|
|
await conn.ConnectAsync(CancellationToken.None);
|
|
broker.DropAll();
|
|
(await WaitUntilAsync(() => conn.State == MqttConnectionState.Reconnecting, TimeSpan.FromSeconds(10)))
|
|
.ShouldBeTrue();
|
|
await conn.ConnectAsync(CancellationToken.None); // caller wins the race
|
|
await Task.Delay(TimeSpan.FromSeconds(7), TestContext.Current.CancellationToken); // a broken loop climbs here
|
|
|
|
broker.DropAll(); // the NEXT genuine outage
|
|
var recovered = await WaitUntilAsync(() => Volatile.Read(ref resubscribes) >= 1, TimeSpan.FromSeconds(5));
|
|
|
|
recovered.ShouldBeTrue("recovery took far longer than min backoff — the attempt counter never reset");
|
|
conn.State.ShouldBe(MqttConnectionState.Connected);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// I2 / M3 — the Reconnected callback is bounded, and Connected means "connected AND subscribed"
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public async Task FireReconnected_PassesTheSuppliedTokenToEverySubscriber()
|
|
{
|
|
await using var conn = new MqttConnection(LoopbackOpts(), driverId: "t", logger: null);
|
|
using var cts = new CancellationTokenSource();
|
|
var seen = new List<CancellationToken>();
|
|
conn.Reconnected += ct => { seen.Add(ct); return Task.CompletedTask; };
|
|
conn.Reconnected += ct => { seen.Add(ct); return Task.CompletedTask; };
|
|
|
|
await conn.FireReconnectedAsync(cts.Token);
|
|
|
|
seen.Count.ShouldBe(2);
|
|
seen.ShouldAllBe(t => t == cts.Token);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A re-subscribe that hangs — broker accepts SUBSCRIBE and never SUBACKs, the frozen-peer
|
|
/// shape again — must not park the supervisor. This subscriber deliberately <b>ignores</b> its
|
|
/// token, so only the fan-out's own ceiling can end the wait.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Asserted at the transport, not at dispose timing: with the ceiling, each hung re-subscribe
|
|
/// is abandoned, the session torn down, and another CONNECT reaches the broker; without it,
|
|
/// the supervisor is parked forever on the first one and the broker never sees another. A
|
|
/// dispose-elapsed assertion cannot tell those apart — <c>DisposeAsync</c>'s own bounded join
|
|
/// returns promptly either way, leaving the supervisor alive behind it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public async Task ReSubscribeThatHangsIgnoringItsToken_DoesNotParkTheSupervisor()
|
|
{
|
|
using var broker = new MiniBroker();
|
|
var opts = BrokerOpts(broker.Port) with
|
|
{
|
|
ConnectTimeoutSeconds = 2, ReconnectMinBackoffSeconds = 1, ReconnectMaxBackoffSeconds = 2,
|
|
};
|
|
var conn = new MqttConnection(opts, driverId: "t", logger: null);
|
|
var entered = new TaskCompletionSource();
|
|
conn.Reconnected += async _ =>
|
|
{
|
|
entered.TrySetResult();
|
|
await Task.Delay(Timeout.InfiniteTimeSpan); // no token — only the ceiling bounds this
|
|
};
|
|
|
|
await conn.ConnectAsync(CancellationToken.None);
|
|
broker.DropAll();
|
|
await entered.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
|
|
|
|
// The first reconnect is accept #2; a supervisor that recovered from the hang makes at least
|
|
// one more. A parked one never does.
|
|
(await WaitUntilAsync(() => broker.AcceptedCount >= 3, TimeSpan.FromSeconds(25)))
|
|
.ShouldBeTrue($"the supervisor is parked behind a hung re-subscribe ({broker.AcceptedCount} connects seen)");
|
|
|
|
var sw = Stopwatch.StartNew();
|
|
await conn.DisposeAsync();
|
|
sw.Stop();
|
|
|
|
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(20), "dispose was parked behind a hung re-subscribe");
|
|
conn.State.ShouldBe(MqttConnectionState.Disposed);
|
|
var acceptedAtDispose = broker.AcceptedCount;
|
|
await Task.Delay(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
|
broker.AcceptedCount.ShouldBe(acceptedAtDispose, "the supervisor outlived DisposeAsync");
|
|
}
|
|
|
|
/// <summary>
|
|
/// M3: on the reconnect path the socket comes up before the subscriptions do. Publishing
|
|
/// <c>Connected</c> in that window advertises exactly the healthy-looking-but-deaf condition
|
|
/// this type exists to prevent. It is also the documented case where <c>State</c> and
|
|
/// <c>IsConnected</c> legitimately disagree.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Reconnect_DoesNotPublishConnectedUntilTheReSubscribeHasCompleted()
|
|
{
|
|
using var broker = new MiniBroker();
|
|
await using var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null);
|
|
var inSubscriber = new TaskCompletionSource();
|
|
var release = new TaskCompletionSource();
|
|
conn.Reconnected += async _ =>
|
|
{
|
|
inSubscriber.TrySetResult();
|
|
await release.Task;
|
|
};
|
|
|
|
await conn.ConnectAsync(CancellationToken.None);
|
|
broker.DropAll();
|
|
await inSubscriber.Task.WaitAsync(TimeSpan.FromSeconds(30), TestContext.Current.CancellationToken);
|
|
|
|
conn.IsConnected.ShouldBeTrue("the socket is up");
|
|
conn.State.ShouldBe(MqttConnectionState.Reconnecting, "…but it has no subscriptions yet");
|
|
|
|
release.SetResult();
|
|
|
|
(await WaitUntilAsync(() => conn.State == MqttConnectionState.Connected, TimeSpan.FromSeconds(10)))
|
|
.ShouldBeTrue();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// Refused CONNACK — MQTTnet 5 reports it as a RESULT, never an exception
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// The Task-13 live-gate defect, reproduced offline: Mosquitto genuinely rejected a wrong
|
|
/// password (<c>Client … disconnected, not authorised.</c>) and <c>ConnectAsync</c> returned
|
|
/// normally, because MQTTnet 5 hands a refused CONNACK back in
|
|
/// <c>MqttClientConnectResult.ResultCode</c> and v5 removed v4's
|
|
/// <c>ThrowOnNonSuccessfulConnectResponse</c> option.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ConnectAsync_BrokerRefusesTheCredentials_Throws_AndFaults()
|
|
{
|
|
using var broker = new MiniBroker { ConnAckReturnCode = MiniBroker.ReturnCodeNotAuthorized };
|
|
await using var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null);
|
|
|
|
var ex = await Should.ThrowAsync<MqttConnectRejectedException>(
|
|
async () => await conn.ConnectAsync(CancellationToken.None));
|
|
|
|
ex.ResultCode.ShouldBe(MqttClientConnectResultCode.NotAuthorized);
|
|
ex.IsUnrecoverable.ShouldBeTrue();
|
|
ex.Message.ShouldContain("rejected the credentials");
|
|
conn.IsConnected.ShouldBeFalse();
|
|
conn.State.ShouldBe(MqttConnectionState.Faulted);
|
|
}
|
|
|
|
/// <summary>Credentials must never reach the message an operator (or a log sink) will see.</summary>
|
|
[Fact]
|
|
public async Task ConnectAsync_RefusedCredentials_DoesNotLeakThePasswordIntoTheRejection()
|
|
{
|
|
const string secret = "sup3r-s3cret-broker-pw";
|
|
using var broker = new MiniBroker { ConnAckReturnCode = MiniBroker.ReturnCodeBadUserNameOrPassword };
|
|
var opts = BrokerOpts(broker.Port) with { Username = "svc", Password = secret };
|
|
await using var conn = new MqttConnection(opts, driverId: "t", logger: null);
|
|
|
|
var ex = await Should.ThrowAsync<MqttConnectRejectedException>(
|
|
async () => await conn.ConnectAsync(CancellationToken.None));
|
|
|
|
ex.ToString().ShouldNotContain(secret);
|
|
}
|
|
|
|
/// <summary>
|
|
/// An unrecoverable rejection must not leave a supervisor grinding away every backoff period
|
|
/// against a broker that will never accept these credentials.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ConnectAsync_RefusedCredentials_StartsNoReconnectSupervisor()
|
|
{
|
|
using var broker = new MiniBroker { ConnAckReturnCode = MiniBroker.ReturnCodeNotAuthorized };
|
|
var opts = BrokerOpts(broker.Port) with { ReconnectMinBackoffSeconds = 1, ReconnectMaxBackoffSeconds = 1 };
|
|
await using var conn = new MqttConnection(opts, driverId: "t", logger: null);
|
|
|
|
await Should.ThrowAsync<MqttConnectRejectedException>(
|
|
async () => await conn.ConnectAsync(CancellationToken.None));
|
|
var acceptedAfterTheAttempt = broker.AcceptedCount;
|
|
|
|
await Task.Delay(TimeSpan.FromSeconds(4), TestContext.Current.CancellationToken);
|
|
|
|
broker.AcceptedCount.ShouldBe(acceptedAfterTheAttempt, "a hopeless rejection is being retried");
|
|
conn.State.ShouldBe(MqttConnectionState.Faulted);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A genuinely transient refusal is NOT a fault. Faulting on <c>ServerUnavailable</c> would take
|
|
/// a recoverable driver down until someone redeployed it.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task ConnectAsync_TransientRefusal_Throws_ButStaysRetryable()
|
|
{
|
|
using var broker = new MiniBroker { ConnAckReturnCode = MiniBroker.ReturnCodeServerUnavailable };
|
|
await using var conn = new MqttConnection(BrokerOpts(broker.Port), driverId: "t", logger: null);
|
|
|
|
var ex = await Should.ThrowAsync<MqttConnectRejectedException>(
|
|
async () => await conn.ConnectAsync(CancellationToken.None));
|
|
|
|
ex.ResultCode.ShouldBe(MqttClientConnectResultCode.ServerUnavailable);
|
|
ex.IsUnrecoverable.ShouldBeFalse();
|
|
conn.State.ShouldNotBe(MqttConnectionState.Faulted);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(MqttClientConnectResultCode.NotAuthorized, true)]
|
|
[InlineData(MqttClientConnectResultCode.BadUserNameOrPassword, true)]
|
|
[InlineData(MqttClientConnectResultCode.ClientIdentifierNotValid, true)]
|
|
[InlineData(MqttClientConnectResultCode.UnsupportedProtocolVersion, true)]
|
|
[InlineData(MqttClientConnectResultCode.Banned, true)]
|
|
[InlineData(MqttClientConnectResultCode.BadAuthenticationMethod, true)]
|
|
[InlineData(MqttClientConnectResultCode.TopicNameInvalid, true)]
|
|
[InlineData(MqttClientConnectResultCode.QoSNotSupported, true)]
|
|
// MQTT 5: 0x9D "permanently use another server" vs 0x9C "temporarily use another server".
|
|
[InlineData(MqttClientConnectResultCode.ServerMoved, true)]
|
|
[InlineData(MqttClientConnectResultCode.UseAnotherServer, false)]
|
|
[InlineData(MqttClientConnectResultCode.ServerUnavailable, false)]
|
|
[InlineData(MqttClientConnectResultCode.ServerBusy, false)]
|
|
[InlineData(MqttClientConnectResultCode.QuotaExceeded, false)]
|
|
[InlineData(MqttClientConnectResultCode.ConnectionRateExceeded, false)]
|
|
// Carries no actionable information, so it must not be allowed to permanently fault a driver.
|
|
[InlineData(MqttClientConnectResultCode.UnspecifiedError, false)]
|
|
[InlineData(MqttClientConnectResultCode.ImplementationSpecificError, false)]
|
|
public void IsUnrecoverableConnackRejection_ClassifiesByWhetherRetryingCouldEverHelp(
|
|
MqttClientConnectResultCode code,
|
|
bool expected)
|
|
=> MqttConnection.IsUnrecoverableConnackRejection(code).ShouldBe(expected);
|
|
|
|
/// <summary>
|
|
/// A reason code this build has never seen must default to retryable — see the classification
|
|
/// remarks for why the ambiguous case fails toward availability, not toward Faulted.
|
|
/// </summary>
|
|
[Fact]
|
|
public void IsUnrecoverableConnackRejection_UnknownCode_DefaultsToRetryable()
|
|
=> MqttConnection.IsUnrecoverableConnackRejection((MqttClientConnectResultCode)250).ShouldBeFalse();
|
|
|
|
/// <summary>
|
|
/// The supervisor's own attempts go through the same CONNACK check. A broker that starts
|
|
/// refusing mid-life (credentials revoked, client banned) must stop the loop, not feed it.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Reconnect_BrokerStartsRefusing_FaultsAndStopsTheSupervisor()
|
|
{
|
|
using var broker = new MiniBroker();
|
|
var opts = BrokerOpts(broker.Port) with { ReconnectMinBackoffSeconds = 1, ReconnectMaxBackoffSeconds = 1 };
|
|
await using var conn = new MqttConnection(opts, driverId: "t", logger: null);
|
|
var resubscribes = 0;
|
|
conn.Reconnected += _ => { Interlocked.Increment(ref resubscribes); return Task.CompletedTask; };
|
|
|
|
await conn.ConnectAsync(CancellationToken.None);
|
|
broker.ConnAckReturnCode = MiniBroker.ReturnCodeNotAuthorized; // credentials revoked
|
|
broker.DropAll();
|
|
|
|
(await WaitUntilAsync(() => conn.State == MqttConnectionState.Faulted, TimeSpan.FromSeconds(20)))
|
|
.ShouldBeTrue("a revoked-credentials reconnect must fault, not retry forever");
|
|
var acceptedAtFault = broker.AcceptedCount;
|
|
await Task.Delay(TimeSpan.FromSeconds(4), TestContext.Current.CancellationToken);
|
|
|
|
broker.AcceptedCount.ShouldBe(acceptedAtFault, "the supervisor kept retrying a hopeless rejection");
|
|
Volatile.Read(ref resubscribes).ShouldBe(0, "nothing was ever re-subscribed on a refused session");
|
|
}
|
|
|
|
/// <summary>The mirror case: a transient refusal keeps the supervisor retrying under backoff.</summary>
|
|
[Fact]
|
|
public async Task Reconnect_TransientRefusal_KeepsRetryingUnderBackoff()
|
|
{
|
|
using var broker = new MiniBroker();
|
|
var opts = BrokerOpts(broker.Port) with { ReconnectMinBackoffSeconds = 1, ReconnectMaxBackoffSeconds = 2 };
|
|
await using var conn = new MqttConnection(opts, driverId: "t", logger: null);
|
|
|
|
await conn.ConnectAsync(CancellationToken.None);
|
|
var acceptedWhileHealthy = broker.AcceptedCount;
|
|
broker.ConnAckReturnCode = MiniBroker.ReturnCodeServerUnavailable;
|
|
broker.DropAll();
|
|
|
|
(await WaitUntilAsync(() => broker.AcceptedCount >= acceptedWhileHealthy + 2, TimeSpan.FromSeconds(20)))
|
|
.ShouldBeTrue("the supervisor gave up on a transiently-unavailable broker");
|
|
conn.State.ShouldBe(MqttConnectionState.Reconnecting, "a busy broker is retryable, not Faulted");
|
|
|
|
// And it recovers on its own once the broker starts accepting again.
|
|
broker.ConnAckReturnCode = MiniBroker.ReturnCodeAccepted;
|
|
(await WaitUntilAsync(() => conn.State == MqttConnectionState.Connected, TimeSpan.FromSeconds(20)))
|
|
.ShouldBeTrue();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// helpers
|
|
// ---------------------------------------------------------------------------------
|
|
|
|
private static MqttDriverOptions LoopbackOpts()
|
|
=> new() { Host = "127.0.0.1", Port = 1, UseTls = false, ConnectTimeoutSeconds = 1 };
|
|
|
|
/// <summary>Options aimed at <see cref="MiniBroker"/>: plaintext v3.1.1, keep-alive parked out of the way.</summary>
|
|
private static MqttDriverOptions BrokerOpts(int port)
|
|
=> new()
|
|
{
|
|
Host = "127.0.0.1",
|
|
Port = port,
|
|
UseTls = false,
|
|
ProtocolVersion = MqttProtocolVersion.V311,
|
|
CleanSession = true,
|
|
KeepAliveSeconds = 300,
|
|
ConnectTimeoutSeconds = 5,
|
|
ReconnectMinBackoffSeconds = 1,
|
|
ReconnectMaxBackoffSeconds = 2,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Captures formatted log messages. The supervisor's failed-attempt path is Debug-only and has
|
|
/// no other externally visible effect — MQTTnet refuses a connect-while-connected before
|
|
/// opening a socket, so the broker never sees it — which is precisely why the C1 defect was
|
|
/// silent. The log is the observable.
|
|
/// </summary>
|
|
private sealed class CapturingLogger : ILogger
|
|
{
|
|
private readonly List<string> _messages = [];
|
|
|
|
public int CountContaining(string fragment)
|
|
{
|
|
lock (_messages)
|
|
{
|
|
return _messages.Count(m => m.Contains(fragment, StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
}
|
|
|
|
public IDisposable? BeginScope<TState>(TState state)
|
|
where TState : notnull
|
|
=> null;
|
|
|
|
public bool IsEnabled(LogLevel logLevel) => true;
|
|
|
|
public void Log<TState>(
|
|
LogLevel logLevel,
|
|
EventId eventId,
|
|
TState state,
|
|
Exception? exception,
|
|
Func<TState, Exception?, string> formatter)
|
|
{
|
|
lock (_messages)
|
|
{
|
|
_messages.Add(formatter(state, exception));
|
|
}
|
|
}
|
|
}
|
|
|
|
private static async Task<bool> WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
|
|
{
|
|
var deadline = Stopwatch.StartNew();
|
|
while (deadline.Elapsed < timeout)
|
|
{
|
|
if (condition())
|
|
{
|
|
return true;
|
|
}
|
|
|
|
await Task.Delay(25);
|
|
}
|
|
|
|
return condition();
|
|
}
|
|
|
|
private static MqttClientTlsOptions BuildTls(MqttDriverOptions opts)
|
|
=> MqttClientOptionsFactory.BuildClientOptions(opts, clientIdSuffix: null)
|
|
.ChannelOptions.ShouldBeOfType<MqttClientTcpOptions>().TlsOptions;
|
|
|
|
private static readonly DateTimeOffset NotBefore = DateTimeOffset.UtcNow.AddDays(-1);
|
|
|
|
/// <summary>A CA certificate, self-signed when <paramref name="issuer"/> is null.</summary>
|
|
private static X509Certificate2 IssueCa(X509Certificate2? issuer, string commonName, int validDays)
|
|
{
|
|
using var key = RSA.Create(2048);
|
|
var request = new CertificateRequest($"CN={commonName}", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
|
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(true, false, 0, true));
|
|
request.CertificateExtensions.Add(
|
|
new X509KeyUsageExtension(X509KeyUsageFlags.KeyCertSign | X509KeyUsageFlags.CrlSign, true));
|
|
request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false));
|
|
|
|
if (issuer is null)
|
|
{
|
|
return request.CreateSelfSigned(NotBefore, NotBefore.AddDays(validDays));
|
|
}
|
|
|
|
// Create() drops the private key; an intermediate needs it back to sign its own children.
|
|
using var issued = request.Create(issuer, NotBefore, NotBefore.AddDays(validDays), NextSerial());
|
|
return issued.CopyWithPrivateKey(key);
|
|
}
|
|
|
|
/// <summary>A server (end-entity) certificate signed by <paramref name="issuer"/>.</summary>
|
|
private static X509Certificate2 IssueLeaf(X509Certificate2 issuer, string commonName, int validDays)
|
|
{
|
|
using var key = RSA.Create(2048);
|
|
var request = new CertificateRequest($"CN={commonName}", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
|
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
|
|
request.CertificateExtensions.Add(
|
|
new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, true));
|
|
request.CertificateExtensions.Add(
|
|
new X509EnhancedKeyUsageExtension([new Oid("1.3.6.1.5.5.7.3.1")], false)); // serverAuth
|
|
request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false));
|
|
|
|
return request.Create(issuer, NotBefore, NotBefore.AddDays(validDays), NextSerial());
|
|
}
|
|
|
|
/// <summary>A self-signed leaf that chains to nothing — the "stranger" a pin must reject.</summary>
|
|
private static X509Certificate2 SelfSignedLeaf(string commonName)
|
|
{
|
|
using var key = RSA.Create(2048);
|
|
var request = new CertificateRequest($"CN={commonName}", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
|
return request.CreateSelfSigned(NotBefore, NotBefore.AddDays(20));
|
|
}
|
|
|
|
private static byte[] NextSerial() => RandomNumberGenerator.GetBytes(8);
|
|
|
|
private static string WritePem(params X509Certificate2[] certificates)
|
|
{
|
|
var path = Path.Combine(Path.GetTempPath(), $"otopcua-mqtt-ca-{Guid.NewGuid():N}.pem");
|
|
File.WriteAllText(path, string.Join(Environment.NewLine, certificates.Select(c => c.ExportCertificatePem())));
|
|
return path;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Mimics what <c>SslStream</c> hands a validation callback: the presented leaf plus a chain
|
|
/// carrying whatever intermediates the peer supplied. The chain is built (and allowed to
|
|
/// fail) purely so <c>ChainElements</c> is populated the way the platform would populate it.
|
|
/// </summary>
|
|
private static MqttClientCertificateValidationEventArgs ValidationArgs(
|
|
MqttClientOptions built,
|
|
X509Certificate2 leaf,
|
|
params X509Certificate2[] presentedIntermediates)
|
|
{
|
|
var chain = new X509Chain();
|
|
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
|
|
chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllFlags;
|
|
foreach (var intermediate in presentedIntermediates)
|
|
{
|
|
chain.ChainPolicy.ExtraStore.Add(intermediate);
|
|
}
|
|
|
|
chain.Build(leaf);
|
|
|
|
return new MqttClientCertificateValidationEventArgs(
|
|
leaf,
|
|
chain,
|
|
SslPolicyErrors.RemoteCertificateChainErrors,
|
|
built.ChannelOptions);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Accepts the TCP connection and then says nothing — the client sends CONNECT and waits
|
|
/// forever for a CONNACK that never comes. This is the frozen-peer shape; a closed port is
|
|
/// not (it refuses instantly and proves nothing about a deadline).
|
|
/// </summary>
|
|
private sealed class BlackholeBroker : IDisposable
|
|
{
|
|
private readonly List<TcpClient> _accepted = [];
|
|
private readonly CancellationTokenSource _cts = new();
|
|
private readonly TcpListener _listener;
|
|
private int _acceptedCount;
|
|
|
|
public BlackholeBroker()
|
|
{
|
|
_listener = new TcpListener(IPAddress.Loopback, 0);
|
|
_listener.Start();
|
|
Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
|
|
_ = Task.Run(AcceptLoopAsync);
|
|
}
|
|
|
|
public int Port { get; }
|
|
|
|
/// <summary>Total TCP connections accepted — how a caller detects a background retry storm.</summary>
|
|
public int AcceptedCount => Volatile.Read(ref _acceptedCount);
|
|
|
|
public void Dispose()
|
|
{
|
|
_cts.Cancel();
|
|
_listener.Stop();
|
|
lock (_accepted)
|
|
{
|
|
foreach (var client in _accepted)
|
|
{
|
|
client.Dispose();
|
|
}
|
|
|
|
_accepted.Clear();
|
|
}
|
|
|
|
_cts.Dispose();
|
|
}
|
|
|
|
private async Task AcceptLoopAsync()
|
|
{
|
|
try
|
|
{
|
|
while (!_cts.IsCancellationRequested)
|
|
{
|
|
var client = await _listener.AcceptTcpClientAsync(_cts.Token);
|
|
Interlocked.Increment(ref _acceptedCount);
|
|
lock (_accepted)
|
|
{
|
|
_accepted.Add(client);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// Listener stopped / token cancelled — the fixture is going away.
|
|
}
|
|
}
|
|
}
|
|
}
|