6eace4bbba
A wildcard writeTopic was published verbatim. MQTT-3.3.2-2 forbids a wildcard in a PUBLISH topic and a conformant broker answers a protocol violation by closing the connection — which for this driver is the one connection every subscription lives on, so a single write to a mis-authored tag took the whole driver dark. The Sparkplug side had the same hole plus a worse one: the binding tuple IS the NCMD/DCMD topic, so a '/' in groupId/edgeNodeId/deviceId silently addressed a different edge node than the tag names. MqttWriteEncoder now refuses both as NoWriteDestination (-> BadNotWritable); the read path is untouched. metricName is exempt on purpose — it rides in the payload and Sparkplug's own canonical names use '/'. MqttTagDefinitionFactory.Inspect gained the matching ADVISORIES (never rejections — the mappers accept these blobs, and the gate must never be stricter than the runtime). Both rules were already hard errors in the AdminUI's typed editor, but that is one authoring path of several: the raw-JSON textarea, CSV import and the API all reach the deploy gate directly, so the authoring surface was stricter than the gate — the drift class the #549-#559 arc exists to close. Shared shape rules single-sourced in the new internal MqttAddressRules. Driver.Mqtt.Tests 724 (was 708); AdminUI.Tests 1466; ControlPlane.Tests 261.
304 lines
13 KiB
C#
304 lines
13 KiB
C#
using System.Text;
|
|
using Google.Protobuf;
|
|
using Org.Eclipse.Tahu.Protobuf;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
|
|
using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="MqttWriteEncoder"/> — the pure half of MQTT write-through (Gitea #508):
|
|
/// what a write encodes to, and which writes are refused before anything reaches a broker.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class MqttWriteEncoderTests
|
|
{
|
|
private static MqttTagDefinition Plain(
|
|
string? writeTopic,
|
|
MqttPayloadFormat format = MqttPayloadFormat.Scalar,
|
|
DriverDataType dataType = DriverDataType.Float64)
|
|
=> new(
|
|
Name: "Plant/Mqtt/dev1/Setpoint",
|
|
Topic: "factory/oven/temp",
|
|
PayloadFormat: format,
|
|
JsonPath: "$",
|
|
DataType: dataType,
|
|
Qos: null,
|
|
RetainSeed: true,
|
|
WriteTopic: writeTopic);
|
|
|
|
private static MqttTagDefinition Sparkplug(
|
|
string? deviceId,
|
|
DriverDataType dataType = DriverDataType.Float64,
|
|
string metricName = "Speed")
|
|
=> new(
|
|
Name: "Plant/Mqtt/dev1/Speed",
|
|
Topic: "",
|
|
PayloadFormat: MqttPayloadFormat.Json,
|
|
JsonPath: "$",
|
|
DataType: dataType,
|
|
Qos: null,
|
|
RetainSeed: true,
|
|
GroupId: "OtOpcUaSim",
|
|
EdgeNodeId: "EdgeA",
|
|
DeviceId: deviceId,
|
|
MetricName: metricName);
|
|
|
|
// ---- The refusal that is the whole point of the feature -------------------------------------
|
|
|
|
[Fact]
|
|
public void A_plain_tag_with_no_write_topic_is_refused_rather_than_published_nowhere()
|
|
{
|
|
// Before #508 there was no IWritable at all — honest read-only. The one outcome that would be
|
|
// WORSE is a node that accepts a write, reports Good and publishes nothing. So this must be a
|
|
// refusal, and specifically the "no destination" one, which the driver maps to BadNotWritable.
|
|
var error = MqttWriteEncoder.TryEncode(MqttMode.Plain, Plain(writeTopic: null), 42.5, out var encoded);
|
|
|
|
error.ShouldBe(MqttWriteEncodeError.NoWriteDestination);
|
|
encoded.Topic.ShouldBeNull();
|
|
encoded.Payload.ShouldBeNull();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public void A_blank_write_topic_is_treated_as_absent(string writeTopic)
|
|
=> MqttWriteEncoder.TryEncode(MqttMode.Plain, Plain(writeTopic), 1, out _)
|
|
.ShouldBe(MqttWriteEncodeError.NoWriteDestination);
|
|
|
|
[Fact]
|
|
public void A_sparkplug_tag_with_an_incomplete_tuple_has_no_command_address()
|
|
{
|
|
// A Sparkplug tag's command address IS its binding tuple, so an incomplete one is the same
|
|
// failure as a missing write topic, not a different kind.
|
|
var def = Sparkplug(deviceId: null) with { MetricName = null };
|
|
|
|
MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, def, 1.0, out _)
|
|
.ShouldBe(MqttWriteEncodeError.NoWriteDestination);
|
|
}
|
|
|
|
// ---- Plain encoding --------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void Plain_publishes_to_the_write_topic_not_the_read_topic()
|
|
{
|
|
// The two-field split is the point: publishing a command onto the tag's own read topic would
|
|
// echo back and read as a confirmed device value.
|
|
var def = Plain("factory/oven/temp/set");
|
|
|
|
MqttWriteEncoder.TryEncode(MqttMode.Plain, def, 42.5, out var encoded)
|
|
.ShouldBe(MqttWriteEncodeError.None);
|
|
|
|
encoded.Topic.ShouldBe("factory/oven/temp/set");
|
|
encoded.Topic.ShouldNotBe(def.Topic);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(MqttPayloadFormat.Scalar)]
|
|
[InlineData(MqttPayloadFormat.Raw)]
|
|
public void Scalar_and_raw_encode_the_value_as_utf8_text(MqttPayloadFormat format)
|
|
{
|
|
MqttWriteEncoder.TryEncode(MqttMode.Plain, Plain("t/set", format), 42.5, out var encoded)
|
|
.ShouldBe(MqttWriteEncodeError.None);
|
|
|
|
Encoding.UTF8.GetString(encoded.Payload).ShouldBe("42.5");
|
|
}
|
|
|
|
[Fact]
|
|
public void Numbers_are_encoded_with_the_invariant_culture()
|
|
{
|
|
// A comma decimal separator would publish a number the device cannot parse — and it would only
|
|
// reproduce on a host with a European locale, which is the worst kind of bug to chase.
|
|
var previous = Thread.CurrentThread.CurrentCulture;
|
|
Thread.CurrentThread.CurrentCulture = new System.Globalization.CultureInfo("de-DE");
|
|
try
|
|
{
|
|
MqttWriteEncoder.TryEncode(MqttMode.Plain, Plain("t/set"), 42.5, out var encoded)
|
|
.ShouldBe(MqttWriteEncodeError.None);
|
|
|
|
Encoding.UTF8.GetString(encoded.Payload).ShouldBe("42.5");
|
|
}
|
|
finally
|
|
{
|
|
Thread.CurrentThread.CurrentCulture = previous;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Json_encodes_a_bare_json_literal()
|
|
{
|
|
MqttWriteEncoder.TryEncode(
|
|
MqttMode.Plain, Plain("t/set", MqttPayloadFormat.Json), 42.5, out var encoded)
|
|
.ShouldBe(MqttWriteEncodeError.None);
|
|
|
|
Encoding.UTF8.GetString(encoded.Payload).ShouldBe("42.5");
|
|
}
|
|
|
|
[Fact]
|
|
public void Json_quotes_a_string_value()
|
|
=> Encoding.UTF8.GetString(
|
|
EncodeOrThrow(MqttMode.Plain, Plain("t/set", MqttPayloadFormat.Json, DriverDataType.String), "on"))
|
|
.ShouldBe("\"on\"");
|
|
|
|
[Fact]
|
|
public void A_null_value_is_refused()
|
|
// A zero-length MQTT payload is a legitimate clear-the-retained-message instruction — an
|
|
// entirely different operation. Encoding null as "no bytes" would conflate the two.
|
|
=> MqttWriteEncoder.TryEncode(MqttMode.Plain, Plain("t/set"), null, out _)
|
|
.ShouldBe(MqttWriteEncodeError.ValueNotRepresentable);
|
|
|
|
// ---- Sparkplug command encoding ---------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void A_device_metric_encodes_as_a_DCMD_addressed_to_that_device()
|
|
{
|
|
// Addressing a device metric with NCMD would command the NODE, which does not own it.
|
|
MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, Sparkplug("Filler1"), 12.5, out var encoded)
|
|
.ShouldBe(MqttWriteEncodeError.None);
|
|
|
|
encoded.Topic.ShouldBe("spBv1.0/OtOpcUaSim/DCMD/EdgeA/Filler1");
|
|
}
|
|
|
|
[Fact]
|
|
public void An_edge_node_metric_encodes_as_an_NCMD()
|
|
=> EncodeTopic(MqttMode.SparkplugB, Sparkplug(deviceId: null))
|
|
.ShouldBe("spBv1.0/OtOpcUaSim/NCMD/EdgeA");
|
|
|
|
[Fact]
|
|
public void The_command_carries_the_metric_by_name_with_an_explicit_datatype()
|
|
{
|
|
// By NAME, never by alias: an alias may be reused for a different metric across a rebirth.
|
|
// Datatype explicitly, because a node that treats it as ambiguous drops the command SILENTLY —
|
|
// the exact failure mode this feature must not have.
|
|
var bytes = EncodeOrThrow(MqttMode.SparkplugB, Sparkplug("Filler1"), 12.5);
|
|
var payload = Payload.Parser.ParseFrom(bytes);
|
|
|
|
payload.Metrics.Count.ShouldBe(1);
|
|
var metric = payload.Metrics[0];
|
|
metric.Name.ShouldBe("Speed");
|
|
metric.Alias.ShouldBe(0ul);
|
|
metric.Datatype.ShouldBe((uint)TahuDataType.Double);
|
|
metric.DoubleValue.ShouldBe(12.5);
|
|
metric.Timestamp.ShouldBeGreaterThan(0ul);
|
|
payload.Timestamp.ShouldBeGreaterThan(0ul);
|
|
}
|
|
|
|
[Fact]
|
|
public void The_command_carries_no_sequence_number()
|
|
{
|
|
// seq belongs to the messages an edge node itself publishes. A strict node validating a
|
|
// host-supplied one against its own counter would have grounds to reject the command.
|
|
var payload = Payload.Parser.ParseFrom(EncodeOrThrow(MqttMode.SparkplugB, Sparkplug("Filler1"), 1.0));
|
|
|
|
payload.HasSeq.ShouldBeFalse();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(DriverDataType.Boolean, true, (uint)TahuDataType.Boolean)]
|
|
[InlineData(DriverDataType.Int32, 7, (uint)TahuDataType.Int32)]
|
|
[InlineData(DriverDataType.Int64, 7L, (uint)TahuDataType.Int64)]
|
|
[InlineData(DriverDataType.UInt32, 7, (uint)TahuDataType.Uint32)]
|
|
[InlineData(DriverDataType.Float32, 1.5f, (uint)TahuDataType.Float)]
|
|
[InlineData(DriverDataType.String, "x", (uint)TahuDataType.String)]
|
|
public void The_declared_data_type_selects_the_wire_datatype(
|
|
DriverDataType dataType, object value, uint expectedWireType)
|
|
{
|
|
var payload = Payload.Parser.ParseFrom(
|
|
EncodeOrThrow(MqttMode.SparkplugB, Sparkplug("Filler1", dataType), value));
|
|
|
|
payload.Metrics[0].Datatype.ShouldBe(expectedWireType);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_unrepresentable_value_is_refused_rather_than_coerced()
|
|
// "not-a-number" into a Float64 tag must not silently become 0 on a live plant.
|
|
=> MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, Sparkplug("Filler1"), "not-a-number", out _)
|
|
.ShouldBe(MqttWriteEncodeError.ValueNotRepresentable);
|
|
|
|
[Fact]
|
|
public void A_type_with_no_command_encoding_is_refused_rather_than_stringified()
|
|
// Reference has no Sparkplug wire representation. Falling back to String would put a value on
|
|
// the wire that is not what the tag's type says it is.
|
|
=> MqttWriteEncoder.TryEncode(
|
|
MqttMode.SparkplugB, Sparkplug("Filler1", DriverDataType.Reference), "ref", out _)
|
|
.ShouldBe(MqttWriteEncodeError.UnsupportedDataType);
|
|
|
|
[Fact]
|
|
public void The_mode_decides_the_shape_not_the_blob()
|
|
{
|
|
// A tag can legitimately carry both shapes' keys (the editor preserves unknown keys across a
|
|
// retype), so sniffing would make one authored tag mean two different things.
|
|
var both = Sparkplug("Filler1") with { WriteTopic = "plain/set" };
|
|
|
|
EncodeTopic(MqttMode.Plain, both).ShouldBe("plain/set");
|
|
EncodeTopic(MqttMode.SparkplugB, both).ShouldBe("spBv1.0/OtOpcUaSim/DCMD/EdgeA/Filler1");
|
|
}
|
|
|
|
// ---- A destination that cannot be published to is not a destination -------------------------
|
|
|
|
[Theory]
|
|
[InlineData("factory/+/set")]
|
|
[InlineData("factory/oven/#")]
|
|
public void A_wildcard_write_topic_is_refused_rather_than_published(string writeTopic)
|
|
{
|
|
// MQTT-3.3.2-2: a PUBLISH topic MUST NOT contain a wildcard, and a conformant broker answers a
|
|
// protocol violation by CLOSING the connection — which, for this driver, is the same connection
|
|
// every subscription lives on. So one write to a mis-authored tag takes the WHOLE driver dark,
|
|
// not just that tag. The typed AdminUI editor already refuses this, but a raw-JSON / CSV /
|
|
// API-authored blob reaches the encoder untouched, so the refusal has to live here too.
|
|
MqttWriteEncoder.TryEncode(MqttMode.Plain, Plain(writeTopic), 1, out var encoded)
|
|
.ShouldBe(MqttWriteEncodeError.NoWriteDestination);
|
|
encoded.Topic.ShouldBeNull();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Ot/Sim", "EdgeA", null)]
|
|
[InlineData("OtSim", "Edge+A", null)]
|
|
[InlineData("OtSim", "EdgeA", "Fill#er")]
|
|
[InlineData("OtSim", "EdgeA", "Fill/er")]
|
|
public void A_binding_tuple_that_is_not_a_legal_topic_segment_has_no_command_address(
|
|
string groupId, string edgeNodeId, string? deviceId)
|
|
{
|
|
// The same protocol hazard on the Sparkplug side, plus one the Plain side does not have: the
|
|
// tuple IS the command topic, so a '/' silently re-shapes it into a DIFFERENT address (an
|
|
// NCMD for group "Ot" node "Sim" rather than the tag's own node) while '+'/'#' make it
|
|
// unpublishable. Refusing is the only honest verdict for all three.
|
|
var def = Sparkplug(deviceId) with { GroupId = groupId, EdgeNodeId = edgeNodeId };
|
|
|
|
MqttWriteEncoder.TryEncode(MqttMode.SparkplugB, def, 1.0, out _)
|
|
.ShouldBe(MqttWriteEncodeError.NoWriteDestination);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_metric_name_containing_a_slash_still_encodes()
|
|
{
|
|
// The falsification control for the rule above. Sparkplug's own canonical metric names use '/'
|
|
// (`Node Control/Rebirth`), and metricName rides in the PAYLOAD, never in the topic — so the
|
|
// segment rule must not reach it or every conformant node-control write would be refused.
|
|
MqttWriteEncoder.TryEncode(
|
|
MqttMode.SparkplugB,
|
|
Sparkplug(deviceId: null, DriverDataType.Boolean, metricName: "Node Control/Rebirth"),
|
|
true,
|
|
out var encoded)
|
|
.ShouldBe(MqttWriteEncodeError.None);
|
|
|
|
encoded.Topic.ShouldBe("spBv1.0/OtOpcUaSim/NCMD/EdgeA");
|
|
Payload.Parser.ParseFrom(encoded.Payload).Metrics[0].Name.ShouldBe("Node Control/Rebirth");
|
|
}
|
|
|
|
private static string EncodeTopic(MqttMode mode, MqttTagDefinition def)
|
|
{
|
|
MqttWriteEncoder.TryEncode(mode, def, 1.0, out var encoded).ShouldBe(MqttWriteEncodeError.None);
|
|
return encoded.Topic;
|
|
}
|
|
|
|
private static byte[] EncodeOrThrow(MqttMode mode, MqttTagDefinition def, object value)
|
|
{
|
|
MqttWriteEncoder.TryEncode(mode, def, value, out var encoded).ShouldBe(MqttWriteEncodeError.None);
|
|
return encoded.Payload;
|
|
}
|
|
}
|