feat: implement full MQTT Go parity across 5 phases — binary protocol, auth/TLS, cross-protocol bridging, monitoring, and JetStream persistence

Phase 1: Binary MQTT 3.1.1 wire protocol with PipeReader-based parsing,
full packet type dispatch, and MQTT 3.1.1 compliance checks.

Phase 2: Auth pipeline routing MQTT CONNECT through AuthService,
TLS transport with SslStream wrapping, pinned cert validation.

Phase 3: IMessageRouter refactor (NatsClient → INatsClient),
MqttNatsClientAdapter for cross-protocol bridging, MqttTopicMapper
with full Go-parity topic/subject translation.

Phase 4: /connz mqtt_client field population, /varz actual MQTT port.

Phase 5: JetStream persistence — MqttStreamInitializer creates 5
internal streams, MqttConsumerManager for QoS 1/2 consumers,
subject-keyed session/retained lookups replacing linear scans.

All 503 MQTT tests and 1589 Core tests pass.
This commit is contained in:
Joseph Doherty
2026-03-13 10:09:40 -04:00
parent 0be321fa53
commit 845441b32c
34 changed files with 3194 additions and 126 deletions

View File

@@ -0,0 +1,154 @@
using NATS.Server.Mqtt;
namespace NATS.Server.Mqtt.Tests;
/// <summary>
/// Tests for MqttTopicMapper with full Go parity including dots in topics,
/// empty levels, '$' prefix protection, and leading/trailing slashes.
/// Go reference: mqtt.go mqttToNATSSubjectConversion ~line 2200.
/// </summary>
public class MqttTopicMapperTests
{
// -----------------------------------------------------------------------
// MqttToNats — basic mapping
// -----------------------------------------------------------------------
[Theory]
[InlineData("a/b/c", "a.b.c")]
[InlineData("sensor/temp", "sensor.temp")]
[InlineData("home/living/light", "home.living.light")]
public void MqttToNats_separator_mapping(string mqtt, string nats)
{
MqttTopicMapper.MqttToNats(mqtt).ShouldBe(nats);
}
[Theory]
[InlineData("+", "*")]
[InlineData("sensor/+", "sensor.*")]
[InlineData("+/temp", "*.temp")]
[InlineData("+/+/+", "*.*.*")]
public void MqttToNats_single_level_wildcard(string mqtt, string nats)
{
MqttTopicMapper.MqttToNats(mqtt).ShouldBe(nats);
}
[Theory]
[InlineData("#", ">")]
[InlineData("sensor/#", "sensor.>")]
[InlineData("home/+/#", "home.*.>")]
public void MqttToNats_multi_level_wildcard(string mqtt, string nats)
{
MqttTopicMapper.MqttToNats(mqtt).ShouldBe(nats);
}
[Fact]
public void MqttToNats_empty_string()
{
MqttTopicMapper.MqttToNats("").ShouldBe("");
}
// -----------------------------------------------------------------------
// MqttToNats — dot escaping (Go parity)
// -----------------------------------------------------------------------
[Theory]
[InlineData("a.b/c", "a_DOT_b.c")]
[InlineData("host.name/metric", "host_DOT_name.metric")]
[InlineData("a.b.c", "a_DOT_b_DOT_c")]
public void MqttToNats_dots_in_topic_are_escaped(string mqtt, string nats)
{
MqttTopicMapper.MqttToNats(mqtt).ShouldBe(nats);
}
// -----------------------------------------------------------------------
// MqttToNats — empty levels (leading/trailing/consecutive slashes)
// -----------------------------------------------------------------------
[Theory]
[InlineData("/a/b", ".a.b")]
[InlineData("a/b/", "a.b.")]
[InlineData("a//b", "a..b")]
[InlineData("//", "..")]
public void MqttToNats_empty_levels(string mqtt, string nats)
{
MqttTopicMapper.MqttToNats(mqtt).ShouldBe(nats);
}
// -----------------------------------------------------------------------
// NatsToMqtt — reverse mapping
// -----------------------------------------------------------------------
[Theory]
[InlineData("a.b.c", "a/b/c")]
[InlineData("sensor.temp", "sensor/temp")]
[InlineData("*", "+")]
[InlineData(">", "#")]
[InlineData("sensor.*", "sensor/+")]
[InlineData("sensor.>", "sensor/#")]
public void NatsToMqtt_basic_reverse(string nats, string mqtt)
{
MqttTopicMapper.NatsToMqtt(nats).ShouldBe(mqtt);
}
[Fact]
public void NatsToMqtt_empty_string()
{
MqttTopicMapper.NatsToMqtt("").ShouldBe("");
}
[Theory]
[InlineData("a_DOT_b.c", "a.b/c")]
[InlineData("host_DOT_name.metric", "host.name/metric")]
public void NatsToMqtt_dot_escape_reversed(string nats, string mqtt)
{
MqttTopicMapper.NatsToMqtt(nats).ShouldBe(mqtt);
}
// -----------------------------------------------------------------------
// Round-trip: MqttToNats → NatsToMqtt should be identity
// -----------------------------------------------------------------------
[Theory]
[InlineData("a/b/c")]
[InlineData("sensor/+/data")]
[InlineData("home/#")]
[InlineData("a.b/c.d")]
[InlineData("/leading")]
[InlineData("trailing/")]
[InlineData("a//b")]
public void RoundTrip_mqtt_to_nats_and_back(string mqtt)
{
var nats = MqttTopicMapper.MqttToNats(mqtt);
var roundTripped = MqttTopicMapper.NatsToMqtt(nats);
roundTripped.ShouldBe(mqtt);
}
// -----------------------------------------------------------------------
// Dollar topic protection (MQTT spec [MQTT-4.7.2-1])
// -----------------------------------------------------------------------
[Fact]
public void IsDollarTopic_detects_system_topics()
{
MqttTopicMapper.IsDollarTopic("$SYS/info").ShouldBeTrue();
MqttTopicMapper.IsDollarTopic("$share/group/topic").ShouldBeTrue();
MqttTopicMapper.IsDollarTopic("normal/topic").ShouldBeFalse();
MqttTopicMapper.IsDollarTopic("").ShouldBeFalse();
}
[Fact]
public void WildcardMatchesDollarTopic_enforces_spec()
{
// Wildcard filters should NOT match $ topics
MqttTopicMapper.WildcardMatchesDollarTopic("#", "$SYS/info").ShouldBeFalse();
MqttTopicMapper.WildcardMatchesDollarTopic("+/info", "$SYS/info").ShouldBeFalse();
// Explicit $ filters match $ topics
MqttTopicMapper.WildcardMatchesDollarTopic("$SYS/#", "$SYS/info").ShouldBeTrue();
MqttTopicMapper.WildcardMatchesDollarTopic("$SYS/+", "$SYS/info").ShouldBeTrue();
// Non-$ topics always matchable
MqttTopicMapper.WildcardMatchesDollarTopic("#", "normal/topic").ShouldBeTrue();
MqttTopicMapper.WildcardMatchesDollarTopic("+/topic", "normal/topic").ShouldBeTrue();
}
}