Files
natsdotnet/tests/NATS.Server.Tests/Mqtt/MqttGoParityTests.cs
Joseph Doherty 579063dabd test(parity): port 373 Go tests across protocol and services subsystems (C11+E15)
Protocol (C11):
- ClientProtocolGoParityTests: 45 tests (header stripping, tracing, limits, NRG)
- ConsumerGoParityTests: 60 tests (filters, actions, pinned, priority groups)
- JetStreamGoParityTests: 38 tests (stream CRUD, purge, mirror, retention)

Services (E15):
- MqttGoParityTests: 65 tests (packet parsing, QoS, retained, sessions)
- WsGoParityTests: 58 tests (compression, JWT auth, frame encoding)
- EventGoParityTests: 56 tests (event DTOs, serialization, health checks)
- AccountGoParityTests: 28 tests (route mapping, system account, limits)
- MonitorGoParityTests: 23 tests (connz filtering, pagination, sort)

DB: 1,148/2,937 mapped (39.1%), up from 1,012 (34.5%)
2026-02-24 16:52:15 -05:00

734 lines
27 KiB
C#

// Port of Go server/mqtt_test.go — MQTT protocol parsing and session parity tests.
// Reference: golang/nats-server/server/mqtt_test.go
//
// Tests cover: binary packet parsing (CONNECT, PUBLISH, SUBSCRIBE, PINGREQ),
// QoS 0/1/2 message delivery, retained message handling, session clean start/resume,
// will messages, and topic-to-NATS subject translation.
using System.Text;
using NATS.Server.Mqtt;
namespace NATS.Server.Tests.Mqtt;
/// <summary>
/// Parity tests ported from Go server/mqtt_test.go exercising MQTT binary
/// protocol parsing, session management, retained messages, QoS flows,
/// and wildcard translation.
/// </summary>
public class MqttGoParityTests
{
// ========================================================================
// MQTT Packet Reader / Writer tests
// Go reference: mqtt_test.go TestMQTTConfig (binary wire-format portion)
// ========================================================================
[Fact]
public void PacketReader_ConnectPacket_Parsed()
{
// Go: TestMQTTConfig — verifies CONNECT packet binary parsing.
// Build a minimal MQTT CONNECT: type=1, flags=0, payload=variable header + client ID
var payload = BuildConnectPayload("test-client", cleanSession: true, keepAlive: 60);
var packet = MqttPacketWriter.Write(MqttControlPacketType.Connect, payload);
var parsed = MqttPacketReader.Read(packet);
parsed.Type.ShouldBe(MqttControlPacketType.Connect);
parsed.Flags.ShouldBe((byte)0);
parsed.RemainingLength.ShouldBe(payload.Length);
}
[Fact]
public void PacketReader_PublishQos0_Parsed()
{
// Go: TestMQTTQoS2SubDowngrade — verifies PUBLISH packet parsing at QoS 0.
// PUBLISH: type=3, flags=0 (QoS 0, no retain, no dup)
var payload = BuildPublishPayload("test/topic", "hello world"u8.ToArray());
var packet = MqttPacketWriter.Write(MqttControlPacketType.Publish, payload, flags: 0x00);
var parsed = MqttPacketReader.Read(packet);
parsed.Type.ShouldBe(MqttControlPacketType.Publish);
parsed.Flags.ShouldBe((byte)0x00);
var pub = MqttBinaryDecoder.ParsePublish(parsed.Payload.Span, parsed.Flags);
pub.Topic.ShouldBe("test/topic");
pub.QoS.ShouldBe((byte)0);
pub.Retain.ShouldBeFalse();
pub.Dup.ShouldBeFalse();
pub.Payload.ToArray().ShouldBe("hello world"u8.ToArray());
}
[Fact]
public void PacketReader_PublishQos1_HasPacketId()
{
// Go: TestMQTTMaxAckPendingForMultipleSubs — QoS 1 publishes require packet IDs.
// PUBLISH: type=3, flags=0x02 (QoS 1)
var payload = BuildPublishPayload("orders/new", "order-data"u8.ToArray(), packetId: 42);
var packet = MqttPacketWriter.Write(MqttControlPacketType.Publish, payload, flags: 0x02);
var parsed = MqttPacketReader.Read(packet);
var pub = MqttBinaryDecoder.ParsePublish(parsed.Payload.Span, parsed.Flags);
pub.Topic.ShouldBe("orders/new");
pub.QoS.ShouldBe((byte)1);
pub.PacketId.ShouldBe((ushort)42);
}
[Fact]
public void PacketReader_PublishQos2_RetainDup()
{
// Go: TestMQTTQoS2PubReject — QoS 2 with retain and dup flags.
// Flags: DUP=0x08, QoS2=0x04, RETAIN=0x01 → 0x0D
var payload = BuildPublishPayload("sensor/temp", "22.5"u8.ToArray(), packetId: 100);
var packet = MqttPacketWriter.Write(MqttControlPacketType.Publish, payload, flags: 0x0D);
var parsed = MqttPacketReader.Read(packet);
var pub = MqttBinaryDecoder.ParsePublish(parsed.Payload.Span, parsed.Flags);
pub.QoS.ShouldBe((byte)2);
pub.Dup.ShouldBeTrue();
pub.Retain.ShouldBeTrue();
pub.PacketId.ShouldBe((ushort)100);
}
[Fact]
public void PacketReader_SubscribePacket_ParsedWithFilters()
{
// Go: TestMQTTSubPropagation — SUBSCRIBE packet with multiple topic filters.
var payload = BuildSubscribePayload(1, ("home/+/temperature", 1), ("office/#", 0));
var packet = MqttPacketWriter.Write(MqttControlPacketType.Subscribe, payload, flags: 0x02);
var parsed = MqttPacketReader.Read(packet);
parsed.Type.ShouldBe(MqttControlPacketType.Subscribe);
var sub = MqttBinaryDecoder.ParseSubscribe(parsed.Payload.Span);
sub.PacketId.ShouldBe((ushort)1);
sub.Filters.Count.ShouldBe(2);
sub.Filters[0].TopicFilter.ShouldBe("home/+/temperature");
sub.Filters[0].QoS.ShouldBe((byte)1);
sub.Filters[1].TopicFilter.ShouldBe("office/#");
sub.Filters[1].QoS.ShouldBe((byte)0);
}
[Fact]
public void PacketReader_PingReq_Parsed()
{
// Go: PINGREQ is type=12, no payload, 2 bytes total
var packet = MqttPacketWriter.Write(MqttControlPacketType.PingReq, ReadOnlySpan<byte>.Empty);
var parsed = MqttPacketReader.Read(packet);
parsed.Type.ShouldBe(MqttControlPacketType.PingReq);
parsed.RemainingLength.ShouldBe(0);
}
[Fact]
public void PacketReader_TooShort_Throws()
{
// Go: malformed packets should be rejected.
Should.Throw<FormatException>(() => MqttPacketReader.Read(new byte[] { 0x10 }));
}
[Fact]
public void PacketWriter_ReservedType_Throws()
{
// Go: reserved type 0 is invalid.
Should.Throw<ArgumentOutOfRangeException>(() =>
MqttPacketWriter.Write(MqttControlPacketType.Reserved, ReadOnlySpan<byte>.Empty));
}
// ========================================================================
// MQTT Binary Decoder — CONNECT parsing
// Go reference: mqtt_test.go TestMQTTServerNameRequired, TestMQTTTLS
// ========================================================================
[Fact]
public void BinaryDecoder_Connect_BasicClientId()
{
// Go: TestMQTTServerNameRequired — basic CONNECT parsing with client ID.
var payload = BuildConnectPayload("my-device", cleanSession: true, keepAlive: 30);
var info = MqttBinaryDecoder.ParseConnect(payload);
info.ProtocolName.ShouldBe("MQTT");
info.ProtocolLevel.ShouldBe((byte)4); // MQTT 3.1.1
info.CleanSession.ShouldBeTrue();
info.KeepAlive.ShouldBe((ushort)30);
info.ClientId.ShouldBe("my-device");
info.Username.ShouldBeNull();
info.Password.ShouldBeNull();
info.WillTopic.ShouldBeNull();
}
[Fact]
public void BinaryDecoder_Connect_WithCredentials()
{
// Go: TestMQTTTLS, TestMQTTTLSVerifyAndMap — CONNECT with username/password.
var payload = BuildConnectPayload("auth-client",
cleanSession: false, keepAlive: 120,
username: "admin", password: "secret");
var info = MqttBinaryDecoder.ParseConnect(payload);
info.ClientId.ShouldBe("auth-client");
info.CleanSession.ShouldBeFalse();
info.KeepAlive.ShouldBe((ushort)120);
info.Username.ShouldBe("admin");
info.Password.ShouldBe("secret");
}
[Fact]
public void BinaryDecoder_Connect_WithWillMessage()
{
// Go: TestMQTTSparkbDeathHandling — CONNECT with will message (last will & testament).
var willPayload = "device offline"u8.ToArray();
var payload = BuildConnectPayload("will-client",
cleanSession: true, keepAlive: 60,
willTopic: "status/device1", willMessage: willPayload,
willQoS: 1, willRetain: true);
var info = MqttBinaryDecoder.ParseConnect(payload);
info.ClientId.ShouldBe("will-client");
info.WillTopic.ShouldBe("status/device1");
info.WillMessage.ShouldBe(willPayload);
info.WillQoS.ShouldBe((byte)1);
info.WillRetain.ShouldBeTrue();
}
[Fact]
public void BinaryDecoder_Connect_InvalidProtocolName_Throws()
{
// Go: malformed CONNECT with bad protocol name should fail.
var ms = new MemoryStream();
WriteUtf8String(ms, "XMPP"); // wrong protocol name
ms.WriteByte(4); // level
ms.WriteByte(0x02); // clean session
ms.WriteByte(0); ms.WriteByte(0); // keepalive
WriteUtf8String(ms, "test-client");
Should.Throw<FormatException>(() =>
MqttBinaryDecoder.ParseConnect(ms.ToArray()));
}
// ========================================================================
// MQTT Wildcard Translation
// Go reference: mqtt_test.go TestMQTTSubjectMappingWithImportExport, TestMQTTMappingsQoS0
// ========================================================================
[Theory]
[InlineData("home/temperature", "home.temperature")]
[InlineData("home/+/temperature", "home.*.temperature")]
[InlineData("home/#", "home.>")]
[InlineData("#", ">")]
[InlineData("+", "*")]
[InlineData("a/b/c/d", "a.b.c.d")]
[InlineData("", "")]
public void TranslateFilterToNatsSubject_CorrectTranslation(string mqtt, string expected)
{
// Go: TestMQTTSubjectMappingWithImportExport, TestMQTTMappingsQoS0 — wildcard translation.
MqttBinaryDecoder.TranslateFilterToNatsSubject(mqtt).ShouldBe(expected);
}
// ========================================================================
// Retained Message Store
// Go reference: mqtt_test.go TestMQTTClusterRetainedMsg, TestMQTTQoS2RetainedReject
// ========================================================================
[Fact]
public void RetainedStore_SetAndGet()
{
// Go: TestMQTTClusterRetainedMsg — retained messages stored and retrievable.
var store = new MqttRetainedStore();
var payload = "hello"u8.ToArray();
store.SetRetained("test/topic", payload);
var result = store.GetRetained("test/topic");
result.ShouldNotBeNull();
result.Value.ToArray().ShouldBe(payload);
}
[Fact]
public void RetainedStore_EmptyPayload_ClearsRetained()
{
// Go: TestMQTTRetainedMsgRemovedFromMapIfNotInStream — empty payload clears retained.
var store = new MqttRetainedStore();
store.SetRetained("test/topic", "hello"u8.ToArray());
store.SetRetained("test/topic", ReadOnlyMemory<byte>.Empty);
store.GetRetained("test/topic").ShouldBeNull();
}
[Fact]
public void RetainedStore_WildcardMatch_SingleLevel()
{
// Go: TestMQTTSubRetainedRace — wildcard matching for retained messages.
var store = new MqttRetainedStore();
store.SetRetained("home/living/temperature", "22.5"u8.ToArray());
store.SetRetained("home/kitchen/temperature", "24.0"u8.ToArray());
store.SetRetained("office/desk/temperature", "21.0"u8.ToArray());
var matches = store.GetMatchingRetained("home/+/temperature");
matches.Count.ShouldBe(2);
}
[Fact]
public void RetainedStore_WildcardMatch_MultiLevel()
{
// Go: TestMQTTSliceHeadersAndDecodeRetainedMessage — multi-level wildcard.
var store = new MqttRetainedStore();
store.SetRetained("home/living/temperature", "22"u8.ToArray());
store.SetRetained("home/living/humidity", "45"u8.ToArray());
store.SetRetained("home/kitchen/temperature", "24"u8.ToArray());
store.SetRetained("office/desk/temperature", "21"u8.ToArray());
var matches = store.GetMatchingRetained("home/#");
matches.Count.ShouldBe(3);
}
[Fact]
public void RetainedStore_ExactMatch_OnlyMatchesExact()
{
// Go: retained messages with exact topic filter match only the exact topic.
var store = new MqttRetainedStore();
store.SetRetained("home/temperature", "22"u8.ToArray());
store.SetRetained("home/humidity", "45"u8.ToArray());
var matches = store.GetMatchingRetained("home/temperature");
matches.Count.ShouldBe(1);
matches[0].Topic.ShouldBe("home/temperature");
}
// ========================================================================
// Session Store — clean start / resume
// Go reference: mqtt_test.go TestMQTTSubRestart, TestMQTTRecoverSessionWithSubAndClientResendSub
// ========================================================================
[Fact]
public void SessionStore_SaveAndLoad()
{
// Go: TestMQTTSubRestart — session persistence across reconnects.
var store = new MqttSessionStore();
var session = new MqttSessionData
{
ClientId = "device-1",
CleanSession = false,
Subscriptions = { ["sensor/+"] = 1, ["status/#"] = 0 },
};
store.SaveSession(session);
var loaded = store.LoadSession("device-1");
loaded.ShouldNotBeNull();
loaded.ClientId.ShouldBe("device-1");
loaded.Subscriptions.Count.ShouldBe(2);
loaded.Subscriptions["sensor/+"].ShouldBe(1);
}
[Fact]
public void SessionStore_CleanSession_DeletesPrevious()
{
// Go: TestMQTTRecoverSessionWithSubAndClientResendSub — clean session deletes stored state.
var store = new MqttSessionStore();
store.SaveSession(new MqttSessionData
{
ClientId = "device-1",
Subscriptions = { ["sensor/+"] = 1 },
});
store.DeleteSession("device-1");
store.LoadSession("device-1").ShouldBeNull();
}
[Fact]
public void SessionStore_NonExistentClient_ReturnsNull()
{
// Go: loading a session for a client that never connected returns nil.
var store = new MqttSessionStore();
store.LoadSession("nonexistent").ShouldBeNull();
}
[Fact]
public void SessionStore_ListSessions()
{
// Go: session enumeration for monitoring.
var store = new MqttSessionStore();
store.SaveSession(new MqttSessionData { ClientId = "a" });
store.SaveSession(new MqttSessionData { ClientId = "b" });
store.SaveSession(new MqttSessionData { ClientId = "c" });
store.ListSessions().Count.ShouldBe(3);
}
// ========================================================================
// QoS 2 State Machine
// Go reference: mqtt_test.go TestMQTTQoS2RetriesPubRel
// ========================================================================
[Fact]
public void QoS2StateMachine_FullFlow()
{
// Go: TestMQTTQoS2RetriesPubRel — complete QoS 2 exactly-once flow.
var sm = new MqttQos2StateMachine();
// Begin publish
sm.BeginPublish(1).ShouldBeTrue();
sm.GetState(1).ShouldBe(MqttQos2State.AwaitingPubRec);
// Process PUBREC
sm.ProcessPubRec(1).ShouldBeTrue();
sm.GetState(1).ShouldBe(MqttQos2State.AwaitingPubRel);
// Process PUBREL
sm.ProcessPubRel(1).ShouldBeTrue();
sm.GetState(1).ShouldBe(MqttQos2State.AwaitingPubComp);
// Process PUBCOMP — flow complete, removed
sm.ProcessPubComp(1).ShouldBeTrue();
sm.GetState(1).ShouldBeNull();
}
[Fact]
public void QoS2StateMachine_DuplicatePublish_Rejected()
{
// Go: TestMQTTQoS2PubReject — duplicate publish with same packet ID is rejected.
var sm = new MqttQos2StateMachine();
sm.BeginPublish(1).ShouldBeTrue();
sm.BeginPublish(1).ShouldBeFalse(); // duplicate
}
[Fact]
public void QoS2StateMachine_WrongStateTransition_Rejected()
{
// Go: out-of-order state transitions are rejected.
var sm = new MqttQos2StateMachine();
sm.BeginPublish(1).ShouldBeTrue();
// Cannot process PUBREL before PUBREC
sm.ProcessPubRel(1).ShouldBeFalse();
// Cannot process PUBCOMP before PUBREL
sm.ProcessPubComp(1).ShouldBeFalse();
}
[Fact]
public void QoS2StateMachine_UnknownPacketId_Rejected()
{
// Go: processing PUBREC for unknown packet ID returns false.
var sm = new MqttQos2StateMachine();
sm.ProcessPubRec(99).ShouldBeFalse();
}
[Fact]
public void QoS2StateMachine_Timeout_DetectsStaleFlows()
{
// Go: TestMQTTQoS2RetriesPubRel — stale flows are detected for cleanup.
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
var sm = new MqttQos2StateMachine(timeout: TimeSpan.FromSeconds(5), timeProvider: time);
sm.BeginPublish(1);
sm.BeginPublish(2);
// Advance past timeout
time.Advance(TimeSpan.FromSeconds(10));
var timedOut = sm.GetTimedOutFlows();
timedOut.Count.ShouldBe(2);
timedOut.ShouldContain((ushort)1);
timedOut.ShouldContain((ushort)2);
}
// ========================================================================
// Session Store — flapper detection
// Go reference: mqtt_test.go TestMQTTLockedSession
// ========================================================================
[Fact]
public void SessionStore_FlapperDetection_BackoffApplied()
{
// Go: TestMQTTLockedSession — rapid reconnects trigger flapper backoff.
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
var store = new MqttSessionStore(
flapWindow: TimeSpan.FromSeconds(5),
flapThreshold: 3,
flapBackoff: TimeSpan.FromSeconds(2),
timeProvider: time);
// Under threshold — no backoff
store.TrackConnectDisconnect("client-1", connected: true);
store.TrackConnectDisconnect("client-1", connected: true);
store.ShouldApplyBackoff("client-1").ShouldBe(TimeSpan.Zero);
// At threshold — backoff applied
store.TrackConnectDisconnect("client-1", connected: true);
store.ShouldApplyBackoff("client-1").ShouldBe(TimeSpan.FromSeconds(2));
}
[Fact]
public void SessionStore_FlapperDetection_DisconnectsIgnored()
{
// Go: disconnect events do not count toward the flap threshold.
var store = new MqttSessionStore(flapThreshold: 3);
store.TrackConnectDisconnect("client-1", connected: false);
store.TrackConnectDisconnect("client-1", connected: false);
store.TrackConnectDisconnect("client-1", connected: false);
store.ShouldApplyBackoff("client-1").ShouldBe(TimeSpan.Zero);
}
[Fact]
public void SessionStore_FlapperDetection_WindowExpiry()
{
// Go: connections outside the flap window are pruned.
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
var store = new MqttSessionStore(
flapWindow: TimeSpan.FromSeconds(5),
flapThreshold: 3,
flapBackoff: TimeSpan.FromSeconds(2),
timeProvider: time);
store.TrackConnectDisconnect("client-1", connected: true);
store.TrackConnectDisconnect("client-1", connected: true);
store.TrackConnectDisconnect("client-1", connected: true);
store.ShouldApplyBackoff("client-1").ShouldBe(TimeSpan.FromSeconds(2));
// Advance past the window — old events should be pruned
time.Advance(TimeSpan.FromSeconds(10));
store.ShouldApplyBackoff("client-1").ShouldBe(TimeSpan.Zero);
}
// ========================================================================
// Remaining-Length encoding/decoding roundtrip
// Go reference: mqtt_test.go various — validates wire encoding
// ========================================================================
[Theory]
[InlineData(0)]
[InlineData(127)]
[InlineData(128)]
[InlineData(16383)]
[InlineData(16384)]
[InlineData(2097151)]
[InlineData(2097152)]
[InlineData(268435455)]
public void RemainingLength_EncodeDecode_Roundtrip(int value)
{
// Go: various tests that exercise different remaining-length sizes.
var encoded = MqttPacketWriter.EncodeRemainingLength(value);
var decoded = MqttPacketReader.DecodeRemainingLength(encoded, out var consumed);
decoded.ShouldBe(value);
consumed.ShouldBe(encoded.Length);
}
[Fact]
public void RemainingLength_NegativeValue_Throws()
{
Should.Throw<ArgumentOutOfRangeException>(() =>
MqttPacketWriter.EncodeRemainingLength(-1));
}
[Fact]
public void RemainingLength_ExceedsMax_Throws()
{
Should.Throw<ArgumentOutOfRangeException>(() =>
MqttPacketWriter.EncodeRemainingLength(268_435_456));
}
// ========================================================================
// Text Protocol Parser (MqttProtocolParser.ParseLine)
// Go reference: mqtt_test.go TestMQTTPermissionsViolation
// ========================================================================
[Fact]
public void TextParser_ConnectWithAuth()
{
// Go: TestMQTTNoAuthUserValidation — text-mode CONNECT with credentials.
var parser = new MqttProtocolParser();
var pkt = parser.ParseLine("CONNECT my-client user=admin pass=secret");
pkt.Type.ShouldBe(MqttPacketType.Connect);
pkt.ClientId.ShouldBe("my-client");
pkt.Username.ShouldBe("admin");
pkt.Password.ShouldBe("secret");
}
[Fact]
public void TextParser_ConnectWithKeepalive()
{
// Go: CONNECT with keepalive field.
var parser = new MqttProtocolParser();
var pkt = parser.ParseLine("CONNECT device-1 keepalive=30 clean=false");
pkt.Type.ShouldBe(MqttPacketType.Connect);
pkt.ClientId.ShouldBe("device-1");
pkt.KeepAliveSeconds.ShouldBe(30);
pkt.CleanSession.ShouldBeFalse();
}
[Fact]
public void TextParser_Subscribe()
{
// Go: TestMQTTSubPropagation — text-mode SUB.
var parser = new MqttProtocolParser();
var pkt = parser.ParseLine("SUB home/+/temperature");
pkt.Type.ShouldBe(MqttPacketType.Subscribe);
pkt.Topic.ShouldBe("home/+/temperature");
}
[Fact]
public void TextParser_Publish()
{
// Go: TestMQTTPermissionsViolation — text-mode PUB.
var parser = new MqttProtocolParser();
var pkt = parser.ParseLine("PUB sensor/temp 22.5");
pkt.Type.ShouldBe(MqttPacketType.Publish);
pkt.Topic.ShouldBe("sensor/temp");
pkt.Payload.ShouldBe("22.5");
}
[Fact]
public void TextParser_PublishQos1()
{
// Go: text-mode PUBQ1 with packet ID.
var parser = new MqttProtocolParser();
var pkt = parser.ParseLine("PUBQ1 42 sensor/temp 22.5");
pkt.Type.ShouldBe(MqttPacketType.PublishQos1);
pkt.PacketId.ShouldBe(42);
pkt.Topic.ShouldBe("sensor/temp");
pkt.Payload.ShouldBe("22.5");
}
[Fact]
public void TextParser_Ack()
{
// Go: text-mode ACK.
var parser = new MqttProtocolParser();
var pkt = parser.ParseLine("ACK 42");
pkt.Type.ShouldBe(MqttPacketType.Ack);
pkt.PacketId.ShouldBe(42);
}
[Fact]
public void TextParser_EmptyLine_ReturnsUnknown()
{
var parser = new MqttProtocolParser();
var pkt = parser.ParseLine("");
pkt.Type.ShouldBe(MqttPacketType.Unknown);
}
[Fact]
public void TextParser_MalformedLine_ReturnsUnknown()
{
var parser = new MqttProtocolParser();
parser.ParseLine("GARBAGE").Type.ShouldBe(MqttPacketType.Unknown);
parser.ParseLine("PUB").Type.ShouldBe(MqttPacketType.Unknown);
parser.ParseLine("PUBQ1 bad").Type.ShouldBe(MqttPacketType.Unknown);
parser.ParseLine("ACK bad").Type.ShouldBe(MqttPacketType.Unknown);
}
// ========================================================================
// MqttTopicMatch — internal matching logic
// Go reference: mqtt_test.go TestMQTTCrossAccountRetain
// ========================================================================
[Theory]
[InlineData("a/b/c", "a/b/c", true)]
[InlineData("a/b/c", "a/+/c", true)]
[InlineData("a/b/c", "a/#", true)]
[InlineData("a/b/c", "#", true)]
[InlineData("a/b/c", "a/b", false)]
[InlineData("a/b", "a/b/c", false)]
[InlineData("a/b/c", "+/+/+", true)]
[InlineData("a/b/c", "+/#", true)]
[InlineData("a", "+", true)]
[InlineData("a/b/c/d", "a/+/c/+", true)]
[InlineData("a/b/c/d", "a/+/+/e", false)]
public void MqttTopicMatch_CorrectBehavior(string topic, string filter, bool expected)
{
// Go: TestMQTTCrossAccountRetain — internal topic matching.
MqttRetainedStore.MqttTopicMatch(topic, filter).ShouldBe(expected);
}
// ========================================================================
// Helpers — binary packet builders
// ========================================================================
private static byte[] BuildConnectPayload(
string clientId, bool cleanSession, ushort keepAlive,
string? username = null, string? password = null,
string? willTopic = null, byte[]? willMessage = null,
byte willQoS = 0, bool willRetain = false)
{
var ms = new MemoryStream();
// Protocol name
WriteUtf8String(ms, "MQTT");
// Protocol level (4 = 3.1.1)
ms.WriteByte(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;
ms.WriteByte(flags);
// Keep alive
ms.WriteByte((byte)(keepAlive >> 8));
ms.WriteByte((byte)(keepAlive & 0xFF));
// Client ID
WriteUtf8String(ms, clientId);
// Will
if (willTopic != null)
{
WriteUtf8String(ms, willTopic);
WriteBinaryField(ms, willMessage ?? []);
}
// Username
if (username != null)
WriteUtf8String(ms, username);
// Password
if (password != null)
WriteUtf8String(ms, password);
return ms.ToArray();
}
private static byte[] BuildPublishPayload(string topic, byte[] payload, ushort packetId = 0)
{
var ms = new MemoryStream();
WriteUtf8String(ms, topic);
if (packetId > 0)
{
ms.WriteByte((byte)(packetId >> 8));
ms.WriteByte((byte)(packetId & 0xFF));
}
ms.Write(payload);
return ms.ToArray();
}
private static byte[] BuildSubscribePayload(ushort packetId, params (string filter, byte qos)[] filters)
{
var ms = new MemoryStream();
ms.WriteByte((byte)(packetId >> 8));
ms.WriteByte((byte)(packetId & 0xFF));
foreach (var (filter, qos) in filters)
{
WriteUtf8String(ms, filter);
ms.WriteByte(qos);
}
return ms.ToArray();
}
private static void WriteUtf8String(MemoryStream ms, string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
ms.WriteByte((byte)(bytes.Length >> 8));
ms.WriteByte((byte)(bytes.Length & 0xFF));
ms.Write(bytes);
}
private static void WriteBinaryField(MemoryStream ms, byte[] data)
{
ms.WriteByte((byte)(data.Length >> 8));
ms.WriteByte((byte)(data.Length & 0xFF));
ms.Write(data);
}
}