Files
natsdotnet/tests/NATS.Server.Tests/Mqtt/MqttAdvancedParityTests.cs
Joseph Doherty 9554d53bf5 feat: Wave 6 batch 1 — monitoring, config reload, client protocol, MQTT, leaf node tests
Port 405 new test methods across 5 subsystems for Go parity:
- Monitoring: 102 tests (varz, connz, routez, subsz, stacksz)
- Leaf Nodes: 85 tests (connection, forwarding, loop detection, subject filter, JetStream)
- MQTT Bridge: 86 tests (advanced, auth, retained messages, topic mapping, will messages)
- Client Protocol: 73 tests (connection handling, protocol violations, limits)
- Config Reload: 59 tests (hot reload, option changes, permission updates)

Total: 1,678 tests passing, 0 failures, 3 skipped
2026-02-23 21:40:29 -05:00

965 lines
39 KiB
C#

// 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;
namespace NATS.Server.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();
// Server should not crash
await Task.Delay(100);
// Verify server is still operational
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__";
}
}
}