using System.Net;
using System.Net.Sockets;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
///
/// The smallest broker that can complete an MQTT 3.1.1 handshake: accept TCP, answer CONNECT with
/// a CONNACK, answer PINGREQ with PINGRESP, and nothing else. That is enough for MQTTnet to report
/// a real connection — which is what makes a real drop () drive a real
/// DisconnectedAsync and a real reconnect, rather than a test seam standing in for one.
///
///
/// Shared across suites rather than nested in one, because both the connection-level and the
/// driver-level rejection tests need it: the driver-level one is the pin for the actual
/// operator-visible symptom (a rejected CONNECT must not report Healthy).
///
internal sealed class MiniBroker : IDisposable
{
/// CONNACK "Connection Accepted" (MQTT 3.1.1 return code 0).
internal const byte ReturnCodeAccepted = 0x00;
/// CONNACK "Identifier rejected" — MQTTnet maps it to ClientIdentifierNotValid.
internal const byte ReturnCodeIdentifierRejected = 0x02;
/// CONNACK "Server unavailable" — MQTTnet maps it to ServerUnavailable (transient).
internal const byte ReturnCodeServerUnavailable = 0x03;
/// CONNACK "Bad user name or password" — MQTTnet maps it to BadUserNameOrPassword.
internal const byte ReturnCodeBadUserNameOrPassword = 0x04;
/// CONNACK "Not authorized" — MQTTnet maps it to NotAuthorized (unrecoverable).
internal const byte ReturnCodeNotAuthorized = 0x05;
private static readonly byte[] PingResp = [0xD0, 0x00];
private readonly CancellationTokenSource _cts = new();
private readonly HashSet _live = [];
private readonly TcpListener _listener;
private int _acceptedCount;
public MiniBroker()
{
_listener = new TcpListener(IPAddress.Loopback, 0);
_listener.Start();
Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
_ = Task.Run(AcceptLoopAsync);
}
public int Port { get; }
/// When set, TCP is still accepted but CONNECT is never answered (frozen peer).
public bool Blackhole { get; set; }
///
/// The MQTT 3.1.1 return code answered to every CONNECT. Anything but
/// is a broker that completes the handshake and then
/// refuses — the shape MQTTnet reports as a result rather than an exception, and the one
/// a real Mosquitto with the wrong password produces.
///
public byte ConnAckReturnCode { get; set; } = ReturnCodeAccepted;
/// Total TCP connections accepted since construction — counts reconnect attempts.
public int AcceptedCount => Volatile.Read(ref _acceptedCount);
///
/// Sockets the broker still has open. Drops to zero only once the peer actually closes, so a
/// leaked-but-live client keeps this above zero.
///
public int LiveConnections
{
get
{
lock (_live)
{
return _live.Count;
}
}
}
/// Kills every established session — the "broker went away" event.
public void DropAll()
{
lock (_live)
{
foreach (var client in _live)
{
try
{
client.Close();
}
catch (Exception)
{
// Already gone.
}
}
_live.Clear();
}
}
public void Dispose()
{
_cts.Cancel();
_listener.Stop();
DropAll();
_cts.Dispose();
}
private async Task AcceptLoopAsync()
{
try
{
while (!_cts.IsCancellationRequested)
{
var client = await _listener.AcceptTcpClientAsync(_cts.Token);
Interlocked.Increment(ref _acceptedCount);
lock (_live)
{
_live.Add(client);
}
_ = Task.Run(() => ServeAsync(client));
}
}
catch (Exception)
{
// Listener stopped / token cancelled — the fixture is going away.
}
}
private async Task ServeAsync(TcpClient client)
{
var buffer = new byte[1024];
try
{
var stream = client.GetStream();
while (!_cts.IsCancellationRequested)
{
var read = await stream.ReadAsync(buffer, _cts.Token);
if (read == 0)
{
break; // peer closed — this is how a disposed client stops counting as live
}
if (Blackhole)
{
continue;
}
var packetType = buffer[0] & 0xF0;
if (packetType == 0x10)
{
await stream.WriteAsync(new byte[] { 0x20, 0x02, 0x00, ConnAckReturnCode }, _cts.Token);
// A real broker closes the socket straight after refusing, which is what drives
// MQTTnet's DisconnectedAsync. Mirroring it keeps the fake honest.
if (ConnAckReturnCode != ReturnCodeAccepted)
{
break;
}
}
else if (packetType == 0xC0)
{
await stream.WriteAsync(PingResp, _cts.Token);
}
}
}
catch (Exception)
{
// Socket torn down by either side.
}
finally
{
lock (_live)
{
_live.Remove(client);
}
try
{
client.Dispose();
}
catch (Exception)
{
// Already gone.
}
}
}
}