// 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.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 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 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 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 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 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 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 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 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 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 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 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 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 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(() => MqttPacketReader.Read(tooShort)); ex.Message.ShouldContain("shorter than fixed header"); } [Fact] public void Read_throws_on_empty_buffer() { byte[] empty = []; Should.Throw(() => 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(() => 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(() => MqttPacketReader.Read(malformed)); } [Fact] public void Remaining_length_encoder_throws_on_negative_value() { Should.Throw( () => MqttPacketWriter.EncodeRemainingLength(-1)); } [Fact] public void Remaining_length_encoder_throws_on_value_exceeding_maximum() { // Maximum MQTT remaining length is 268435455 (0x0FFFFFFF). Should.Throw( () => 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 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 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); } }