feat(mqtt): implement PUBLISH QoS 0, SUBSCRIBE, and UNSUBSCRIBE handlers

Add ParsePub, ParseSubsOrUnsubs, ProcessPub (QoS 0), ProcessSubs,
ProcessUnsubs, EnqueueSubAck, and EnqueueUnsubAck to MqttPacketHandlers.
Wire PUB/SUB/UNSUB dispatch cases in MqttParser. Add ReadSlice to
MqttReader for raw payload extraction. 18 new unit tests covering
parsing, flags, error cases, QoS downgrade, and full flow. 1 new
integration test verifying SUBSCRIBE handshake over TCP.
This commit is contained in:
Joseph Doherty
2026-03-01 16:04:37 -05:00
parent 95cf20b00b
commit 715367b9ea
7 changed files with 947 additions and 21 deletions

View File

@@ -250,6 +250,104 @@ public sealed class ServerBootTests : IDisposable
return result.ToArray();
}
/// <summary>
/// End-to-end: CONNECT → SUBSCRIBE → verify SUBACK over the wire.
/// </summary>
[Fact]
public async Task MqttBoot_SubscribeHandshake_ShouldReceiveSubAck()
{
var opts = new ServerOptions
{
Host = "127.0.0.1",
Port = 0,
Mqtt = { Port = -1, Host = "127.0.0.1" },
};
var (server, err) = NatsServer.NewServer(opts);
err.ShouldBeNull();
server.ShouldNotBeNull();
try
{
server!.Start();
var mqttAddr = server.MqttAddr();
mqttAddr.ShouldNotBeNull();
using var tcp = new System.Net.Sockets.TcpClient();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await tcp.ConnectAsync(mqttAddr!.Address, mqttAddr.Port, cts.Token);
var stream = tcp.GetStream();
// 1. CONNECT → CONNACK
var connectPacket = BuildMqttConnectPacket("sub-test");
await stream.WriteAsync(connectPacket, cts.Token);
await stream.FlushAsync(cts.Token);
var connack = new byte[4];
await ReadExactAsync(stream, connack, cts.Token);
connack[0].ShouldBe((byte)0x20); // CONNACK
connack[3].ShouldBe((byte)0x00); // Accepted
// 2. SUBSCRIBE to "test/sub" QoS 0 → SUBACK
var subscribePacket = BuildMqttSubscribePacket(packetId: 1, topic: "test/sub", qos: 0);
await stream.WriteAsync(subscribePacket, cts.Token);
await stream.FlushAsync(cts.Token);
// SUBACK: [0x90] [0x03] [PI high] [PI low] [granted QoS]
var suback = new byte[5];
await ReadExactAsync(stream, suback, cts.Token);
suback[0].ShouldBe((byte)0x90); // SUBACK
suback[1].ShouldBe((byte)0x03); // remaining length = 3
suback[2].ShouldBe((byte)0x00); // PI high
suback[3].ShouldBe((byte)0x01); // PI low = 1
suback[4].ShouldBe((byte)0x00); // granted QoS 0
}
finally
{
server!.Shutdown();
}
}
/// <summary>Builds a minimal MQTT SUBSCRIBE packet.</summary>
private static byte[] BuildMqttSubscribePacket(ushort packetId, string topic, byte qos)
{
var topicBytes = System.Text.Encoding.UTF8.GetBytes(topic);
var payload = new List<byte>();
payload.Add((byte)(packetId >> 8));
payload.Add((byte)(packetId & 0xFF));
payload.Add((byte)(topicBytes.Length >> 8));
payload.Add((byte)(topicBytes.Length & 0xFF));
payload.AddRange(topicBytes);
payload.Add(qos);
var result = new List<byte>();
result.Add(0x82); // SUBSCRIBE + flags 0x02
var remLen = payload.Count;
do
{
var b = (byte)(remLen & 0x7F);
remLen >>= 7;
if (remLen > 0) b |= 0x80;
result.Add(b);
} while (remLen > 0);
result.AddRange(payload);
return result.ToArray();
}
/// <summary>Reads exactly <paramref name="buffer"/>.Length bytes from the stream.</summary>
private static async Task ReadExactAsync(System.Net.Sockets.NetworkStream stream, byte[] buffer, CancellationToken ct)
{
var totalRead = 0;
while (totalRead < buffer.Length)
{
var n = await stream.ReadAsync(buffer.AsMemory(totalRead, buffer.Length - totalRead), ct);
if (n == 0) break;
totalRead += n;
}
totalRead.ShouldBe(buffer.Length);
}
/// <summary>
/// Validates that Shutdown() after Start() completes cleanly.
/// Uses DontListen to skip TCP binding — tests lifecycle only.