Files
natsdotnet/tests/NATS.Server.Tests/Mqtt/MqttPacketParsingParityTests.cs
Joseph Doherty 553483b6ba feat: phase D protocol surfaces test parity — 75 new tests across MQTT and JWT
MQTT packet parsing (41 tests), QoS/session delivery (8 tests),
and JWT claim edge cases (43 new tests). All 4 phases complete.
1081 total tests passing, 0 failures.
2026-02-23 20:06:54 -05:00

469 lines
18 KiB
C#

// 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<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);
}
}