feat(mqtt): implement CONNECT/CONNACK/DISCONNECT packet handlers
Implement Task 3 of MQTT orchestration: - Create MqttPacketHandlers.cs with ParseConnect(), ProcessConnect(), EnqueueConnAck(), HandleDisconnect() - Wire CONNECT and DISCONNECT dispatch in MqttParser.cs - Parse CONNECT: protocol name/level, flags, keep-alive, client ID, will, auth - Send CONNACK (4-byte fixed packet with return code) - DISCONNECT clears will message and closes connection cleanly - Auto-generate client ID for empty ID + clean session - Validate reserved bit, will flags, username/password consistency - Add Reader field to MqttHandler for per-connection parsing - 11 unit tests for CONNECT parsing and processing - 1 end-to-end integration test: TCP → CONNECT → CONNACK over the wire
This commit is contained in:
240
dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttPacketHandlers.cs
Normal file
240
dotnet/src/ZB.MOM.NatsNet.Server/Mqtt/MqttPacketHandlers.cs
Normal file
@@ -0,0 +1,240 @@
|
||||
// Copyright 2020-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
// Adapted from server/mqtt.go in the NATS server Go source.
|
||||
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Mqtt;
|
||||
|
||||
/// <summary>
|
||||
/// MQTT packet parsing and processing handlers.
|
||||
/// Mirrors the mqttParseConnect / mqttProcessConnect / mqttEnqueueConnAck
|
||||
/// functions in server/mqtt.go.
|
||||
/// </summary>
|
||||
internal static class MqttPacketHandlers
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses an MQTT CONNECT packet from the reader.
|
||||
/// Returns (returnCode, connectProto, error).
|
||||
/// returnCode == 0 means success; non-zero is a CONNACK return code.
|
||||
/// Mirrors Go <c>mqttParseConnect()</c>.
|
||||
/// </summary>
|
||||
public static (byte rc, MqttConnectProto? cp, Exception? err) ParseConnect(MqttReader r)
|
||||
{
|
||||
// --- Protocol Name ---
|
||||
string protoName;
|
||||
try { protoName = r.ReadString("protocol name"); }
|
||||
catch (Exception ex) { return (0, null, ex); }
|
||||
|
||||
if (protoName != "MQTT")
|
||||
return (MqttConnAckRc.UnacceptableProtocol, null,
|
||||
new InvalidOperationException($"unsupported MQTT protocol: \"{protoName}\""));
|
||||
|
||||
// --- Protocol Level ---
|
||||
byte level;
|
||||
try { level = r.ReadByte("protocol level"); }
|
||||
catch (Exception ex) { return (0, null, ex); }
|
||||
|
||||
if (level != 0x04)
|
||||
return (MqttConnAckRc.UnacceptableProtocol, null,
|
||||
new InvalidOperationException($"unsupported MQTT protocol level: {level}"));
|
||||
|
||||
// --- Connect Flags ---
|
||||
byte flags;
|
||||
try { flags = r.ReadByte("connect flags"); }
|
||||
catch (Exception ex) { return (0, null, ex); }
|
||||
|
||||
if ((flags & MqttConnectFlag.Reserved) != 0)
|
||||
return (0, null, new InvalidOperationException("CONNECT flags reserved bit must be 0"));
|
||||
|
||||
bool cleanSession = (flags & MqttConnectFlag.CleanSession) != 0;
|
||||
bool willFlag = (flags & MqttConnectFlag.WillFlag) != 0;
|
||||
byte willQos = (byte)((flags & MqttConnectFlag.WillQoS) >> 3);
|
||||
bool willRetain = (flags & MqttConnectFlag.WillRetain) != 0;
|
||||
bool hasPassword = (flags & MqttConnectFlag.PasswordFlag) != 0;
|
||||
bool hasUsername = (flags & MqttConnectFlag.UsernameFlag) != 0;
|
||||
|
||||
// Validate Will flags.
|
||||
if (!willFlag)
|
||||
{
|
||||
if (willQos != 0)
|
||||
return (0, null, new InvalidOperationException("Will QoS must be 0 when Will Flag is 0"));
|
||||
if (willRetain)
|
||||
return (0, null, new InvalidOperationException("Will Retain must be 0 when Will Flag is 0"));
|
||||
}
|
||||
else
|
||||
{
|
||||
if (willQos > 2)
|
||||
return (0, null, new InvalidOperationException($"invalid Will QoS: {willQos}"));
|
||||
}
|
||||
|
||||
// Username/password consistency.
|
||||
if (hasPassword && !hasUsername)
|
||||
return (0, null, new InvalidOperationException("password flag without username flag"));
|
||||
|
||||
// --- Keep Alive ---
|
||||
ushort keepAlive;
|
||||
try { keepAlive = r.ReadUInt16("keep alive"); }
|
||||
catch (Exception ex) { return (0, null, ex); }
|
||||
|
||||
// --- Client ID ---
|
||||
string clientId;
|
||||
try { clientId = r.ReadString("client id"); }
|
||||
catch (Exception ex) { return (0, null, ex); }
|
||||
|
||||
if (string.IsNullOrEmpty(clientId))
|
||||
{
|
||||
if (!cleanSession)
|
||||
return (MqttConnAckRc.IdentifierRejected, null,
|
||||
new InvalidOperationException("empty client ID requires clean session flag"));
|
||||
// Generate a unique client ID.
|
||||
clientId = Guid.NewGuid().ToString("N");
|
||||
}
|
||||
|
||||
// --- Will Topic & Message ---
|
||||
MqttWill? will = null;
|
||||
if (willFlag)
|
||||
{
|
||||
string willTopic;
|
||||
try { willTopic = r.ReadString("will topic"); }
|
||||
catch (Exception ex) { return (0, null, ex); }
|
||||
|
||||
if (string.IsNullOrEmpty(willTopic))
|
||||
return (0, null, new InvalidOperationException("empty will topic"));
|
||||
|
||||
byte[] willMsg;
|
||||
try { willMsg = r.ReadBytes("will message", copy: true); }
|
||||
catch (Exception ex) { return (0, null, ex); }
|
||||
|
||||
// Convert MQTT topic to NATS subject.
|
||||
var topicBytes = Encoding.UTF8.GetBytes(willTopic);
|
||||
byte[] subjectBytes;
|
||||
try { subjectBytes = MqttSubjectConverter.MqttTopicToNatsPubSubject(topicBytes); }
|
||||
catch (Exception ex) { return (0, null, ex); }
|
||||
|
||||
will = new MqttWill
|
||||
{
|
||||
Topic = willTopic,
|
||||
Subject = Encoding.UTF8.GetString(subjectBytes),
|
||||
Msg = willMsg.Length > 0 ? willMsg : null,
|
||||
Qos = willQos,
|
||||
Retain = willRetain,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Username ---
|
||||
string username = string.Empty;
|
||||
if (hasUsername)
|
||||
{
|
||||
try { username = r.ReadString("username"); }
|
||||
catch (Exception ex) { return (0, null, ex); }
|
||||
|
||||
if (string.IsNullOrEmpty(username))
|
||||
return (0, null, new InvalidOperationException("empty username"));
|
||||
}
|
||||
|
||||
// --- Password ---
|
||||
byte[]? password = null;
|
||||
if (hasPassword)
|
||||
{
|
||||
try { password = r.ReadBytes("password", copy: true); }
|
||||
catch (Exception ex) { return (0, null, ex); }
|
||||
}
|
||||
|
||||
var cp = new MqttConnectProto
|
||||
{
|
||||
ClientId = clientId,
|
||||
Will = will,
|
||||
Username = username,
|
||||
Password = password,
|
||||
CleanSession = cleanSession,
|
||||
KeepAlive = keepAlive,
|
||||
};
|
||||
|
||||
return (MqttConnAckRc.Accepted, cp, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes a parsed CONNECT packet: sets client state, sends CONNACK.
|
||||
/// Minimal implementation — full session management deferred to Task 6.
|
||||
/// Mirrors Go <c>mqttProcessConnect()</c>.
|
||||
/// </summary>
|
||||
public static Exception? ProcessConnect(ClientConnection c, MqttConnectProto cp)
|
||||
{
|
||||
var mqtt = c.Mqtt!;
|
||||
|
||||
// Store client identity.
|
||||
mqtt.ClientId = cp.ClientId;
|
||||
mqtt.CleanSession = cp.CleanSession;
|
||||
mqtt.KeepAlive = cp.KeepAlive;
|
||||
mqtt.Will = cp.Will;
|
||||
|
||||
// Store auth credentials on client options.
|
||||
if (!string.IsNullOrEmpty(cp.Username))
|
||||
c.Opts.Username = cp.Username;
|
||||
if (cp.Password != null)
|
||||
c.Opts.Password = Encoding.UTF8.GetString(cp.Password);
|
||||
|
||||
// Mark as connected.
|
||||
c.Flags |= ClientFlags.ConnectReceived;
|
||||
|
||||
// Set keep-alive read deadline.
|
||||
if (cp.KeepAlive > 0)
|
||||
{
|
||||
// MQTT spec: server MUST disconnect if no packet within 1.5x keep-alive.
|
||||
var deadline = TimeSpan.FromSeconds(cp.KeepAlive * 1.5);
|
||||
mqtt.KeepAlive = cp.KeepAlive;
|
||||
// TODO: set read deadline on connection stream (Task 7)
|
||||
}
|
||||
|
||||
// Send CONNACK (accepted, no session present for now).
|
||||
EnqueueConnAck(c, MqttConnAckRc.Accepted, sessionPresent: false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enqueues a CONNACK packet to the client.
|
||||
/// Mirrors Go <c>mqttEnqueueConnAck()</c>.
|
||||
/// </summary>
|
||||
public static void EnqueueConnAck(ClientConnection c, byte rc, bool sessionPresent)
|
||||
{
|
||||
byte sp = 0;
|
||||
if (rc == MqttConnAckRc.Accepted && sessionPresent)
|
||||
sp = 1;
|
||||
|
||||
ReadOnlySpan<byte> connack = [MqttPacket.ConnectAck, 0x02, sp, rc];
|
||||
lock (c)
|
||||
{
|
||||
c.EnqueueProto(connack);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles DISCONNECT: clears the will message and closes the connection.
|
||||
/// Mirrors Go DISCONNECT case in mqttParse().
|
||||
/// </summary>
|
||||
public static void HandleDisconnect(ClientConnection c)
|
||||
{
|
||||
// Per MQTT spec 3.1.2-8: discard the will message on clean disconnect.
|
||||
lock (c)
|
||||
{
|
||||
if (c.Mqtt != null)
|
||||
c.Mqtt.Will = null;
|
||||
}
|
||||
|
||||
// Close the connection cleanly.
|
||||
c.CloseConnection(ClosedState.ClientClosed);
|
||||
}
|
||||
}
|
||||
@@ -126,18 +126,32 @@ internal static class MqttParser
|
||||
case MqttPacket.Connect:
|
||||
if (connected)
|
||||
{
|
||||
// Second CONNECT on same connection is a protocol violation.
|
||||
err = new InvalidOperationException("second CONNECT packet not allowed");
|
||||
break;
|
||||
}
|
||||
// TODO: Task 3 — MqttParseConnect + MqttProcessConnect
|
||||
err = new NotImplementedException("CONNECT not yet implemented");
|
||||
var (rc, cp, parseErr) = MqttPacketHandlers.ParseConnect(r);
|
||||
if (parseErr != null)
|
||||
{
|
||||
// Send CONNACK with error code if we have one, then close.
|
||||
if (rc != MqttConnAckRc.Accepted)
|
||||
MqttPacketHandlers.EnqueueConnAck(c, rc, false);
|
||||
err = parseErr;
|
||||
break;
|
||||
}
|
||||
if (rc != MqttConnAckRc.Accepted)
|
||||
{
|
||||
MqttPacketHandlers.EnqueueConnAck(c, rc, false);
|
||||
err = new InvalidOperationException($"CONNECT rejected with code 0x{rc:X2}");
|
||||
break;
|
||||
}
|
||||
err = MqttPacketHandlers.ProcessConnect(c, cp!);
|
||||
if (err == null)
|
||||
connected = true;
|
||||
break;
|
||||
|
||||
case MqttPacket.Disconnect:
|
||||
// TODO: Task 3 — handle DISCONNECT
|
||||
err = new NotImplementedException("DISCONNECT not yet implemented");
|
||||
break;
|
||||
MqttPacketHandlers.HandleDisconnect(c);
|
||||
return null; // Connection closed, exit parse loop.
|
||||
|
||||
default:
|
||||
err = new InvalidOperationException($"unknown MQTT packet type: 0x{pt:X2}");
|
||||
|
||||
@@ -160,6 +160,96 @@ public sealed class ServerBootTests : IDisposable
|
||||
server.Running().ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end: TCP connect → send MQTT CONNECT → receive CONNACK.
|
||||
/// Validates the full MQTT handshake over the wire.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MqttBoot_ConnectHandshake_ShouldReceiveConnAck()
|
||||
{
|
||||
var opts = new ServerOptions
|
||||
{
|
||||
Host = "127.0.0.1",
|
||||
Port = 0,
|
||||
Mqtt = { Port = -1, Host = "127.0.0.1" },
|
||||
};
|
||||
|
||||
var (server, err) = NatsServer.NewServer(opts);
|
||||
err.ShouldBeNull();
|
||||
server.ShouldNotBeNull();
|
||||
|
||||
try
|
||||
{
|
||||
server!.Start();
|
||||
var mqttAddr = server.MqttAddr();
|
||||
mqttAddr.ShouldNotBeNull();
|
||||
|
||||
using var tcp = new System.Net.Sockets.TcpClient();
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await tcp.ConnectAsync(mqttAddr!.Address, mqttAddr.Port, cts.Token);
|
||||
|
||||
var stream = tcp.GetStream();
|
||||
|
||||
// Build and send MQTT CONNECT packet.
|
||||
var connectPacket = BuildMqttConnectPacket("integration-test");
|
||||
await stream.WriteAsync(connectPacket, cts.Token);
|
||||
await stream.FlushAsync(cts.Token);
|
||||
|
||||
// Read CONNACK response (4 bytes).
|
||||
var response = new byte[4];
|
||||
var totalRead = 0;
|
||||
while (totalRead < 4)
|
||||
{
|
||||
var n = await stream.ReadAsync(response.AsMemory(totalRead, 4 - totalRead), cts.Token);
|
||||
if (n == 0) break;
|
||||
totalRead += n;
|
||||
}
|
||||
|
||||
totalRead.ShouldBe(4, "Should receive 4-byte CONNACK");
|
||||
response[0].ShouldBe((byte)0x20, "Packet type should be CONNACK");
|
||||
response[1].ShouldBe((byte)0x02, "Remaining length should be 2");
|
||||
response[3].ShouldBe((byte)0x00, "Return code should be Accepted (0)");
|
||||
}
|
||||
finally
|
||||
{
|
||||
server!.Shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Builds a minimal MQTT CONNECT packet.</summary>
|
||||
private static byte[] BuildMqttConnectPacket(string clientId)
|
||||
{
|
||||
var payload = new List<byte>();
|
||||
// Protocol name "MQTT"
|
||||
payload.AddRange(new byte[] { 0x00, 0x04 });
|
||||
payload.AddRange(System.Text.Encoding.UTF8.GetBytes("MQTT"));
|
||||
// Protocol level 4
|
||||
payload.Add(0x04);
|
||||
// Flags: clean session
|
||||
payload.Add(0x02);
|
||||
// Keep alive: 60s
|
||||
payload.AddRange(new byte[] { 0x00, 0x3C });
|
||||
// Client ID
|
||||
var cidBytes = System.Text.Encoding.UTF8.GetBytes(clientId);
|
||||
payload.Add((byte)(cidBytes.Length >> 8));
|
||||
payload.Add((byte)(cidBytes.Length & 0xFF));
|
||||
payload.AddRange(cidBytes);
|
||||
|
||||
// Fixed header
|
||||
var result = new List<byte>();
|
||||
result.Add(0x10); // CONNECT type
|
||||
var remLen = payload.Count;
|
||||
do
|
||||
{
|
||||
var b = (byte)(remLen & 0x7F);
|
||||
remLen >>= 7;
|
||||
if (remLen > 0) b |= 0x80;
|
||||
result.Add(b);
|
||||
} while (remLen > 0);
|
||||
result.AddRange(payload);
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that Shutdown() after Start() completes cleanly.
|
||||
/// Uses DontListen to skip TCP binding — tests lifecycle only.
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
// Copyright 2020-2026 The NATS Authors
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
using System.Text;
|
||||
using Shouldly;
|
||||
using ZB.MOM.NatsNet.Server;
|
||||
using ZB.MOM.NatsNet.Server.Internal;
|
||||
using ZB.MOM.NatsNet.Server.Mqtt;
|
||||
|
||||
namespace ZB.MOM.NatsNet.Server.Tests.Mqtt;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for MQTT CONNECT/CONNACK/DISCONNECT packet handling.
|
||||
/// </summary>
|
||||
public sealed class MqttConnectTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a minimal valid MQTT CONNECT packet.
|
||||
/// </summary>
|
||||
private static byte[] BuildConnectPacket(
|
||||
string clientId = "test-client",
|
||||
bool cleanSession = true,
|
||||
ushort keepAlive = 60,
|
||||
string? willTopic = null,
|
||||
byte[]? willMessage = null,
|
||||
byte willQos = 0,
|
||||
bool willRetain = false,
|
||||
string? username = null,
|
||||
string? password = null)
|
||||
{
|
||||
var payload = new List<byte>();
|
||||
|
||||
// Variable header.
|
||||
// Protocol name "MQTT".
|
||||
var protoName = Encoding.UTF8.GetBytes("MQTT");
|
||||
payload.Add((byte)(protoName.Length >> 8));
|
||||
payload.Add((byte)(protoName.Length & 0xFF));
|
||||
payload.AddRange(protoName);
|
||||
|
||||
// Protocol level.
|
||||
payload.Add(0x04);
|
||||
|
||||
// Connect flags.
|
||||
byte flags = 0;
|
||||
if (cleanSession) flags |= MqttConnectFlag.CleanSession;
|
||||
if (willTopic != null)
|
||||
{
|
||||
flags |= MqttConnectFlag.WillFlag;
|
||||
flags |= (byte)((willQos & 0x03) << 3);
|
||||
if (willRetain) flags |= MqttConnectFlag.WillRetain;
|
||||
}
|
||||
if (username != null) flags |= MqttConnectFlag.UsernameFlag;
|
||||
if (password != null) flags |= MqttConnectFlag.PasswordFlag;
|
||||
payload.Add(flags);
|
||||
|
||||
// Keep alive.
|
||||
payload.Add((byte)(keepAlive >> 8));
|
||||
payload.Add((byte)(keepAlive & 0xFF));
|
||||
|
||||
// Client ID.
|
||||
var cidBytes = Encoding.UTF8.GetBytes(clientId);
|
||||
payload.Add((byte)(cidBytes.Length >> 8));
|
||||
payload.Add((byte)(cidBytes.Length & 0xFF));
|
||||
payload.AddRange(cidBytes);
|
||||
|
||||
// Will topic + message.
|
||||
if (willTopic != null)
|
||||
{
|
||||
var topicBytes = Encoding.UTF8.GetBytes(willTopic);
|
||||
payload.Add((byte)(topicBytes.Length >> 8));
|
||||
payload.Add((byte)(topicBytes.Length & 0xFF));
|
||||
payload.AddRange(topicBytes);
|
||||
|
||||
var msg = willMessage ?? [];
|
||||
payload.Add((byte)(msg.Length >> 8));
|
||||
payload.Add((byte)(msg.Length & 0xFF));
|
||||
payload.AddRange(msg);
|
||||
}
|
||||
|
||||
// Username.
|
||||
if (username != null)
|
||||
{
|
||||
var userBytes = Encoding.UTF8.GetBytes(username);
|
||||
payload.Add((byte)(userBytes.Length >> 8));
|
||||
payload.Add((byte)(userBytes.Length & 0xFF));
|
||||
payload.AddRange(userBytes);
|
||||
}
|
||||
|
||||
// Password.
|
||||
if (password != null)
|
||||
{
|
||||
var passBytes = Encoding.UTF8.GetBytes(password);
|
||||
payload.Add((byte)(passBytes.Length >> 8));
|
||||
payload.Add((byte)(passBytes.Length & 0xFF));
|
||||
payload.AddRange(passBytes);
|
||||
}
|
||||
|
||||
// Build full packet: type byte + remaining length + payload.
|
||||
var result = new List<byte>();
|
||||
result.Add(MqttPacket.Connect);
|
||||
|
||||
// Encode remaining length.
|
||||
var remLen = payload.Count;
|
||||
do
|
||||
{
|
||||
var encoded = (byte)(remLen & 0x7F);
|
||||
remLen >>= 7;
|
||||
if (remLen > 0) encoded |= 0x80;
|
||||
result.Add(encoded);
|
||||
} while (remLen > 0);
|
||||
|
||||
result.AddRange(payload);
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
private static ClientConnection CreateMqttClient()
|
||||
{
|
||||
var ms = new MemoryStream();
|
||||
var c = new ClientConnection(ClientKind.Client, nc: ms);
|
||||
c.InitMqtt(new MqttHandler());
|
||||
return c;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ParseConnect tests
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void ParseConnect_ValidMinimal_ShouldSucceed()
|
||||
{
|
||||
var buf = BuildConnectPacket();
|
||||
var r = new MqttReader();
|
||||
// Skip the fixed header (type + remaining length) — parser handles that.
|
||||
// For direct ParseConnect testing, we feed only the variable header + payload.
|
||||
r.Reset(buf[2..]); // Skip type byte and 1-byte remaining length.
|
||||
|
||||
var (rc, cp, err) = MqttPacketHandlers.ParseConnect(r);
|
||||
err.ShouldBeNull();
|
||||
rc.ShouldBe(MqttConnAckRc.Accepted);
|
||||
cp.ShouldNotBeNull();
|
||||
cp!.ClientId.ShouldBe("test-client");
|
||||
cp.CleanSession.ShouldBeTrue();
|
||||
cp.KeepAlive.ShouldBe((ushort)60);
|
||||
cp.Will.ShouldBeNull();
|
||||
cp.Username.ShouldBeEmpty();
|
||||
cp.Password.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseConnect_WithWill_ShouldParseCorrectly()
|
||||
{
|
||||
var buf = BuildConnectPacket(
|
||||
willTopic: "test/will",
|
||||
willMessage: Encoding.UTF8.GetBytes("goodbye"),
|
||||
willQos: 1,
|
||||
willRetain: true);
|
||||
|
||||
// Find remaining length to skip header correctly.
|
||||
int headerLen = 1; // type byte
|
||||
int remLen = 0;
|
||||
int mult = 1;
|
||||
for (int i = 1; i < buf.Length; i++)
|
||||
{
|
||||
remLen += (buf[i] & 0x7F) * mult;
|
||||
headerLen++;
|
||||
if ((buf[i] & 0x80) == 0) break;
|
||||
mult *= 128;
|
||||
}
|
||||
|
||||
var r = new MqttReader();
|
||||
r.Reset(buf[headerLen..]);
|
||||
|
||||
var (rc, cp, err) = MqttPacketHandlers.ParseConnect(r);
|
||||
err.ShouldBeNull();
|
||||
rc.ShouldBe(MqttConnAckRc.Accepted);
|
||||
cp!.Will.ShouldNotBeNull();
|
||||
cp.Will!.Topic.ShouldBe("test/will");
|
||||
cp.Will.Subject.ShouldNotBeEmpty(); // NATS-converted subject
|
||||
cp.Will.Msg.ShouldNotBeNull();
|
||||
Encoding.UTF8.GetString(cp.Will.Msg!).ShouldBe("goodbye");
|
||||
cp.Will.Qos.ShouldBe((byte)1);
|
||||
cp.Will.Retain.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseConnect_WithAuth_ShouldParseCorrectly()
|
||||
{
|
||||
var buf = BuildConnectPacket(username: "user1", password: "pass1");
|
||||
var r = new MqttReader();
|
||||
r.Reset(buf[2..]);
|
||||
|
||||
var (rc, cp, err) = MqttPacketHandlers.ParseConnect(r);
|
||||
err.ShouldBeNull();
|
||||
rc.ShouldBe(MqttConnAckRc.Accepted);
|
||||
cp!.Username.ShouldBe("user1");
|
||||
cp.Password.ShouldNotBeNull();
|
||||
Encoding.UTF8.GetString(cp.Password!).ShouldBe("pass1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseConnect_WrongProtocolName_ShouldRejectWithCode()
|
||||
{
|
||||
// Build a packet with wrong protocol name.
|
||||
var buf = new List<byte>();
|
||||
var wrong = Encoding.UTF8.GetBytes("MQIsdp");
|
||||
buf.Add((byte)(wrong.Length >> 8));
|
||||
buf.Add((byte)(wrong.Length & 0xFF));
|
||||
buf.AddRange(wrong);
|
||||
buf.Add(0x03); // level
|
||||
buf.Add(0x02); // clean session
|
||||
buf.AddRange(new byte[] { 0x00, 0x3C }); // keepalive=60
|
||||
buf.AddRange(new byte[] { 0x00, 0x02, 0x41, 0x42 }); // clientId="AB"
|
||||
|
||||
var r = new MqttReader();
|
||||
r.Reset(buf.ToArray());
|
||||
|
||||
var (rc, cp, err) = MqttPacketHandlers.ParseConnect(r);
|
||||
rc.ShouldBe(MqttConnAckRc.UnacceptableProtocol);
|
||||
err.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseConnect_EmptyClientIdWithoutCleanSession_ShouldReject()
|
||||
{
|
||||
var buf = BuildConnectPacket(clientId: "", cleanSession: false);
|
||||
var r = new MqttReader();
|
||||
r.Reset(buf[2..]);
|
||||
|
||||
var (rc, cp, err) = MqttPacketHandlers.ParseConnect(r);
|
||||
rc.ShouldBe(MqttConnAckRc.IdentifierRejected);
|
||||
err.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseConnect_EmptyClientIdWithCleanSession_ShouldAutoGenerate()
|
||||
{
|
||||
var buf = BuildConnectPacket(clientId: "", cleanSession: true);
|
||||
var r = new MqttReader();
|
||||
r.Reset(buf[2..]);
|
||||
|
||||
var (rc, cp, err) = MqttPacketHandlers.ParseConnect(r);
|
||||
err.ShouldBeNull();
|
||||
rc.ShouldBe(MqttConnAckRc.Accepted);
|
||||
cp!.ClientId.ShouldNotBeEmpty(); // Auto-generated
|
||||
cp.ClientId.Length.ShouldBe(32); // GUID "N" format
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseConnect_ReservedBitSet_ShouldError()
|
||||
{
|
||||
// Build manually with reserved bit set.
|
||||
var payload = new List<byte>();
|
||||
var proto = Encoding.UTF8.GetBytes("MQTT");
|
||||
payload.Add(0); payload.Add(4);
|
||||
payload.AddRange(proto);
|
||||
payload.Add(0x04); // level
|
||||
payload.Add(0x03); // clean session + reserved bit!
|
||||
payload.AddRange(new byte[] { 0x00, 0x00 }); // keepalive
|
||||
payload.AddRange(new byte[] { 0x00, 0x02, 0x41, 0x42 }); // clientId
|
||||
|
||||
var r = new MqttReader();
|
||||
r.Reset(payload.ToArray());
|
||||
|
||||
var (rc, cp, err) = MqttPacketHandlers.ParseConnect(r);
|
||||
err.ShouldNotBeNull();
|
||||
err.Message.ShouldContain("reserved bit");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// ProcessConnect + CONNACK tests
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void ProcessConnect_ShouldSetFlagsAndSendConnAck()
|
||||
{
|
||||
var c = CreateMqttClient();
|
||||
var cp = new MqttConnectProto
|
||||
{
|
||||
ClientId = "test-123",
|
||||
CleanSession = true,
|
||||
KeepAlive = 30,
|
||||
};
|
||||
|
||||
var err = MqttPacketHandlers.ProcessConnect(c, cp);
|
||||
err.ShouldBeNull();
|
||||
|
||||
// Verify state.
|
||||
c.Mqtt!.ClientId.ShouldBe("test-123");
|
||||
c.Mqtt.CleanSession.ShouldBeTrue();
|
||||
(c.Flags & ClientFlags.ConnectReceived).ShouldNotBe((ClientFlags)0);
|
||||
|
||||
// Verify CONNACK was written.
|
||||
var ms = (MemoryStream)typeof(ClientConnection)
|
||||
.GetField("_nc", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
||||
.GetValue(c)!;
|
||||
var data = ms.ToArray();
|
||||
data.Length.ShouldBe(4);
|
||||
data[0].ShouldBe(MqttPacket.ConnectAck);
|
||||
data[1].ShouldBe((byte)0x02);
|
||||
data[2].ShouldBe((byte)0x00); // No session present
|
||||
data[3].ShouldBe(MqttConnAckRc.Accepted);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Full CONNECT via parser integration
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void Parser_ConnectPacket_ShouldParseAndSendConnAck()
|
||||
{
|
||||
var c = CreateMqttClient();
|
||||
var buf = BuildConnectPacket(clientId: "mqtt-parser-test");
|
||||
|
||||
var err = MqttParser.Parse(c, buf, buf.Length);
|
||||
err.ShouldBeNull();
|
||||
|
||||
// Verify connected.
|
||||
(c.Flags & ClientFlags.ConnectReceived).ShouldNotBe((ClientFlags)0);
|
||||
c.Mqtt!.ClientId.ShouldBe("mqtt-parser-test");
|
||||
|
||||
// Verify CONNACK written.
|
||||
var ms = (MemoryStream)typeof(ClientConnection)
|
||||
.GetField("_nc", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
||||
.GetValue(c)!;
|
||||
var data = ms.ToArray();
|
||||
data.Length.ShouldBe(4);
|
||||
data[0].ShouldBe(MqttPacket.ConnectAck);
|
||||
data[3].ShouldBe(MqttConnAckRc.Accepted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parser_ConnectThenPing_ShouldSucceed()
|
||||
{
|
||||
var c = CreateMqttClient();
|
||||
var connectBuf = BuildConnectPacket();
|
||||
var pingBuf = new byte[] { MqttPacket.Ping, 0x00 };
|
||||
|
||||
// Concatenate CONNECT + PING into one buffer.
|
||||
var buf = new byte[connectBuf.Length + pingBuf.Length];
|
||||
Buffer.BlockCopy(connectBuf, 0, buf, 0, connectBuf.Length);
|
||||
Buffer.BlockCopy(pingBuf, 0, buf, connectBuf.Length, pingBuf.Length);
|
||||
|
||||
var err = MqttParser.Parse(c, buf, buf.Length);
|
||||
err.ShouldBeNull();
|
||||
|
||||
// Verify CONNACK + PINGRESP written.
|
||||
var ms = (MemoryStream)typeof(ClientConnection)
|
||||
.GetField("_nc", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
||||
.GetValue(c)!;
|
||||
var data = ms.ToArray();
|
||||
data.Length.ShouldBe(6); // 4 (CONNACK) + 2 (PINGRESP)
|
||||
data[0].ShouldBe(MqttPacket.ConnectAck);
|
||||
data[4].ShouldBe(MqttPacket.PingResp);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// DISCONNECT tests
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void Parser_Disconnect_ShouldClearWillAndClose()
|
||||
{
|
||||
var c = CreateMqttClient();
|
||||
|
||||
// First, process a CONNECT with a will.
|
||||
var connectBuf = BuildConnectPacket(
|
||||
willTopic: "test/will",
|
||||
willMessage: Encoding.UTF8.GetBytes("bye"));
|
||||
|
||||
var err = MqttParser.Parse(c, connectBuf, connectBuf.Length);
|
||||
err.ShouldBeNull();
|
||||
c.Mqtt!.Will.ShouldNotBeNull();
|
||||
|
||||
// Now send DISCONNECT.
|
||||
var disconnectBuf = new byte[] { MqttPacket.Disconnect, 0x00 };
|
||||
err = MqttParser.Parse(c, disconnectBuf, disconnectBuf.Length);
|
||||
err.ShouldBeNull();
|
||||
|
||||
// Will should be cleared.
|
||||
c.Mqtt.Will.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -56,16 +56,39 @@ public sealed class MqttParserTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ConnectFirst_ShouldNotRejectAsNonConnect()
|
||||
public void Parse_ConnectFirst_ShouldAcceptConnect()
|
||||
{
|
||||
var c = CreateMqttClient();
|
||||
// CONNECT packet (minimal): type=0x10, remaining len=0
|
||||
// This will hit the "not yet implemented" but NOT the "first packet" error.
|
||||
var buf = new byte[] { MqttPacket.Connect, 0x00 };
|
||||
var err = MqttParser.Parse(c, buf, buf.Length);
|
||||
err.ShouldNotBeNull(); // Will be NotImplementedException
|
||||
err.ShouldBeOfType<NotImplementedException>();
|
||||
err.Message.ShouldContain("CONNECT not yet implemented");
|
||||
// Use a MemoryStream so CONNACK can be written.
|
||||
typeof(ClientConnection)
|
||||
.GetField("_nc", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
|
||||
.SetValue(c, new MemoryStream());
|
||||
|
||||
// Build a valid CONNECT packet.
|
||||
var payload = new List<byte>();
|
||||
payload.AddRange(new byte[] { 0x00, 0x04 }); // protocol name length
|
||||
payload.AddRange(System.Text.Encoding.UTF8.GetBytes("MQTT"));
|
||||
payload.Add(0x04); // level
|
||||
payload.Add(0x02); // flags: clean session
|
||||
payload.AddRange(new byte[] { 0x00, 0x3C }); // keep alive = 60
|
||||
payload.AddRange(new byte[] { 0x00, 0x04 }); // client id length
|
||||
payload.AddRange(System.Text.Encoding.UTF8.GetBytes("test"));
|
||||
|
||||
var buf = new List<byte> { MqttPacket.Connect };
|
||||
// Remaining length
|
||||
var remLen = payload.Count;
|
||||
do
|
||||
{
|
||||
var b = (byte)(remLen & 0x7F);
|
||||
remLen >>= 7;
|
||||
if (remLen > 0) b |= 0x80;
|
||||
buf.Add(b);
|
||||
} while (remLen > 0);
|
||||
buf.AddRange(payload);
|
||||
|
||||
var err = MqttParser.Parse(c, buf.ToArray(), buf.Count);
|
||||
err.ShouldBeNull("CONNECT should be accepted, not rejected as non-CONNECT");
|
||||
(c.Flags & ClientFlags.ConnectReceived).ShouldNotBe((ClientFlags)0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# NATS .NET Porting Status Report
|
||||
|
||||
Generated: 2026-03-01 20:41:46 UTC
|
||||
Generated: 2026-03-01 20:48:23 UTC
|
||||
|
||||
## Modules (12 total)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user