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.
This commit is contained in:
977
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttAdvancedParityTests.cs
Normal file
977
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttAdvancedParityTests.cs
Normal file
@@ -0,0 +1,977 @@
|
||||
// Ports advanced MQTT behaviors from Go reference:
|
||||
// golang/nats-server/server/mqtt_test.go — TestMQTTSub, TestMQTTUnsub, TestMQTTSubWithSpaces,
|
||||
// TestMQTTSubCaseSensitive, TestMQTTSubDups, TestMQTTParseSub, TestMQTTParseUnsub,
|
||||
// TestMQTTSubAck, TestMQTTPublish, TestMQTTPublishTopicErrors, TestMQTTParsePub,
|
||||
// TestMQTTMaxPayloadEnforced, TestMQTTCleanSession, TestMQTTDuplicateClientID,
|
||||
// TestMQTTConnAckFirstPacket, TestMQTTStart, TestMQTTValidateOptions,
|
||||
// TestMQTTPreventSubWithMQTTSubPrefix, TestMQTTConnKeepAlive, TestMQTTDontSetPinger,
|
||||
// TestMQTTPartial, TestMQTTSubQoS2, TestMQTTPubSubMatrix, TestMQTTRedeliveryAckWait,
|
||||
// TestMQTTFlappingSession
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using NATS.Server.Mqtt;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests.Mqtt;
|
||||
|
||||
public class MqttAdvancedParityTests
|
||||
{
|
||||
// =========================================================================
|
||||
// Subscribe / Unsubscribe runtime tests
|
||||
// =========================================================================
|
||||
|
||||
// Go: TestMQTTSub — 1 level match
|
||||
// server/mqtt_test.go:2306
|
||||
[Fact]
|
||||
public async Task Subscribe_exact_topic_receives_matching_publish()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var sub = new TcpClient();
|
||||
await sub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var ss = sub.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(ss, "CONNECT sub-exact clean=true");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ss, 1000)).ShouldBe("CONNACK");
|
||||
await MqttAdvancedWire.WriteLineAsync(ss, "SUB foo");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ss, 1000))!.ShouldContain("SUBACK");
|
||||
|
||||
using var pub = new TcpClient();
|
||||
await pub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var ps = pub.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(ps, "CONNECT pub-exact clean=true");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ps, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
await MqttAdvancedWire.WriteLineAsync(ps, "PUB foo msg");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ss, 1000)).ShouldBe("MSG foo msg");
|
||||
}
|
||||
|
||||
// Go: TestMQTTSub — 1 level no match
|
||||
// server/mqtt_test.go:2326
|
||||
[Fact]
|
||||
public async Task Subscribe_exact_topic_does_not_receive_non_matching_publish()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var sub = new TcpClient();
|
||||
await sub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var ss = sub.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(ss, "CONNECT sub-nomatch clean=true");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ss, 1000)).ShouldBe("CONNACK");
|
||||
await MqttAdvancedWire.WriteLineAsync(ss, "SUB foo");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ss, 1000))!.ShouldContain("SUBACK");
|
||||
|
||||
using var pub = new TcpClient();
|
||||
await pub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var ps = pub.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(ps, "CONNECT pub-nomatch clean=true");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ps, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
await MqttAdvancedWire.WriteLineAsync(ps, "PUB bar msg");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ss, 300)).ShouldBeNull();
|
||||
}
|
||||
|
||||
// Go: TestMQTTSub — 2 levels match
|
||||
// server/mqtt_test.go:2327
|
||||
[Fact]
|
||||
public async Task Subscribe_two_level_topic_receives_matching_publish()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var sub = new TcpClient();
|
||||
await sub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var ss = sub.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(ss, "CONNECT sub-2level clean=true");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ss, 1000)).ShouldBe("CONNACK");
|
||||
await MqttAdvancedWire.WriteLineAsync(ss, "SUB foo.bar");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ss, 1000))!.ShouldContain("SUBACK");
|
||||
|
||||
using var pub = new TcpClient();
|
||||
await pub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var ps = pub.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(ps, "CONNECT pub-2level clean=true");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ps, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
await MqttAdvancedWire.WriteLineAsync(ps, "PUB foo.bar msg");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ss, 1000)).ShouldBe("MSG foo.bar msg");
|
||||
}
|
||||
|
||||
// Go: TestMQTTUnsub — subscribe, receive, unsub, no more messages
|
||||
// server/mqtt_test.go:4018
|
||||
[Fact]
|
||||
public async Task Unsubscribe_stops_message_delivery()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var sub = new TcpClient();
|
||||
await sub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var ss = sub.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(ss, "CONNECT sub-unsub clean=true");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ss, 1000)).ShouldBe("CONNACK");
|
||||
await MqttAdvancedWire.WriteLineAsync(ss, "SUB unsub.topic");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ss, 1000))!.ShouldContain("SUBACK");
|
||||
|
||||
using var pub = new TcpClient();
|
||||
await pub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var ps = pub.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(ps, "CONNECT pub-unsub clean=true");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ps, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
// Verify message received before unsub
|
||||
await MqttAdvancedWire.WriteLineAsync(ps, "PUB unsub.topic before");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ss, 1000)).ShouldBe("MSG unsub.topic before");
|
||||
|
||||
// After disconnect + reconnect without subscription, no delivery.
|
||||
// (The lightweight listener doesn't support UNSUB command, so we test
|
||||
// via reconnect with no subscription.)
|
||||
sub.Dispose();
|
||||
|
||||
using var sub2 = new TcpClient();
|
||||
await sub2.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var ss2 = sub2.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(ss2, "CONNECT sub-unsub clean=true");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ss2, 1000)).ShouldBe("CONNACK");
|
||||
// No subscription registered — publish should not reach this client
|
||||
|
||||
await MqttAdvancedWire.WriteLineAsync(ps, "PUB unsub.topic after");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ss2, 300)).ShouldBeNull();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Publish tests
|
||||
// =========================================================================
|
||||
|
||||
// Go: TestMQTTPublish — QoS 0, 1 publishes work
|
||||
// server/mqtt_test.go:2270
|
||||
[Fact]
|
||||
public async Task Publish_qos0_and_qos1_both_work()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = client.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(stream, "CONNECT pub-both clean=true");
|
||||
(await MqttAdvancedWire.ReadLineAsync(stream, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
// QoS 0 — no PUBACK
|
||||
await MqttAdvancedWire.WriteLineAsync(stream, "PUB foo msg0");
|
||||
(await MqttAdvancedWire.ReadRawAsync(stream, 300)).ShouldBe("__timeout__");
|
||||
|
||||
// QoS 1 — PUBACK returned
|
||||
await MqttAdvancedWire.WriteLineAsync(stream, "PUBQ1 1 foo msg1");
|
||||
(await MqttAdvancedWire.ReadLineAsync(stream, 1000)).ShouldBe("PUBACK 1");
|
||||
}
|
||||
|
||||
// Go: TestMQTTParsePub — PUBLISH packet parsing
|
||||
// server/mqtt_test.go:2221
|
||||
[Fact]
|
||||
public void Publish_packet_parses_topic_and_payload_from_bytes()
|
||||
{
|
||||
// PUBLISH QoS 0: topic "a/b" + payload "hi"
|
||||
ReadOnlySpan<byte> bytes =
|
||||
[
|
||||
0x30, 0x07,
|
||||
0x00, 0x03, (byte)'a', (byte)'/', (byte)'b',
|
||||
(byte)'h', (byte)'i',
|
||||
];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
packet.Type.ShouldBe(MqttControlPacketType.Publish);
|
||||
|
||||
var payload = packet.Payload.Span;
|
||||
// Topic length prefix
|
||||
var topicLen = (payload[0] << 8) | payload[1];
|
||||
topicLen.ShouldBe(3);
|
||||
payload[2].ShouldBe((byte)'a');
|
||||
payload[3].ShouldBe((byte)'/');
|
||||
payload[4].ShouldBe((byte)'b');
|
||||
// Payload data
|
||||
payload[5].ShouldBe((byte)'h');
|
||||
payload[6].ShouldBe((byte)'i');
|
||||
}
|
||||
|
||||
// Go: TestMQTTParsePIMsg — PUBACK packet identifier parsing
|
||||
// server/mqtt_test.go:2250
|
||||
[Fact]
|
||||
public void Puback_packet_identifier_parsed_from_payload()
|
||||
{
|
||||
ReadOnlySpan<byte> bytes =
|
||||
[
|
||||
0x40, 0x02, // PUBACK, remaining length 2
|
||||
0x00, 0x07, // packet identifier 7
|
||||
];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
packet.Type.ShouldBe(MqttControlPacketType.PubAck);
|
||||
|
||||
var pi = (packet.Payload.Span[0] << 8) | packet.Payload.Span[1];
|
||||
pi.ShouldBe(7);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// SUBSCRIBE packet parsing errors
|
||||
// Go: TestMQTTParseSub server/mqtt_test.go:1898
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void Subscribe_packet_with_packet_id_zero_is_invalid()
|
||||
{
|
||||
// Go: "packet id cannot be zero" — packet-id 0x0000 is invalid
|
||||
ReadOnlySpan<byte> bytes =
|
||||
[
|
||||
0x82, 0x08,
|
||||
0x00, 0x00, // packet-id 0 — INVALID
|
||||
0x00, 0x03, (byte)'a', (byte)'/', (byte)'b',
|
||||
0x00,
|
||||
];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
packet.Type.ShouldBe(MqttControlPacketType.Subscribe);
|
||||
var pi = (packet.Payload.Span[0] << 8) | packet.Payload.Span[1];
|
||||
pi.ShouldBe(0); // Zero PI is protocol violation that server should reject
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Subscribe_packet_with_valid_qos_values()
|
||||
{
|
||||
// Go: "invalid qos" — QoS must be 0, 1 or 2
|
||||
// Test that QoS 0, 1, 2 are all representable in the packet
|
||||
foreach (byte qos in new byte[] { 0, 1, 2 })
|
||||
{
|
||||
ReadOnlySpan<byte> bytes =
|
||||
[
|
||||
0x82, 0x08,
|
||||
0x00, 0x01, // packet-id 1
|
||||
0x00, 0x03, (byte)'a', (byte)'/', (byte)'b',
|
||||
qos,
|
||||
];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
var lastByte = packet.Payload.Span[^1];
|
||||
lastByte.ShouldBe(qos);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Subscribe_packet_invalid_qos_value_3_in_payload()
|
||||
{
|
||||
// Go: "invalid qos" — QoS value 3 is invalid per MQTT spec
|
||||
ReadOnlySpan<byte> bytes =
|
||||
[
|
||||
0x82, 0x08,
|
||||
0x00, 0x01,
|
||||
0x00, 0x03, (byte)'a', (byte)'/', (byte)'b',
|
||||
0x03, // QoS 3 is invalid
|
||||
];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
var lastByte = packet.Payload.Span[^1];
|
||||
lastByte.ShouldBe((byte)3);
|
||||
// The packet reader returns raw bytes; validation is done by the server layer
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// UNSUBSCRIBE packet parsing
|
||||
// Go: TestMQTTParseUnsub server/mqtt_test.go:3961
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void Unsubscribe_packet_parses_topic_filter_from_payload()
|
||||
{
|
||||
ReadOnlySpan<byte> bytes =
|
||||
[
|
||||
0xA2, 0x09,
|
||||
0x00, 0x02, // packet-id 2
|
||||
0x00, 0x05, (byte)'h', (byte)'e', (byte)'l', (byte)'l', (byte)'o',
|
||||
];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
((byte)packet.Type).ShouldBe((byte)10); // Unsubscribe = 0xA0 >> 4 = 10
|
||||
packet.Flags.ShouldBe((byte)0x02);
|
||||
|
||||
var pi = (packet.Payload.Span[0] << 8) | packet.Payload.Span[1];
|
||||
pi.ShouldBe(2);
|
||||
|
||||
var topicLen = (packet.Payload.Span[2] << 8) | packet.Payload.Span[3];
|
||||
topicLen.ShouldBe(5);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// PINGREQ / PINGRESP
|
||||
// Go: TestMQTTDontSetPinger server/mqtt_test.go:1756
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void Pingreq_and_pingresp_are_two_byte_packets()
|
||||
{
|
||||
// PINGREQ = 0xC0 0x00
|
||||
ReadOnlySpan<byte> pingreq = [0xC0, 0x00];
|
||||
var req = MqttPacketReader.Read(pingreq);
|
||||
req.Type.ShouldBe(MqttControlPacketType.PingReq);
|
||||
req.RemainingLength.ShouldBe(0);
|
||||
|
||||
// PINGRESP = 0xD0 0x00
|
||||
ReadOnlySpan<byte> pingresp = [0xD0, 0x00];
|
||||
var resp = MqttPacketReader.Read(pingresp);
|
||||
resp.Type.ShouldBe(MqttControlPacketType.PingResp);
|
||||
resp.RemainingLength.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pingreq_round_trips_through_writer()
|
||||
{
|
||||
var encoded = MqttPacketWriter.Write(MqttControlPacketType.PingReq, ReadOnlySpan<byte>.Empty);
|
||||
encoded.Length.ShouldBe(2);
|
||||
encoded[0].ShouldBe((byte)0xC0);
|
||||
encoded[1].ShouldBe((byte)0x00);
|
||||
|
||||
var decoded = MqttPacketReader.Read(encoded);
|
||||
decoded.Type.ShouldBe(MqttControlPacketType.PingReq);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Client ID generation and validation
|
||||
// Go: TestMQTTParseConnect — "empty client ID" requires clean session
|
||||
// server/mqtt_test.go:1681
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void Connect_with_empty_client_id_and_clean_session_is_accepted()
|
||||
{
|
||||
// Go: empty client-id + clean-session flag → accepted
|
||||
ReadOnlySpan<byte> bytes =
|
||||
[
|
||||
0x10, 0x0C,
|
||||
0x00, 0x04, (byte)'M', (byte)'Q', (byte)'T', (byte)'T',
|
||||
0x04, 0x02, 0x00, 0x3C, // clean session flag
|
||||
0x00, 0x00, // empty client-id
|
||||
];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
packet.Type.ShouldBe(MqttControlPacketType.Connect);
|
||||
|
||||
// Verify client-id is empty (2-byte length prefix = 0)
|
||||
var clientIdLen = (packet.Payload.Span[10] << 8) | packet.Payload.Span[11];
|
||||
clientIdLen.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Connect_with_client_id_parses_correctly()
|
||||
{
|
||||
// Go: CONNECT with client-id "test"
|
||||
ReadOnlySpan<byte> bytes =
|
||||
[
|
||||
0x10, 0x10,
|
||||
0x00, 0x04, (byte)'M', (byte)'Q', (byte)'T', (byte)'T',
|
||||
0x04, 0x02, 0x00, 0x3C,
|
||||
0x00, 0x04, (byte)'t', (byte)'e', (byte)'s', (byte)'t', // client-id "test"
|
||||
];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
var clientIdLen = (packet.Payload.Span[10] << 8) | packet.Payload.Span[11];
|
||||
clientIdLen.ShouldBe(4);
|
||||
packet.Payload.Span[12].ShouldBe((byte)'t');
|
||||
packet.Payload.Span[13].ShouldBe((byte)'e');
|
||||
packet.Payload.Span[14].ShouldBe((byte)'s');
|
||||
packet.Payload.Span[15].ShouldBe((byte)'t');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Go: TestMQTTSubCaseSensitive server/mqtt_test.go:2724
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Subscription_matching_is_case_sensitive()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var sub = new TcpClient();
|
||||
await sub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var ss = sub.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(ss, "CONNECT sub-case clean=true");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ss, 1000)).ShouldBe("CONNACK");
|
||||
await MqttAdvancedWire.WriteLineAsync(ss, "SUB Foo.Bar");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ss, 1000))!.ShouldContain("SUBACK");
|
||||
|
||||
using var pub = new TcpClient();
|
||||
await pub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var ps = pub.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(ps, "CONNECT pub-case clean=true");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ps, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
// Exact case match → delivered
|
||||
await MqttAdvancedWire.WriteLineAsync(ps, "PUB Foo.Bar msg");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ss, 1000)).ShouldBe("MSG Foo.Bar msg");
|
||||
|
||||
// Different case → not delivered
|
||||
await MqttAdvancedWire.WriteLineAsync(ps, "PUB foo.bar msg");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ss, 300)).ShouldBeNull();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Go: TestMQTTCleanSession server/mqtt_test.go:4773
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Clean_session_reconnect_produces_no_pending_messages()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
// Connect with persistent session and publish QoS 1
|
||||
using (var first = new TcpClient())
|
||||
{
|
||||
await first.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var s = first.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(s, "CONNECT clean-sess-test clean=false");
|
||||
(await MqttAdvancedWire.ReadLineAsync(s, 1000)).ShouldBe("CONNACK");
|
||||
await MqttAdvancedWire.WriteLineAsync(s, "PUBQ1 1 x y");
|
||||
(await MqttAdvancedWire.ReadLineAsync(s, 1000)).ShouldBe("PUBACK 1");
|
||||
}
|
||||
|
||||
// Reconnect with clean=true
|
||||
using var second = new TcpClient();
|
||||
await second.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = second.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(stream, "CONNECT clean-sess-test clean=true");
|
||||
(await MqttAdvancedWire.ReadLineAsync(stream, 1000)).ShouldBe("CONNACK");
|
||||
(await MqttAdvancedWire.ReadLineAsync(stream, 300)).ShouldBeNull();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Go: TestMQTTDuplicateClientID server/mqtt_test.go:4801
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Duplicate_client_id_second_connection_accepted()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var c1 = new TcpClient();
|
||||
await c1.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var s1 = c1.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(s1, "CONNECT dup-client clean=false");
|
||||
(await MqttAdvancedWire.ReadLineAsync(s1, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
using var c2 = new TcpClient();
|
||||
await c2.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var s2 = c2.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(s2, "CONNECT dup-client clean=false");
|
||||
(await MqttAdvancedWire.ReadLineAsync(s2, 1000)).ShouldBe("CONNACK");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Go: TestMQTTStart server/mqtt_test.go:667
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Server_accepts_tcp_connections()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
listener.Port.ShouldBeGreaterThan(0);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
client.Connected.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Go: TestMQTTConnAckFirstPacket server/mqtt_test.go:5456
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Connack_is_first_response_to_connect()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = client.GetStream();
|
||||
|
||||
await MqttAdvancedWire.WriteLineAsync(stream, "CONNECT first-packet clean=true");
|
||||
var response = await MqttAdvancedWire.ReadLineAsync(stream, 1000);
|
||||
response.ShouldBe("CONNACK");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Go: TestMQTTSubDups server/mqtt_test.go:2588
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Multiple_subscriptions_to_same_topic_do_not_cause_duplicates()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var sub = new TcpClient();
|
||||
await sub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var ss = sub.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(ss, "CONNECT sub-dup clean=true");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ss, 1000)).ShouldBe("CONNACK");
|
||||
await MqttAdvancedWire.WriteLineAsync(ss, "SUB dup.topic");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ss, 1000))!.ShouldContain("SUBACK");
|
||||
// Subscribe again to the same topic
|
||||
await MqttAdvancedWire.WriteLineAsync(ss, "SUB dup.topic");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ss, 1000))!.ShouldContain("SUBACK");
|
||||
|
||||
using var pub = new TcpClient();
|
||||
await pub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var ps = pub.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(ps, "CONNECT pub-dup clean=true");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ps, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
await MqttAdvancedWire.WriteLineAsync(ps, "PUB dup.topic hello");
|
||||
// Should receive the message (at least once)
|
||||
(await MqttAdvancedWire.ReadLineAsync(ss, 1000)).ShouldBe("MSG dup.topic hello");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Go: TestMQTTFlappingSession server/mqtt_test.go:5138
|
||||
// Rapidly connecting and disconnecting with the same client ID
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Rapid_connect_disconnect_cycles_do_not_crash_server()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = client.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(stream, "CONNECT flap-client clean=false");
|
||||
(await MqttAdvancedWire.ReadLineAsync(stream, 1000)).ShouldBe("CONNACK");
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Go: TestMQTTRedeliveryAckWait server/mqtt_test.go:5514
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Unacked_qos1_messages_are_redelivered_on_reconnect()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
// Publish QoS 1, don't ACK, disconnect
|
||||
using (var first = new TcpClient())
|
||||
{
|
||||
await first.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var s = first.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(s, "CONNECT redeliver-test clean=false");
|
||||
(await MqttAdvancedWire.ReadLineAsync(s, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
await MqttAdvancedWire.WriteLineAsync(s, "PUBQ1 42 topic.redeliver payload");
|
||||
(await MqttAdvancedWire.ReadLineAsync(s, 1000)).ShouldBe("PUBACK 42");
|
||||
// No ACK sent — disconnect
|
||||
}
|
||||
|
||||
// Reconnect with same client ID, persistent session
|
||||
using var second = new TcpClient();
|
||||
await second.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = second.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(stream, "CONNECT redeliver-test clean=false");
|
||||
(await MqttAdvancedWire.ReadLineAsync(stream, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
// Server should redeliver the unacked message
|
||||
(await MqttAdvancedWire.ReadLineAsync(stream, 1000)).ShouldBe("REDLIVER 42 topic.redeliver payload");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Go: TestMQTTMaxPayloadEnforced server/mqtt_test.go:8022
|
||||
// Binary packet parsing: oversized messages
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void Packet_reader_handles_maximum_remaining_length_encoding()
|
||||
{
|
||||
// Maximum MQTT remaining length = 268435455 = 0xFF 0xFF 0xFF 0x7F
|
||||
var encoded = MqttPacketWriter.EncodeRemainingLength(268_435_455);
|
||||
encoded.Length.ShouldBe(4);
|
||||
var decoded = MqttPacketReader.DecodeRemainingLength(encoded, out var consumed);
|
||||
decoded.ShouldBe(268_435_455);
|
||||
consumed.ShouldBe(4);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Go: TestMQTTPartial server/mqtt_test.go:6402
|
||||
// Partial packet reads / buffer boundary handling
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void Packet_reader_rejects_truncated_remaining_length()
|
||||
{
|
||||
// Only continuation byte, no terminator — should throw
|
||||
byte[] malformed = [0x30, 0x80]; // continuation byte without terminator
|
||||
Should.Throw<FormatException>(() => MqttPacketReader.Read(malformed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Packet_reader_rejects_buffer_overflow()
|
||||
{
|
||||
// Remaining length says 100 bytes but buffer only has 2
|
||||
byte[] short_buffer = [0x30, 0x64, 0x00, 0x01];
|
||||
Should.Throw<FormatException>(() => MqttPacketReader.Read(short_buffer));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Go: TestMQTTValidateOptions server/mqtt_test.go:446
|
||||
// Options validation — ported as unit tests against config validators
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void Mqtt_protocol_level_4_is_valid()
|
||||
{
|
||||
// Go: mqttProtoLevel = 4 (MQTT 3.1.1)
|
||||
ReadOnlySpan<byte> bytes =
|
||||
[
|
||||
0x10, 0x0C,
|
||||
0x00, 0x04, (byte)'M', (byte)'Q', (byte)'T', (byte)'T',
|
||||
0x04, 0x02, 0x00, 0x3C,
|
||||
0x00, 0x00,
|
||||
];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
packet.Payload.Span[6].ShouldBe((byte)0x04); // protocol level
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mqtt_protocol_level_5_is_representable()
|
||||
{
|
||||
// MQTT 5.0 protocol level = 5
|
||||
ReadOnlySpan<byte> bytes =
|
||||
[
|
||||
0x10, 0x0C,
|
||||
0x00, 0x04, (byte)'M', (byte)'Q', (byte)'T', (byte)'T',
|
||||
0x05, 0x02, 0x00, 0x3C,
|
||||
0x00, 0x00,
|
||||
];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
packet.Payload.Span[6].ShouldBe((byte)0x05);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Go: TestMQTTConfigReload server/mqtt_test.go:6166
|
||||
// Server lifecycle: listener port allocation
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Listener_allocates_dynamic_port_when_zero_specified()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
listener.Port.ShouldBeGreaterThan(0);
|
||||
listener.Port.ShouldBeLessThan(65536);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Go: TestMQTTStreamInfoReturnsNonEmptySubject server/mqtt_test.go:6256
|
||||
// Multiple subscribers on different topics
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Multiple_subscribers_on_different_topics_receive_correct_messages()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var sub1 = new TcpClient();
|
||||
await sub1.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var s1 = sub1.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(s1, "CONNECT sub-multi1 clean=true");
|
||||
(await MqttAdvancedWire.ReadLineAsync(s1, 1000)).ShouldBe("CONNACK");
|
||||
await MqttAdvancedWire.WriteLineAsync(s1, "SUB topic.one");
|
||||
(await MqttAdvancedWire.ReadLineAsync(s1, 1000))!.ShouldContain("SUBACK");
|
||||
|
||||
using var sub2 = new TcpClient();
|
||||
await sub2.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var s2 = sub2.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(s2, "CONNECT sub-multi2 clean=true");
|
||||
(await MqttAdvancedWire.ReadLineAsync(s2, 1000)).ShouldBe("CONNACK");
|
||||
await MqttAdvancedWire.WriteLineAsync(s2, "SUB topic.two");
|
||||
(await MqttAdvancedWire.ReadLineAsync(s2, 1000))!.ShouldContain("SUBACK");
|
||||
|
||||
using var pub = new TcpClient();
|
||||
await pub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var ps = pub.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(ps, "CONNECT pub-multi clean=true");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ps, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
await MqttAdvancedWire.WriteLineAsync(ps, "PUB topic.one msg1");
|
||||
(await MqttAdvancedWire.ReadLineAsync(s1, 1000)).ShouldBe("MSG topic.one msg1");
|
||||
(await MqttAdvancedWire.ReadLineAsync(s2, 300)).ShouldBeNull();
|
||||
|
||||
await MqttAdvancedWire.WriteLineAsync(ps, "PUB topic.two msg2");
|
||||
(await MqttAdvancedWire.ReadLineAsync(s2, 1000)).ShouldBe("MSG topic.two msg2");
|
||||
(await MqttAdvancedWire.ReadLineAsync(s1, 300)).ShouldBeNull();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Go: TestMQTTConnectAndDisconnectEvent server/mqtt_test.go:6603
|
||||
// Client lifecycle events
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Client_connect_and_disconnect_lifecycle()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = client.GetStream();
|
||||
|
||||
await MqttAdvancedWire.WriteLineAsync(stream, "CONNECT lifecycle-client clean=true");
|
||||
(await MqttAdvancedWire.ReadLineAsync(stream, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
// Perform some operations
|
||||
await MqttAdvancedWire.WriteLineAsync(stream, "PUBQ1 1 lifecycle.topic data");
|
||||
(await MqttAdvancedWire.ReadLineAsync(stream, 1000)).ShouldBe("PUBACK 1");
|
||||
|
||||
// Disconnect
|
||||
client.Dispose();
|
||||
|
||||
// Verify server is still operational by polling until it accepts a new connection
|
||||
var connected = await PollHelper.WaitUntilAsync(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var probe = new TcpClient();
|
||||
await probe.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}, timeoutMs: 2000, intervalMs: 10);
|
||||
connected.ShouldBeTrue("Server should still accept connections after client disconnect");
|
||||
|
||||
using var client2 = new TcpClient();
|
||||
await client2.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var s2 = client2.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(s2, "CONNECT lifecycle-client2 clean=true");
|
||||
(await MqttAdvancedWire.ReadLineAsync(s2, 1000)).ShouldBe("CONNACK");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// SUBACK response format
|
||||
// Go: TestMQTTSubAck server/mqtt_test.go:1969
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void Suback_packet_type_is_0x90()
|
||||
{
|
||||
// Go: mqttPacketSubAck = 0x90
|
||||
ReadOnlySpan<byte> bytes =
|
||||
[
|
||||
0x90, 0x03, // SUBACK, remaining length 3
|
||||
0x00, 0x01, // packet-id 1
|
||||
0x00, // QoS 0 granted
|
||||
];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
packet.Type.ShouldBe(MqttControlPacketType.SubAck);
|
||||
packet.RemainingLength.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Suback_with_multiple_granted_qos_values()
|
||||
{
|
||||
ReadOnlySpan<byte> bytes =
|
||||
[
|
||||
0x90, 0x05,
|
||||
0x00, 0x01,
|
||||
0x00, // QoS 0
|
||||
0x01, // QoS 1
|
||||
0x02, // QoS 2
|
||||
];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
packet.Type.ShouldBe(MqttControlPacketType.SubAck);
|
||||
packet.Payload.Span[2].ShouldBe((byte)0x00);
|
||||
packet.Payload.Span[3].ShouldBe((byte)0x01);
|
||||
packet.Payload.Span[4].ShouldBe((byte)0x02);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Go: TestMQTTPersistedSession — persistent session with QoS1
|
||||
// server/mqtt_test.go:4822
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Persistent_session_redelivers_unacked_on_reconnect()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
// First connection: publish QoS 1, don't ACK, disconnect
|
||||
using (var first = new TcpClient())
|
||||
{
|
||||
await first.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var s = first.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(s, "CONNECT persist-adv clean=false");
|
||||
(await MqttAdvancedWire.ReadLineAsync(s, 1000)).ShouldBe("CONNACK");
|
||||
await MqttAdvancedWire.WriteLineAsync(s, "PUBQ1 99 persist.topic data");
|
||||
(await MqttAdvancedWire.ReadLineAsync(s, 1000)).ShouldBe("PUBACK 99");
|
||||
}
|
||||
|
||||
// Reconnect with same client ID, persistent session
|
||||
using var second = new TcpClient();
|
||||
await second.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = second.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(stream, "CONNECT persist-adv clean=false");
|
||||
(await MqttAdvancedWire.ReadLineAsync(stream, 1000)).ShouldBe("CONNACK");
|
||||
(await MqttAdvancedWire.ReadLineAsync(stream, 1000)).ShouldBe("REDLIVER 99 persist.topic data");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Protocol-level edge cases
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void Writer_produces_correct_connack_bytes()
|
||||
{
|
||||
// CONNACK: type 2 (0x20), remaining length 2, session present = 0, return code = 0
|
||||
ReadOnlySpan<byte> payload = [0x00, 0x00]; // session-present=0, rc=0
|
||||
var bytes = MqttPacketWriter.Write(MqttControlPacketType.ConnAck, payload);
|
||||
bytes[0].ShouldBe((byte)0x20); // CONNACK type
|
||||
bytes[1].ShouldBe((byte)0x02); // remaining length
|
||||
bytes[2].ShouldBe((byte)0x00); // session present
|
||||
bytes[3].ShouldBe((byte)0x00); // return code: accepted
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Writer_produces_correct_disconnect_bytes()
|
||||
{
|
||||
var bytes = MqttPacketWriter.Write(MqttControlPacketType.Disconnect, ReadOnlySpan<byte>.Empty);
|
||||
bytes.Length.ShouldBe(2);
|
||||
bytes[0].ShouldBe((byte)0xE0);
|
||||
bytes[1].ShouldBe((byte)0x00);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_publishers_deliver_to_single_subscriber()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var sub = new TcpClient();
|
||||
await sub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var ss = sub.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(ss, "CONNECT sub-concurrent clean=true");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ss, 1000)).ShouldBe("CONNACK");
|
||||
await MqttAdvancedWire.WriteLineAsync(ss, "SUB concurrent.topic");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ss, 1000))!.ShouldContain("SUBACK");
|
||||
|
||||
// Pub A
|
||||
using var pubA = new TcpClient();
|
||||
await pubA.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var psA = pubA.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(psA, "CONNECT pub-concurrent-a clean=true");
|
||||
(await MqttAdvancedWire.ReadLineAsync(psA, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
// Pub B
|
||||
using var pubB = new TcpClient();
|
||||
await pubB.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var psB = pubB.GetStream();
|
||||
await MqttAdvancedWire.WriteLineAsync(psB, "CONNECT pub-concurrent-b clean=true");
|
||||
(await MqttAdvancedWire.ReadLineAsync(psB, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
await MqttAdvancedWire.WriteLineAsync(psA, "PUB concurrent.topic from-a");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ss, 1000)).ShouldBe("MSG concurrent.topic from-a");
|
||||
|
||||
await MqttAdvancedWire.WriteLineAsync(psB, "PUB concurrent.topic from-b");
|
||||
(await MqttAdvancedWire.ReadLineAsync(ss, 1000)).ShouldBe("MSG concurrent.topic from-b");
|
||||
}
|
||||
}
|
||||
|
||||
// Duplicated per-file as required — each test file is self-contained.
|
||||
internal static class MqttAdvancedWire
|
||||
{
|
||||
public static async Task WriteLineAsync(NetworkStream stream, string line)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(line + "\n");
|
||||
await stream.WriteAsync(bytes);
|
||||
await stream.FlushAsync();
|
||||
}
|
||||
|
||||
public static async Task<string?> ReadLineAsync(NetworkStream stream, int timeoutMs)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(timeoutMs);
|
||||
var bytes = new List<byte>();
|
||||
var one = new byte[1];
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var read = await stream.ReadAsync(one.AsMemory(0, 1), timeout.Token);
|
||||
if (read == 0)
|
||||
return null;
|
||||
if (one[0] == (byte)'\n')
|
||||
break;
|
||||
if (one[0] != (byte)'\r')
|
||||
bytes.Add(one[0]);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString([.. bytes]);
|
||||
}
|
||||
|
||||
public static async Task<string?> ReadRawAsync(NetworkStream stream, int timeoutMs)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(timeoutMs);
|
||||
var one = new byte[1];
|
||||
try
|
||||
{
|
||||
var read = await stream.ReadAsync(one.AsMemory(0, 1), timeout.Token);
|
||||
if (read == 0)
|
||||
return null;
|
||||
|
||||
return Encoding.UTF8.GetString(one, 0, read);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return "__timeout__";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using NATS.Server.Mqtt;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests.Mqtt;
|
||||
|
||||
public class MqttAuthIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Invalid_mqtt_credentials_or_keepalive_timeout_close_session_with_protocol_error()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0, requiredUsername: "mqtt", requiredPassword: "secret");
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = client.GetStream();
|
||||
|
||||
await MqttRuntimeWire.WriteLineAsync(stream, "CONNECT auth-client user=bad pass=wrong");
|
||||
(await MqttRuntimeWire.ReadLineAsync(stream, 1000)).ShouldBe("ERR mqtt auth failed");
|
||||
(await MqttRuntimeWire.ReadRawAsync(stream, 1000)).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
371
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttAuthParityTests.cs
Normal file
371
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttAuthParityTests.cs
Normal file
@@ -0,0 +1,371 @@
|
||||
// Ports MQTT authentication behavior from Go reference:
|
||||
// golang/nats-server/server/mqtt_test.go — TestMQTTBasicAuth, TestMQTTTokenAuth,
|
||||
// TestMQTTAuthTimeout, TestMQTTUsersAuth, TestMQTTNoAuthUser,
|
||||
// TestMQTTConnectNotFirstPacket, TestMQTTSecondConnect, TestMQTTParseConnect,
|
||||
// TestMQTTConnKeepAlive
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using NATS.Server.Auth;
|
||||
using NATS.Server.Mqtt;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests.Mqtt;
|
||||
|
||||
public class MqttAuthParityTests
|
||||
{
|
||||
// Go ref: TestMQTTBasicAuth — correct credentials accepted
|
||||
// server/mqtt_test.go:1159
|
||||
[Fact]
|
||||
public async Task Correct_mqtt_credentials_connect_accepted()
|
||||
{
|
||||
await using var listener = new MqttListener(
|
||||
"127.0.0.1", 0,
|
||||
requiredUsername: "mqtt",
|
||||
requiredPassword: "client");
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = client.GetStream();
|
||||
|
||||
await MqttAuthWire.WriteLineAsync(stream, "CONNECT auth-ok clean=true user=mqtt pass=client");
|
||||
(await MqttAuthWire.ReadLineAsync(stream, 1000)).ShouldBe("CONNACK");
|
||||
}
|
||||
|
||||
// Go ref: TestMQTTBasicAuth — wrong credentials rejected
|
||||
[Fact]
|
||||
public async Task Wrong_mqtt_credentials_connect_rejected()
|
||||
{
|
||||
await using var listener = new MqttListener(
|
||||
"127.0.0.1", 0,
|
||||
requiredUsername: "mqtt",
|
||||
requiredPassword: "client");
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = client.GetStream();
|
||||
|
||||
await MqttAuthWire.WriteLineAsync(stream, "CONNECT auth-fail clean=true user=wrong pass=client");
|
||||
var response = await MqttAuthWire.ReadLineAsync(stream, 1000);
|
||||
response.ShouldNotBeNull();
|
||||
response!.ShouldContain("ERR");
|
||||
}
|
||||
|
||||
// Go ref: TestMQTTBasicAuth — wrong password rejected
|
||||
[Fact]
|
||||
public async Task Wrong_password_connect_rejected()
|
||||
{
|
||||
await using var listener = new MqttListener(
|
||||
"127.0.0.1", 0,
|
||||
requiredUsername: "mqtt",
|
||||
requiredPassword: "secret");
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = client.GetStream();
|
||||
|
||||
await MqttAuthWire.WriteLineAsync(stream, "CONNECT auth-badpass clean=true user=mqtt pass=wrong");
|
||||
var response = await MqttAuthWire.ReadLineAsync(stream, 1000);
|
||||
response.ShouldNotBeNull();
|
||||
response!.ShouldContain("ERR");
|
||||
}
|
||||
|
||||
// Go ref: TestMQTTBasicAuth — no auth configured, any credentials accepted
|
||||
[Fact]
|
||||
public async Task No_auth_configured_connects_without_credentials()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = client.GetStream();
|
||||
|
||||
await MqttAuthWire.WriteLineAsync(stream, "CONNECT no-auth-client clean=true");
|
||||
(await MqttAuthWire.ReadLineAsync(stream, 1000)).ShouldBe("CONNACK");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task No_auth_configured_accepts_any_credentials()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = client.GetStream();
|
||||
|
||||
await MqttAuthWire.WriteLineAsync(stream, "CONNECT any-creds clean=true user=whatever pass=doesntmatter");
|
||||
(await MqttAuthWire.ReadLineAsync(stream, 1000)).ShouldBe("CONNACK");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Go: TestMQTTTokenAuth — ValidateMqttCredentials tests
|
||||
// server/mqtt_test.go:1307
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void ValidateMqttCredentials_returns_true_when_no_auth_configured()
|
||||
{
|
||||
AuthService.ValidateMqttCredentials(null, null, null, null).ShouldBeTrue();
|
||||
AuthService.ValidateMqttCredentials(null, null, "anything", "anything").ShouldBeTrue();
|
||||
AuthService.ValidateMqttCredentials(string.Empty, string.Empty, null, null).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMqttCredentials_returns_true_for_matching_credentials()
|
||||
{
|
||||
AuthService.ValidateMqttCredentials("mqtt", "client", "mqtt", "client").ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMqttCredentials_returns_false_for_wrong_username()
|
||||
{
|
||||
AuthService.ValidateMqttCredentials("mqtt", "client", "wrong", "client").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMqttCredentials_returns_false_for_wrong_password()
|
||||
{
|
||||
AuthService.ValidateMqttCredentials("mqtt", "client", "mqtt", "wrong").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMqttCredentials_returns_false_for_null_credentials_when_auth_configured()
|
||||
{
|
||||
AuthService.ValidateMqttCredentials("mqtt", "client", null, null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateMqttCredentials_case_sensitive_comparison()
|
||||
{
|
||||
AuthService.ValidateMqttCredentials("MQTT", "Client", "mqtt", "client").ShouldBeFalse();
|
||||
AuthService.ValidateMqttCredentials("MQTT", "Client", "MQTT", "Client").ShouldBeTrue();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Go: TestMQTTUsersAuth — multiple users
|
||||
// server/mqtt_test.go:1466
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Multiple_clients_with_different_credentials_authenticate_independently()
|
||||
{
|
||||
await using var listener = new MqttListener(
|
||||
"127.0.0.1", 0,
|
||||
requiredUsername: "admin",
|
||||
requiredPassword: "password");
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var client1 = new TcpClient();
|
||||
await client1.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var s1 = client1.GetStream();
|
||||
await MqttAuthWire.WriteLineAsync(s1, "CONNECT user1 clean=true user=admin pass=password");
|
||||
(await MqttAuthWire.ReadLineAsync(s1, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
using var client2 = new TcpClient();
|
||||
await client2.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var s2 = client2.GetStream();
|
||||
await MqttAuthWire.WriteLineAsync(s2, "CONNECT user2 clean=true user=admin pass=wrong");
|
||||
var response = await MqttAuthWire.ReadLineAsync(s2, 1000);
|
||||
response.ShouldNotBeNull();
|
||||
response!.ShouldContain("ERR");
|
||||
|
||||
await MqttAuthWire.WriteLineAsync(s1, "PUBQ1 1 auth.test ok");
|
||||
(await MqttAuthWire.ReadLineAsync(s1, 1000)).ShouldBe("PUBACK 1");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Go: TestMQTTConnKeepAlive server/mqtt_test.go:1741
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Keepalive_timeout_disconnects_idle_client()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = client.GetStream();
|
||||
|
||||
await MqttAuthWire.WriteLineAsync(stream, "CONNECT keepalive-client clean=true keepalive=1");
|
||||
(await MqttAuthWire.ReadLineAsync(stream, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
// Poll until the server closes the connection due to keepalive expiry (keepalive=1s)
|
||||
var disconnected = await PollHelper.WaitUntilAsync(async () =>
|
||||
{
|
||||
var result = await MqttAuthWire.ReadRawAsync(stream, 200);
|
||||
return result == null || result == "__timeout__";
|
||||
}, timeoutMs: 5000, intervalMs: 100);
|
||||
disconnected.ShouldBeTrue("Server should disconnect idle client after keepalive timeout");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Go: TestMQTTParseConnect — username/password flags
|
||||
// server/mqtt_test.go:1661
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void Connect_packet_with_username_flag_has_username_in_payload()
|
||||
{
|
||||
ReadOnlySpan<byte> bytes =
|
||||
[
|
||||
0x10, 0x10,
|
||||
0x00, 0x04, (byte)'M', (byte)'Q', (byte)'T', (byte)'T',
|
||||
0x04, 0x82, 0x00, 0x3C,
|
||||
0x00, 0x01, (byte)'c',
|
||||
0x00, 0x01, (byte)'u',
|
||||
];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
packet.Type.ShouldBe(MqttControlPacketType.Connect);
|
||||
var connectFlags = packet.Payload.Span[7];
|
||||
(connectFlags & 0x80).ShouldNotBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Connect_packet_with_username_and_password_flags()
|
||||
{
|
||||
ReadOnlySpan<byte> bytes =
|
||||
[
|
||||
0x10, 0x13,
|
||||
0x00, 0x04, (byte)'M', (byte)'Q', (byte)'T', (byte)'T',
|
||||
0x04, 0xC2, 0x00, 0x3C,
|
||||
0x00, 0x01, (byte)'c',
|
||||
0x00, 0x01, (byte)'u',
|
||||
0x00, 0x01, (byte)'p',
|
||||
];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
var connectFlags = packet.Payload.Span[7];
|
||||
(connectFlags & 0x80).ShouldNotBe(0); // username flag
|
||||
(connectFlags & 0x40).ShouldNotBe(0); // password flag
|
||||
}
|
||||
|
||||
// Go: TestMQTTParseConnect — "no user but password" server/mqtt_test.go:1678
|
||||
[Fact]
|
||||
public void Connect_flags_password_without_user_is_protocol_violation()
|
||||
{
|
||||
byte connectFlags = 0x40;
|
||||
(connectFlags & 0x80).ShouldBe(0);
|
||||
(connectFlags & 0x40).ShouldNotBe(0);
|
||||
}
|
||||
|
||||
// Go: TestMQTTParseConnect — "reserved flag" server/mqtt_test.go:1674
|
||||
[Fact]
|
||||
public void Connect_flags_reserved_bit_must_be_zero()
|
||||
{
|
||||
byte connectFlags = 0x01;
|
||||
(connectFlags & 0x01).ShouldNotBe(0);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Go: TestMQTTConnectNotFirstPacket server/mqtt_test.go:1618
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public async Task Non_connect_as_first_packet_is_handled()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = client.GetStream();
|
||||
|
||||
await MqttAuthWire.WriteLineAsync(stream, "PUB some.topic hello");
|
||||
|
||||
var response = await MqttAuthWire.ReadLineAsync(stream, 1000);
|
||||
if (response != null)
|
||||
{
|
||||
response.ShouldNotBe("CONNACK");
|
||||
}
|
||||
}
|
||||
|
||||
// Go: TestMQTTSecondConnect server/mqtt_test.go:1645
|
||||
[Fact]
|
||||
public async Task Second_connect_from_same_tcp_connection_is_handled()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = client.GetStream();
|
||||
|
||||
await MqttAuthWire.WriteLineAsync(stream, "CONNECT second-conn clean=true");
|
||||
(await MqttAuthWire.ReadLineAsync(stream, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
await MqttAuthWire.WriteLineAsync(stream, "CONNECT second-conn clean=true");
|
||||
var response = await MqttAuthWire.ReadLineAsync(stream, 1000);
|
||||
_ = response; // Just verify no crash
|
||||
}
|
||||
}
|
||||
|
||||
internal static class MqttAuthWire
|
||||
{
|
||||
public static async Task WriteLineAsync(NetworkStream stream, string line)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(line + "\n");
|
||||
await stream.WriteAsync(bytes);
|
||||
await stream.FlushAsync();
|
||||
}
|
||||
|
||||
public static async Task<string?> ReadLineAsync(NetworkStream stream, int timeoutMs)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(timeoutMs);
|
||||
var bytes = new List<byte>();
|
||||
var one = new byte[1];
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var read = await stream.ReadAsync(one.AsMemory(0, 1), timeout.Token);
|
||||
if (read == 0)
|
||||
return null;
|
||||
if (one[0] == (byte)'\n')
|
||||
break;
|
||||
if (one[0] != (byte)'\r')
|
||||
bytes.Add(one[0]);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString([.. bytes]);
|
||||
}
|
||||
|
||||
public static async Task<string?> ReadRawAsync(NetworkStream stream, int timeoutMs)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(timeoutMs);
|
||||
var one = new byte[1];
|
||||
try
|
||||
{
|
||||
var read = await stream.ReadAsync(one.AsMemory(0, 1), timeout.Token);
|
||||
if (read == 0)
|
||||
return null;
|
||||
|
||||
return Encoding.UTF8.GetString(one, 0, read);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return "__timeout__";
|
||||
}
|
||||
}
|
||||
}
|
||||
477
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttBinaryParserTests.cs
Normal file
477
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttBinaryParserTests.cs
Normal file
@@ -0,0 +1,477 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
248
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttFlapperDetectionTests.cs
Normal file
248
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttFlapperDetectionTests.cs
Normal file
@@ -0,0 +1,248 @@
|
||||
// MQTT flapper detection tests — exponential backoff for rapid reconnectors.
|
||||
// Go reference: golang/nats-server/server/mqtt.go mqttCheckFlapper ~lines 300–360.
|
||||
|
||||
using NATS.Server.Mqtt;
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests.Mqtt;
|
||||
|
||||
public class MqttFlapperDetectionTests
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// Helper: FakeTimeProvider from Microsoft.Extensions.TimeProvider.Testing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static MqttSessionStore CreateStore(
|
||||
FakeTimeProvider? time = null,
|
||||
int flapThreshold = 3,
|
||||
TimeSpan? flapWindow = null) =>
|
||||
new(
|
||||
flapWindow: flapWindow ?? TimeSpan.FromSeconds(10),
|
||||
flapThreshold: flapThreshold,
|
||||
timeProvider: time);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. TrackConnectDisconnect_counts_events
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void TrackConnectDisconnect_counts_events()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttCheckFlapper — each connect increments the counter.
|
||||
var store = CreateStore();
|
||||
|
||||
var s1 = store.TrackConnectDisconnect("client-a");
|
||||
s1.ConnectDisconnectCount.ShouldBe(1);
|
||||
|
||||
var s2 = store.TrackConnectDisconnect("client-a");
|
||||
s2.ConnectDisconnectCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. Not_flapper_below_threshold
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Not_flapper_below_threshold()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttCheckFlapper — threshold is 3; 2 events should not mark as flapper.
|
||||
var store = CreateStore();
|
||||
|
||||
store.TrackConnectDisconnect("client-b");
|
||||
store.TrackConnectDisconnect("client-b");
|
||||
|
||||
store.IsFlapper("client-b").ShouldBeFalse();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3. Becomes_flapper_at_threshold
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Becomes_flapper_at_threshold()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttCheckFlapper — 3 events within window marks the client.
|
||||
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
var store = CreateStore(time);
|
||||
|
||||
store.TrackConnectDisconnect("client-c");
|
||||
time.Advance(TimeSpan.FromSeconds(1));
|
||||
store.TrackConnectDisconnect("client-c");
|
||||
time.Advance(TimeSpan.FromSeconds(1));
|
||||
store.TrackConnectDisconnect("client-c");
|
||||
|
||||
store.IsFlapper("client-c").ShouldBeTrue();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4. Backoff_increases_exponentially
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Backoff_increases_exponentially()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttCheckFlapper — backoff doubles on each new flap trigger.
|
||||
// Level 0 → 1 s, Level 1 → 2 s, Level 2 → 4 s.
|
||||
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
var store = CreateStore(time);
|
||||
|
||||
// First flap at level 0 (1 s backoff)
|
||||
store.TrackConnectDisconnect("client-d");
|
||||
store.TrackConnectDisconnect("client-d");
|
||||
var s1 = store.TrackConnectDisconnect("client-d");
|
||||
s1.BackoffLevel.ShouldBe(1); // incremented after applying level 0
|
||||
s1.BackoffUntil.ShouldNotBeNull();
|
||||
var backoff1 = s1.BackoffUntil!.Value - time.GetUtcNow().UtcDateTime;
|
||||
backoff1.TotalMilliseconds.ShouldBeInRange(900, 1100); // ~1 000 ms
|
||||
|
||||
// Advance past the backoff and trigger again — level 1 (2 s)
|
||||
time.Advance(TimeSpan.FromSeconds(2));
|
||||
var s2 = store.TrackConnectDisconnect("client-d");
|
||||
s2.BackoffLevel.ShouldBe(2);
|
||||
var backoff2 = s2.BackoffUntil!.Value - time.GetUtcNow().UtcDateTime;
|
||||
backoff2.TotalMilliseconds.ShouldBeInRange(1900, 2100); // ~2 000 ms
|
||||
|
||||
// Advance past and trigger once more — level 2 (4 s)
|
||||
time.Advance(TimeSpan.FromSeconds(3));
|
||||
var s3 = store.TrackConnectDisconnect("client-d");
|
||||
s3.BackoffLevel.ShouldBe(3);
|
||||
var backoff3 = s3.BackoffUntil!.Value - time.GetUtcNow().UtcDateTime;
|
||||
backoff3.TotalMilliseconds.ShouldBeInRange(3900, 4100); // ~4 000 ms
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5. Backoff_capped_at_60_seconds
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Backoff_capped_at_60_seconds()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttCheckFlapper — cap the maximum backoff at 60 s.
|
||||
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
var store = CreateStore(time);
|
||||
|
||||
// Trigger enough flaps to overflow past 60 s (level 6 = 64 s, which should cap at 60 s)
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
store.TrackConnectDisconnect("client-e");
|
||||
time.Advance(TimeSpan.FromMilliseconds(100));
|
||||
}
|
||||
|
||||
var state = store.TrackConnectDisconnect("client-e");
|
||||
var remaining = state.BackoffUntil!.Value - time.GetUtcNow().UtcDateTime;
|
||||
remaining.TotalMilliseconds.ShouldBeLessThanOrEqualTo(60_001); // max 60 s (±1 ms tolerance)
|
||||
remaining.TotalMilliseconds.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 6. GetBackoffMs_returns_remaining
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetBackoffMs_returns_remaining()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttCheckFlapper — caller can query remaining backoff time.
|
||||
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
var store = CreateStore(time);
|
||||
|
||||
store.TrackConnectDisconnect("client-f");
|
||||
store.TrackConnectDisconnect("client-f");
|
||||
store.TrackConnectDisconnect("client-f"); // threshold hit
|
||||
|
||||
var ms = store.GetBackoffMs("client-f");
|
||||
ms.ShouldBeGreaterThan(0);
|
||||
ms.ShouldBeLessThanOrEqualTo(1000);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 7. GetBackoffMs_zero_when_not_flapping
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetBackoffMs_zero_when_not_flapping()
|
||||
{
|
||||
// Not enough events to trigger backoff — remaining ms should be 0.
|
||||
var store = CreateStore();
|
||||
|
||||
store.TrackConnectDisconnect("client-g");
|
||||
store.TrackConnectDisconnect("client-g");
|
||||
|
||||
store.GetBackoffMs("client-g").ShouldBe(0);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 8. ClearFlapperState_removes_tracking
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void ClearFlapperState_removes_tracking()
|
||||
{
|
||||
// Go reference: server/mqtt.go — stable clients should have state purged.
|
||||
var store = CreateStore();
|
||||
|
||||
store.TrackConnectDisconnect("client-h");
|
||||
store.TrackConnectDisconnect("client-h");
|
||||
store.TrackConnectDisconnect("client-h");
|
||||
store.IsFlapper("client-h").ShouldBeTrue();
|
||||
|
||||
store.ClearFlapperState("client-h");
|
||||
|
||||
store.IsFlapper("client-h").ShouldBeFalse();
|
||||
store.GetBackoffMs("client-h").ShouldBe(0);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 9. Window_resets_after_10_seconds
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Window_resets_after_10_seconds()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttCheckFlapper — window-based detection resets.
|
||||
// Track 2 events, advance past the window, add 1 more — should NOT be a flapper.
|
||||
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
var store = CreateStore(time);
|
||||
|
||||
store.TrackConnectDisconnect("client-i");
|
||||
store.TrackConnectDisconnect("client-i");
|
||||
|
||||
// Advance past the 10 s flap window
|
||||
time.Advance(TimeSpan.FromSeconds(11));
|
||||
|
||||
// Directly set the WindowStart via the returned state to simulate the old window
|
||||
// being in the past. A single new event in a new window should not cross threshold.
|
||||
store.TrackConnectDisconnect("client-i");
|
||||
|
||||
store.IsFlapper("client-i").ShouldBeFalse();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 10. CheckAndClearStableClients_clears_old
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void CheckAndClearStableClients_clears_old()
|
||||
{
|
||||
// Go reference: server/mqtt.go — periodic sweep clears long-stable flapper records.
|
||||
var time = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
var store = CreateStore(time);
|
||||
|
||||
// Trigger flap
|
||||
store.TrackConnectDisconnect("client-j");
|
||||
store.TrackConnectDisconnect("client-j");
|
||||
var state = store.TrackConnectDisconnect("client-j");
|
||||
store.IsFlapper("client-j").ShouldBeTrue();
|
||||
|
||||
// Manually backdate BackoffUntil so it's already expired
|
||||
lock (state)
|
||||
{
|
||||
state.BackoffUntil = time.GetUtcNow().UtcDateTime - TimeSpan.FromSeconds(61);
|
||||
}
|
||||
|
||||
// A stable-threshold sweep of 60 s should evict the now-expired entry
|
||||
store.CheckAndClearStableClients(TimeSpan.FromSeconds(60));
|
||||
|
||||
store.IsFlapper("client-j").ShouldBeFalse();
|
||||
store.GetBackoffMs("client-j").ShouldBe(0);
|
||||
}
|
||||
}
|
||||
146
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttFlowControllerTests.cs
Normal file
146
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttFlowControllerTests.cs
Normal file
@@ -0,0 +1,146 @@
|
||||
// Go reference: server/mqtt.go — mqttMaxAckPending, flow control logic.
|
||||
|
||||
using NATS.Server.Mqtt;
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests.Mqtt;
|
||||
|
||||
public sealed class MqttFlowControllerTests
|
||||
{
|
||||
// 1. TryAcquire succeeds when under limit
|
||||
[Fact]
|
||||
public async Task TryAcquire_succeeds_when_under_limit()
|
||||
{
|
||||
using var fc = new MqttFlowController(defaultMaxAckPending: 1024);
|
||||
|
||||
var result = await fc.TryAcquireAsync("sub-1");
|
||||
|
||||
result.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// 2. TryAcquire fails when at limit
|
||||
[Fact]
|
||||
public async Task TryAcquire_fails_when_at_limit()
|
||||
{
|
||||
using var fc = new MqttFlowController(defaultMaxAckPending: 1);
|
||||
|
||||
var first = await fc.TryAcquireAsync("sub-1");
|
||||
var second = await fc.TryAcquireAsync("sub-1");
|
||||
|
||||
first.ShouldBeTrue();
|
||||
second.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// 3. Release allows next acquire
|
||||
[Fact]
|
||||
public async Task Release_allows_next_acquire()
|
||||
{
|
||||
using var fc = new MqttFlowController(defaultMaxAckPending: 1);
|
||||
|
||||
var first = await fc.TryAcquireAsync("sub-1");
|
||||
first.ShouldBeTrue();
|
||||
|
||||
// At limit — second should fail
|
||||
var atLimit = await fc.TryAcquireAsync("sub-1");
|
||||
atLimit.ShouldBeFalse();
|
||||
|
||||
fc.Release("sub-1");
|
||||
|
||||
// After release a slot is available again
|
||||
var afterRelease = await fc.TryAcquireAsync("sub-1");
|
||||
afterRelease.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// 4. GetPendingCount tracks pending
|
||||
[Fact]
|
||||
public async Task GetPendingCount_tracks_pending()
|
||||
{
|
||||
using var fc = new MqttFlowController(defaultMaxAckPending: 10);
|
||||
|
||||
await fc.AcquireAsync("sub-1");
|
||||
await fc.AcquireAsync("sub-1");
|
||||
await fc.AcquireAsync("sub-1");
|
||||
|
||||
fc.GetPendingCount("sub-1").ShouldBe(3);
|
||||
}
|
||||
|
||||
// 5. GetPendingCount decrements on release
|
||||
[Fact]
|
||||
public async Task GetPendingCount_decrements_on_release()
|
||||
{
|
||||
using var fc = new MqttFlowController(defaultMaxAckPending: 10);
|
||||
|
||||
await fc.AcquireAsync("sub-1");
|
||||
await fc.AcquireAsync("sub-1");
|
||||
await fc.AcquireAsync("sub-1");
|
||||
|
||||
fc.Release("sub-1");
|
||||
|
||||
fc.GetPendingCount("sub-1").ShouldBe(2);
|
||||
}
|
||||
|
||||
// 6. GetPendingCount returns zero for unknown subscription
|
||||
[Fact]
|
||||
public void GetPendingCount_zero_for_unknown()
|
||||
{
|
||||
using var fc = new MqttFlowController();
|
||||
|
||||
fc.GetPendingCount("does-not-exist").ShouldBe(0);
|
||||
}
|
||||
|
||||
// 7. RemoveSubscription cleans up
|
||||
[Fact]
|
||||
public async Task RemoveSubscription_cleans_up()
|
||||
{
|
||||
using var fc = new MqttFlowController(defaultMaxAckPending: 10);
|
||||
|
||||
await fc.AcquireAsync("sub-1");
|
||||
fc.SubscriptionCount.ShouldBe(1);
|
||||
|
||||
fc.RemoveSubscription("sub-1");
|
||||
|
||||
fc.SubscriptionCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
// 8. SubscriptionCount tracks independent subscriptions
|
||||
[Fact]
|
||||
public async Task SubscriptionCount_tracks_subscriptions()
|
||||
{
|
||||
using var fc = new MqttFlowController(defaultMaxAckPending: 10);
|
||||
|
||||
await fc.AcquireAsync("sub-a");
|
||||
await fc.AcquireAsync("sub-b");
|
||||
await fc.AcquireAsync("sub-c");
|
||||
|
||||
fc.SubscriptionCount.ShouldBe(3);
|
||||
}
|
||||
|
||||
// 9. DefaultMaxAckPending can be updated via UpdateLimit
|
||||
[Fact]
|
||||
public void DefaultMaxAckPending_can_be_updated()
|
||||
{
|
||||
using var fc = new MqttFlowController(defaultMaxAckPending: 1024);
|
||||
fc.DefaultMaxAckPending.ShouldBe(1024);
|
||||
|
||||
fc.UpdateLimit(512);
|
||||
|
||||
fc.DefaultMaxAckPending.ShouldBe(512);
|
||||
}
|
||||
|
||||
// 10. Dispose cleans up all subscriptions
|
||||
[Fact]
|
||||
public async Task Dispose_cleans_up_all()
|
||||
{
|
||||
var fc = new MqttFlowController(defaultMaxAckPending: 10);
|
||||
|
||||
await fc.AcquireAsync("sub-x");
|
||||
await fc.AcquireAsync("sub-y");
|
||||
await fc.AcquireAsync("sub-z");
|
||||
|
||||
fc.SubscriptionCount.ShouldBe(3);
|
||||
|
||||
fc.Dispose();
|
||||
|
||||
fc.SubscriptionCount.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
733
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttGoParityTests.cs
Normal file
733
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttGoParityTests.cs
Normal file
@@ -0,0 +1,733 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
32
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttKeepAliveTests.cs
Normal file
32
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttKeepAliveTests.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using NATS.Server.Mqtt;
|
||||
using NATS.Server.TestUtilities;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests.Mqtt;
|
||||
|
||||
public class MqttKeepAliveTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Invalid_mqtt_credentials_or_keepalive_timeout_close_session_with_protocol_error()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = client.GetStream();
|
||||
|
||||
await MqttRuntimeWire.WriteLineAsync(stream, "CONNECT keepalive-client keepalive=1");
|
||||
(await MqttRuntimeWire.ReadLineAsync(stream, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
// Poll until the server closes the connection due to keepalive expiry (keepalive=1s)
|
||||
var disconnected = await PollHelper.WaitUntilAsync(async () =>
|
||||
{
|
||||
var result = await MqttRuntimeWire.ReadRawAsync(stream, 200);
|
||||
return result == null;
|
||||
}, timeoutMs: 5000, intervalMs: 100);
|
||||
disconnected.ShouldBeTrue("Server should disconnect idle client after keepalive timeout");
|
||||
}
|
||||
}
|
||||
73
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttListenerParityTests.cs
Normal file
73
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttListenerParityTests.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using NATS.Server.Mqtt;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests;
|
||||
|
||||
public class MqttListenerParityTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Mqtt_listener_accepts_connect_and_routes_publish_to_matching_subscription()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var sub = new TcpClient();
|
||||
await sub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var subStream = sub.GetStream();
|
||||
await MqttTestWire.WriteLineAsync(subStream, "CONNECT sub");
|
||||
(await MqttTestWire.ReadLineAsync(subStream, 1000)).ShouldBe("CONNACK");
|
||||
await MqttTestWire.WriteLineAsync(subStream, "SUB sensors.temp");
|
||||
var subAck = await MqttTestWire.ReadLineAsync(subStream, 1000);
|
||||
subAck.ShouldNotBeNull();
|
||||
subAck.ShouldContain("SUBACK");
|
||||
|
||||
using var pub = new TcpClient();
|
||||
await pub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var pubStream = pub.GetStream();
|
||||
await MqttTestWire.WriteLineAsync(pubStream, "CONNECT pub");
|
||||
_ = await MqttTestWire.ReadLineAsync(pubStream, 1000);
|
||||
await MqttTestWire.WriteLineAsync(pubStream, "PUB sensors.temp 42");
|
||||
|
||||
var message = await MqttTestWire.ReadLineAsync(subStream, 1000);
|
||||
message.ShouldBe("MSG sensors.temp 42");
|
||||
}
|
||||
}
|
||||
|
||||
internal static class MqttTestWire
|
||||
{
|
||||
public static async Task WriteLineAsync(NetworkStream stream, string line)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(line + "\n");
|
||||
await stream.WriteAsync(bytes);
|
||||
await stream.FlushAsync();
|
||||
}
|
||||
|
||||
public static async Task<string?> ReadLineAsync(NetworkStream stream, int timeoutMs)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(timeoutMs);
|
||||
var bytes = new List<byte>();
|
||||
var one = new byte[1];
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var read = await stream.ReadAsync(one.AsMemory(0, 1), timeout.Token);
|
||||
if (read == 0)
|
||||
return null;
|
||||
if (one[0] == (byte)'\n')
|
||||
break;
|
||||
if (one[0] != (byte)'\r')
|
||||
bytes.Add(one[0]);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString([.. bytes]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using NATS.Server.Mqtt;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests.Mqtt;
|
||||
|
||||
public class MqttModelParityBatch3Tests
|
||||
{
|
||||
[Fact]
|
||||
public void Mqtt_helper_models_cover_go_core_shapes()
|
||||
{
|
||||
var jsa = new MqttJsa
|
||||
{
|
||||
AccountName = "A",
|
||||
ReplyPrefix = "$MQTT.JSA.A",
|
||||
Domain = "D1",
|
||||
};
|
||||
|
||||
var pubMsg = new MqttJsPubMsg
|
||||
{
|
||||
Subject = "$MQTT.msgs.s1",
|
||||
Payload = new byte[] { 1, 2, 3 },
|
||||
ReplyTo = "$MQTT.JSA.A.reply",
|
||||
};
|
||||
|
||||
var delete = new MqttRetMsgDel
|
||||
{
|
||||
Topic = "devices/x",
|
||||
Sequence = 123,
|
||||
};
|
||||
|
||||
var persisted = new MqttPersistedSession
|
||||
{
|
||||
ClientId = "c1",
|
||||
LastPacketId = 7,
|
||||
MaxAckPending = 1024,
|
||||
};
|
||||
|
||||
var retainedRef = new MqttRetainedMessageRef
|
||||
{
|
||||
StreamSequence = 88,
|
||||
Subject = "$MQTT.rmsgs.devices/x",
|
||||
};
|
||||
|
||||
var sub = new MqttSub
|
||||
{
|
||||
Filter = "devices/+",
|
||||
Qos = 1,
|
||||
JsDur = "DUR-c1",
|
||||
Prm = true,
|
||||
Reserved = false,
|
||||
};
|
||||
|
||||
var filter = new MqttFilter
|
||||
{
|
||||
Filter = "devices/#",
|
||||
Qos = 1,
|
||||
TopicToken = "devices",
|
||||
};
|
||||
|
||||
var parsedHeader = new MqttParsedPublishNatsHeader
|
||||
{
|
||||
Subject = "devices/x",
|
||||
Mapped = "devices.y",
|
||||
IsPublish = true,
|
||||
IsPubRel = false,
|
||||
};
|
||||
|
||||
jsa.AccountName.ShouldBe("A");
|
||||
pubMsg.Payload.ShouldBe(new byte[] { 1, 2, 3 });
|
||||
delete.Sequence.ShouldBe(123UL);
|
||||
persisted.MaxAckPending.ShouldBe(1024);
|
||||
retainedRef.StreamSequence.ShouldBe(88UL);
|
||||
sub.JsDur.ShouldBe("DUR-c1");
|
||||
filter.TopicToken.ShouldBe("devices");
|
||||
parsedHeader.IsPublish.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retained_message_model_includes_origin_flags_and_source_fields()
|
||||
{
|
||||
var msg = new MqttRetainedMessage(
|
||||
Topic: "devices/x",
|
||||
Payload: new byte[] { 0x41, 0x42 },
|
||||
Origin: "origin-a",
|
||||
Flags: 0b_0000_0011,
|
||||
Source: "src-a");
|
||||
|
||||
msg.Topic.ShouldBe("devices/x");
|
||||
msg.Origin.ShouldBe("origin-a");
|
||||
msg.Flags.ShouldBe((byte)0b_0000_0011);
|
||||
msg.Source.ShouldBe("src-a");
|
||||
}
|
||||
}
|
||||
26
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttPacketParserTests.cs
Normal file
26
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttPacketParserTests.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using NATS.Server.Mqtt;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests.Mqtt;
|
||||
|
||||
public class MqttPacketParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void Connect_packet_fixed_header_and_remaining_length_parse_correctly()
|
||||
{
|
||||
var packet = MqttPacketReader.Read(ConnectPacketBytes.Sample);
|
||||
packet.Type.ShouldBe(MqttControlPacketType.Connect);
|
||||
packet.RemainingLength.ShouldBe(12);
|
||||
packet.Payload.Length.ShouldBe(12);
|
||||
}
|
||||
|
||||
private static class ConnectPacketBytes
|
||||
{
|
||||
public static readonly byte[] Sample =
|
||||
[
|
||||
0x10, 0x0C, // CONNECT + remaining length
|
||||
0x00, 0x04, (byte)'M', (byte)'Q', (byte)'T', (byte)'T',
|
||||
0x04, 0x02, 0x00, 0x3C, // protocol level/flags/keepalive
|
||||
0x00, 0x00, // empty client id
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
// Ported from golang/nats-server/server/mqtt_test.go — TestMQTTReader, TestMQTTWriter, and
|
||||
// packet-level scenarios exercised inline throughout the Go test suite.
|
||||
// Go reference: server/mqtt.go constants mqttPacketConnect=0x10, mqttPacketPub=0x30,
|
||||
// mqttPacketSub=0x80, mqttPacketUnsub=0xa0, mqttPacketPing=0xc0, mqttPacketDisconnect=0xe0.
|
||||
|
||||
using NATS.Server.Mqtt;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests.Mqtt;
|
||||
|
||||
public class MqttPacketParsingParityTests
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. CONNECT packet parsing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Connect_packet_type_is_parsed_from_first_nibble()
|
||||
{
|
||||
// Fixed header 0x10 = type 1 (Connect), flags 0.
|
||||
// Variable header: protocol name "MQTT" (4 bytes + 2-byte length prefix),
|
||||
// protocol level 0x04, connect flags 0x02 (clean session), keepalive 0x00 0x3C (60s).
|
||||
// Payload: 2-byte length-prefixed empty client-id.
|
||||
ReadOnlySpan<byte> bytes =
|
||||
[
|
||||
0x10, 0x0C, // CONNECT, remaining length 12
|
||||
0x00, 0x04, (byte)'M', (byte)'Q', (byte)'T', (byte)'T',
|
||||
0x04, 0x02, 0x00, 0x3C, // protocol level 4, clean-session flag, keepalive 60
|
||||
0x00, 0x00, // empty client-id
|
||||
];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
|
||||
packet.Type.ShouldBe(MqttControlPacketType.Connect);
|
||||
packet.Flags.ShouldBe((byte)0x00);
|
||||
packet.RemainingLength.ShouldBe(12);
|
||||
packet.Payload.Length.ShouldBe(12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Connect_packet_payload_contains_protocol_name_and_flags()
|
||||
{
|
||||
// The variable-header for a CONNECT begins with a 2-byte-length-prefixed protocol
|
||||
// name ("MQTT"), then protocol level (4), then connect-flags byte.
|
||||
ReadOnlySpan<byte> bytes =
|
||||
[
|
||||
0x10, 0x0C,
|
||||
0x00, 0x04, (byte)'M', (byte)'Q', (byte)'T', (byte)'T',
|
||||
0x04, 0x02, 0x00, 0x3C,
|
||||
0x00, 0x00,
|
||||
];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
var payload = packet.Payload.Span;
|
||||
|
||||
// Bytes 0-5: 0x00 0x04 'M' 'Q' 'T' 'T'
|
||||
payload[0].ShouldBe((byte)0x00);
|
||||
payload[1].ShouldBe((byte)0x04);
|
||||
payload[2].ShouldBe((byte)'M');
|
||||
payload[3].ShouldBe((byte)'Q');
|
||||
payload[4].ShouldBe((byte)'T');
|
||||
payload[5].ShouldBe((byte)'T');
|
||||
// Byte 6: protocol level 4
|
||||
payload[6].ShouldBe((byte)0x04);
|
||||
// Byte 7: connect flags — 0x02 = clean-session
|
||||
payload[7].ShouldBe((byte)0x02);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Connect_keepalive_bytes_are_present_in_payload()
|
||||
{
|
||||
// Keepalive is a big-endian uint16 at bytes 8-9 of the variable header.
|
||||
// Here 0x00 0x3C = 60 seconds.
|
||||
ReadOnlySpan<byte> bytes =
|
||||
[
|
||||
0x10, 0x0C,
|
||||
0x00, 0x04, (byte)'M', (byte)'Q', (byte)'T', (byte)'T',
|
||||
0x04, 0x02, 0x00, 0x3C,
|
||||
0x00, 0x00,
|
||||
];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
var payload = packet.Payload.Span;
|
||||
|
||||
var keepalive = (payload[8] << 8) | payload[9];
|
||||
keepalive.ShouldBe(60);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. PUBLISH packet parsing — QoS 0 and QoS 1
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Publish_qos0_packet_fixed_header_byte_is_0x30()
|
||||
{
|
||||
// PUBLISH with QoS=0, DUP=0, RETAIN=0 → fixed header high nibble 0x3, flags nibble 0x0.
|
||||
// Topic "a/b" (length 3, encoded as 0x00 0x03 'a' '/' 'b') + payload "hello".
|
||||
ReadOnlySpan<byte> bytes =
|
||||
[
|
||||
0x30, 0x0A, // PUBLISH QoS 0, remaining length 10
|
||||
0x00, 0x03, (byte)'a', (byte)'/', (byte)'b', // topic "a/b"
|
||||
(byte)'h', (byte)'e', (byte)'l', (byte)'l', (byte)'o', // payload "hello"
|
||||
];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
|
||||
packet.Type.ShouldBe(MqttControlPacketType.Publish);
|
||||
packet.Flags.ShouldBe((byte)0x00);
|
||||
packet.RemainingLength.ShouldBe(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Publish_qos1_flags_nibble_is_0x02()
|
||||
{
|
||||
// PUBLISH with QoS=1 → flags nibble 0x2. Packet identifier (2 bytes) follows topic.
|
||||
// Topic "t" (0x00 0x01 't') + packet-id 0x00 0x01 + payload "data".
|
||||
ReadOnlySpan<byte> bytes =
|
||||
[
|
||||
0x32, 0x09, // PUBLISH QoS 1 (flags=0x02), remaining length 9
|
||||
0x00, 0x01, (byte)'t', // topic "t"
|
||||
0x00, 0x01, // packet identifier 1
|
||||
(byte)'d', (byte)'a', (byte)'t', (byte)'a', // payload "data"
|
||||
];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
|
||||
packet.Type.ShouldBe(MqttControlPacketType.Publish);
|
||||
// QoS 1 is encoded in bits 2-1 of the flags nibble: 0x02
|
||||
packet.Flags.ShouldBe((byte)0x02);
|
||||
packet.RemainingLength.ShouldBe(9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Publish_payload_starts_after_topic_length_prefix()
|
||||
{
|
||||
// Topic "ab" length-prefix 0x00 0x02, payload bytes follow remaining-length boundary.
|
||||
ReadOnlySpan<byte> bytes =
|
||||
[
|
||||
0x30, 0x07,
|
||||
0x00, 0x02, (byte)'a', (byte)'b',
|
||||
(byte)'x', (byte)'y', (byte)'z',
|
||||
];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
var payload = packet.Payload.Span;
|
||||
|
||||
// payload[0..1] = topic length, [2..3] = "ab", [4..6] = "xyz"
|
||||
payload.Length.ShouldBe(7);
|
||||
payload[4].ShouldBe((byte)'x');
|
||||
payload[5].ShouldBe((byte)'y');
|
||||
payload[6].ShouldBe((byte)'z');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3. SUBSCRIBE packet parsing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Subscribe_packet_type_is_parsed_correctly()
|
||||
{
|
||||
// SUBSCRIBE fixed header = 0x82 (type 0x80 | flags 0x02 — required by MQTT spec).
|
||||
// Variable header: packet-id 0x00 0x01.
|
||||
// Payload: topic filter "test/#" with QoS 0.
|
||||
ReadOnlySpan<byte> bytes =
|
||||
[
|
||||
0x82, 0x0B, // SUBSCRIBE, remaining length 11
|
||||
0x00, 0x01, // packet identifier 1
|
||||
0x00, 0x06, // topic filter length 6
|
||||
(byte)'t', (byte)'e', (byte)'s', (byte)'t', (byte)'/', (byte)'#',
|
||||
0x00, // requested QoS 0
|
||||
];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
|
||||
packet.Type.ShouldBe(MqttControlPacketType.Subscribe);
|
||||
packet.Flags.ShouldBe((byte)0x02);
|
||||
packet.RemainingLength.ShouldBe(11);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Subscribe_payload_contains_packet_id_and_topic_filter()
|
||||
{
|
||||
ReadOnlySpan<byte> bytes =
|
||||
[
|
||||
0x82, 0x0B,
|
||||
0x00, 0x01,
|
||||
0x00, 0x06,
|
||||
(byte)'t', (byte)'e', (byte)'s', (byte)'t', (byte)'/', (byte)'#',
|
||||
0x00,
|
||||
];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
var payload = packet.Payload.Span;
|
||||
|
||||
// Packet identifier at bytes 0-1
|
||||
var packetId = (payload[0] << 8) | payload[1];
|
||||
packetId.ShouldBe(1);
|
||||
|
||||
// Topic filter length at bytes 2-3
|
||||
var filterLen = (payload[2] << 8) | payload[3];
|
||||
filterLen.ShouldBe(6);
|
||||
|
||||
// Topic filter characters
|
||||
payload[4].ShouldBe((byte)'t');
|
||||
payload[9].ShouldBe((byte)'#');
|
||||
|
||||
// QoS byte at the end
|
||||
payload[10].ShouldBe((byte)0x00);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4. UNSUBSCRIBE and DISCONNECT parsing
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Unsubscribe_packet_type_is_parsed_correctly()
|
||||
{
|
||||
// UNSUBSCRIBE fixed header = 0xA2 (type 0xA0 | flags 0x02).
|
||||
// Variable header: packet-id 0x00 0x02.
|
||||
// Payload: topic filter "sensors/+" (length 9).
|
||||
ReadOnlySpan<byte> bytes =
|
||||
[
|
||||
0xA2, 0x0D,
|
||||
0x00, 0x02,
|
||||
0x00, 0x09,
|
||||
(byte)'s', (byte)'e', (byte)'n', (byte)'s', (byte)'o', (byte)'r', (byte)'s', (byte)'/', (byte)'+',
|
||||
];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
|
||||
// 0xA0 >> 4 = 10, which is not in the MqttControlPacketType enum — the reader
|
||||
// returns whatever type byte is encoded; cast to byte for verification.
|
||||
((byte)packet.Type).ShouldBe((byte)10);
|
||||
packet.Flags.ShouldBe((byte)0x02);
|
||||
packet.RemainingLength.ShouldBe(13);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Disconnect_packet_is_two_bytes_with_zero_remaining_length()
|
||||
{
|
||||
// DISCONNECT fixed header = 0xE0, remaining length = 0x00.
|
||||
// Total wire size: exactly 2 bytes (Go: mqttPacketDisconnect = 0xe0).
|
||||
ReadOnlySpan<byte> bytes = [0xE0, 0x00];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
|
||||
((byte)packet.Type).ShouldBe((byte)14); // MqttControlPacketType.Disconnect = 14
|
||||
packet.Type.ShouldBe(MqttControlPacketType.Disconnect);
|
||||
packet.Flags.ShouldBe((byte)0x00);
|
||||
packet.RemainingLength.ShouldBe(0);
|
||||
packet.Payload.Length.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pingreq_packet_is_two_bytes_with_zero_remaining_length()
|
||||
{
|
||||
// PINGREQ fixed header = 0xC0, remaining length = 0x00.
|
||||
// Go: mqttPacketPing = 0xc0.
|
||||
ReadOnlySpan<byte> bytes = [0xC0, 0x00];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
|
||||
packet.Type.ShouldBe(MqttControlPacketType.PingReq);
|
||||
packet.Flags.ShouldBe((byte)0x00);
|
||||
packet.RemainingLength.ShouldBe(0);
|
||||
packet.Payload.Length.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pingresp_packet_is_two_bytes_with_zero_remaining_length()
|
||||
{
|
||||
// PINGRESP fixed header = 0xD0, remaining length = 0x00.
|
||||
// Go: mqttPacketPingResp = 0xd0.
|
||||
ReadOnlySpan<byte> bytes = [0xD0, 0x00];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
|
||||
packet.Type.ShouldBe(MqttControlPacketType.PingResp);
|
||||
packet.RemainingLength.ShouldBe(0);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5. Remaining length encoding edge cases (Go TestMQTTWriter VarInt table)
|
||||
// -------------------------------------------------------------------------
|
||||
// Go test: ints = {0,1,127,128,16383,16384,2097151,2097152,268435455}
|
||||
// lens = {1,1,1, 2, 2, 3, 3, 4, 4}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 1, new byte[] { 0x00 })]
|
||||
[InlineData(1, 1, new byte[] { 0x01 })]
|
||||
[InlineData(127, 1, new byte[] { 0x7F })]
|
||||
[InlineData(128, 2, new byte[] { 0x80, 0x01 })]
|
||||
[InlineData(16383, 2, new byte[] { 0xFF, 0x7F })]
|
||||
[InlineData(16384, 3, new byte[] { 0x80, 0x80, 0x01 })]
|
||||
[InlineData(2097151, 3, new byte[] { 0xFF, 0xFF, 0x7F })]
|
||||
[InlineData(2097152, 4, new byte[] { 0x80, 0x80, 0x80, 0x01 })]
|
||||
[InlineData(268435455, 4, new byte[] { 0xFF, 0xFF, 0xFF, 0x7F })]
|
||||
public void Remaining_length_encodes_to_correct_byte_count_and_bytes(
|
||||
int value, int expectedByteCount, byte[] expectedBytes)
|
||||
{
|
||||
var encoded = MqttPacketWriter.EncodeRemainingLength(value);
|
||||
|
||||
encoded.Length.ShouldBe(expectedByteCount);
|
||||
encoded.ShouldBe(expectedBytes);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(new byte[] { 0x00 }, 0)]
|
||||
[InlineData(new byte[] { 0x01 }, 1)]
|
||||
[InlineData(new byte[] { 0x7F }, 127)]
|
||||
[InlineData(new byte[] { 0x80, 0x01 }, 128)]
|
||||
[InlineData(new byte[] { 0xFF, 0x7F }, 16383)]
|
||||
[InlineData(new byte[] { 0x80, 0x80, 0x01 }, 16384)]
|
||||
[InlineData(new byte[] { 0xFF, 0xFF, 0x7F }, 2097151)]
|
||||
[InlineData(new byte[] { 0x80, 0x80, 0x80, 0x01 }, 2097152)]
|
||||
[InlineData(new byte[] { 0xFF, 0xFF, 0xFF, 0x7F }, 268435455)]
|
||||
public void Remaining_length_decodes_from_correct_byte_sequences(byte[] encoded, int expectedValue)
|
||||
{
|
||||
var decoded = MqttPacketReader.DecodeRemainingLength(encoded, out var consumed);
|
||||
|
||||
decoded.ShouldBe(expectedValue);
|
||||
consumed.ShouldBe(encoded.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remaining_length_two_byte_encoding_round_trips_through_reader()
|
||||
{
|
||||
// Go TestMQTTReader: r.reset([]byte{0x82, 0xff, 0x3}); expects l == 0xff82
|
||||
// 0x82 0xFF 0x03 → value = (0x02) + (0x7F * 128) + (0x03 * 16384)
|
||||
// = 2 + 16256 + 49152 = 65410 = 0xFF82
|
||||
ReadOnlySpan<byte> encoded = [0x82, 0xFF, 0x03];
|
||||
|
||||
var value = MqttPacketReader.DecodeRemainingLength(encoded, out var consumed);
|
||||
|
||||
value.ShouldBe(0xFF82);
|
||||
consumed.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Writer_round_trips_remaining_length_through_reader_for_all_boundary_values()
|
||||
{
|
||||
// Mirrors the Go TestMQTTWriter loop: encode then decode each boundary value.
|
||||
int[] values = [0, 1, 127, 128, 16383, 16384, 2097151, 2097152, 268435455];
|
||||
|
||||
foreach (var v in values)
|
||||
{
|
||||
var encoded = MqttPacketWriter.EncodeRemainingLength(v);
|
||||
var decoded = MqttPacketReader.DecodeRemainingLength(encoded, out _);
|
||||
decoded.ShouldBe(v, $"Round-trip failed for value {v}");
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 6. Invalid packet handling
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Read_throws_on_buffer_shorter_than_two_bytes()
|
||||
{
|
||||
// Any MQTT packet must have at least 2 bytes (fixed header + remaining length byte).
|
||||
// Use byte[] so the array can be captured inside the Should.Throw lambda.
|
||||
byte[] tooShort = [0x10];
|
||||
|
||||
var ex = Should.Throw<FormatException>(() => MqttPacketReader.Read(tooShort));
|
||||
ex.Message.ShouldContain("shorter than fixed header");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_throws_on_empty_buffer()
|
||||
{
|
||||
byte[] empty = [];
|
||||
|
||||
Should.Throw<FormatException>(() => MqttPacketReader.Read(empty));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_throws_when_remaining_length_exceeds_buffer()
|
||||
{
|
||||
// Fixed header says remaining length = 10, but only 2 extra bytes are provided.
|
||||
byte[] truncated = [0x30, 0x0A, 0x00, 0x02];
|
||||
|
||||
Should.Throw<FormatException>(() => MqttPacketReader.Read(truncated));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_throws_on_malformed_five_byte_varint_remaining_length()
|
||||
{
|
||||
// Go TestMQTTReader: r.reset([]byte{0xff, 0xff, 0xff, 0xff, 0xff}); expects "malformed" error.
|
||||
// Five continuation bytes with no terminator — the MQTT spec caps remaining-length at 4 bytes.
|
||||
// We embed this after a valid type byte to exercise the length-decode path.
|
||||
byte[] malformed = [0x30, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF];
|
||||
|
||||
Should.Throw<FormatException>(() => MqttPacketReader.Read(malformed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remaining_length_encoder_throws_on_negative_value()
|
||||
{
|
||||
Should.Throw<ArgumentOutOfRangeException>(
|
||||
() => MqttPacketWriter.EncodeRemainingLength(-1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Remaining_length_encoder_throws_on_value_exceeding_maximum()
|
||||
{
|
||||
// Maximum MQTT remaining length is 268435455 (0x0FFFFFFF).
|
||||
Should.Throw<ArgumentOutOfRangeException>(
|
||||
() => MqttPacketWriter.EncodeRemainingLength(268_435_456));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 7. Round-trip: writer → reader
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Puback_packet_round_trips_through_writer_and_reader()
|
||||
{
|
||||
// PUBACK carries a 2-byte packet identifier in its payload (remaining length = 2).
|
||||
ReadOnlySpan<byte> piPayload = [0x00, 0x07]; // packet-id = 7
|
||||
|
||||
var encoded = MqttPacketWriter.Write(MqttControlPacketType.PubAck, piPayload);
|
||||
var decoded = MqttPacketReader.Read(encoded);
|
||||
|
||||
decoded.Type.ShouldBe(MqttControlPacketType.PubAck);
|
||||
decoded.RemainingLength.ShouldBe(2);
|
||||
decoded.Payload.Span[0].ShouldBe((byte)0x00);
|
||||
decoded.Payload.Span[1].ShouldBe((byte)0x07);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Subscribe_packet_round_trips_with_flags_preserved()
|
||||
{
|
||||
// SUBSCRIBE requires flags = 0x02 per the MQTT 3.1.1 spec.
|
||||
ReadOnlySpan<byte> subPayload =
|
||||
[
|
||||
0x00, 0x05, // packet-id 5
|
||||
0x00, 0x03, (byte)'a', (byte)'/', (byte)'b', // topic "a/b"
|
||||
0x01, // QoS 1
|
||||
];
|
||||
|
||||
var encoded = MqttPacketWriter.Write(MqttControlPacketType.Subscribe, subPayload, flags: 0x02);
|
||||
var decoded = MqttPacketReader.Read(encoded);
|
||||
|
||||
decoded.Type.ShouldBe(MqttControlPacketType.Subscribe);
|
||||
decoded.Flags.ShouldBe((byte)0x02);
|
||||
decoded.RemainingLength.ShouldBe(subPayload.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Large_publish_payload_remaining_length_encodes_to_two_bytes()
|
||||
{
|
||||
// A 130-byte payload requires a 2-byte remaining-length encoding
|
||||
// (128 = 0x80 0x01; anything ≥ 128 crosses the 1-byte boundary).
|
||||
var payload = new byte[130];
|
||||
payload.AsSpan().Fill(0xAB);
|
||||
|
||||
var encoded = MqttPacketWriter.Write(MqttControlPacketType.Publish, payload);
|
||||
|
||||
// Byte 0: fixed header 0x30 (PUBLISH, QoS 0)
|
||||
encoded[0].ShouldBe((byte)0x30);
|
||||
// Bytes 1-2: remaining length 130 encoded as 0x82 0x01
|
||||
encoded[1].ShouldBe((byte)0x82);
|
||||
encoded[2].ShouldBe((byte)0x01);
|
||||
|
||||
var decoded = MqttPacketReader.Read(encoded);
|
||||
decoded.RemainingLength.ShouldBe(130);
|
||||
decoded.Payload.Length.ShouldBe(130);
|
||||
}
|
||||
}
|
||||
20
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttPacketWriterTests.cs
Normal file
20
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttPacketWriterTests.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using NATS.Server.Mqtt;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests.Mqtt;
|
||||
|
||||
public class MqttPacketWriterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Writer_emits_fixed_header_and_round_trips_with_reader()
|
||||
{
|
||||
byte[] payload = Enumerable.Repeat((byte)0xAB, 130).ToArray();
|
||||
|
||||
var encoded = MqttPacketWriter.Write(MqttControlPacketType.Publish, payload);
|
||||
encoded[0].ShouldBe((byte)0x30); // PUBLISH type with default flags
|
||||
|
||||
var decoded = MqttPacketReader.Read(encoded);
|
||||
decoded.Type.ShouldBe(MqttControlPacketType.Publish);
|
||||
decoded.RemainingLength.ShouldBe(payload.Length);
|
||||
decoded.Payload.ToArray().ShouldBe(payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Text;
|
||||
using NATS.Server.Mqtt;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests.Mqtt;
|
||||
|
||||
public class MqttProtocolConstantsParityBatch1Tests
|
||||
{
|
||||
[Fact]
|
||||
public void Constants_match_mqtt_go_reference_values()
|
||||
{
|
||||
MqttProtocolConstants.SubscribeFlags.ShouldBe((byte)0x02);
|
||||
|
||||
MqttProtocolConstants.ConnAckAccepted.ShouldBe((byte)0x00);
|
||||
MqttProtocolConstants.ConnAckUnacceptableProtocolVersion.ShouldBe((byte)0x01);
|
||||
MqttProtocolConstants.ConnAckIdentifierRejected.ShouldBe((byte)0x02);
|
||||
MqttProtocolConstants.ConnAckServerUnavailable.ShouldBe((byte)0x03);
|
||||
MqttProtocolConstants.ConnAckBadUserNameOrPassword.ShouldBe((byte)0x04);
|
||||
MqttProtocolConstants.ConnAckNotAuthorized.ShouldBe((byte)0x05);
|
||||
|
||||
MqttProtocolConstants.MaxPayloadSize.ShouldBe(268_435_455);
|
||||
MqttProtocolConstants.DefaultAckWait.ShouldBe(TimeSpan.FromSeconds(30));
|
||||
MqttProtocolConstants.MaxAckTotalLimit.ShouldBe(0xFFFF);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSubscribe_accepts_required_subscribe_flags()
|
||||
{
|
||||
var payload = CreateSubscribePayload(packetId: 7, ("sport/tennis/#", 1));
|
||||
|
||||
var info = MqttBinaryDecoder.ParseSubscribe(payload, flags: MqttProtocolConstants.SubscribeFlags);
|
||||
|
||||
info.PacketId.ShouldBe((ushort)7);
|
||||
info.Filters.Count.ShouldBe(1);
|
||||
info.Filters[0].TopicFilter.ShouldBe("sport/tennis/#");
|
||||
info.Filters[0].QoS.ShouldBe((byte)1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseSubscribe_rejects_invalid_subscribe_flags()
|
||||
{
|
||||
var payload = CreateSubscribePayload(packetId: 5, ("topic/one", 0));
|
||||
|
||||
var ex = Should.Throw<FormatException>(() => MqttBinaryDecoder.ParseSubscribe(payload, flags: 0x00));
|
||||
ex.Message.ShouldContain("invalid fixed-header flags");
|
||||
}
|
||||
|
||||
private static byte[] CreateSubscribePayload(ushort packetId, params (string Topic, byte Qos)[] filters)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using var writer = new BinaryWriter(ms);
|
||||
|
||||
WriteUInt16BigEndian(writer, packetId);
|
||||
foreach (var (topic, qos) in filters)
|
||||
{
|
||||
WriteString(writer, topic);
|
||||
writer.Write(qos);
|
||||
}
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static void WriteString(BinaryWriter writer, string value)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(value);
|
||||
WriteUInt16BigEndian(writer, (ushort)bytes.Length);
|
||||
writer.Write(bytes);
|
||||
}
|
||||
|
||||
private static void WriteUInt16BigEndian(BinaryWriter writer, ushort value)
|
||||
{
|
||||
writer.Write((byte)(value >> 8));
|
||||
writer.Write((byte)(value & 0xFF));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Text;
|
||||
using NATS.Server.Mqtt;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests.Mqtt;
|
||||
|
||||
public class MqttProtocolConstantsParityBatch2Tests
|
||||
{
|
||||
[Fact]
|
||||
public void Extended_constants_match_go_reference_values()
|
||||
{
|
||||
MqttProtocolConstants.MultiLevelSidSuffix.ShouldBe(" fwc");
|
||||
MqttProtocolConstants.Prefix.ShouldBe("$MQTT.");
|
||||
MqttProtocolConstants.SubPrefix.ShouldBe("$MQTT.sub.");
|
||||
|
||||
MqttProtocolConstants.StreamName.ShouldBe("$MQTT_msgs");
|
||||
MqttProtocolConstants.StreamSubjectPrefix.ShouldBe("$MQTT.msgs.");
|
||||
MqttProtocolConstants.RetainedMsgsStreamName.ShouldBe("$MQTT_rmsgs");
|
||||
MqttProtocolConstants.RetainedMsgsStreamSubject.ShouldBe("$MQTT.rmsgs.");
|
||||
MqttProtocolConstants.SessStreamName.ShouldBe("$MQTT_sess");
|
||||
MqttProtocolConstants.SessStreamSubjectPrefix.ShouldBe("$MQTT.sess.");
|
||||
MqttProtocolConstants.SessionsStreamNamePrefix.ShouldBe("$MQTT_sess_");
|
||||
MqttProtocolConstants.QoS2IncomingMsgsStreamName.ShouldBe("$MQTT_qos2in");
|
||||
MqttProtocolConstants.QoS2IncomingMsgsStreamSubjectPrefix.ShouldBe("$MQTT.qos2.in.");
|
||||
|
||||
MqttProtocolConstants.OutStreamName.ShouldBe("$MQTT_out");
|
||||
MqttProtocolConstants.OutSubjectPrefix.ShouldBe("$MQTT.out.");
|
||||
MqttProtocolConstants.PubRelSubjectPrefix.ShouldBe("$MQTT.out.pubrel.");
|
||||
MqttProtocolConstants.PubRelDeliverySubjectPrefix.ShouldBe("$MQTT.deliver.pubrel.");
|
||||
MqttProtocolConstants.PubRelConsumerDurablePrefix.ShouldBe("$MQTT_PUBREL_");
|
||||
|
||||
MqttProtocolConstants.JSARepliesPrefix.ShouldBe("$MQTT.JSA.");
|
||||
MqttProtocolConstants.JSAIdTokenPos.ShouldBe(3);
|
||||
MqttProtocolConstants.JSATokenPos.ShouldBe(4);
|
||||
MqttProtocolConstants.JSAClientIDPos.ShouldBe(5);
|
||||
MqttProtocolConstants.JSAStreamCreate.ShouldBe("SC");
|
||||
MqttProtocolConstants.JSAStreamUpdate.ShouldBe("SU");
|
||||
MqttProtocolConstants.JSAStreamLookup.ShouldBe("SL");
|
||||
MqttProtocolConstants.JSAStreamDel.ShouldBe("SD");
|
||||
MqttProtocolConstants.JSAConsumerCreate.ShouldBe("CC");
|
||||
MqttProtocolConstants.JSAConsumerLookup.ShouldBe("CL");
|
||||
MqttProtocolConstants.JSAConsumerDel.ShouldBe("CD");
|
||||
MqttProtocolConstants.JSAMsgStore.ShouldBe("MS");
|
||||
MqttProtocolConstants.JSAMsgLoad.ShouldBe("ML");
|
||||
MqttProtocolConstants.JSAMsgDelete.ShouldBe("MD");
|
||||
MqttProtocolConstants.JSASessPersist.ShouldBe("SP");
|
||||
MqttProtocolConstants.JSARetainedMsgDel.ShouldBe("RD");
|
||||
MqttProtocolConstants.JSAStreamNames.ShouldBe("SN");
|
||||
|
||||
MqttProtocolConstants.SparkbNBirth.ShouldBe("NBIRTH");
|
||||
MqttProtocolConstants.SparkbDBirth.ShouldBe("DBIRTH");
|
||||
MqttProtocolConstants.SparkbNDeath.ShouldBe("NDEATH");
|
||||
MqttProtocolConstants.SparkbDDeath.ShouldBe("DDEATH");
|
||||
Encoding.ASCII.GetString(MqttProtocolConstants.SparkbNamespaceTopicPrefix).ShouldBe("spBv1.0/");
|
||||
Encoding.ASCII.GetString(MqttProtocolConstants.SparkbCertificatesTopicPrefix).ShouldBe("$sparkplug/certificates/");
|
||||
|
||||
MqttProtocolConstants.NatsHeaderPublish.ShouldBe("Nmqtt-Pub");
|
||||
MqttProtocolConstants.NatsRetainedMessageTopic.ShouldBe("Nmqtt-RTopic");
|
||||
MqttProtocolConstants.NatsRetainedMessageOrigin.ShouldBe("Nmqtt-ROrigin");
|
||||
MqttProtocolConstants.NatsRetainedMessageFlags.ShouldBe("Nmqtt-RFlags");
|
||||
MqttProtocolConstants.NatsRetainedMessageSource.ShouldBe("Nmqtt-RSource");
|
||||
MqttProtocolConstants.NatsPubRelHeader.ShouldBe("Nmqtt-PubRel");
|
||||
MqttProtocolConstants.NatsHeaderSubject.ShouldBe("Nmqtt-Subject");
|
||||
MqttProtocolConstants.NatsHeaderMapped.ShouldBe("Nmqtt-Mapped");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteString_writes_length_prefixed_utf8()
|
||||
{
|
||||
var encoded = MqttPacketWriter.WriteString("MQTT");
|
||||
|
||||
encoded.Length.ShouldBe(6);
|
||||
encoded[0].ShouldBe((byte)0x00);
|
||||
encoded[1].ShouldBe((byte)0x04);
|
||||
Encoding.UTF8.GetString(encoded.AsSpan(2)).ShouldBe("MQTT");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteBytes_writes_length_prefixed_binary_payload()
|
||||
{
|
||||
var encoded = MqttPacketWriter.WriteBytes(new byte[] { 0xAA, 0xBB, 0xCC });
|
||||
|
||||
encoded.ShouldBe(new byte[] { 0x00, 0x03, 0xAA, 0xBB, 0xCC });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteBytes_rejects_payload_larger_than_uint16()
|
||||
{
|
||||
var payload = new byte[ushort.MaxValue + 1];
|
||||
Should.Throw<ArgumentOutOfRangeException>(() => MqttPacketWriter.WriteBytes(payload));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using NATS.Server.Mqtt;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests;
|
||||
|
||||
public class MqttPublishSubscribeParityTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Mqtt_publish_only_reaches_matching_topic_subscribers()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var sub = new TcpClient();
|
||||
await sub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var subStream = sub.GetStream();
|
||||
await MqttTestWire.WriteLineAsync(subStream, "CONNECT sub");
|
||||
_ = await MqttTestWire.ReadLineAsync(subStream, 1000);
|
||||
await MqttTestWire.WriteLineAsync(subStream, "SUB sensors.temp");
|
||||
_ = await MqttTestWire.ReadLineAsync(subStream, 1000);
|
||||
|
||||
using var pub = new TcpClient();
|
||||
await pub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var pubStream = pub.GetStream();
|
||||
await MqttTestWire.WriteLineAsync(pubStream, "CONNECT pub");
|
||||
_ = await MqttTestWire.ReadLineAsync(pubStream, 1000);
|
||||
await MqttTestWire.WriteLineAsync(pubStream, "PUB sensors.humidity 90");
|
||||
|
||||
(await MqttTestWire.ReadLineAsync(subStream, 150)).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
135
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttQoSTrackingTests.cs
Normal file
135
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttQoSTrackingTests.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
// Tests for MqttQoS1Tracker (Gap 6.3 — JetStream-backed QoS 1/2 tracking).
|
||||
// Go reference: golang/nats-server/server/mqtt.go mqttProcessPub (~line 1200).
|
||||
|
||||
using NATS.Server.Mqtt;
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests.Mqtt;
|
||||
|
||||
public sealed class MqttQoSTrackingTests
|
||||
{
|
||||
// ── QoS 1 Tracker ────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Register_assigns_packet_id()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttProcessPub — assigns non-zero packet ID for QoS 1
|
||||
var tracker = new MqttQoS1Tracker();
|
||||
|
||||
var id = tracker.Register("sensors/temp", [0x01, 0x02]);
|
||||
|
||||
id.ShouldNotBe((ushort)0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Register_increments_packet_id()
|
||||
{
|
||||
// Go reference: server/mqtt.go — each outgoing QoS 1 message gets a unique packet ID
|
||||
var tracker = new MqttQoS1Tracker();
|
||||
|
||||
var id1 = tracker.Register("sensors/temp", [0x01]);
|
||||
var id2 = tracker.Register("sensors/humidity", [0x02]);
|
||||
|
||||
id1.ShouldNotBe(id2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_removes_pending()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttProcessPubAck — removes message from pending set
|
||||
var tracker = new MqttQoS1Tracker();
|
||||
var id = tracker.Register("sensors/temp", [0xAB]);
|
||||
|
||||
tracker.PendingCount.ShouldBe(1);
|
||||
var removed = tracker.Acknowledge(id);
|
||||
|
||||
removed.ShouldBeTrue();
|
||||
tracker.PendingCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Acknowledge_returns_false_for_unknown()
|
||||
{
|
||||
// Go reference: server/mqtt.go — PUBACK for unknown packet ID is silently ignored
|
||||
var tracker = new MqttQoS1Tracker();
|
||||
|
||||
var result = tracker.Acknowledge(9999);
|
||||
|
||||
result.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PendingCount_reflects_current_state()
|
||||
{
|
||||
// Register 3 messages, acknowledge 1, expect count of 2
|
||||
var tracker = new MqttQoS1Tracker();
|
||||
var id1 = tracker.Register("a/b", [1]);
|
||||
tracker.Register("c/d", [2]);
|
||||
tracker.Register("e/f", [3]);
|
||||
|
||||
tracker.Acknowledge(id1);
|
||||
|
||||
tracker.PendingCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPending_true_for_registered()
|
||||
{
|
||||
// Go reference: server/mqtt.go — registered QoS 1 message is in the pending set
|
||||
var tracker = new MqttQoS1Tracker();
|
||||
var id = tracker.Register("topic/x", [0xFF]);
|
||||
|
||||
tracker.IsPending(id).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsPending_false_after_acknowledge()
|
||||
{
|
||||
// Go reference: server/mqtt.go — message is removed from pending after PUBACK
|
||||
var tracker = new MqttQoS1Tracker();
|
||||
var id = tracker.Register("topic/x", [0xFF]);
|
||||
tracker.Acknowledge(id);
|
||||
|
||||
tracker.IsPending(id).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPendingForRedelivery_returns_all_pending()
|
||||
{
|
||||
// Go reference: server/mqtt.go reconnect path — all unacked messages are redelivered
|
||||
var tracker = new MqttQoS1Tracker();
|
||||
tracker.Register("a", [1]);
|
||||
tracker.Register("b", [2]);
|
||||
tracker.Register("c", [3]);
|
||||
|
||||
var pending = tracker.GetPendingForRedelivery();
|
||||
|
||||
pending.Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetPendingForRedelivery_increments_delivery_count()
|
||||
{
|
||||
// Go reference: server/mqtt.go reconnect redelivery — DUP flag set, delivery count increments
|
||||
var tracker = new MqttQoS1Tracker();
|
||||
tracker.Register("test/topic", [0xDE, 0xAD]);
|
||||
|
||||
var pending = tracker.GetPendingForRedelivery();
|
||||
|
||||
pending[0].DeliveryCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clear_removes_all_pending()
|
||||
{
|
||||
// Go reference: server/mqtt.go session cleanup — all pending messages discarded on clean session
|
||||
var tracker = new MqttQoS1Tracker();
|
||||
tracker.Register("x", [1]);
|
||||
tracker.Register("y", [2]);
|
||||
tracker.Register("z", [3]);
|
||||
|
||||
tracker.Clear();
|
||||
|
||||
tracker.PendingCount.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
26
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttQosAckRuntimeTests.cs
Normal file
26
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttQosAckRuntimeTests.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using NATS.Server.Mqtt;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests.Mqtt;
|
||||
|
||||
public class MqttQosAckRuntimeTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Qos1_publish_receives_puback_and_redelivery_on_session_reconnect_when_unacked()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = client.GetStream();
|
||||
|
||||
await MqttRuntimeWire.WriteLineAsync(stream, "CONNECT qos-client clean=false");
|
||||
(await MqttRuntimeWire.ReadLineAsync(stream, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
await MqttRuntimeWire.WriteLineAsync(stream, "PUBQ1 7 sensors.temp 42");
|
||||
(await MqttRuntimeWire.ReadLineAsync(stream, 1000)).ShouldBe("PUBACK 7");
|
||||
}
|
||||
}
|
||||
172
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttQosDeliveryParityTests.cs
Normal file
172
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttQosDeliveryParityTests.cs
Normal file
@@ -0,0 +1,172 @@
|
||||
// Ports QoS delivery behavior from Go reference:
|
||||
// golang/nats-server/server/mqtt_test.go — TestMQTTPublish, TestMQTTSubQoS1, TestMQTTParsePub
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using NATS.Server.Mqtt;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests.Mqtt;
|
||||
|
||||
public class MqttQosDeliveryParityTests
|
||||
{
|
||||
// Go ref: TestMQTTPublish — QoS 0 is fire-and-forget; publisher sends PUB and receives no PUBACK.
|
||||
[Fact]
|
||||
public async Task Qos0_publish_is_fire_and_forget_no_puback_returned()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = client.GetStream();
|
||||
|
||||
await MqttQosWire.WriteLineAsync(stream, "CONNECT qos0-client clean=false");
|
||||
(await MqttQosWire.ReadLineAsync(stream, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
// PUB is QoS 0 — no PUBACK should come back
|
||||
await MqttQosWire.WriteLineAsync(stream, "PUB sensors.temp 25");
|
||||
|
||||
// Server must not send anything back for QoS 0
|
||||
(await MqttQosWire.ReadRawAsync(stream, 200)).ShouldBe("__timeout__");
|
||||
}
|
||||
|
||||
// Go ref: TestMQTTSubQoS1 — QoS 1 publisher receives PUBACK; subscriber on matching topic receives MSG.
|
||||
[Fact]
|
||||
public async Task Qos1_publish_with_subscriber_delivers_message_to_subscriber()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
// Set up subscriber first
|
||||
using var sub = new TcpClient();
|
||||
await sub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var subStream = sub.GetStream();
|
||||
await MqttQosWire.WriteLineAsync(subStream, "CONNECT sub-client clean=false");
|
||||
(await MqttQosWire.ReadLineAsync(subStream, 1000)).ShouldBe("CONNACK");
|
||||
await MqttQosWire.WriteLineAsync(subStream, "SUB sensors.temp");
|
||||
var subAck = await MqttQosWire.ReadLineAsync(subStream, 1000);
|
||||
subAck.ShouldNotBeNull();
|
||||
subAck.ShouldContain("SUBACK");
|
||||
|
||||
// Publisher sends QoS 1
|
||||
using var pub = new TcpClient();
|
||||
await pub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var pubStream = pub.GetStream();
|
||||
await MqttQosWire.WriteLineAsync(pubStream, "CONNECT pub-client clean=false");
|
||||
(await MqttQosWire.ReadLineAsync(pubStream, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
await MqttQosWire.WriteLineAsync(pubStream, "PUBQ1 3 sensors.temp 72");
|
||||
|
||||
// Publisher receives PUBACK
|
||||
(await MqttQosWire.ReadLineAsync(pubStream, 1000)).ShouldBe("PUBACK 3");
|
||||
|
||||
// Subscriber receives the published message
|
||||
(await MqttQosWire.ReadLineAsync(subStream, 1000)).ShouldBe("MSG sensors.temp 72");
|
||||
}
|
||||
|
||||
// Go ref: TestMQTTSubQoS1 — QoS 1 PUBACK is sent by the server regardless of whether any subscriber exists.
|
||||
[Fact]
|
||||
public async Task Qos1_publish_without_subscriber_still_returns_puback_to_publisher()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = client.GetStream();
|
||||
|
||||
await MqttQosWire.WriteLineAsync(stream, "CONNECT lonely-publisher clean=false");
|
||||
(await MqttQosWire.ReadLineAsync(stream, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
// Publish QoS 1 with no subscribers registered
|
||||
await MqttQosWire.WriteLineAsync(stream, "PUBQ1 9 nowhere.topic hello");
|
||||
|
||||
// Server must still acknowledge the publish
|
||||
(await MqttQosWire.ReadLineAsync(stream, 1000)).ShouldBe("PUBACK 9");
|
||||
}
|
||||
|
||||
// Go ref: TestMQTTSubQoS1 — each QoS 1 publish carries a distinct packet identifier assigned by the sender.
|
||||
[Fact]
|
||||
public async Task Multiple_qos1_publishes_use_incrementing_packet_ids()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = client.GetStream();
|
||||
|
||||
await MqttQosWire.WriteLineAsync(stream, "CONNECT multi-pub-client clean=false");
|
||||
(await MqttQosWire.ReadLineAsync(stream, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
// Send three QoS 1 publishes with consecutive packet IDs
|
||||
await MqttQosWire.WriteLineAsync(stream, "PUBQ1 1 sensor.a alpha");
|
||||
(await MqttQosWire.ReadLineAsync(stream, 1000)).ShouldBe("PUBACK 1");
|
||||
|
||||
await MqttQosWire.WriteLineAsync(stream, "PUBQ1 2 sensor.b beta");
|
||||
(await MqttQosWire.ReadLineAsync(stream, 1000)).ShouldBe("PUBACK 2");
|
||||
|
||||
await MqttQosWire.WriteLineAsync(stream, "PUBQ1 3 sensor.c gamma");
|
||||
(await MqttQosWire.ReadLineAsync(stream, 1000)).ShouldBe("PUBACK 3");
|
||||
}
|
||||
}
|
||||
|
||||
// Duplicated per-file as required — each test file is self-contained.
|
||||
internal static class MqttQosWire
|
||||
{
|
||||
public static async Task WriteLineAsync(NetworkStream stream, string line)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(line + "\n");
|
||||
await stream.WriteAsync(bytes);
|
||||
await stream.FlushAsync();
|
||||
}
|
||||
|
||||
public static async Task<string?> ReadLineAsync(NetworkStream stream, int timeoutMs)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(timeoutMs);
|
||||
var bytes = new List<byte>();
|
||||
var one = new byte[1];
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var read = await stream.ReadAsync(one.AsMemory(0, 1), timeout.Token);
|
||||
if (read == 0)
|
||||
return null;
|
||||
if (one[0] == (byte)'\n')
|
||||
break;
|
||||
if (one[0] != (byte)'\r')
|
||||
bytes.Add(one[0]);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString([.. bytes]);
|
||||
}
|
||||
|
||||
public static async Task<string?> ReadRawAsync(NetworkStream stream, int timeoutMs)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(timeoutMs);
|
||||
var one = new byte[1];
|
||||
try
|
||||
{
|
||||
var read = await stream.ReadAsync(one.AsMemory(0, 1), timeout.Token);
|
||||
if (read == 0)
|
||||
return null;
|
||||
|
||||
return Encoding.UTF8.GetString(one, 0, read);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return "__timeout__";
|
||||
}
|
||||
}
|
||||
}
|
||||
190
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttQosTests.cs
Normal file
190
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttQosTests.cs
Normal file
@@ -0,0 +1,190 @@
|
||||
// MQTT QoS and retained message tests.
|
||||
// Go reference: golang/nats-server/server/mqtt.go
|
||||
// Retained messages — mqttHandleRetainedMsg / mqttGetRetainedMessages (~lines 1600–1700)
|
||||
// QoS 2 flow — mqttProcessPubRec / mqttProcessPubRel / mqttProcessPubComp (~lines 1300–1400)
|
||||
|
||||
using System.Text;
|
||||
using NATS.Server.Mqtt;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests.Mqtt;
|
||||
|
||||
public class MqttQosTests
|
||||
{
|
||||
[Fact]
|
||||
public void RetainedStore_SetAndGet_RoundTrips()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttHandleRetainedMsg — store and retrieve
|
||||
var store = new MqttRetainedStore();
|
||||
var payload = Encoding.UTF8.GetBytes("temperature=72.5");
|
||||
|
||||
store.SetRetained("sensors/temp", payload);
|
||||
|
||||
var result = store.GetRetained("sensors/temp");
|
||||
result.ShouldNotBeNull();
|
||||
Encoding.UTF8.GetString(result.Value.Span).ShouldBe("temperature=72.5");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetainedStore_EmptyPayload_ClearsRetained()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttHandleRetainedMsg — empty payload clears
|
||||
var store = new MqttRetainedStore();
|
||||
store.SetRetained("sensors/temp", Encoding.UTF8.GetBytes("old-value"));
|
||||
|
||||
store.SetRetained("sensors/temp", ReadOnlyMemory<byte>.Empty);
|
||||
|
||||
store.GetRetained("sensors/temp").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetainedStore_Overwrite_ReplacesOld()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttHandleRetainedMsg — overwrite replaces
|
||||
var store = new MqttRetainedStore();
|
||||
store.SetRetained("sensors/temp", Encoding.UTF8.GetBytes("first"));
|
||||
|
||||
store.SetRetained("sensors/temp", Encoding.UTF8.GetBytes("second"));
|
||||
|
||||
var result = store.GetRetained("sensors/temp");
|
||||
result.ShouldNotBeNull();
|
||||
Encoding.UTF8.GetString(result.Value.Span).ShouldBe("second");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetainedStore_GetMatching_WildcardPlus()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttGetRetainedMessages — '+' single-level wildcard
|
||||
var store = new MqttRetainedStore();
|
||||
store.SetRetained("sensors/temp", Encoding.UTF8.GetBytes("72.5"));
|
||||
store.SetRetained("sensors/humidity", Encoding.UTF8.GetBytes("45%"));
|
||||
store.SetRetained("alerts/fire", Encoding.UTF8.GetBytes("!"));
|
||||
|
||||
var matches = store.GetMatchingRetained("sensors/+");
|
||||
|
||||
matches.Count.ShouldBe(2);
|
||||
matches.Select(m => m.Topic).ShouldBe(
|
||||
new[] { "sensors/temp", "sensors/humidity" },
|
||||
ignoreOrder: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetainedStore_GetMatching_WildcardHash()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttGetRetainedMessages — '#' multi-level wildcard
|
||||
var store = new MqttRetainedStore();
|
||||
store.SetRetained("home/living/temp", Encoding.UTF8.GetBytes("22"));
|
||||
store.SetRetained("home/living/light", Encoding.UTF8.GetBytes("on"));
|
||||
store.SetRetained("home/kitchen/temp", Encoding.UTF8.GetBytes("24"));
|
||||
store.SetRetained("office/desk/light", Encoding.UTF8.GetBytes("off"));
|
||||
|
||||
var matches = store.GetMatchingRetained("home/#");
|
||||
|
||||
matches.Count.ShouldBe(3);
|
||||
matches.Select(m => m.Topic).ShouldBe(
|
||||
new[] { "home/living/temp", "home/living/light", "home/kitchen/temp" },
|
||||
ignoreOrder: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Qos2_FullFlow_PubRecPubRelPubComp()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttProcessPubRec / mqttProcessPubRel / mqttProcessPubComp
|
||||
var sm = new MqttQos2StateMachine();
|
||||
|
||||
// Begin publish
|
||||
sm.BeginPublish(100).ShouldBeTrue();
|
||||
sm.GetState(100).ShouldBe(MqttQos2State.AwaitingPubRec);
|
||||
|
||||
// PUBREC
|
||||
sm.ProcessPubRec(100).ShouldBeTrue();
|
||||
sm.GetState(100).ShouldBe(MqttQos2State.AwaitingPubRel);
|
||||
|
||||
// PUBREL
|
||||
sm.ProcessPubRel(100).ShouldBeTrue();
|
||||
sm.GetState(100).ShouldBe(MqttQos2State.AwaitingPubComp);
|
||||
|
||||
// PUBCOMP — completes and removes flow
|
||||
sm.ProcessPubComp(100).ShouldBeTrue();
|
||||
sm.GetState(100).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Qos2_DuplicatePublish_Rejected()
|
||||
{
|
||||
// Go reference: server/mqtt.go — duplicate packet ID rejected during active flow
|
||||
var sm = new MqttQos2StateMachine();
|
||||
|
||||
sm.BeginPublish(200).ShouldBeTrue();
|
||||
|
||||
// Same packet ID while flow is active — should be rejected
|
||||
sm.BeginPublish(200).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Qos2_IncompleteFlow_TimesOut()
|
||||
{
|
||||
// Go reference: server/mqtt.go — incomplete QoS 2 flows time out
|
||||
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 1, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
var sm = new MqttQos2StateMachine(timeout: TimeSpan.FromSeconds(5), timeProvider: fakeTime);
|
||||
|
||||
sm.BeginPublish(300).ShouldBeTrue();
|
||||
|
||||
// Not timed out yet
|
||||
fakeTime.Advance(TimeSpan.FromSeconds(3));
|
||||
sm.GetTimedOutFlows().ShouldBeEmpty();
|
||||
|
||||
// Advance past timeout
|
||||
fakeTime.Advance(TimeSpan.FromSeconds(3));
|
||||
var timedOut = sm.GetTimedOutFlows();
|
||||
timedOut.Count.ShouldBe(1);
|
||||
timedOut[0].ShouldBe((ushort)300);
|
||||
|
||||
// Clean up
|
||||
sm.RemoveFlow(300);
|
||||
sm.GetState(300).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Qos1_Puback_RemovesPending()
|
||||
{
|
||||
// Go reference: server/mqtt.go — QoS 1 PUBACK removes from pending
|
||||
// This tests the existing MqttListener pending publish / ack mechanism
|
||||
// in the context of the session store.
|
||||
var store = new MqttSessionStore();
|
||||
var session = new MqttSessionData
|
||||
{
|
||||
ClientId = "qos1-client",
|
||||
PendingPublishes =
|
||||
[
|
||||
new MqttPendingPublish(1, "topic/a", "payload-a"),
|
||||
new MqttPendingPublish(2, "topic/b", "payload-b"),
|
||||
],
|
||||
};
|
||||
|
||||
store.SaveSession(session);
|
||||
|
||||
// Simulate PUBACK for packet 1: remove it from pending
|
||||
var loaded = store.LoadSession("qos1-client");
|
||||
loaded.ShouldNotBeNull();
|
||||
loaded.PendingPublishes.RemoveAll(p => p.PacketId == 1);
|
||||
store.SaveSession(loaded);
|
||||
|
||||
// Verify only packet 2 remains
|
||||
var updated = store.LoadSession("qos1-client");
|
||||
updated.ShouldNotBeNull();
|
||||
updated.PendingPublishes.Count.ShouldBe(1);
|
||||
updated.PendingPublishes[0].PacketId.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RetainedStore_GetMatching_NoMatch_ReturnsEmpty()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttGetRetainedMessages — no match returns empty
|
||||
var store = new MqttRetainedStore();
|
||||
store.SetRetained("sensors/temp", Encoding.UTF8.GetBytes("72"));
|
||||
|
||||
var matches = store.GetMatchingRetained("alerts/+");
|
||||
|
||||
matches.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
163
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttRetainedDeliveryTests.cs
Normal file
163
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttRetainedDeliveryTests.cs
Normal file
@@ -0,0 +1,163 @@
|
||||
// Tests for retained message delivery on MQTT SUBSCRIBE.
|
||||
// Covers GetMatchingRetained and DeliverRetainedOnSubscribe with MQTT wildcard matching.
|
||||
// Go reference: server/mqtt.go mqttGetRetainedMessages ~line 1650.
|
||||
|
||||
using System.Text;
|
||||
using NATS.Server.Mqtt;
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests.Mqtt;
|
||||
|
||||
public class MqttRetainedDeliveryTests
|
||||
{
|
||||
// Go ref: server/mqtt.go mqttGetRetainedMessages — exact topic lookup
|
||||
[Fact]
|
||||
public void GetMatchingRetained_exact_topic_match()
|
||||
{
|
||||
var store = new MqttRetainedStore();
|
||||
store.SetRetained("a/b", Encoding.UTF8.GetBytes("hello"));
|
||||
|
||||
var results = store.GetMatchingRetained("a/b");
|
||||
|
||||
results.Count.ShouldBe(1);
|
||||
results[0].Topic.ShouldBe("a/b");
|
||||
Encoding.UTF8.GetString(results[0].Payload.Span).ShouldBe("hello");
|
||||
}
|
||||
|
||||
// Go ref: server/mqtt.go mqttGetRetainedMessages — '+' single-level wildcard
|
||||
[Fact]
|
||||
public void GetMatchingRetained_plus_wildcard()
|
||||
{
|
||||
var store = new MqttRetainedStore();
|
||||
store.SetRetained("a/b", Encoding.UTF8.GetBytes("payload-b"));
|
||||
store.SetRetained("a/c", Encoding.UTF8.GetBytes("payload-c"));
|
||||
|
||||
var results = store.GetMatchingRetained("a/+");
|
||||
|
||||
results.Count.ShouldBe(2);
|
||||
results.Select(r => r.Topic).ShouldContain("a/b");
|
||||
results.Select(r => r.Topic).ShouldContain("a/c");
|
||||
}
|
||||
|
||||
// Go ref: server/mqtt.go mqttGetRetainedMessages — '#' multi-level wildcard
|
||||
[Fact]
|
||||
public void GetMatchingRetained_hash_wildcard()
|
||||
{
|
||||
var store = new MqttRetainedStore();
|
||||
store.SetRetained("a/b/c", Encoding.UTF8.GetBytes("deep"));
|
||||
|
||||
var results = store.GetMatchingRetained("a/#");
|
||||
|
||||
results.Count.ShouldBe(1);
|
||||
results[0].Topic.ShouldBe("a/b/c");
|
||||
}
|
||||
|
||||
// Go ref: server/mqtt.go mqttGetRetainedMessages — '#' alone matches all topics
|
||||
[Fact]
|
||||
public void GetMatchingRetained_hash_matches_all()
|
||||
{
|
||||
var store = new MqttRetainedStore();
|
||||
store.SetRetained("x/y", Encoding.UTF8.GetBytes("v1"));
|
||||
store.SetRetained("a/b", Encoding.UTF8.GetBytes("v2"));
|
||||
|
||||
var results = store.GetMatchingRetained("#");
|
||||
|
||||
results.Count.ShouldBe(2);
|
||||
results.Select(r => r.Topic).ShouldContain("x/y");
|
||||
results.Select(r => r.Topic).ShouldContain("a/b");
|
||||
}
|
||||
|
||||
// Go ref: server/mqtt.go mqttGetRetainedMessages — no match returns empty list
|
||||
[Fact]
|
||||
public void GetMatchingRetained_no_match()
|
||||
{
|
||||
var store = new MqttRetainedStore();
|
||||
store.SetRetained("a/b", Encoding.UTF8.GetBytes("data"));
|
||||
|
||||
var results = store.GetMatchingRetained("c/d");
|
||||
|
||||
results.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
// Go ref: server/mqtt.go mqttGetRetainedMessages — callback invoked for each match
|
||||
[Fact]
|
||||
public void DeliverRetainedOnSubscribe_calls_deliver_for_each_match()
|
||||
{
|
||||
var store = new MqttRetainedStore();
|
||||
store.SetRetained("sensor/temp", Encoding.UTF8.GetBytes("25"));
|
||||
store.SetRetained("sensor/humidity", Encoding.UTF8.GetBytes("60"));
|
||||
|
||||
var deliveredTopics = new List<string>();
|
||||
store.DeliverRetainedOnSubscribe("sensor/+", (topic, _, _, _) => deliveredTopics.Add(topic));
|
||||
|
||||
deliveredTopics.Count.ShouldBe(2);
|
||||
deliveredTopics.ShouldContain("sensor/temp");
|
||||
deliveredTopics.ShouldContain("sensor/humidity");
|
||||
}
|
||||
|
||||
// Go ref: server/mqtt.go mqttGetRetainedMessages — retain flag is always true on delivery
|
||||
[Fact]
|
||||
public void DeliverRetainedOnSubscribe_passes_retain_flag_true()
|
||||
{
|
||||
var store = new MqttRetainedStore();
|
||||
store.SetRetained("home/light", Encoding.UTF8.GetBytes("on"));
|
||||
|
||||
bool? capturedRetain = null;
|
||||
store.DeliverRetainedOnSubscribe("home/+", (_, _, _, retain) => capturedRetain = retain);
|
||||
|
||||
capturedRetain.ShouldBe(true);
|
||||
}
|
||||
|
||||
// Go ref: server/mqtt.go mqttGetRetainedMessages — return value equals number of deliveries
|
||||
[Fact]
|
||||
public void DeliverRetainedOnSubscribe_returns_count()
|
||||
{
|
||||
var store = new MqttRetainedStore();
|
||||
store.SetRetained("dev/a", Encoding.UTF8.GetBytes("1"));
|
||||
store.SetRetained("dev/b", Encoding.UTF8.GetBytes("2"));
|
||||
store.SetRetained("dev/c", Encoding.UTF8.GetBytes("3"));
|
||||
|
||||
var count = store.DeliverRetainedOnSubscribe("dev/+", (_, _, _, _) => { });
|
||||
|
||||
count.ShouldBe(3);
|
||||
}
|
||||
|
||||
// Go ref: server/mqtt.go mqttGetRetainedMessages — '+' does NOT match multiple levels
|
||||
[Fact]
|
||||
public void GetMatchingRetained_plus_does_not_cross_levels()
|
||||
{
|
||||
var store = new MqttRetainedStore();
|
||||
store.SetRetained("a/b/c", Encoding.UTF8.GetBytes("deep"));
|
||||
|
||||
// "a/+" matches exactly two levels: "a/<one token>". "a/b/c" has three levels.
|
||||
var results = store.GetMatchingRetained("a/+");
|
||||
|
||||
results.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
// Go ref: server/mqtt.go mqttGetRetainedMessages — empty store delivers nothing
|
||||
[Fact]
|
||||
public void DeliverRetainedOnSubscribe_empty_store_returns_zero()
|
||||
{
|
||||
var store = new MqttRetainedStore();
|
||||
|
||||
var count = store.DeliverRetainedOnSubscribe("#", (_, _, _, _) => { });
|
||||
|
||||
count.ShouldBe(0);
|
||||
}
|
||||
|
||||
// Go ref: server/mqtt.go mqttGetRetainedMessages — payload bytes are passed correctly
|
||||
[Fact]
|
||||
public void DeliverRetainedOnSubscribe_passes_correct_payload()
|
||||
{
|
||||
var store = new MqttRetainedStore();
|
||||
var expected = Encoding.UTF8.GetBytes("temperature=42");
|
||||
store.SetRetained("env/temp", expected);
|
||||
|
||||
byte[]? capturedPayload = null;
|
||||
store.DeliverRetainedOnSubscribe("env/+", (_, payload, _, _) => capturedPayload = payload);
|
||||
|
||||
capturedPayload.ShouldNotBeNull();
|
||||
capturedPayload.ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
// Ports retained message behavior from Go reference:
|
||||
// golang/nats-server/server/mqtt_test.go — TestMQTTPublishRetain, TestMQTTRetainFlag,
|
||||
// TestMQTTPersistRetainedMsg, TestMQTTRetainedMsgCleanup, TestMQTTRestoreRetainedMsgs,
|
||||
// TestMQTTDecodeRetainedMessage, TestMQTTRetainedNoMsgBodyCorruption
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using NATS.Server.Mqtt;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests.Mqtt;
|
||||
|
||||
public class MqttRetainedMessageParityTests
|
||||
{
|
||||
// Go ref: TestMQTTPublishRetain server/mqtt_test.go:4407
|
||||
[Fact]
|
||||
public async Task Retained_message_not_delivered_when_subscriber_connects_after_publish()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var pub = new TcpClient();
|
||||
await pub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var pubStream = pub.GetStream();
|
||||
await MqttRetainedWire.WriteLineAsync(pubStream, "CONNECT pub-client clean=true");
|
||||
(await MqttRetainedWire.ReadLineAsync(pubStream, 1000)).ShouldBe("CONNACK");
|
||||
await MqttRetainedWire.WriteLineAsync(pubStream, "PUB sensors.temp 72");
|
||||
|
||||
using var sub = new TcpClient();
|
||||
await sub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var subStream = sub.GetStream();
|
||||
await MqttRetainedWire.WriteLineAsync(subStream, "CONNECT sub-client clean=true");
|
||||
(await MqttRetainedWire.ReadLineAsync(subStream, 1000)).ShouldBe("CONNACK");
|
||||
await MqttRetainedWire.WriteLineAsync(subStream, "SUB sensors.temp");
|
||||
(await MqttRetainedWire.ReadLineAsync(subStream, 1000))!.ShouldContain("SUBACK");
|
||||
|
||||
(await MqttRetainedWire.ReadLineAsync(subStream, 300)).ShouldBeNull();
|
||||
}
|
||||
|
||||
// Go ref: TestMQTTPublishRetain — non-retained publish delivers to existing subscriber
|
||||
// server/mqtt_test.go:4407
|
||||
[Fact]
|
||||
public async Task Non_retained_publish_delivers_to_existing_subscriber()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var sub = new TcpClient();
|
||||
await sub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var subStream = sub.GetStream();
|
||||
await MqttRetainedWire.WriteLineAsync(subStream, "CONNECT sub-retain clean=true");
|
||||
(await MqttRetainedWire.ReadLineAsync(subStream, 1000)).ShouldBe("CONNACK");
|
||||
await MqttRetainedWire.WriteLineAsync(subStream, "SUB sensors.temp");
|
||||
(await MqttRetainedWire.ReadLineAsync(subStream, 1000))!.ShouldContain("SUBACK");
|
||||
|
||||
using var pub = new TcpClient();
|
||||
await pub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var pubStream = pub.GetStream();
|
||||
await MqttRetainedWire.WriteLineAsync(pubStream, "CONNECT pub-retain clean=true");
|
||||
(await MqttRetainedWire.ReadLineAsync(pubStream, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
await MqttRetainedWire.WriteLineAsync(pubStream, "PUB sensors.temp 72");
|
||||
(await MqttRetainedWire.ReadLineAsync(subStream, 1000)).ShouldBe("MSG sensors.temp 72");
|
||||
}
|
||||
|
||||
// Go ref: TestMQTTRetainFlag — live messages not flagged as retained [MQTT-3.3.1-9]
|
||||
// server/mqtt_test.go:4495
|
||||
[Fact]
|
||||
public async Task Live_message_delivered_to_existing_subscriber_is_not_flagged_retained()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var sub = new TcpClient();
|
||||
await sub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var subStream = sub.GetStream();
|
||||
await MqttRetainedWire.WriteLineAsync(subStream, "CONNECT sub-live clean=true");
|
||||
(await MqttRetainedWire.ReadLineAsync(subStream, 1000)).ShouldBe("CONNACK");
|
||||
await MqttRetainedWire.WriteLineAsync(subStream, "SUB foo.zero");
|
||||
(await MqttRetainedWire.ReadLineAsync(subStream, 1000))!.ShouldContain("SUBACK");
|
||||
|
||||
using var pub = new TcpClient();
|
||||
await pub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var pubStream = pub.GetStream();
|
||||
await MqttRetainedWire.WriteLineAsync(pubStream, "CONNECT pub-live clean=true");
|
||||
(await MqttRetainedWire.ReadLineAsync(pubStream, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
await MqttRetainedWire.WriteLineAsync(pubStream, "PUB foo.zero flag-not-set");
|
||||
var msg = await MqttRetainedWire.ReadLineAsync(subStream, 1000);
|
||||
msg.ShouldBe("MSG foo.zero flag-not-set");
|
||||
}
|
||||
|
||||
// Go ref: TestMQTTPersistRetainedMsg server/mqtt_test.go:5279
|
||||
[Fact]
|
||||
public async Task Multiple_publishers_deliver_to_same_subscriber()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var sub = new TcpClient();
|
||||
await sub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var subStream = sub.GetStream();
|
||||
await MqttRetainedWire.WriteLineAsync(subStream, "CONNECT sub-multi clean=true");
|
||||
(await MqttRetainedWire.ReadLineAsync(subStream, 1000)).ShouldBe("CONNACK");
|
||||
await MqttRetainedWire.WriteLineAsync(subStream, "SUB data.feed");
|
||||
(await MqttRetainedWire.ReadLineAsync(subStream, 1000))!.ShouldContain("SUBACK");
|
||||
|
||||
using var pubA = new TcpClient();
|
||||
await pubA.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var streamA = pubA.GetStream();
|
||||
await MqttRetainedWire.WriteLineAsync(streamA, "CONNECT pub-a clean=true");
|
||||
(await MqttRetainedWire.ReadLineAsync(streamA, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
using var pubB = new TcpClient();
|
||||
await pubB.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var streamB = pubB.GetStream();
|
||||
await MqttRetainedWire.WriteLineAsync(streamB, "CONNECT pub-b clean=true");
|
||||
(await MqttRetainedWire.ReadLineAsync(streamB, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
await MqttRetainedWire.WriteLineAsync(streamA, "PUB data.feed alpha");
|
||||
(await MqttRetainedWire.ReadLineAsync(subStream, 1000)).ShouldBe("MSG data.feed alpha");
|
||||
|
||||
await MqttRetainedWire.WriteLineAsync(streamB, "PUB data.feed beta");
|
||||
(await MqttRetainedWire.ReadLineAsync(subStream, 1000)).ShouldBe("MSG data.feed beta");
|
||||
}
|
||||
|
||||
// Go ref: TestMQTTRetainedNoMsgBodyCorruption server/mqtt_test.go:3432
|
||||
[Fact]
|
||||
public async Task Message_payload_is_not_corrupted_through_broker()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var sub = new TcpClient();
|
||||
await sub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var subStream = sub.GetStream();
|
||||
await MqttRetainedWire.WriteLineAsync(subStream, "CONNECT sub-integrity clean=true");
|
||||
(await MqttRetainedWire.ReadLineAsync(subStream, 1000)).ShouldBe("CONNACK");
|
||||
await MqttRetainedWire.WriteLineAsync(subStream, "SUB integrity.test");
|
||||
(await MqttRetainedWire.ReadLineAsync(subStream, 1000))!.ShouldContain("SUBACK");
|
||||
|
||||
using var pub = new TcpClient();
|
||||
await pub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var pubStream = pub.GetStream();
|
||||
await MqttRetainedWire.WriteLineAsync(pubStream, "CONNECT pub-integrity clean=true");
|
||||
(await MqttRetainedWire.ReadLineAsync(pubStream, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
var payload = "hello-world-12345-!@#$%";
|
||||
await MqttRetainedWire.WriteLineAsync(pubStream, $"PUB integrity.test {payload}");
|
||||
var msg = await MqttRetainedWire.ReadLineAsync(subStream, 1000);
|
||||
msg.ShouldBe($"MSG integrity.test {payload}");
|
||||
}
|
||||
|
||||
// Go ref: TestMQTTRetainedMsgCleanup server/mqtt_test.go:5378
|
||||
[Fact]
|
||||
public async Task Sequential_publishes_all_deliver()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var sub = new TcpClient();
|
||||
await sub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var subStream = sub.GetStream();
|
||||
await MqttRetainedWire.WriteLineAsync(subStream, "CONNECT sub-empty clean=true");
|
||||
(await MqttRetainedWire.ReadLineAsync(subStream, 1000)).ShouldBe("CONNACK");
|
||||
await MqttRetainedWire.WriteLineAsync(subStream, "SUB cleanup.topic");
|
||||
(await MqttRetainedWire.ReadLineAsync(subStream, 1000))!.ShouldContain("SUBACK");
|
||||
|
||||
using var pub = new TcpClient();
|
||||
await pub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var pubStream = pub.GetStream();
|
||||
await MqttRetainedWire.WriteLineAsync(pubStream, "CONNECT pub-empty clean=true");
|
||||
(await MqttRetainedWire.ReadLineAsync(pubStream, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
await MqttRetainedWire.WriteLineAsync(pubStream, "PUB cleanup.topic data");
|
||||
(await MqttRetainedWire.ReadLineAsync(subStream, 1000)).ShouldBe("MSG cleanup.topic data");
|
||||
|
||||
await MqttRetainedWire.WriteLineAsync(pubStream, "PUB cleanup.topic x");
|
||||
(await MqttRetainedWire.ReadLineAsync(subStream, 1000)).ShouldBe("MSG cleanup.topic x");
|
||||
}
|
||||
|
||||
// Go ref: TestMQTTDecodeRetainedMessage server/mqtt_test.go:7760
|
||||
[Fact]
|
||||
public async Task Multiple_topics_receive_messages_independently()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var sub1 = new TcpClient();
|
||||
await sub1.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var s1 = sub1.GetStream();
|
||||
await MqttRetainedWire.WriteLineAsync(s1, "CONNECT sub-topic1 clean=true");
|
||||
(await MqttRetainedWire.ReadLineAsync(s1, 1000)).ShouldBe("CONNACK");
|
||||
await MqttRetainedWire.WriteLineAsync(s1, "SUB topic.alpha");
|
||||
(await MqttRetainedWire.ReadLineAsync(s1, 1000))!.ShouldContain("SUBACK");
|
||||
|
||||
using var sub2 = new TcpClient();
|
||||
await sub2.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var s2 = sub2.GetStream();
|
||||
await MqttRetainedWire.WriteLineAsync(s2, "CONNECT sub-topic2 clean=true");
|
||||
(await MqttRetainedWire.ReadLineAsync(s2, 1000)).ShouldBe("CONNACK");
|
||||
await MqttRetainedWire.WriteLineAsync(s2, "SUB topic.beta");
|
||||
(await MqttRetainedWire.ReadLineAsync(s2, 1000))!.ShouldContain("SUBACK");
|
||||
|
||||
using var pub = new TcpClient();
|
||||
await pub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var ps = pub.GetStream();
|
||||
await MqttRetainedWire.WriteLineAsync(ps, "CONNECT pub-topics clean=true");
|
||||
(await MqttRetainedWire.ReadLineAsync(ps, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
await MqttRetainedWire.WriteLineAsync(ps, "PUB topic.alpha alpha-data");
|
||||
(await MqttRetainedWire.ReadLineAsync(s1, 1000)).ShouldBe("MSG topic.alpha alpha-data");
|
||||
|
||||
await MqttRetainedWire.WriteLineAsync(ps, "PUB topic.beta beta-data");
|
||||
(await MqttRetainedWire.ReadLineAsync(s2, 1000)).ShouldBe("MSG topic.beta beta-data");
|
||||
|
||||
(await MqttRetainedWire.ReadLineAsync(s1, 300)).ShouldBeNull();
|
||||
(await MqttRetainedWire.ReadLineAsync(s2, 300)).ShouldBeNull();
|
||||
}
|
||||
|
||||
// Go ref: TestMQTTRestoreRetainedMsgs server/mqtt_test.go:5408
|
||||
[Fact]
|
||||
public async Task Subscriber_reconnect_resubscribe_receives_new_messages()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var sub1 = new TcpClient();
|
||||
await sub1.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var s1 = sub1.GetStream();
|
||||
await MqttRetainedWire.WriteLineAsync(s1, "CONNECT sub-reconnect clean=true");
|
||||
(await MqttRetainedWire.ReadLineAsync(s1, 1000)).ShouldBe("CONNACK");
|
||||
await MqttRetainedWire.WriteLineAsync(s1, "SUB restore.topic");
|
||||
(await MqttRetainedWire.ReadLineAsync(s1, 1000))!.ShouldContain("SUBACK");
|
||||
|
||||
using var pub = new TcpClient();
|
||||
await pub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var ps = pub.GetStream();
|
||||
await MqttRetainedWire.WriteLineAsync(ps, "CONNECT pub-restore clean=true");
|
||||
(await MqttRetainedWire.ReadLineAsync(ps, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
await MqttRetainedWire.WriteLineAsync(ps, "PUB restore.topic msg1");
|
||||
(await MqttRetainedWire.ReadLineAsync(s1, 1000)).ShouldBe("MSG restore.topic msg1");
|
||||
|
||||
sub1.Dispose();
|
||||
|
||||
using var sub2 = new TcpClient();
|
||||
await sub2.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var s2 = sub2.GetStream();
|
||||
await MqttRetainedWire.WriteLineAsync(s2, "CONNECT sub-reconnect clean=true");
|
||||
(await MqttRetainedWire.ReadLineAsync(s2, 1000)).ShouldBe("CONNACK");
|
||||
await MqttRetainedWire.WriteLineAsync(s2, "SUB restore.topic");
|
||||
(await MqttRetainedWire.ReadLineAsync(s2, 1000))!.ShouldContain("SUBACK");
|
||||
|
||||
await MqttRetainedWire.WriteLineAsync(ps, "PUB restore.topic msg2");
|
||||
(await MqttRetainedWire.ReadLineAsync(s2, 1000)).ShouldBe("MSG restore.topic msg2");
|
||||
}
|
||||
}
|
||||
|
||||
internal static class MqttRetainedWire
|
||||
{
|
||||
public static async Task WriteLineAsync(NetworkStream stream, string line)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(line + "\n");
|
||||
await stream.WriteAsync(bytes);
|
||||
await stream.FlushAsync();
|
||||
}
|
||||
|
||||
public static async Task<string?> ReadLineAsync(NetworkStream stream, int timeoutMs)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(timeoutMs);
|
||||
var bytes = new List<byte>();
|
||||
var one = new byte[1];
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var read = await stream.ReadAsync(one.AsMemory(0, 1), timeout.Token);
|
||||
if (read == 0)
|
||||
return null;
|
||||
if (one[0] == (byte)'\n')
|
||||
break;
|
||||
if (one[0] != (byte)'\r')
|
||||
bytes.Add(one[0]);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString([.. bytes]);
|
||||
}
|
||||
}
|
||||
212
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttSessionParityTests.cs
Normal file
212
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttSessionParityTests.cs
Normal file
@@ -0,0 +1,212 @@
|
||||
// Ports session management behavior from Go reference:
|
||||
// golang/nats-server/server/mqtt_test.go — TestMQTTCleanSession, TestMQTTPersistedSession,
|
||||
// TestMQTTDuplicateClientID, TestMQTTRecoverSessionAndAddNewSub
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using NATS.Server.Mqtt;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests.Mqtt;
|
||||
|
||||
public class MqttSessionParityTests
|
||||
{
|
||||
// Go ref: TestMQTTCleanSession — connecting with clean=true discards any previous session state.
|
||||
// A clean-session client never receives redeliveries from prior disconnected sessions.
|
||||
[Fact]
|
||||
public async Task Clean_session_true_discards_previous_session_state()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
// First connection: send a QoS 1 publish that goes unacked (session-client, persistent)
|
||||
using (var first = new TcpClient())
|
||||
{
|
||||
await first.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var s = first.GetStream();
|
||||
await MqttSessionWire.WriteLineAsync(s, "CONNECT clean-test-client clean=false");
|
||||
(await MqttSessionWire.ReadLineAsync(s, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
// Publish QoS 1 — server records pending, client disconnects without ACKing
|
||||
await MqttSessionWire.WriteLineAsync(s, "PUBQ1 5 device.status online");
|
||||
(await MqttSessionWire.ReadLineAsync(s, 1000)).ShouldBe("PUBACK 5");
|
||||
}
|
||||
|
||||
// Second connection with clean=true — session state must be purged, no REDLIVER
|
||||
using var second = new TcpClient();
|
||||
await second.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = second.GetStream();
|
||||
await MqttSessionWire.WriteLineAsync(stream, "CONNECT clean-test-client clean=true");
|
||||
(await MqttSessionWire.ReadLineAsync(stream, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
// No redelivery expected because clean session wiped state
|
||||
(await MqttSessionWire.ReadLineAsync(stream, 300)).ShouldBeNull();
|
||||
}
|
||||
|
||||
// Go ref: TestMQTTPersistedSession — clean=false preserves unacked QoS 1 publishes across reconnect.
|
||||
[Fact]
|
||||
public async Task Clean_session_false_preserves_unacked_publishes_across_reconnect()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
// First connection: publish QoS 1 without sending ACK, then drop
|
||||
using (var first = new TcpClient())
|
||||
{
|
||||
await first.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var s = first.GetStream();
|
||||
await MqttSessionWire.WriteLineAsync(s, "CONNECT persist-client clean=false");
|
||||
(await MqttSessionWire.ReadLineAsync(s, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
await MqttSessionWire.WriteLineAsync(s, "PUBQ1 12 alarm.fire detected");
|
||||
(await MqttSessionWire.ReadLineAsync(s, 1000)).ShouldBe("PUBACK 12");
|
||||
// Disconnect without sending ACK 12
|
||||
}
|
||||
|
||||
// Second connection with same clientId, clean=false — server must redeliver
|
||||
using var second = new TcpClient();
|
||||
await second.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = second.GetStream();
|
||||
await MqttSessionWire.WriteLineAsync(stream, "CONNECT persist-client clean=false");
|
||||
(await MqttSessionWire.ReadLineAsync(stream, 1000)).ShouldBe("CONNACK");
|
||||
(await MqttSessionWire.ReadLineAsync(stream, 1000)).ShouldBe("REDLIVER 12 alarm.fire detected");
|
||||
}
|
||||
|
||||
// Go ref: TestMQTTCleanSession — after clean disconnect the session entry is removed;
|
||||
// a subsequent persistent reconnect starts fresh with no pending messages.
|
||||
[Fact]
|
||||
public async Task Session_disconnect_cleans_up_client_tracking_on_clean_session()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
// Connect and immediately disconnect without publishing anything (clean=true)
|
||||
using (var first = new TcpClient())
|
||||
{
|
||||
await first.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var s = first.GetStream();
|
||||
await MqttSessionWire.WriteLineAsync(s, "CONNECT transient-client clean=true");
|
||||
(await MqttSessionWire.ReadLineAsync(s, 1000)).ShouldBe("CONNACK");
|
||||
}
|
||||
|
||||
// Reconnect with clean=false — no session was saved, so no redeliveries
|
||||
using var second = new TcpClient();
|
||||
await second.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var stream = second.GetStream();
|
||||
await MqttSessionWire.WriteLineAsync(stream, "CONNECT transient-client clean=false");
|
||||
(await MqttSessionWire.ReadLineAsync(stream, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
// Nothing pending from the previous clean-session connection
|
||||
(await MqttSessionWire.ReadLineAsync(stream, 300)).ShouldBeNull();
|
||||
}
|
||||
|
||||
// Go ref: TestMQTTDuplicateClientID — multiple concurrent sessions on distinct client IDs
|
||||
// operate independently with no cross-contamination of messages or session state.
|
||||
[Fact]
|
||||
public async Task Multiple_concurrent_sessions_on_different_client_ids_work_independently()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
// Client A — persistent session, QoS 1 publish unacked
|
||||
using var clientA = new TcpClient();
|
||||
await clientA.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var streamA = clientA.GetStream();
|
||||
await MqttSessionWire.WriteLineAsync(streamA, "CONNECT client-alpha clean=false");
|
||||
(await MqttSessionWire.ReadLineAsync(streamA, 1000)).ShouldBe("CONNACK");
|
||||
await MqttSessionWire.WriteLineAsync(streamA, "PUBQ1 7 alpha.topic alpha-payload");
|
||||
(await MqttSessionWire.ReadLineAsync(streamA, 1000)).ShouldBe("PUBACK 7");
|
||||
|
||||
// Client B — independent persistent session, different topic and packet ID
|
||||
using var clientB = new TcpClient();
|
||||
await clientB.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var streamB = clientB.GetStream();
|
||||
await MqttSessionWire.WriteLineAsync(streamB, "CONNECT client-beta clean=false");
|
||||
(await MqttSessionWire.ReadLineAsync(streamB, 1000)).ShouldBe("CONNACK");
|
||||
await MqttSessionWire.WriteLineAsync(streamB, "PUBQ1 8 beta.topic beta-payload");
|
||||
(await MqttSessionWire.ReadLineAsync(streamB, 1000)).ShouldBe("PUBACK 8");
|
||||
|
||||
// Disconnect both without ACKing
|
||||
clientA.Dispose();
|
||||
clientB.Dispose();
|
||||
|
||||
// Reconnect alpha — must only redeliver alpha's pending publish
|
||||
using var reconnectA = new TcpClient();
|
||||
await reconnectA.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var rsA = reconnectA.GetStream();
|
||||
await MqttSessionWire.WriteLineAsync(rsA, "CONNECT client-alpha clean=false");
|
||||
(await MqttSessionWire.ReadLineAsync(rsA, 1000)).ShouldBe("CONNACK");
|
||||
(await MqttSessionWire.ReadLineAsync(rsA, 1000)).ShouldBe("REDLIVER 7 alpha.topic alpha-payload");
|
||||
|
||||
// Reconnect beta — must only redeliver beta's pending publish
|
||||
using var reconnectB = new TcpClient();
|
||||
await reconnectB.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var rsB = reconnectB.GetStream();
|
||||
await MqttSessionWire.WriteLineAsync(rsB, "CONNECT client-beta clean=false");
|
||||
(await MqttSessionWire.ReadLineAsync(rsB, 1000)).ShouldBe("CONNACK");
|
||||
(await MqttSessionWire.ReadLineAsync(rsB, 1000)).ShouldBe("REDLIVER 8 beta.topic beta-payload");
|
||||
|
||||
// Alpha should not see beta's message and vice-versa (no cross-contamination)
|
||||
(await MqttSessionWire.ReadLineAsync(rsA, 200)).ShouldBeNull();
|
||||
(await MqttSessionWire.ReadLineAsync(rsB, 200)).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
// Duplicated per-file as required — each test file is self-contained.
|
||||
internal static class MqttSessionWire
|
||||
{
|
||||
public static async Task WriteLineAsync(NetworkStream stream, string line)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(line + "\n");
|
||||
await stream.WriteAsync(bytes);
|
||||
await stream.FlushAsync();
|
||||
}
|
||||
|
||||
public static async Task<string?> ReadLineAsync(NetworkStream stream, int timeoutMs)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(timeoutMs);
|
||||
var bytes = new List<byte>();
|
||||
var one = new byte[1];
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var read = await stream.ReadAsync(one.AsMemory(0, 1), timeout.Token);
|
||||
if (read == 0)
|
||||
return null;
|
||||
if (one[0] == (byte)'\n')
|
||||
break;
|
||||
if (one[0] != (byte)'\r')
|
||||
bytes.Add(one[0]);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString([.. bytes]);
|
||||
}
|
||||
|
||||
public static async Task<string?> ReadRawAsync(NetworkStream stream, int timeoutMs)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(timeoutMs);
|
||||
var one = new byte[1];
|
||||
try
|
||||
{
|
||||
var read = await stream.ReadAsync(one.AsMemory(0, 1), timeout.Token);
|
||||
if (read == 0)
|
||||
return null;
|
||||
|
||||
return Encoding.UTF8.GetString(one, 0, read);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return "__timeout__";
|
||||
}
|
||||
}
|
||||
}
|
||||
209
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttSessionPersistenceTests.cs
Normal file
209
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttSessionPersistenceTests.cs
Normal file
@@ -0,0 +1,209 @@
|
||||
// MQTT session persistence tests.
|
||||
// Go reference: golang/nats-server/server/mqtt.go:253-360
|
||||
// Session store — mqttInitSessionStore / mqttStoreSession / mqttLoadSession
|
||||
// Flapper detection — mqttCheckFlapper (~lines 300–360)
|
||||
|
||||
using NATS.Server.Mqtt;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests.Mqtt;
|
||||
|
||||
public class MqttSessionPersistenceTests
|
||||
{
|
||||
[Fact]
|
||||
public void SaveSession_ThenLoad_RoundTrips()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttStoreSession / mqttLoadSession
|
||||
var store = new MqttSessionStore();
|
||||
var session = new MqttSessionData
|
||||
{
|
||||
ClientId = "client-1",
|
||||
Subscriptions = new Dictionary<string, int> { ["sensors/temp"] = 1, ["alerts/#"] = 0 },
|
||||
PendingPublishes = [new MqttPendingPublish(42, "sensors/temp", "72.5")],
|
||||
WillTopic = "clients/offline",
|
||||
WillPayload = [0x01, 0x02],
|
||||
WillQoS = 1,
|
||||
WillRetain = true,
|
||||
CleanSession = false,
|
||||
ConnectedAtUtc = new DateTime(2026, 1, 15, 10, 30, 0, DateTimeKind.Utc),
|
||||
LastActivityUtc = new DateTime(2026, 1, 15, 10, 35, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
store.SaveSession(session);
|
||||
var loaded = store.LoadSession("client-1");
|
||||
|
||||
loaded.ShouldNotBeNull();
|
||||
loaded.ClientId.ShouldBe("client-1");
|
||||
loaded.Subscriptions.Count.ShouldBe(2);
|
||||
loaded.Subscriptions["sensors/temp"].ShouldBe(1);
|
||||
loaded.Subscriptions["alerts/#"].ShouldBe(0);
|
||||
loaded.PendingPublishes.Count.ShouldBe(1);
|
||||
loaded.PendingPublishes[0].PacketId.ShouldBe(42);
|
||||
loaded.PendingPublishes[0].Topic.ShouldBe("sensors/temp");
|
||||
loaded.PendingPublishes[0].Payload.ShouldBe("72.5");
|
||||
loaded.WillTopic.ShouldBe("clients/offline");
|
||||
loaded.WillPayload.ShouldBe(new byte[] { 0x01, 0x02 });
|
||||
loaded.WillQoS.ShouldBe(1);
|
||||
loaded.WillRetain.ShouldBeTrue();
|
||||
loaded.CleanSession.ShouldBeFalse();
|
||||
loaded.ConnectedAtUtc.ShouldBe(new DateTime(2026, 1, 15, 10, 30, 0, DateTimeKind.Utc));
|
||||
loaded.LastActivityUtc.ShouldBe(new DateTime(2026, 1, 15, 10, 35, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveSession_Update_OverwritesPrevious()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttStoreSession — overwrites existing
|
||||
var store = new MqttSessionStore();
|
||||
|
||||
store.SaveSession(new MqttSessionData
|
||||
{
|
||||
ClientId = "client-x",
|
||||
Subscriptions = new Dictionary<string, int> { ["old/topic"] = 0 },
|
||||
});
|
||||
|
||||
store.SaveSession(new MqttSessionData
|
||||
{
|
||||
ClientId = "client-x",
|
||||
Subscriptions = new Dictionary<string, int> { ["new/topic"] = 1 },
|
||||
});
|
||||
|
||||
var loaded = store.LoadSession("client-x");
|
||||
loaded.ShouldNotBeNull();
|
||||
loaded.Subscriptions.ShouldContainKey("new/topic");
|
||||
loaded.Subscriptions.ShouldNotContainKey("old/topic");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadSession_NonExistent_ReturnsNull()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttLoadSession — returns nil for missing
|
||||
var store = new MqttSessionStore();
|
||||
|
||||
var loaded = store.LoadSession("does-not-exist");
|
||||
|
||||
loaded.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteSession_RemovesFromStore()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttDeleteSession
|
||||
var store = new MqttSessionStore();
|
||||
store.SaveSession(new MqttSessionData { ClientId = "to-delete" });
|
||||
|
||||
store.DeleteSession("to-delete");
|
||||
|
||||
store.LoadSession("to-delete").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteSession_NonExistent_NoError()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttDeleteSession — no-op on missing
|
||||
var store = new MqttSessionStore();
|
||||
|
||||
// Should not throw
|
||||
store.DeleteSession("phantom");
|
||||
|
||||
store.LoadSession("phantom").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ListSessions_ReturnsAllActive()
|
||||
{
|
||||
// Go reference: server/mqtt.go session enumeration
|
||||
var store = new MqttSessionStore();
|
||||
store.SaveSession(new MqttSessionData { ClientId = "alpha" });
|
||||
store.SaveSession(new MqttSessionData { ClientId = "beta" });
|
||||
store.SaveSession(new MqttSessionData { ClientId = "gamma" });
|
||||
|
||||
var sessions = store.ListSessions();
|
||||
|
||||
sessions.Count.ShouldBe(3);
|
||||
sessions.Select(s => s.ClientId).ShouldBe(
|
||||
new[] { "alpha", "beta", "gamma" },
|
||||
ignoreOrder: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlapperDetection_ThreeConnectsInTenSeconds_BackoffApplied()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttCheckFlapper ~line 300
|
||||
// Three connects within the flap window triggers backoff.
|
||||
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 1, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
var store = new MqttSessionStore(
|
||||
flapWindow: TimeSpan.FromSeconds(10),
|
||||
flapThreshold: 3,
|
||||
flapBackoff: TimeSpan.FromSeconds(1),
|
||||
timeProvider: fakeTime);
|
||||
|
||||
// Three rapid connects
|
||||
store.TrackConnectDisconnect("flapper", connected: true);
|
||||
fakeTime.Advance(TimeSpan.FromSeconds(1));
|
||||
store.TrackConnectDisconnect("flapper", connected: true);
|
||||
fakeTime.Advance(TimeSpan.FromSeconds(1));
|
||||
store.TrackConnectDisconnect("flapper", connected: true);
|
||||
|
||||
var backoff = store.ShouldApplyBackoff("flapper");
|
||||
backoff.ShouldBeGreaterThan(TimeSpan.Zero);
|
||||
backoff.ShouldBe(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlapperDetection_SlowConnects_NoBackoff()
|
||||
{
|
||||
// Go reference: server/mqtt.go mqttCheckFlapper — slow connects should not trigger
|
||||
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 1, 15, 12, 0, 0, TimeSpan.Zero));
|
||||
var store = new MqttSessionStore(
|
||||
flapWindow: TimeSpan.FromSeconds(10),
|
||||
flapThreshold: 3,
|
||||
flapBackoff: TimeSpan.FromSeconds(1),
|
||||
timeProvider: fakeTime);
|
||||
|
||||
// Three connects, but spread out beyond the window
|
||||
store.TrackConnectDisconnect("slow-client", connected: true);
|
||||
fakeTime.Advance(TimeSpan.FromSeconds(5));
|
||||
store.TrackConnectDisconnect("slow-client", connected: true);
|
||||
fakeTime.Advance(TimeSpan.FromSeconds(6)); // first connect now outside window
|
||||
store.TrackConnectDisconnect("slow-client", connected: true);
|
||||
|
||||
var backoff = store.ShouldApplyBackoff("slow-client");
|
||||
backoff.ShouldBe(TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CleanSession_DeletesOnConnect()
|
||||
{
|
||||
// Go reference: server/mqtt.go — clean session flag clears stored state
|
||||
var store = new MqttSessionStore();
|
||||
|
||||
// Pre-populate a session
|
||||
store.SaveSession(new MqttSessionData
|
||||
{
|
||||
ClientId = "ephemeral",
|
||||
Subscriptions = new Dictionary<string, int> { ["topic/a"] = 1 },
|
||||
CleanSession = false,
|
||||
});
|
||||
|
||||
store.LoadSession("ephemeral").ShouldNotBeNull();
|
||||
|
||||
// Simulate clean session connect: delete the old session
|
||||
store.DeleteSession("ephemeral");
|
||||
|
||||
store.LoadSession("ephemeral").ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fake <see cref="TimeProvider"/> for deterministic time control in tests.
|
||||
/// </summary>
|
||||
internal sealed class FakeTimeProvider(DateTimeOffset startTime) : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _current = startTime;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _current;
|
||||
|
||||
public void Advance(TimeSpan duration) => _current += duration;
|
||||
|
||||
public void SetUtcNow(DateTimeOffset value) => _current = value;
|
||||
}
|
||||
89
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttSessionRuntimeTests.cs
Normal file
89
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttSessionRuntimeTests.cs
Normal file
@@ -0,0 +1,89 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using NATS.Server.Mqtt;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests.Mqtt;
|
||||
|
||||
public class MqttSessionRuntimeTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Qos1_publish_receives_puback_and_redelivery_on_session_reconnect_when_unacked()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using (var first = new TcpClient())
|
||||
{
|
||||
await first.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var firstStream = first.GetStream();
|
||||
await MqttRuntimeWire.WriteLineAsync(firstStream, "CONNECT session-client clean=false");
|
||||
(await MqttRuntimeWire.ReadLineAsync(firstStream, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
await MqttRuntimeWire.WriteLineAsync(firstStream, "PUBQ1 21 sensors.temp 99");
|
||||
(await MqttRuntimeWire.ReadLineAsync(firstStream, 1000)).ShouldBe("PUBACK 21");
|
||||
}
|
||||
|
||||
using var second = new TcpClient();
|
||||
await second.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var secondStream = second.GetStream();
|
||||
await MqttRuntimeWire.WriteLineAsync(secondStream, "CONNECT session-client clean=false");
|
||||
(await MqttRuntimeWire.ReadLineAsync(secondStream, 1000)).ShouldBe("CONNACK");
|
||||
(await MqttRuntimeWire.ReadLineAsync(secondStream, 1000)).ShouldBe("REDLIVER 21 sensors.temp 99");
|
||||
}
|
||||
}
|
||||
|
||||
internal static class MqttRuntimeWire
|
||||
{
|
||||
public static async Task WriteLineAsync(NetworkStream stream, string line)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(line + "\n");
|
||||
await stream.WriteAsync(bytes);
|
||||
await stream.FlushAsync();
|
||||
}
|
||||
|
||||
public static async Task<string?> ReadLineAsync(NetworkStream stream, int timeoutMs)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(timeoutMs);
|
||||
var bytes = new List<byte>();
|
||||
var one = new byte[1];
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var read = await stream.ReadAsync(one.AsMemory(0, 1), timeout.Token);
|
||||
if (read == 0)
|
||||
return null;
|
||||
if (one[0] == (byte)'\n')
|
||||
break;
|
||||
if (one[0] != (byte)'\r')
|
||||
bytes.Add(one[0]);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString([.. bytes]);
|
||||
}
|
||||
|
||||
public static async Task<string?> ReadRawAsync(NetworkStream stream, int timeoutMs)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(timeoutMs);
|
||||
var one = new byte[1];
|
||||
try
|
||||
{
|
||||
var read = await stream.ReadAsync(one.AsMemory(0, 1), timeout.Token);
|
||||
if (read == 0)
|
||||
return null;
|
||||
|
||||
return Encoding.UTF8.GetString(one, 0, read);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return "__timeout__";
|
||||
}
|
||||
}
|
||||
}
|
||||
384
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttTopicMappingParityTests.cs
Normal file
384
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttTopicMappingParityTests.cs
Normal file
@@ -0,0 +1,384 @@
|
||||
// Ports MQTT topic/subject conversion behavior from Go reference:
|
||||
// golang/nats-server/server/mqtt_test.go — TestMQTTTopicAndSubjectConversion,
|
||||
// TestMQTTFilterConversion, TestMQTTTopicWithDot, TestMQTTSubjectWildcardStart
|
||||
// golang/nats-server/server/mqtt.go — mqttTopicToNATSPubSubject, mqttFilterToNATSSubject,
|
||||
// natsSubjectToMQTTTopic, mqttToNATSSubjectConversion
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests.Mqtt;
|
||||
|
||||
/// <summary>
|
||||
/// Tests MQTT topic to NATS subject conversion and vice versa, porting the
|
||||
/// Go TestMQTTTopicAndSubjectConversion and TestMQTTFilterConversion tests.
|
||||
/// These are pure-logic conversion tests -- no server needed.
|
||||
/// </summary>
|
||||
public class MqttTopicMappingParityTests
|
||||
{
|
||||
// -------------------------------------------------------------------------
|
||||
// Helper: MQTT topic -> NATS subject conversion
|
||||
// Mirrors Go: mqttTopicToNATSPubSubject / mqttToNATSSubjectConversion(mt, false)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static string MqttTopicToNatsSubject(string mqttTopic)
|
||||
{
|
||||
var mt = mqttTopic.AsSpan();
|
||||
var res = new List<char>(mt.Length + 10);
|
||||
|
||||
var end = mt.Length - 1;
|
||||
for (var i = 0; i < mt.Length; i++)
|
||||
{
|
||||
switch (mt[i])
|
||||
{
|
||||
case '/':
|
||||
if (i == 0 || (res.Count > 0 && res[^1] == '.'))
|
||||
{
|
||||
res.Add('/');
|
||||
res.Add('.');
|
||||
}
|
||||
else if (i == end || mt[i + 1] == '/')
|
||||
{
|
||||
res.Add('.');
|
||||
res.Add('/');
|
||||
}
|
||||
else
|
||||
{
|
||||
res.Add('.');
|
||||
}
|
||||
|
||||
break;
|
||||
case ' ':
|
||||
throw new FormatException("spaces not supported in MQTT topic");
|
||||
case '.':
|
||||
res.Add('/');
|
||||
res.Add('/');
|
||||
break;
|
||||
case '+':
|
||||
case '#':
|
||||
throw new FormatException("wildcards not allowed in publish topic");
|
||||
default:
|
||||
res.Add(mt[i]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (res.Count > 0 && res[^1] == '.')
|
||||
{
|
||||
res.Add('/');
|
||||
}
|
||||
|
||||
return new string(res.ToArray());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helper: MQTT filter -> NATS subject conversion (wildcards allowed)
|
||||
// Mirrors Go: mqttFilterToNATSSubject / mqttToNATSSubjectConversion(filter, true)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static string MqttFilterToNatsSubject(string mqttFilter)
|
||||
{
|
||||
var mt = mqttFilter.AsSpan();
|
||||
var res = new List<char>(mt.Length + 10);
|
||||
|
||||
var end = mt.Length - 1;
|
||||
for (var i = 0; i < mt.Length; i++)
|
||||
{
|
||||
switch (mt[i])
|
||||
{
|
||||
case '/':
|
||||
if (i == 0 || (res.Count > 0 && res[^1] == '.'))
|
||||
{
|
||||
res.Add('/');
|
||||
res.Add('.');
|
||||
}
|
||||
else if (i == end || mt[i + 1] == '/')
|
||||
{
|
||||
res.Add('.');
|
||||
res.Add('/');
|
||||
}
|
||||
else
|
||||
{
|
||||
res.Add('.');
|
||||
}
|
||||
|
||||
break;
|
||||
case ' ':
|
||||
throw new FormatException("spaces not supported in MQTT topic");
|
||||
case '.':
|
||||
res.Add('/');
|
||||
res.Add('/');
|
||||
break;
|
||||
case '+':
|
||||
res.Add('*');
|
||||
break;
|
||||
case '#':
|
||||
res.Add('>');
|
||||
break;
|
||||
default:
|
||||
res.Add(mt[i]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (res.Count > 0 && res[^1] == '.')
|
||||
{
|
||||
res.Add('/');
|
||||
}
|
||||
|
||||
return new string(res.ToArray());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helper: NATS subject -> MQTT topic conversion
|
||||
// Mirrors Go: natsSubjectToMQTTTopic
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static string NatsSubjectToMqttTopic(string natsSubject)
|
||||
{
|
||||
var subject = natsSubject.AsSpan();
|
||||
var topic = new char[subject.Length];
|
||||
var end = subject.Length - 1;
|
||||
var j = 0;
|
||||
for (var i = 0; i < subject.Length; i++)
|
||||
{
|
||||
switch (subject[i])
|
||||
{
|
||||
case '/':
|
||||
if (i < end)
|
||||
{
|
||||
var c = subject[i + 1];
|
||||
if (c == '.' || c == '/')
|
||||
{
|
||||
topic[j] = c == '.' ? '/' : '.';
|
||||
j++;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case '.':
|
||||
topic[j] = '/';
|
||||
j++;
|
||||
break;
|
||||
default:
|
||||
topic[j] = subject[i];
|
||||
j++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new string(topic, 0, j);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Go: TestMQTTTopicAndSubjectConversion server/mqtt_test.go:1779
|
||||
// =========================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData("/", "/./")]
|
||||
[InlineData("//", "/././")]
|
||||
[InlineData("///", "/./././")]
|
||||
[InlineData("////", "/././././")]
|
||||
[InlineData("foo", "foo")]
|
||||
[InlineData("/foo", "/.foo")]
|
||||
[InlineData("//foo", "/./.foo")]
|
||||
[InlineData("///foo", "/././.foo")]
|
||||
[InlineData("///foo/", "/././.foo./")]
|
||||
[InlineData("///foo//", "/././.foo././")]
|
||||
[InlineData("///foo///", "/././.foo./././")]
|
||||
[InlineData("//.foo.//", "/././/foo//././")]
|
||||
[InlineData("foo/bar", "foo.bar")]
|
||||
[InlineData("/foo/bar", "/.foo.bar")]
|
||||
[InlineData("/foo/bar/", "/.foo.bar./")]
|
||||
[InlineData("foo/bar/baz", "foo.bar.baz")]
|
||||
[InlineData("/foo/bar/baz", "/.foo.bar.baz")]
|
||||
[InlineData("/foo/bar/baz/", "/.foo.bar.baz./")]
|
||||
[InlineData("bar/", "bar./")]
|
||||
[InlineData("bar//", "bar././")]
|
||||
[InlineData("bar///", "bar./././")]
|
||||
[InlineData("foo//bar", "foo./.bar")]
|
||||
[InlineData("foo///bar", "foo././.bar")]
|
||||
[InlineData("foo////bar", "foo./././.bar")]
|
||||
[InlineData(".", "//")]
|
||||
[InlineData("..", "////")]
|
||||
[InlineData("...", "//////")]
|
||||
[InlineData("./", "//./")]
|
||||
[InlineData(".//.", "//././/")]
|
||||
[InlineData("././.", "//.//.//")]
|
||||
[InlineData("././/.", "//.//././/")]
|
||||
[InlineData(".foo", "//foo")]
|
||||
[InlineData("foo.", "foo//")]
|
||||
[InlineData(".foo.", "//foo//")]
|
||||
[InlineData("foo../bar/", "foo////.bar./")]
|
||||
[InlineData("foo../bar/.", "foo////.bar.//")]
|
||||
[InlineData("/foo/", "/.foo./")]
|
||||
[InlineData("./foo/.", "//.foo.//")]
|
||||
[InlineData("foo.bar/baz", "foo//bar.baz")]
|
||||
public void Topic_to_nats_subject_converts_correctly(string mqttTopic, string expectedNatsSubject)
|
||||
{
|
||||
// Go: mqttTopicToNATSPubSubject server/mqtt_test.go:1779
|
||||
var natsSubject = MqttTopicToNatsSubject(mqttTopic);
|
||||
natsSubject.ShouldBe(expectedNatsSubject);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/", "/./")]
|
||||
[InlineData("//", "/././")]
|
||||
[InlineData("foo", "foo")]
|
||||
[InlineData("foo/bar", "foo.bar")]
|
||||
[InlineData("/foo/bar", "/.foo.bar")]
|
||||
[InlineData(".", "//")]
|
||||
[InlineData(".foo", "//foo")]
|
||||
[InlineData("foo.", "foo//")]
|
||||
[InlineData("foo.bar/baz", "foo//bar.baz")]
|
||||
[InlineData("foo//bar", "foo./.bar")]
|
||||
[InlineData("/foo/", "/.foo./")]
|
||||
public void Topic_round_trips_through_nats_subject_and_back(string mqttTopic, string natsSubject)
|
||||
{
|
||||
// Go: TestMQTTTopicAndSubjectConversion verifies round-trip server/mqtt_test.go:1843
|
||||
var converted = MqttTopicToNatsSubject(mqttTopic);
|
||||
converted.ShouldBe(natsSubject);
|
||||
|
||||
var backToMqtt = NatsSubjectToMqttTopic(converted);
|
||||
backToMqtt.ShouldBe(mqttTopic);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("foo/+", "wildcards not allowed")]
|
||||
[InlineData("foo/#", "wildcards not allowed")]
|
||||
[InlineData("foo bar", "not supported")]
|
||||
public void Topic_to_nats_subject_rejects_invalid_topics(string mqttTopic, string expectedErrorSubstring)
|
||||
{
|
||||
// Go: TestMQTTTopicAndSubjectConversion error cases server/mqtt_test.go:1826
|
||||
var ex = Should.Throw<FormatException>(() => MqttTopicToNatsSubject(mqttTopic));
|
||||
ex.Message.ShouldContain(expectedErrorSubstring, Case.Insensitive);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Go: TestMQTTFilterConversion server/mqtt_test.go:1852
|
||||
// =========================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData("+", "*")]
|
||||
[InlineData("/+", "/.*")]
|
||||
[InlineData("+/", "*./")]
|
||||
[InlineData("/+/", "/.*./")]
|
||||
[InlineData("foo/+", "foo.*")]
|
||||
[InlineData("foo/+/", "foo.*./")]
|
||||
[InlineData("foo/+/bar", "foo.*.bar")]
|
||||
[InlineData("foo/+/+", "foo.*.*")]
|
||||
[InlineData("foo/+/+/", "foo.*.*./")]
|
||||
[InlineData("foo/+/+/bar", "foo.*.*.bar")]
|
||||
[InlineData("foo//+", "foo./.*")]
|
||||
[InlineData("foo//+/", "foo./.*./")]
|
||||
[InlineData("foo//+//", "foo./.*././")]
|
||||
[InlineData("foo//+//bar", "foo./.*./.bar")]
|
||||
[InlineData("foo///+///bar", "foo././.*././.bar")]
|
||||
[InlineData("foo.bar///+///baz", "foo//bar././.*././.baz")]
|
||||
public void Filter_single_level_wildcard_converts_plus_to_star(string mqttFilter, string expectedNatsSubject)
|
||||
{
|
||||
// Go: TestMQTTFilterConversion single level wildcard server/mqtt_test.go:1860
|
||||
var natsSubject = MqttFilterToNatsSubject(mqttFilter);
|
||||
natsSubject.ShouldBe(expectedNatsSubject);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("#", ">")]
|
||||
[InlineData("/#", "/.>")]
|
||||
[InlineData("/foo/#", "/.foo.>")]
|
||||
[InlineData("foo/#", "foo.>")]
|
||||
[InlineData("foo//#", "foo./.>")]
|
||||
[InlineData("foo///#", "foo././.>")]
|
||||
[InlineData("foo/bar/#", "foo.bar.>")]
|
||||
[InlineData("foo/bar.baz/#", "foo.bar//baz.>")]
|
||||
public void Filter_multi_level_wildcard_converts_hash_to_greater_than(string mqttFilter, string expectedNatsSubject)
|
||||
{
|
||||
// Go: TestMQTTFilterConversion multi level wildcard server/mqtt_test.go:1877
|
||||
var natsSubject = MqttFilterToNatsSubject(mqttFilter);
|
||||
natsSubject.ShouldBe(expectedNatsSubject);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Go: TestMQTTTopicWithDot server/mqtt_test.go:7674
|
||||
// =========================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData("foo//bar", "foo.bar")]
|
||||
[InlineData("//foo", ".foo")]
|
||||
[InlineData("foo//", "foo.")]
|
||||
[InlineData("//", ".")]
|
||||
public void Nats_subject_with_slash_slash_converts_to_mqtt_dot(string natsSubject, string expectedMqttTopic)
|
||||
{
|
||||
// Go: natsSubjectToMQTTTopic converts '//' back to '.'
|
||||
var mqttTopic = NatsSubjectToMqttTopic(natsSubject);
|
||||
mqttTopic.ShouldBe(expectedMqttTopic);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nats_subject_dot_becomes_mqtt_topic_slash()
|
||||
{
|
||||
// Go: basic '.' -> '/' conversion
|
||||
var result = NatsSubjectToMqttTopic("foo.bar.baz");
|
||||
result.ShouldBe("foo/bar/baz");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Additional conversion edge cases
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void Empty_topic_converts_to_empty_subject()
|
||||
{
|
||||
var result = MqttTopicToNatsSubject(string.Empty);
|
||||
result.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Single_character_topic_converts_identity()
|
||||
{
|
||||
var result = MqttTopicToNatsSubject("a");
|
||||
result.ShouldBe("a");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Nats_subject_to_mqtt_topic_simple_passes_through()
|
||||
{
|
||||
var result = NatsSubjectToMqttTopic("foo");
|
||||
result.ShouldBe("foo");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Filter_conversion_preserves_mixed_wildcards()
|
||||
{
|
||||
var result = MqttFilterToNatsSubject("+/foo/#");
|
||||
result.ShouldBe("*.foo.>");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("+", "*")]
|
||||
[InlineData("+/foo", "*.foo")]
|
||||
[InlineData("+/+", "*.*")]
|
||||
[InlineData("#", ">")]
|
||||
public void Filter_starting_with_wildcard_converts_correctly(string mqttFilter, string expectedNatsSubject)
|
||||
{
|
||||
// Go: TestMQTTSubjectWildcardStart server/mqtt_test.go:7552
|
||||
var result = MqttFilterToNatsSubject(mqttFilter);
|
||||
result.ShouldBe(expectedNatsSubject);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Go: TestMQTTPublishTopicErrors server/mqtt_test.go:4084
|
||||
// =========================================================================
|
||||
|
||||
[Theory]
|
||||
[InlineData("foo/+")]
|
||||
[InlineData("foo/#")]
|
||||
public void Publish_topic_with_wildcards_throws(string mqttTopic)
|
||||
{
|
||||
Should.Throw<FormatException>(() => MqttTopicToNatsSubject(mqttTopic));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Publish_topic_with_space_throws()
|
||||
{
|
||||
Should.Throw<FormatException>(() => MqttTopicToNatsSubject("foo bar"));
|
||||
}
|
||||
}
|
||||
264
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttWillMessageParityTests.cs
Normal file
264
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttWillMessageParityTests.cs
Normal file
@@ -0,0 +1,264 @@
|
||||
// Ports will/last-will message behavior from Go reference:
|
||||
// golang/nats-server/server/mqtt_test.go — TestMQTTWill, TestMQTTWillRetain,
|
||||
// TestMQTTQoS2WillReject, TestMQTTWillRetainPermViolation
|
||||
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
using NATS.Server.Mqtt;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests.Mqtt;
|
||||
|
||||
public class MqttWillMessageParityTests
|
||||
{
|
||||
// Go ref: TestMQTTWill — will message delivery on abrupt disconnect
|
||||
// server/mqtt_test.go:4129
|
||||
[Fact]
|
||||
public async Task Subscriber_receives_message_on_abrupt_publisher_disconnect()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var sub = new TcpClient();
|
||||
await sub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var subStream = sub.GetStream();
|
||||
await MqttWillWire.WriteLineAsync(subStream, "CONNECT sub-will clean=true");
|
||||
(await MqttWillWire.ReadLineAsync(subStream, 1000)).ShouldBe("CONNACK");
|
||||
await MqttWillWire.WriteLineAsync(subStream, "SUB will.topic");
|
||||
(await MqttWillWire.ReadLineAsync(subStream, 1000))!.ShouldContain("SUBACK");
|
||||
|
||||
using var pub = new TcpClient();
|
||||
await pub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var pubStream = pub.GetStream();
|
||||
await MqttWillWire.WriteLineAsync(pubStream, "CONNECT pub-will clean=true");
|
||||
(await MqttWillWire.ReadLineAsync(pubStream, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
await MqttWillWire.WriteLineAsync(pubStream, "PUB will.topic bye");
|
||||
(await MqttWillWire.ReadLineAsync(subStream, 1000)).ShouldBe("MSG will.topic bye");
|
||||
}
|
||||
|
||||
// Go ref: TestMQTTWill — QoS 1 will message delivery
|
||||
// server/mqtt_test.go:4147
|
||||
[Fact]
|
||||
public async Task Qos1_will_message_is_delivered_to_subscriber()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var sub = new TcpClient();
|
||||
await sub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var subStream = sub.GetStream();
|
||||
await MqttWillWire.WriteLineAsync(subStream, "CONNECT sub-qos1-will clean=true");
|
||||
(await MqttWillWire.ReadLineAsync(subStream, 1000)).ShouldBe("CONNACK");
|
||||
await MqttWillWire.WriteLineAsync(subStream, "SUB will.qos1");
|
||||
(await MqttWillWire.ReadLineAsync(subStream, 1000))!.ShouldContain("SUBACK");
|
||||
|
||||
using var pub = new TcpClient();
|
||||
await pub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var pubStream = pub.GetStream();
|
||||
await MqttWillWire.WriteLineAsync(pubStream, "CONNECT pub-qos1-will clean=true");
|
||||
(await MqttWillWire.ReadLineAsync(pubStream, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
await MqttWillWire.WriteLineAsync(pubStream, "PUBQ1 1 will.qos1 bye-qos1");
|
||||
(await MqttWillWire.ReadLineAsync(pubStream, 1000)).ShouldBe("PUBACK 1");
|
||||
(await MqttWillWire.ReadLineAsync(subStream, 1000)).ShouldBe("MSG will.qos1 bye-qos1");
|
||||
}
|
||||
|
||||
// Go ref: TestMQTTWill — proper DISCONNECT should NOT trigger will message
|
||||
// server/mqtt_test.go:4150
|
||||
[Fact]
|
||||
public async Task Graceful_disconnect_does_not_deliver_extra_messages()
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var sub = new TcpClient();
|
||||
await sub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var subStream = sub.GetStream();
|
||||
await MqttWillWire.WriteLineAsync(subStream, "CONNECT sub-graceful clean=true");
|
||||
(await MqttWillWire.ReadLineAsync(subStream, 1000)).ShouldBe("CONNACK");
|
||||
await MqttWillWire.WriteLineAsync(subStream, "SUB graceful.topic");
|
||||
(await MqttWillWire.ReadLineAsync(subStream, 1000))!.ShouldContain("SUBACK");
|
||||
|
||||
using var pub = new TcpClient();
|
||||
await pub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var pubStream = pub.GetStream();
|
||||
await MqttWillWire.WriteLineAsync(pubStream, "CONNECT pub-graceful clean=true");
|
||||
(await MqttWillWire.ReadLineAsync(pubStream, 1000)).ShouldBe("CONNACK");
|
||||
await MqttWillWire.WriteLineAsync(pubStream, "PUB graceful.topic normal-message");
|
||||
(await MqttWillWire.ReadLineAsync(subStream, 1000)).ShouldBe("MSG graceful.topic normal-message");
|
||||
|
||||
pub.Dispose();
|
||||
|
||||
(await MqttWillWire.ReadLineAsync(subStream, 500)).ShouldBeNull();
|
||||
}
|
||||
|
||||
// Go ref: TestMQTTWill — will messages at various QoS levels
|
||||
// server/mqtt_test.go:4142-4149
|
||||
[Theory]
|
||||
[InlineData(0, "bye-qos0")]
|
||||
[InlineData(1, "bye-qos1")]
|
||||
public async Task Will_message_at_various_qos_levels_reaches_subscriber(int qos, string payload)
|
||||
{
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var sub = new TcpClient();
|
||||
await sub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var subStream = sub.GetStream();
|
||||
await MqttWillWire.WriteLineAsync(subStream, "CONNECT sub-qos-will clean=true");
|
||||
(await MqttWillWire.ReadLineAsync(subStream, 1000)).ShouldBe("CONNACK");
|
||||
await MqttWillWire.WriteLineAsync(subStream, "SUB will.multi");
|
||||
(await MqttWillWire.ReadLineAsync(subStream, 1000))!.ShouldContain("SUBACK");
|
||||
|
||||
using var pub = new TcpClient();
|
||||
await pub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var pubStream = pub.GetStream();
|
||||
await MqttWillWire.WriteLineAsync(pubStream, "CONNECT pub-qos-will clean=true");
|
||||
(await MqttWillWire.ReadLineAsync(pubStream, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
if (qos == 0)
|
||||
{
|
||||
await MqttWillWire.WriteLineAsync(pubStream, $"PUB will.multi {payload}");
|
||||
}
|
||||
else
|
||||
{
|
||||
await MqttWillWire.WriteLineAsync(pubStream, $"PUBQ1 1 will.multi {payload}");
|
||||
(await MqttWillWire.ReadLineAsync(pubStream, 1000)).ShouldBe("PUBACK 1");
|
||||
}
|
||||
|
||||
(await MqttWillWire.ReadLineAsync(subStream, 1000)).ShouldBe($"MSG will.multi {payload}");
|
||||
}
|
||||
|
||||
// Go ref: TestMQTTParseConnect will-related fields server/mqtt_test.go:1683
|
||||
[Fact]
|
||||
public void Connect_packet_with_will_flag_parses_will_topic_from_payload()
|
||||
{
|
||||
ReadOnlySpan<byte> bytes =
|
||||
[
|
||||
0x10, 0x13,
|
||||
0x00, 0x04, (byte)'M', (byte)'Q', (byte)'T', (byte)'T',
|
||||
0x04, 0x06, 0x00, 0x3C,
|
||||
0x00, 0x01, (byte)'c',
|
||||
0x00, 0x01, (byte)'w',
|
||||
0x00, 0x01, (byte)'m',
|
||||
];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
packet.Type.ShouldBe(MqttControlPacketType.Connect);
|
||||
var connectFlags = packet.Payload.Span[7];
|
||||
(connectFlags & 0x04).ShouldNotBe(0); // will flag bit
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Connect_packet_will_flag_and_retain_flag_in_connect_flags()
|
||||
{
|
||||
ReadOnlySpan<byte> bytes =
|
||||
[
|
||||
0x10, 0x13,
|
||||
0x00, 0x04, (byte)'M', (byte)'Q', (byte)'T', (byte)'T',
|
||||
0x04, 0x26, 0x00, 0x3C,
|
||||
0x00, 0x01, (byte)'c',
|
||||
0x00, 0x01, (byte)'w',
|
||||
0x00, 0x01, (byte)'m',
|
||||
];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
var connectFlags = packet.Payload.Span[7];
|
||||
(connectFlags & 0x04).ShouldNotBe(0); // will flag
|
||||
(connectFlags & 0x20).ShouldNotBe(0); // will retain flag
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Connect_packet_will_qos_bits_parsed_from_flags()
|
||||
{
|
||||
ReadOnlySpan<byte> bytes =
|
||||
[
|
||||
0x10, 0x13,
|
||||
0x00, 0x04, (byte)'M', (byte)'Q', (byte)'T', (byte)'T',
|
||||
0x04, 0x0E, 0x00, 0x3C,
|
||||
0x00, 0x01, (byte)'c',
|
||||
0x00, 0x01, (byte)'w',
|
||||
0x00, 0x01, (byte)'m',
|
||||
];
|
||||
|
||||
var packet = MqttPacketReader.Read(bytes);
|
||||
var connectFlags = packet.Payload.Span[7];
|
||||
var willQos = (connectFlags >> 3) & 0x03;
|
||||
willQos.ShouldBe(1);
|
||||
}
|
||||
|
||||
// Go ref: TestMQTTWillRetain — will retained at various QoS combinations
|
||||
// server/mqtt_test.go:4217
|
||||
[Theory]
|
||||
[InlineData(0, 0)]
|
||||
[InlineData(0, 1)]
|
||||
[InlineData(1, 0)]
|
||||
[InlineData(1, 1)]
|
||||
public async Task Will_message_delivered_at_various_pub_sub_qos_combinations(int pubQos, int subQos)
|
||||
{
|
||||
_ = pubQos;
|
||||
_ = subQos;
|
||||
|
||||
await using var listener = new MqttListener("127.0.0.1", 0);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await listener.StartAsync(cts.Token);
|
||||
|
||||
using var sub = new TcpClient();
|
||||
await sub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var subStream = sub.GetStream();
|
||||
await MqttWillWire.WriteLineAsync(subStream, "CONNECT sub-combo clean=true");
|
||||
(await MqttWillWire.ReadLineAsync(subStream, 1000)).ShouldBe("CONNACK");
|
||||
await MqttWillWire.WriteLineAsync(subStream, "SUB will.retain.topic");
|
||||
(await MqttWillWire.ReadLineAsync(subStream, 1000))!.ShouldContain("SUBACK");
|
||||
|
||||
using var pub = new TcpClient();
|
||||
await pub.ConnectAsync(IPAddress.Loopback, listener.Port);
|
||||
var pubStream = pub.GetStream();
|
||||
await MqttWillWire.WriteLineAsync(pubStream, "CONNECT pub-combo clean=true");
|
||||
(await MqttWillWire.ReadLineAsync(pubStream, 1000)).ShouldBe("CONNACK");
|
||||
|
||||
await MqttWillWire.WriteLineAsync(pubStream, "PUB will.retain.topic bye");
|
||||
(await MqttWillWire.ReadLineAsync(subStream, 1000)).ShouldBe("MSG will.retain.topic bye");
|
||||
}
|
||||
}
|
||||
|
||||
internal static class MqttWillWire
|
||||
{
|
||||
public static async Task WriteLineAsync(NetworkStream stream, string line)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(line + "\n");
|
||||
await stream.WriteAsync(bytes);
|
||||
await stream.FlushAsync();
|
||||
}
|
||||
|
||||
public static async Task<string?> ReadLineAsync(NetworkStream stream, int timeoutMs)
|
||||
{
|
||||
using var timeout = new CancellationTokenSource(timeoutMs);
|
||||
var bytes = new List<byte>();
|
||||
var one = new byte[1];
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var read = await stream.ReadAsync(one.AsMemory(0, 1), timeout.Token);
|
||||
if (read == 0)
|
||||
return null;
|
||||
if (one[0] == (byte)'\n')
|
||||
break;
|
||||
if (one[0] != (byte)'\r')
|
||||
bytes.Add(one[0]);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString([.. bytes]);
|
||||
}
|
||||
}
|
||||
202
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttWillMessageTests.cs
Normal file
202
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttWillMessageTests.cs
Normal file
@@ -0,0 +1,202 @@
|
||||
// Unit tests for MQTT will message delivery on abnormal disconnection.
|
||||
// Go reference: golang/nats-server/server/mqtt.go — mqttDeliverWill ~line 490,
|
||||
// TestMQTTWill server/mqtt_test.go:4129
|
||||
|
||||
using NATS.Server.Mqtt;
|
||||
using Shouldly;
|
||||
|
||||
namespace NATS.Server.Mqtt.Tests.Mqtt;
|
||||
|
||||
public class MqttWillMessageTests
|
||||
{
|
||||
// Go ref: mqtt.go mqttSession will field — will message is stored on CONNECT with will flag.
|
||||
[Fact]
|
||||
public void SetWill_stores_will_message()
|
||||
{
|
||||
var store = new MqttSessionStore();
|
||||
var will = new WillMessage { Topic = "client/status", Payload = "offline"u8.ToArray(), QoS = 0, Retain = false };
|
||||
|
||||
store.SetWill("client-1", will);
|
||||
|
||||
var stored = store.GetWill("client-1");
|
||||
stored.ShouldNotBeNull();
|
||||
stored.Topic.ShouldBe("client/status");
|
||||
stored.Payload.ShouldBe("offline"u8.ToArray());
|
||||
}
|
||||
|
||||
// Go ref: mqttDeliverWill — on graceful DISCONNECT, will is cleared (not delivered).
|
||||
[Fact]
|
||||
public void ClearWill_removes_will()
|
||||
{
|
||||
var store = new MqttSessionStore();
|
||||
var will = new WillMessage { Topic = "client/status", Payload = "offline"u8.ToArray() };
|
||||
|
||||
store.SetWill("client-2", will);
|
||||
store.ClearWill("client-2");
|
||||
|
||||
store.GetWill("client-2").ShouldBeNull();
|
||||
}
|
||||
|
||||
// Go ref: TestMQTTWill server/mqtt_test.go:4129 — will is published on abnormal disconnect.
|
||||
[Fact]
|
||||
public void PublishWillMessage_publishes_on_abnormal_disconnect()
|
||||
{
|
||||
var store = new MqttSessionStore();
|
||||
string? publishedTopic = null;
|
||||
byte[]? publishedPayload = null;
|
||||
byte publishedQoS = 0xFF;
|
||||
bool publishedRetain = false;
|
||||
|
||||
store.OnPublish = (topic, payload, qos, retain) =>
|
||||
{
|
||||
publishedTopic = topic;
|
||||
publishedPayload = payload;
|
||||
publishedQoS = qos;
|
||||
publishedRetain = retain;
|
||||
};
|
||||
|
||||
var will = new WillMessage { Topic = "device/gone", Payload = "disconnected"u8.ToArray(), QoS = 1, Retain = false };
|
||||
store.SetWill("client-3", will);
|
||||
|
||||
var result = store.PublishWillMessage("client-3");
|
||||
|
||||
result.ShouldBeTrue();
|
||||
publishedTopic.ShouldBe("device/gone");
|
||||
publishedPayload.ShouldBe("disconnected"u8.ToArray());
|
||||
publishedQoS.ShouldBe((byte)1);
|
||||
publishedRetain.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Go ref: mqttDeliverWill — no-op when no will is registered.
|
||||
[Fact]
|
||||
public void PublishWillMessage_returns_false_when_no_will()
|
||||
{
|
||||
var store = new MqttSessionStore();
|
||||
var invoked = false;
|
||||
store.OnPublish = (_, _, _, _) => { invoked = true; };
|
||||
|
||||
var result = store.PublishWillMessage("client-no-will");
|
||||
|
||||
result.ShouldBeFalse();
|
||||
invoked.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Go ref: mqttDeliverWill — will is consumed (not published twice).
|
||||
[Fact]
|
||||
public void PublishWillMessage_clears_will_after_publish()
|
||||
{
|
||||
var store = new MqttSessionStore();
|
||||
store.OnPublish = (_, _, _, _) => { };
|
||||
|
||||
var will = new WillMessage { Topic = "sensor/status", Payload = "gone"u8.ToArray() };
|
||||
store.SetWill("client-5", will);
|
||||
|
||||
store.PublishWillMessage("client-5");
|
||||
|
||||
store.GetWill("client-5").ShouldBeNull();
|
||||
store.PublishWillMessage("client-5").ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Go ref: TestMQTTWill — graceful DISCONNECT clears the will before disconnect;
|
||||
// subsequent PublishWillMessage has no effect.
|
||||
[Fact]
|
||||
public void CleanDisconnect_does_not_publish_will()
|
||||
{
|
||||
var store = new MqttSessionStore();
|
||||
var invoked = false;
|
||||
store.OnPublish = (_, _, _, _) => { invoked = true; };
|
||||
|
||||
var will = new WillMessage { Topic = "client/status", Payload = "bye"u8.ToArray() };
|
||||
store.SetWill("client-6", will);
|
||||
|
||||
// Simulate graceful DISCONNECT: clear will before triggering publish path
|
||||
store.ClearWill("client-6");
|
||||
var result = store.PublishWillMessage("client-6");
|
||||
|
||||
result.ShouldBeFalse();
|
||||
invoked.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// Go ref: TestMQTTWill — published topic and payload must exactly match what was registered.
|
||||
[Fact]
|
||||
public void WillMessage_preserves_topic_and_payload()
|
||||
{
|
||||
var store = new MqttSessionStore();
|
||||
var capturedTopic = string.Empty;
|
||||
var capturedPayload = Array.Empty<byte>();
|
||||
store.OnPublish = (topic, payload, _, _) =>
|
||||
{
|
||||
capturedTopic = topic;
|
||||
capturedPayload = payload;
|
||||
};
|
||||
|
||||
var originalPayload = "sensor-offline-payload"u8.ToArray();
|
||||
store.SetWill("client-7", new WillMessage { Topic = "sensors/temperature/offline", Payload = originalPayload });
|
||||
store.PublishWillMessage("client-7");
|
||||
|
||||
capturedTopic.ShouldBe("sensors/temperature/offline");
|
||||
capturedPayload.ShouldBe(originalPayload);
|
||||
}
|
||||
|
||||
// Go ref: TestMQTTWill — QoS level from the will is forwarded to the broker publish path.
|
||||
[Fact]
|
||||
public void WillMessage_preserves_qos()
|
||||
{
|
||||
var store = new MqttSessionStore();
|
||||
byte capturedQoS = 0xFF;
|
||||
store.OnPublish = (_, _, qos, _) => { capturedQoS = qos; };
|
||||
|
||||
store.SetWill("client-8", new WillMessage { Topic = "t", Payload = [], QoS = 1 });
|
||||
store.PublishWillMessage("client-8");
|
||||
|
||||
capturedQoS.ShouldBe((byte)1);
|
||||
}
|
||||
|
||||
// Go ref: TestMQTTWillRetain — retain flag from the will is forwarded to the broker publish path.
|
||||
[Fact]
|
||||
public void WillMessage_preserves_retain_flag()
|
||||
{
|
||||
var store = new MqttSessionStore();
|
||||
bool capturedRetain = false;
|
||||
store.OnPublish = (_, _, _, retain) => { capturedRetain = retain; };
|
||||
|
||||
store.SetWill("client-9", new WillMessage { Topic = "t", Payload = [], Retain = true });
|
||||
store.PublishWillMessage("client-9");
|
||||
|
||||
capturedRetain.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Go ref: MQTT 5.0 Will-Delay-Interval — a will with delay > 0 is not immediately published;
|
||||
// it is tracked as a delayed will and OnPublish is NOT called immediately.
|
||||
[Fact]
|
||||
public void PublishWillMessage_with_delay_stores_delayed_will_and_does_not_call_OnPublish()
|
||||
{
|
||||
var store = new MqttSessionStore();
|
||||
var immediatelyPublished = false;
|
||||
store.OnPublish = (_, _, _, _) => { immediatelyPublished = true; };
|
||||
|
||||
var will = new WillMessage
|
||||
{
|
||||
Topic = "device/status",
|
||||
Payload = "gone"u8.ToArray(),
|
||||
QoS = 0,
|
||||
Retain = false,
|
||||
DelayIntervalSeconds = 30
|
||||
};
|
||||
store.SetWill("client-10", will);
|
||||
|
||||
var result = store.PublishWillMessage("client-10");
|
||||
|
||||
// Returns true because a will was found
|
||||
result.ShouldBeTrue();
|
||||
|
||||
// OnPublish must NOT have been called — it is delayed
|
||||
immediatelyPublished.ShouldBeFalse();
|
||||
|
||||
// The will must be tracked as a pending delayed will
|
||||
var delayed = store.GetDelayedWill("client-10");
|
||||
delayed.ShouldNotBeNull();
|
||||
delayed!.Value.Will.Topic.ShouldBe("device/status");
|
||||
delayed.Value.Will.DelayIntervalSeconds.ShouldBe(30);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user