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:
154
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttTopicMapperTests.cs
Normal file
154
tests/NATS.Server.Mqtt.Tests/Mqtt/MqttTopicMapperTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user