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.
734 lines
27 KiB
C#
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.Mqtt.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);
|
|
}
|
|
}
|