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.
155 lines
5.3 KiB
C#
155 lines
5.3 KiB
C#
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();
|
|
}
|
|
}
|