feat(mqtt): add MQTT packet parser, dispatch, and ReadLoop integration

Implement Task 2 of MQTT orchestration:
- Create MqttParser.cs with loop-based packet parser and dispatch switch
- Add MqttReader field to MqttHandler for per-connection parsing state
- Wire ReadLoop to call MqttParser for MQTT connections
- Implement PINGREQ handler (enqueues PINGRESP)
- CONNECT-first enforcement (rejects non-CONNECT as first packet)
- Partial packet handling via MqttReader pending buffer
- 13 unit tests covering parser, dispatch, partial packets, and edge cases
- Stub dispatch entries for CONNECT, PUBLISH, SUB, UNSUB, DISCONNECT (NotImplementedException)
This commit is contained in:
Joseph Doherty
2026-03-01 15:41:45 -05:00
parent 6fb7f43335
commit 2e2ffee41a
5 changed files with 504 additions and 1 deletions

View File

@@ -0,0 +1,279 @@
// Copyright 2020-2026 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
using Shouldly;
using ZB.MOM.NatsNet.Server;
using ZB.MOM.NatsNet.Server.Internal;
using ZB.MOM.NatsNet.Server.Mqtt;
namespace ZB.MOM.NatsNet.Server.Tests.Mqtt;
/// <summary>
/// Unit tests for <see cref="MqttParser"/> — validates packet type extraction,
/// remaining length decoding, CONNECT-first enforcement, partial packet handling,
/// and PINGREQ dispatch.
/// </summary>
public sealed class MqttParserTests
{
/// <summary>
/// Creates a minimal ClientConnection with MQTT handler for testing.
/// </summary>
private static ClientConnection CreateMqttClient()
{
var c = new ClientConnection(ClientKind.Client);
c.InitMqtt(new MqttHandler());
return c;
}
// =========================================================================
// CONNECT-first enforcement
// =========================================================================
[Fact]
public void Parse_NonConnectFirst_ShouldReturnError()
{
var c = CreateMqttClient();
// PINGREQ before CONNECT → error
var buf = new byte[] { MqttPacket.Ping, 0x00 };
var err = MqttParser.Parse(c, buf, buf.Length);
err.ShouldNotBeNull();
err.Message.ShouldContain("first packet should be a CONNECT");
}
[Fact]
public void Parse_PublishBeforeConnect_ShouldReturnError()
{
var c = CreateMqttClient();
// PUBLISH QoS 0 before CONNECT
var buf = new byte[] { MqttPacket.Pub, 0x05, 0x00, 0x01, (byte)'t', 0x68, 0x69 };
var err = MqttParser.Parse(c, buf, buf.Length);
err.ShouldNotBeNull();
err.Message.ShouldContain("first packet should be a CONNECT");
}
[Fact]
public void Parse_ConnectFirst_ShouldNotRejectAsNonConnect()
{
var c = CreateMqttClient();
// CONNECT packet (minimal): type=0x10, remaining len=0
// This will hit the "not yet implemented" but NOT the "first packet" error.
var buf = new byte[] { MqttPacket.Connect, 0x00 };
var err = MqttParser.Parse(c, buf, buf.Length);
err.ShouldNotBeNull(); // Will be NotImplementedException
err.ShouldBeOfType<NotImplementedException>();
err.Message.ShouldContain("CONNECT not yet implemented");
}
[Fact]
public void Parse_SecondConnect_ShouldReturnError()
{
var c = CreateMqttClient();
// Simulate that CONNECT was already received.
c.Flags |= ClientFlags.ConnectReceived;
var buf = new byte[] { MqttPacket.Connect, 0x00 };
var err = MqttParser.Parse(c, buf, buf.Length);
err.ShouldNotBeNull();
err.Message.ShouldContain("second CONNECT");
}
// =========================================================================
// PINGREQ dispatch
// =========================================================================
[Fact]
public void Parse_PingReq_ShouldEnqueuePingResp()
{
var c = CreateMqttClient();
// Mark as connected so ping is accepted.
c.Flags |= ClientFlags.ConnectReceived;
// Use a memory stream to capture EnqueueProto output.
var ms = new MemoryStream();
// Set up the connection's network stream.
typeof(ClientConnection)
.GetField("_nc", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.SetValue(c, ms);
var buf = new byte[] { MqttPacket.Ping, 0x00 };
var err = MqttParser.Parse(c, buf, buf.Length);
err.ShouldBeNull();
// Verify PINGRESP was written.
var written = ms.ToArray();
written.Length.ShouldBe(2);
written[0].ShouldBe(MqttPacket.PingResp);
written[1].ShouldBe((byte)0x00);
}
[Fact]
public void Parse_MultiplePings_ShouldSucceed()
{
var c = CreateMqttClient();
c.Flags |= ClientFlags.ConnectReceived;
var ms = new MemoryStream();
typeof(ClientConnection)
.GetField("_nc", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.SetValue(c, ms);
// Two PINGREQ packets in the same buffer.
var buf = new byte[] { MqttPacket.Ping, 0x00, MqttPacket.Ping, 0x00 };
var err = MqttParser.Parse(c, buf, buf.Length);
err.ShouldBeNull();
// Two PINGRESP packets should have been written.
var written = ms.ToArray();
written.Length.ShouldBe(4);
written[0].ShouldBe(MqttPacket.PingResp);
written[1].ShouldBe((byte)0x00);
written[2].ShouldBe(MqttPacket.PingResp);
written[3].ShouldBe((byte)0x00);
}
// =========================================================================
// Remaining length decoding
// =========================================================================
[Fact]
public void Parse_SingleByteRemainingLength_ShouldWork()
{
// SUB packet with remaining length = 5 (single byte < 128)
// After CONNECT is received, a SUB packet should parse the remaining length correctly.
var c = CreateMqttClient();
c.Flags |= ClientFlags.ConnectReceived;
// SUBSCRIBE: type=0x82, remaining len=5, then 5 bytes of payload
var buf = new byte[] { 0x82, 0x05, 0x00, 0x01, 0x00, 0x01, 0x74 };
var err = MqttParser.Parse(c, buf, buf.Length);
// Will hit NotImplementedException for SUBSCRIBE — that's fine, it proves parsing worked.
err.ShouldNotBeNull();
err.ShouldBeOfType<NotImplementedException>();
}
[Fact]
public void Parse_TwoByteRemainingLength_ShouldWork()
{
var c = CreateMqttClient();
c.Flags |= ClientFlags.ConnectReceived;
// Remaining length = 200 → encoded as [0xC8, 0x01]
// (200 & 0x7F) | 0x80 = 0xC8, 200 >> 7 = 1 → 0x01
// type(1) + remlen(2) + payload(200) = 203 bytes total.
var buf = new byte[203];
buf[0] = MqttPacket.Pub;
buf[1] = 0xC8;
buf[2] = 0x01;
// Remaining 200 bytes are zero (payload).
var err = MqttParser.Parse(c, buf, buf.Length);
err.ShouldNotBeNull();
err.ShouldBeOfType<NotImplementedException>(); // PUBLISH not yet implemented
}
// =========================================================================
// Partial packet handling
// =========================================================================
[Fact]
public void Parse_PartialPacket_ShouldSaveState()
{
var c = CreateMqttClient();
c.Flags |= ClientFlags.ConnectReceived;
// Send only the first byte of a PING packet (missing the 0x00 remaining length).
var buf = new byte[] { MqttPacket.Ping };
var err = MqttParser.Parse(c, buf, buf.Length);
// Should succeed (partial packet saved) — no error because it just stops.
// Actually, the ReadByte for packet type succeeds, then ReadPacketLen has no data,
// so it returns (0, false) — incomplete, state saved.
err.ShouldBeNull();
// Now send the remaining length byte.
var buf2 = new byte[] { 0x00 };
// The reader's pending buffer should have the first byte saved.
var ms = new MemoryStream();
typeof(ClientConnection)
.GetField("_nc", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.SetValue(c, ms);
err = MqttParser.Parse(c, buf2, buf2.Length);
err.ShouldBeNull();
// PINGRESP should have been written.
var written = ms.ToArray();
written.Length.ShouldBe(2);
written[0].ShouldBe(MqttPacket.PingResp);
}
[Fact]
public void Parse_PartialPayload_ShouldSaveState()
{
var c = CreateMqttClient();
c.Flags |= ClientFlags.ConnectReceived;
// SUBSCRIBE packet: type=0x82, remaining length=10, but only send 5 payload bytes.
var buf = new byte[] { 0x82, 0x0A, 0x01, 0x02, 0x03, 0x04, 0x05 };
// remaining length = 10, but only 5 bytes of payload present → partial.
var err = MqttParser.Parse(c, buf, buf.Length);
// Should save state and return null (needs more data).
err.ShouldBeNull();
}
// =========================================================================
// Unknown packet type
// =========================================================================
[Fact]
public void Parse_UnknownPacketType_ShouldReturnError()
{
var c = CreateMqttClient();
c.Flags |= ClientFlags.ConnectReceived;
// Packet type 0x00 is reserved/invalid.
var buf = new byte[] { 0x00, 0x00 };
var err = MqttParser.Parse(c, buf, buf.Length);
err.ShouldNotBeNull();
err.Message.ShouldContain("unknown MQTT packet type");
}
// =========================================================================
// Empty buffer
// =========================================================================
[Fact]
public void Parse_EmptyBuffer_ShouldSucceed()
{
var c = CreateMqttClient();
var buf = Array.Empty<byte>();
var err = MqttParser.Parse(c, buf, 0);
err.ShouldBeNull(); // Nothing to parse.
}
// =========================================================================
// Buffer length parameter
// =========================================================================
[Fact]
public void Parse_LenSmallerThanBuffer_ShouldOnlyParseLenBytes()
{
var c = CreateMqttClient();
c.Flags |= ClientFlags.ConnectReceived;
var ms = new MemoryStream();
typeof(ClientConnection)
.GetField("_nc", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.SetValue(c, ms);
// Buffer has two PING packets, but len says only the first one.
var buf = new byte[] { MqttPacket.Ping, 0x00, MqttPacket.Ping, 0x00 };
var err = MqttParser.Parse(c, buf, 2); // Only first 2 bytes.
err.ShouldBeNull();
var written = ms.ToArray();
written.Length.ShouldBe(2); // Only one PINGRESP.
}
}