Files
natsdotnet/tests/NATS.Server.Mqtt.Tests/Mqtt/MqttBinaryParserTests.cs
Joseph Doherty a6be5e11ed refactor: extract NATS.Server.Mqtt.Tests project
Move 29 MQTT test files from NATS.Server.Tests into a dedicated
NATS.Server.Mqtt.Tests project. Update namespaces, add
InternalsVisibleTo, and replace Task.Delay calls with
PollHelper.WaitUntilAsync for proper synchronization.
2026-03-12 15:03:12 -04:00

478 lines
18 KiB
C#

// Binary MQTT packet parser tests.
// Go reference: golang/nats-server/server/mqtt.go
// CONNECT parsing — mqttParseConnect (~line 700)
// PUBLISH parsing — mqttParsePublish (~line 1200)
// SUBSCRIBE parsing — mqttParseSub (~line 1400)
// Wildcard translation — mqttToNATSSubjectConversion (~line 2200)
using System.Text;
using NATS.Server.Mqtt;
namespace NATS.Server.Mqtt.Tests.Mqtt;
public class MqttBinaryParserTests
{
// =========================================================================
// Helpers — build well-formed CONNECT packet payloads
// =========================================================================
/// <summary>
/// Builds the payload bytes (everything after the fixed header) of an MQTT
/// 3.1.1 CONNECT packet.
/// </summary>
private static byte[] BuildConnectPayload(
string clientId,
bool cleanSession = true,
ushort keepAlive = 60,
string? username = null,
string? password = null,
string? willTopic = null,
byte[]? willMessage = null,
byte willQoS = 0,
bool willRetain = false)
{
using var ms = new System.IO.MemoryStream();
using var w = new System.IO.BinaryWriter(ms);
// Protocol name "MQTT"
WriteString(w, "MQTT");
// Protocol level 4 (MQTT 3.1.1)
w.Write((byte)4);
// Connect flags
byte flags = 0;
if (cleanSession) flags |= 0x02;
if (willTopic != null) flags |= 0x04;
flags |= (byte)((willQoS & 0x03) << 3);
if (willRetain) flags |= 0x20;
if (password != null) flags |= 0x40;
if (username != null) flags |= 0x80;
w.Write(flags);
// Keep-alive (big-endian)
WriteUInt16BE(w, keepAlive);
// Payload fields
WriteString(w, clientId);
if (willTopic != null)
{
WriteString(w, willTopic);
WriteBinaryField(w, willMessage ?? []);
}
if (username != null) WriteString(w, username);
if (password != null) WriteString(w, password);
return ms.ToArray();
}
private static void WriteString(System.IO.BinaryWriter w, string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
WriteUInt16BE(w, (ushort)bytes.Length);
w.Write(bytes);
}
private static void WriteBinaryField(System.IO.BinaryWriter w, byte[] data)
{
WriteUInt16BE(w, (ushort)data.Length);
w.Write(data);
}
private static void WriteUInt16BE(System.IO.BinaryWriter w, ushort value)
{
w.Write((byte)(value >> 8));
w.Write((byte)(value & 0xFF));
}
// =========================================================================
// 1. ParseConnect — valid packet
// Go reference: server/mqtt.go mqttParseConnect ~line 700
// =========================================================================
[Fact]
public void ParseConnect_ValidPacket_ReturnsConnectInfo()
{
// Go: mqttParseConnect — basic CONNECT with protocol name, level, and empty client ID
var payload = BuildConnectPayload("test-client", cleanSession: true, keepAlive: 30);
var info = MqttBinaryDecoder.ParseConnect(payload);
info.ProtocolName.ShouldBe("MQTT");
info.ProtocolLevel.ShouldBe((byte)4);
info.CleanSession.ShouldBeTrue();
info.KeepAlive.ShouldBe((ushort)30);
info.ClientId.ShouldBe("test-client");
info.Username.ShouldBeNull();
info.Password.ShouldBeNull();
info.WillTopic.ShouldBeNull();
info.WillMessage.ShouldBeNull();
}
// =========================================================================
// 2. ParseConnect — with credentials
// Go reference: server/mqtt.go mqttParseConnect ~line 780
// =========================================================================
[Fact]
public void ParseConnect_WithCredentials()
{
// Go: mqttParseConnect — username and password flags set in connect flags byte
var payload = BuildConnectPayload(
"cred-client",
cleanSession: true,
keepAlive: 60,
username: "alice",
password: "s3cr3t");
var info = MqttBinaryDecoder.ParseConnect(payload);
info.ClientId.ShouldBe("cred-client");
info.Username.ShouldBe("alice");
info.Password.ShouldBe("s3cr3t");
}
// =========================================================================
// 3. ParseConnect — with will message
// Go reference: server/mqtt.go mqttParseConnect ~line 740
// =========================================================================
[Fact]
public void ParseConnect_WithWillMessage()
{
// Go: mqttParseConnect — WillFlag + WillTopic + WillMessage in payload
var willBytes = Encoding.UTF8.GetBytes("offline");
var payload = BuildConnectPayload(
"will-client",
willTopic: "status/device",
willMessage: willBytes,
willQoS: 1,
willRetain: true);
var info = MqttBinaryDecoder.ParseConnect(payload);
info.ClientId.ShouldBe("will-client");
info.WillTopic.ShouldBe("status/device");
info.WillMessage.ShouldNotBeNull();
info.WillMessage!.ShouldBe(willBytes);
info.WillQoS.ShouldBe((byte)1);
info.WillRetain.ShouldBeTrue();
}
// =========================================================================
// 4. ParseConnect — clean session flag
// Go reference: server/mqtt.go mqttParseConnect ~line 710
// =========================================================================
[Fact]
public void ParseConnect_CleanSessionFlag()
{
// Go: mqttParseConnect — clean session bit 1 of connect flags
var withClean = BuildConnectPayload("c1", cleanSession: true);
var withoutClean = BuildConnectPayload("c2", cleanSession: false);
MqttBinaryDecoder.ParseConnect(withClean).CleanSession.ShouldBeTrue();
MqttBinaryDecoder.ParseConnect(withoutClean).CleanSession.ShouldBeFalse();
}
// =========================================================================
// 5. ParsePublish — QoS 0 (no packet ID)
// Go reference: server/mqtt.go mqttParsePublish ~line 1200
// =========================================================================
[Fact]
public void ParsePublish_QoS0()
{
// Go: mqttParsePublish — QoS 0: no packet identifier present
// Build payload: 2-byte length + "sensors/temp" + message bytes
var topic = "sensors/temp";
var topicBytes = Encoding.UTF8.GetBytes(topic);
var message = Encoding.UTF8.GetBytes("23.5");
using var ms = new System.IO.MemoryStream();
using var w = new System.IO.BinaryWriter(ms);
WriteUInt16BE(w, (ushort)topicBytes.Length);
w.Write(topicBytes);
w.Write(message);
var payload = ms.ToArray();
// flags = 0x00 → QoS 0, no DUP, no RETAIN
var info = MqttBinaryDecoder.ParsePublish(payload, flags: 0x00);
info.Topic.ShouldBe("sensors/temp");
info.QoS.ShouldBe((byte)0);
info.PacketId.ShouldBe((ushort)0);
info.Dup.ShouldBeFalse();
info.Retain.ShouldBeFalse();
Encoding.UTF8.GetString(info.Payload.Span).ShouldBe("23.5");
}
// =========================================================================
// 6. ParsePublish — QoS 1 (has packet ID)
// Go reference: server/mqtt.go mqttParsePublish ~line 1230
// =========================================================================
[Fact]
public void ParsePublish_QoS1()
{
// Go: mqttParsePublish — QoS 1: 2-byte packet identifier follows topic
var topic = "events/click";
var topicBytes = Encoding.UTF8.GetBytes(topic);
var message = Encoding.UTF8.GetBytes("payload-data");
using var ms = new System.IO.MemoryStream();
using var w = new System.IO.BinaryWriter(ms);
WriteUInt16BE(w, (ushort)topicBytes.Length);
w.Write(topicBytes);
WriteUInt16BE(w, 42); // packet ID = 42
w.Write(message);
var payload = ms.ToArray();
// flags = 0x02 → QoS 1 (bits 2-1 = 01)
var info = MqttBinaryDecoder.ParsePublish(payload, flags: 0x02);
info.Topic.ShouldBe("events/click");
info.QoS.ShouldBe((byte)1);
info.PacketId.ShouldBe((ushort)42);
Encoding.UTF8.GetString(info.Payload.Span).ShouldBe("payload-data");
}
// =========================================================================
// 7. ParsePublish — retain flag
// Go reference: server/mqtt.go mqttParsePublish ~line 1210
// =========================================================================
[Fact]
public void ParsePublish_RetainFlag()
{
// Go: mqttParsePublish — RETAIN flag is bit 0 of the fixed-header flags nibble
var topicBytes = Encoding.UTF8.GetBytes("home/light");
using var ms = new System.IO.MemoryStream();
using var w = new System.IO.BinaryWriter(ms);
WriteUInt16BE(w, (ushort)topicBytes.Length);
w.Write(topicBytes);
w.Write(Encoding.UTF8.GetBytes("on"));
var payload = ms.ToArray();
// flags = 0x01 → RETAIN set, QoS 0
var info = MqttBinaryDecoder.ParsePublish(payload, flags: 0x01);
info.Topic.ShouldBe("home/light");
info.Retain.ShouldBeTrue();
info.QoS.ShouldBe((byte)0);
}
// =========================================================================
// 8. ParseSubscribe — single topic
// Go reference: server/mqtt.go mqttParseSub ~line 1400
// =========================================================================
[Fact]
public void ParseSubscribe_SingleTopic()
{
// Go: mqttParseSub — SUBSCRIBE with a single topic filter entry
// Payload: 2-byte packet-id + (2-byte len + topic + 1-byte QoS) per entry
using var ms = new System.IO.MemoryStream();
using var w = new System.IO.BinaryWriter(ms);
WriteUInt16BE(w, 7); // packet ID = 7
WriteString(w, "sport/tennis/#"); // topic filter
w.Write((byte)0); // QoS 0
var payload = ms.ToArray();
var info = MqttBinaryDecoder.ParseSubscribe(payload);
info.PacketId.ShouldBe((ushort)7);
info.Filters.Count.ShouldBe(1);
info.Filters[0].TopicFilter.ShouldBe("sport/tennis/#");
info.Filters[0].QoS.ShouldBe((byte)0);
}
// =========================================================================
// 9. ParseSubscribe — multiple topics with different QoS
// Go reference: server/mqtt.go mqttParseSub ~line 1420
// =========================================================================
[Fact]
public void ParseSubscribe_MultipleTopics()
{
// Go: mqttParseSub — multiple topic filter entries in one SUBSCRIBE
using var ms = new System.IO.MemoryStream();
using var w = new System.IO.BinaryWriter(ms);
WriteUInt16BE(w, 99); // packet ID = 99
WriteString(w, "sensors/+"); // filter 1
w.Write((byte)0); // QoS 0
WriteString(w, "events/#"); // filter 2
w.Write((byte)1); // QoS 1
WriteString(w, "alerts/critical"); // filter 3
w.Write((byte)2); // QoS 2
var payload = ms.ToArray();
var info = MqttBinaryDecoder.ParseSubscribe(payload);
info.PacketId.ShouldBe((ushort)99);
info.Filters.Count.ShouldBe(3);
info.Filters[0].TopicFilter.ShouldBe("sensors/+");
info.Filters[0].QoS.ShouldBe((byte)0);
info.Filters[1].TopicFilter.ShouldBe("events/#");
info.Filters[1].QoS.ShouldBe((byte)1);
info.Filters[2].TopicFilter.ShouldBe("alerts/critical");
info.Filters[2].QoS.ShouldBe((byte)2);
}
// =========================================================================
// 10. TranslateWildcard — '+' → '*'
// Go reference: server/mqtt.go mqttToNATSSubjectConversion ~line 2200
// =========================================================================
[Fact]
public void TranslateWildcard_Plus()
{
// Go: mqttToNATSSubjectConversion — '+' maps to '*' (single-level)
var result = MqttBinaryDecoder.TranslateFilterToNatsSubject("+");
result.ShouldBe("*");
}
// =========================================================================
// 11. TranslateWildcard — '#' → '>'
// Go reference: server/mqtt.go mqttToNATSSubjectConversion ~line 2210
// =========================================================================
[Fact]
public void TranslateWildcard_Hash()
{
// Go: mqttToNATSSubjectConversion — '#' maps to '>' (multi-level)
var result = MqttBinaryDecoder.TranslateFilterToNatsSubject("#");
result.ShouldBe(">");
}
// =========================================================================
// 12. TranslateWildcard — '/' → '.'
// Go reference: server/mqtt.go mqttToNATSSubjectConversion ~line 2220
// =========================================================================
[Fact]
public void TranslateWildcard_Slash()
{
// Go: mqttToNATSSubjectConversion — '/' separator maps to '.'
var result = MqttBinaryDecoder.TranslateFilterToNatsSubject("a/b/c");
result.ShouldBe("a.b.c");
}
// =========================================================================
// 13. TranslateWildcard — complex combined translation
// Go reference: server/mqtt.go mqttToNATSSubjectConversion ~line 2200
// =========================================================================
[Fact]
public void TranslateWildcard_Complex()
{
// Go: mqttToNATSSubjectConversion — combines '/', '+', '#'
// sport/+/score/# → sport.*.score.>
var result = MqttBinaryDecoder.TranslateFilterToNatsSubject("sport/+/score/#");
result.ShouldBe("sport.*.score.>");
}
// =========================================================================
// 14. DecodeRemainingLength — multi-byte values (VarInt edge cases)
// Go reference: server/mqtt.go TestMQTTReader / TestMQTTWriter
// =========================================================================
[Theory]
[InlineData(new byte[] { 0x00 }, 0, 1)]
[InlineData(new byte[] { 0x01 }, 1, 1)]
[InlineData(new byte[] { 0x7F }, 127, 1)]
[InlineData(new byte[] { 0x80, 0x01 }, 128, 2)]
[InlineData(new byte[] { 0xFF, 0x7F }, 16383, 2)]
[InlineData(new byte[] { 0x80, 0x80, 0x01 }, 16384, 3)]
[InlineData(new byte[] { 0xFF, 0xFF, 0x7F }, 2097151, 3)]
[InlineData(new byte[] { 0x80, 0x80, 0x80, 0x01 }, 2097152, 4)]
[InlineData(new byte[] { 0xFF, 0xFF, 0xFF, 0x7F }, 268435455, 4)]
public void DecodeRemainingLength_MultiByteValues(byte[] encoded, int expectedValue, int expectedConsumed)
{
// Go TestMQTTReader: verifies variable-length integer decoding at all boundary values
var value = MqttPacketReader.DecodeRemainingLength(encoded, out var consumed);
value.ShouldBe(expectedValue);
consumed.ShouldBe(expectedConsumed);
}
// =========================================================================
// Additional edge-case tests
// =========================================================================
[Fact]
public void ParsePublish_DupFlag_IsSet()
{
// DUP flag is bit 3 of the fixed-header flags nibble (0x08).
// When QoS > 0, a 2-byte packet identifier must follow the topic.
var topicBytes = Encoding.UTF8.GetBytes("dup/topic");
using var ms = new System.IO.MemoryStream();
using var w = new System.IO.BinaryWriter(ms);
WriteUInt16BE(w, (ushort)topicBytes.Length);
w.Write(topicBytes);
WriteUInt16BE(w, 5); // packet ID = 5 (required for QoS 1)
var payload = ms.ToArray();
// flags = 0x0A → DUP (bit 3) + QoS 1 (bits 2-1)
var info = MqttBinaryDecoder.ParsePublish(payload, flags: 0x0A);
info.Dup.ShouldBeTrue();
info.QoS.ShouldBe((byte)1);
info.PacketId.ShouldBe((ushort)5);
}
[Fact]
public void ParseConnect_EmptyClientId_IsAllowed()
{
// MQTT 3.1.1 §3.1.3.1 allows empty client IDs with CleanSession=true
var payload = BuildConnectPayload("", cleanSession: true);
var info = MqttBinaryDecoder.ParseConnect(payload);
info.ClientId.ShouldBe(string.Empty);
info.CleanSession.ShouldBeTrue();
}
[Fact]
public void TranslateWildcard_EmptyString_ReturnsEmpty()
{
var result = MqttBinaryDecoder.TranslateFilterToNatsSubject(string.Empty);
result.ShouldBe(string.Empty);
}
[Fact]
public void TranslateWildcard_PlainTopic_NoChange()
{
// A topic with no wildcards or slashes should pass through unchanged
var result = MqttBinaryDecoder.TranslateFilterToNatsSubject("plainword");
result.ShouldBe("plainword");
}
[Fact]
public void ParsePublish_EmptyPayload_IsAllowed()
{
// A PUBLISH with no application payload is valid (e.g. retain-delete)
var topicBytes = Encoding.UTF8.GetBytes("empty/payload");
using var ms = new System.IO.MemoryStream();
using var w = new System.IO.BinaryWriter(ms);
WriteUInt16BE(w, (ushort)topicBytes.Length);
w.Write(topicBytes);
var payload = ms.ToArray();
var info = MqttBinaryDecoder.ParsePublish(payload, flags: 0x00);
info.Topic.ShouldBe("empty/payload");
info.Payload.Length.ShouldBe(0);
}
}