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;
///
/// Covers 's two Task-3 responsibilities: the pure
/// options assembly (TLS / CA pin /
/// credentials / protocol knobs) and the bounded
/// deadline.
///
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)
// ---------------------------------------------------------------------------------
///
/// 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.
///
[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(async () => await conn.ConnectAsync(CancellationToken.None));
sw.Stop();
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10));
conn.IsConnected.ShouldBeFalse();
}
///
/// 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 CreateLinkedTokenSource and this test
/// hangs for a minute.
///
[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(async () => await conn.ConnectAsync(caller.Token));
sw.Stop();
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10));
}
///
/// The deadline leg is distinguishable from a caller cancellation — a timed-out connect
/// surfaces , not a bare ,
/// so an operator can tell "broker never answered" from "we were asked to stop".
///
[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(async () => await conn.ConnectAsync(CancellationToken.None));
}
///
/// Task 4's reconnect loop retries connect on the SAME instance, reusing the
/// IMqttClient held across failures. A failed attempt must therefore leave the
/// connection usable rather than poisoned.
///
[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(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(async () => await conn.ConnectAsync(CancellationToken.None));
conn.IsConnected.ShouldBeFalse();
}
///
/// Connecting a disposed instance is an — the same
/// classification the in-flight dispose race folds into. Dispose is idempotent.
///
[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(async () => await conn.ConnectAsync(CancellationToken.None));
}
/// Never log or throw credentials — the plan's cross-cutting secrets rule.
[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(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();
}
///
/// 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.
///
[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 = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);
var tls = built.ChannelOptions.ShouldBeOfType().TlsOptions;
tls.CertificateValidationHandler.ShouldNotBeNull();
using var stranger = SelfSignedLeaf(Host);
tls.CertificateValidationHandler(ValidationArgs(built, stranger)).ShouldBeFalse();
}
/// An untrusted, unpinned server certificate is rejected — the pin actually validates.
[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 = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);
var tls = built.ChannelOptions.ShouldBeOfType().TlsOptions;
using var stranger = SelfSignedLeaf(Host);
tls.CertificateValidationHandler!(ValidationArgs(built, stranger)).ShouldBeFalse();
}
finally
{
File.Delete(caPath);
}
}
///
/// The accept path. Without this every certificate test asserts rejection, so deleting the
/// return true — or getting TrustMode / CustomTrustStore wrong — would
/// leave the suite green while no broker could ever connect.
///
[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 = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);
var tls = built.ChannelOptions.ShouldBeOfType().TlsOptions;
tls.CertificateValidationHandler!(ValidationArgs(built, leaf)).ShouldBeTrue();
}
finally
{
File.Delete(caPath);
}
}
///
/// 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.
///
[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 = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);
var tls = built.ChannelOptions.ShouldBeOfType().TlsOptions;
tls.CertificateValidationHandler!(ValidationArgs(built, leaf, intermediate)).ShouldBeTrue();
}
finally
{
File.Delete(caPath);
}
}
/// Default posture: OS trust store, no custom handler, escape hatch off.
[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();
}
/// The dev/on-prem escape hatch only opens when explicitly asked for.
[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 = MqttConnection.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();
tcp.RemoteEndpoint.ShouldBeOfType().Host.ShouldBe(Host);
tcp.RemoteEndpoint.ShouldBeOfType().Port.ShouldBe(8883);
}
[Fact]
public void BuildClientOptions_ClientIdSuffix_IsAppended()
{
var opts = new MqttDriverOptions { Host = Host, ClientId = "otopcua" };
MqttConnection.BuildClientOptions(opts, clientIdSuffix: "-browse").ClientId.ShouldBe("otopcua-browse");
}
[Fact]
public void BuildClientOptions_NoUsername_SendsNoCredentials()
=> MqttConnection.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 = MqttConnection.BuildClientOptions(opts, clientIdSuffix: null);
built.Credentials.ShouldNotBeNull();
built.Credentials.GetUserName(built).ShouldBe("svc");
}
/// The ctor must not touch the network — the universal-browser throwaway-instance pattern.
[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));
///
/// A shift-based implementation (min << attempt) wraps: 1 << 32 is
/// 1, 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.
///
[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;
}
}
///
/// A misconfigured max < min must not collapse the floor — the floor is the only
/// thing standing between a dead broker and a hot reconnect loop.
///
[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);
}
///
/// Awaiting a multicast Func<Task> 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.
///
[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(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);
}
///
/// Faulted is reserved for config that cannot succeed no matter how long we retry. A broker
/// that is merely down is , indefinitely.
///
[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(async () => await conn.ConnectAsync(CancellationToken.None));
conn.State.ShouldBe(MqttConnectionState.Faulted);
}
/// A broker that is simply unreachable is retryable, never Faulted.
[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(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();
}
///
/// A failed FIRST connect is the caller's problem (driver-host resilience retries
/// InitializeAsync) — it must not silently leave a background reconnect worker
/// hammering an endpoint the caller has already given up on.
///
[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(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
// ---------------------------------------------------------------------------------
///
/// THE load-bearing invariant. MQTT subscriptions do not survive a clean session, so a
/// reconnect that completes without firing Reconnected leaves a healthy-looking
/// connection that receives nothing, forever, with no error to show for it. Driven through
/// the real socket → real MQTTnet DisconnectedAsync → real re-CONNECT path, not a
/// test seam.
///
[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);
}
///
/// A broker that stays down keeps the supervisor in Reconnecting indefinitely (never
/// Faulted) — and the backoff keeps the retry rate low enough that a handful of
/// attempts, not hundreds, land in the observation window.
///
[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)");
}
///
/// Dispose during a reconnect backoff must stop the supervisor dead — no leaked task, no
/// later attempt resurrecting a connection the caller has torn down.
///
[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");
}
///
/// 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
/// DisposeAsync can ever reach; the broker sees the leak as a connection that never
/// closes.
///
[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(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
// ---------------------------------------------------------------------------------
///
/// Direction (b), the simplest form: MQTTnet throws
/// InvalidOperationException: It is not allowed to connect with a server after the
/// connection is established for a connect-while-connected. That is outside this type's
/// documented contract, so a caller re-running InitializeAsync against a healthy
/// session would look like a hard driver failure.
///
[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");
}
///
/// Direction (b) through the supervisor: the supervisor restores the session, then the driver
/// host re-runs InitializeAsync — exactly the sequence this design relies on.
///
[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);
}
///
/// Direction (a): a caller reconnects while the supervisor is asleep in backoff. The
/// supervisor must find the restored session and stand down — silently, without firing
/// Reconnected (the caller owns its own re-subscribe). Left unfixed it fails forever
/// against a perfectly healthy connection, at Debug level, inflating attempt so the
/// NEXT genuine outage waits max backoff instead of min.
///
[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");
}
///
/// 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.
///
///
/// 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.
///
[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();
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);
}
///
/// A re-subscribe that hangs — broker accepts SUBSCRIBE and never SUBACKs, the frozen-peer
/// shape again — must not park the supervisor. This subscriber deliberately ignores its
/// token, so only the fan-out's own ceiling can end the wait.
///
///
/// 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 — DisposeAsync's own bounded join
/// returns promptly either way, leaving the supervisor alive behind it.
///
[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");
}
///
/// M3: on the reconnect path the socket comes up before the subscriptions do. Publishing
/// Connected in that window advertises exactly the healthy-looking-but-deaf condition
/// this type exists to prevent. It is also the documented case where State and
/// IsConnected legitimately disagree.
///
[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
// ---------------------------------------------------------------------------------
///
/// The Task-13 live-gate defect, reproduced offline: Mosquitto genuinely rejected a wrong
/// password (Client … disconnected, not authorised.) and ConnectAsync returned
/// normally, because MQTTnet 5 hands a refused CONNACK back in
/// MqttClientConnectResult.ResultCode and v5 removed v4's
/// ThrowOnNonSuccessfulConnectResponse option.
///
[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(
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);
}
/// Credentials must never reach the message an operator (or a log sink) will see.
[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(
async () => await conn.ConnectAsync(CancellationToken.None));
ex.ToString().ShouldNotContain(secret);
}
///
/// An unrecoverable rejection must not leave a supervisor grinding away every backoff period
/// against a broker that will never accept these credentials.
///
[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(
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);
}
///
/// A genuinely transient refusal is NOT a fault. Faulting on ServerUnavailable would take
/// a recoverable driver down until someone redeployed it.
///
[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(
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);
///
/// 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.
///
[Fact]
public void IsUnrecoverableConnackRejection_UnknownCode_DefaultsToRetryable()
=> MqttConnection.IsUnrecoverableConnackRejection((MqttClientConnectResultCode)250).ShouldBeFalse();
///
/// 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.
///
[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");
}
/// The mirror case: a transient refusal keeps the supervisor retrying under backoff.
[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 };
/// Options aimed at : plaintext v3.1.1, keep-alive parked out of the way.
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,
};
///
/// 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.
///
private sealed class CapturingLogger : ILogger
{
private readonly List _messages = [];
public int CountContaining(string fragment)
{
lock (_messages)
{
return _messages.Count(m => m.Contains(fragment, StringComparison.OrdinalIgnoreCase));
}
}
public IDisposable? BeginScope(TState state)
where TState : notnull
=> null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func formatter)
{
lock (_messages)
{
_messages.Add(formatter(state, exception));
}
}
}
private static async Task WaitUntilAsync(Func 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)
=> MqttConnection.BuildClientOptions(opts, clientIdSuffix: null)
.ChannelOptions.ShouldBeOfType().TlsOptions;
private static readonly DateTimeOffset NotBefore = DateTimeOffset.UtcNow.AddDays(-1);
/// A CA certificate, self-signed when is null.
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);
}
/// A server (end-entity) certificate signed by .
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());
}
/// A self-signed leaf that chains to nothing — the "stranger" a pin must reject.
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;
}
///
/// Mimics what SslStream 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 ChainElements is populated the way the platform would populate it.
///
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);
}
///
/// 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).
///
private sealed class BlackholeBroker : IDisposable
{
private readonly List _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; }
/// Total TCP connections accepted — how a caller detects a background retry storm.
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.
}
}
}
}