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.
320 lines
18 KiB
C#
320 lines
18 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
|
|
|
/// <summary>
|
|
/// Gitea #551: <see cref="MqttTagDefinitionFactory.Inspect"/> gained an accept gate — see
|
|
/// <c>ModbusInspectAcceptGateTests</c>'s remarks for why both directions (never stricter, never silent)
|
|
/// are pinned.
|
|
/// <para>
|
|
/// MQTT's gate is the <b>UNION</b> of its two mappers, because <c>Inspect</c> is handed a blob with
|
|
/// no mode: the driver's <c>MqttMode</c> decides whether
|
|
/// <see cref="MqttTagDefinitionFactory.FromTagConfig"/> or
|
|
/// <see cref="MqttTagDefinitionFactory.FromSparkplugTagConfig"/> reads it, and nothing on a tag row
|
|
/// says which. Gating on the Plain mapper alone would warn that every correctly-authored Sparkplug
|
|
/// tag has no topic — the falsification control below is exactly that case.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class MqttInspectAcceptGateTests
|
|
{
|
|
private const string RawPath = "cell/mqtt/dev1/T1";
|
|
|
|
private static bool EitherModeAccepts(string tagConfig)
|
|
=> MqttTagDefinitionFactory.FromTagConfig(tagConfig, RawPath, out _)
|
|
|| MqttTagDefinitionFactory.FromSparkplugTagConfig(tagConfig, RawPath, out _);
|
|
|
|
/// <summary>Blobs at least one mapper ACCEPTS, with no wildcard advisory — the gate must be silent.</summary>
|
|
private static readonly string[] AcceptedBlobs =
|
|
[
|
|
"""{"topic":"plant/line1/temp"}""",
|
|
"""{"topic":"plant/line1/temp","payloadFormat":"Json","dataType":"Float64","qos":1}""",
|
|
// Sparkplug: no topic at all, and that is CORRECT — the control this whole union exists for.
|
|
"""{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","metricName":"Temp"}""",
|
|
"""{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","deviceId":"Filler1","metricName":"Node Control/Rebirth"}""",
|
|
];
|
|
|
|
/// <summary>Blobs NEITHER mapper accepts — the gate must produce at least one warning for each.</summary>
|
|
private static readonly string[] RejectedBlobs =
|
|
[
|
|
"{}", // no address of either shape
|
|
"""{"payloadFormat":"Json","dataType":"String"}""", // still no address
|
|
"""{"groupId":"G","edgeNodeId":"E"}""", // incomplete Sparkplug tuple
|
|
"""{"topic":"a/b","payloadFormat":"Jsonn"}""", // typo'd payloadFormat
|
|
"""{"topic":"a/b","dataType":"Flooat64"}""", // typo'd dataType
|
|
"""{"topic":"a/b","qos":5}""", // out-of-range qos
|
|
"""{"groupId":"G","edgeNodeId":"E","metricName":"M","qos":"high"}""", // non-numeric qos, both modes
|
|
];
|
|
|
|
/// <summary>Blobs at least one mapper ACCEPTS, with no wildcard advisory — the gate must be silent.</summary>
|
|
/// <returns>The accepted fixtures.</returns>
|
|
public static TheoryData<string> Accepted() => [.. AcceptedBlobs];
|
|
|
|
/// <summary>Blobs NEITHER mapper accepts — the gate must produce at least one warning for each.</summary>
|
|
/// <returns>The rejected fixtures.</returns>
|
|
public static TheoryData<string> Rejected() => [.. RejectedBlobs];
|
|
|
|
[Theory]
|
|
[MemberData(nameof(Accepted))]
|
|
public void A_config_a_mapper_accepts_draws_no_warning(string tagConfig)
|
|
{
|
|
EitherModeAccepts(tagConfig).ShouldBeTrue("fixture must be accepted by at least one mode");
|
|
MqttTagDefinitionFactory.Inspect(tagConfig).ShouldBeEmpty();
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(Rejected))]
|
|
public void A_config_neither_mapper_accepts_is_never_passed_over_in_silence(string tagConfig)
|
|
{
|
|
EitherModeAccepts(tagConfig).ShouldBeFalse("fixture must be rejected by both modes");
|
|
MqttTagDefinitionFactory.Inspect(tagConfig).ShouldNotBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void A_blob_with_no_address_of_either_shape_names_both()
|
|
{
|
|
var warnings = MqttTagDefinitionFactory.Inspect("{}");
|
|
warnings.ShouldContain(w => w.Contains("topic") && w.Contains("metricName"));
|
|
}
|
|
|
|
/// <summary>The wildcard advisory is about an ACCEPTED blob, so the accept gate must not swallow it.</summary>
|
|
[Fact]
|
|
public void A_wildcard_topic_still_warns_even_though_the_mapper_accepts_it()
|
|
{
|
|
const string Blob = """{"topic":"plant/+/temp"}""";
|
|
MqttTagDefinitionFactory.FromTagConfig(Blob, RawPath, out _).ShouldBeTrue("a wildcard topic is accepted on purpose");
|
|
MqttTagDefinitionFactory.Inspect(Blob).ShouldContain(w => w.Contains("wildcard"));
|
|
}
|
|
|
|
[Fact]
|
|
public void A_blank_reference_draws_nothing()
|
|
=> MqttTagDefinitionFactory.Inspect(" ").ShouldBeEmpty();
|
|
|
|
[Fact]
|
|
public void Unparseable_json_names_the_json()
|
|
=> MqttTagDefinitionFactory.Inspect("{oops").ShouldContain(w => w.Contains("not valid JSON"));
|
|
|
|
// ---- Gitea #559: leading whitespace no longer changes ANY verdict (was #556 item 1) ----
|
|
|
|
/// <summary>Padding a blob with leading whitespace.</summary>
|
|
private const string Pad = " \t\r\n ";
|
|
|
|
/// <summary>Every fixture above — accepted and rejected alike — so the padding claim is checked on both.</summary>
|
|
/// <returns>The full fixture corpus.</returns>
|
|
public static TheoryData<string> AcceptedAndRejected() => [.. AcceptedBlobs, .. RejectedBlobs];
|
|
|
|
/// <summary>
|
|
/// Gitea #559. MQTT used to be the one factory of the seven whose RUNTIME mappers carried their own
|
|
/// <c>[0] != '{'</c> framing gate — run on the UNTRIMMED string — so a blob whose only sin was
|
|
/// leading whitespace was refused by this driver alone and read <c>BadNodeIdUnknown</c> forever,
|
|
/// while the identical blob was served by Modbus, S7, AbCip, AbLegacy, TwinCAT and FOCAS (whose
|
|
/// mappers hand it to a whitespace-tolerant <c>JsonDocument.Parse</c>). The mappers now do the same,
|
|
/// so the pad is inert everywhere: the verdict AND the inspection are whatever the unpadded blob's
|
|
/// content earns.
|
|
/// <para>
|
|
/// Asserting <b>parity with the unpadded fixture</b> rather than hardcoding "accepted" is the
|
|
/// stronger form — it holds for the rejected fixtures too, and it goes red if a future change
|
|
/// makes padding matter in either direction.
|
|
/// </para>
|
|
/// </summary>
|
|
/// <param name="tagConfig">A fixture, padded below.</param>
|
|
[Theory]
|
|
[MemberData(nameof(AcceptedAndRejected))]
|
|
public void Leading_whitespace_changes_neither_the_verdict_nor_the_inspection(string tagConfig)
|
|
{
|
|
var padded = Pad + tagConfig;
|
|
|
|
EitherModeAccepts(padded).ShouldBe(
|
|
EitherModeAccepts(tagConfig),
|
|
$"leading whitespace must not change the union verdict. Blob: {tagConfig}");
|
|
MqttTagDefinitionFactory.Inspect(padded).ShouldBe(
|
|
MqttTagDefinitionFactory.Inspect(tagConfig),
|
|
$"leading whitespace must not change the inspection. Blob: {tagConfig}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The anti-vacuity control for the theory above: the corpus it runs over must contain both verdicts,
|
|
/// or "padded == unpadded" could hold across a corpus that is uniformly refused (which is exactly what
|
|
/// the pre-#559 code did — every padded blob was refused, so a parity claim stated only over the
|
|
/// accepted fixtures would have been the thing that changed).
|
|
/// </summary>
|
|
[Fact]
|
|
public void The_padding_corpus_carries_both_verdicts()
|
|
{
|
|
string[] blobs = [.. AcceptedBlobs, .. RejectedBlobs];
|
|
blobs.ShouldContain(b => EitherModeAccepts(Pad + b), "no padded fixture is ACCEPTED");
|
|
blobs.ShouldContain(b => !EitherModeAccepts(Pad + b), "no padded fixture is REFUSED");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Trimming widens the gate ONLY for JSON objects — a reference still not object-framed after the
|
|
/// trim is not an equipment-tag TagConfig at all and keeps drawing nothing.
|
|
/// </summary>
|
|
/// <param name="reference">A padded, non-object reference.</param>
|
|
[Theory]
|
|
[InlineData(" just a string")]
|
|
[InlineData(" [1,2,3]")]
|
|
[InlineData(" \t\r\n 42")]
|
|
[InlineData(" \"a\"")]
|
|
[InlineData(" ")]
|
|
public void A_non_object_reference_still_draws_nothing_after_trimming(string reference)
|
|
{
|
|
EitherModeAccepts(reference).ShouldBeFalse();
|
|
MqttTagDefinitionFactory.Inspect(reference).ShouldBeEmpty();
|
|
}
|
|
|
|
// ---- Gitea #556 item 2: an advisory can no longer swallow the catch-all rejection ----
|
|
|
|
/// <summary>
|
|
/// The structural invariant the backstop exists to hold: a blob the readers REFUSE always draws
|
|
/// strictly more than the advisories that same blob would draw if it were accepted. Before #556 the
|
|
/// backstop was gated on <c>warnings.Count == 0</c> — and an advisory fires on an ACCEPTED blob too,
|
|
/// so it says nothing about the refusal while still satisfying that guard. Any refusal no
|
|
/// <c>DescribeRejection</c> branch can name was therefore dropped whenever an advisory was present.
|
|
/// Comparing the two counts states the property without string-matching a message to guess its class,
|
|
/// which is the whole point of keeping the two classes in separate lists.
|
|
/// <para>
|
|
/// ⚠️ <b>Gitea #559 made MQTT's own backstop UNREACHABLE, so this replaced a constructed
|
|
/// vehicle.</b> That vehicle was a leading-whitespace blob: refused by both mappers' own framing
|
|
/// gates while every describe branch — which walks the PARSED document — found it perfectly
|
|
/// well-formed. #559 deleted those gates (the refusal was the defect), and no other vehicle
|
|
/// exists: a union refusal carrying a <c>topic</c> requires a strict-field failure, which is
|
|
/// named; without a topic and without the full Sparkplug tuple the no-address branch fires, which
|
|
/// is also named. So the guard is protective here rather than a live fix — exactly the posture
|
|
/// <c>FocasInspectAcceptGateTests</c> pins for FOCAS, and pinned the same way. What this holds is
|
|
/// the property that must survive the next rejection cause someone adds without a describe branch.
|
|
/// </para>
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_rejected_config_always_draws_more_than_its_advisories_alone()
|
|
{
|
|
// Identical but for the out-of-range qos, so both carry the SAME wildcard advisory.
|
|
const string Accepted = """{"topic":"plant/+/temp","qos":2}""";
|
|
const string Rejected = """{"topic":"plant/+/temp","qos":5}""";
|
|
|
|
EitherModeAccepts(Accepted).ShouldBeTrue("control must be accepted");
|
|
EitherModeAccepts(Rejected).ShouldBeFalse("subject must be rejected");
|
|
|
|
var advisoriesOnly = MqttTagDefinitionFactory.Inspect(Accepted);
|
|
var withRejection = MqttTagDefinitionFactory.Inspect(Rejected);
|
|
|
|
advisoriesOnly.Count.ShouldBe(1, "the control's only warning is the wildcard advisory");
|
|
withRejection.Count.ShouldBeGreaterThan(
|
|
advisoriesOnly.Count,
|
|
"a refusal must add a rejection-class warning — the backstop supplies one when nothing names it");
|
|
}
|
|
|
|
// ---- Gitea #557 item 2: the payloadFormat mode-asymmetry contract ----
|
|
|
|
/// <summary>
|
|
/// The decided contract. An invalid <c>payloadFormat</c> is fatal to the Plain reader and invisible
|
|
/// to the Sparkplug one (which hard-codes <c>Json</c> and never consults the key), so the UNION
|
|
/// verdict both gates use ADMITS a Sparkplug-shaped blob carrying one. That is intended: in
|
|
/// Sparkplug mode the driver genuinely serves the tag, and a gate that rejected it would be
|
|
/// stricter than the runtime. The cost — the served behaviour depends on the driver's
|
|
/// <c>MqttMode</c>, which no tag row records — is paid by an ADVISORY, not by changing the verdict.
|
|
/// </summary>
|
|
[Fact]
|
|
public void An_invalid_payloadFormat_on_a_Sparkplug_blob_is_accepted_and_advised_not_rejected()
|
|
{
|
|
const string Blob =
|
|
"""{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","metricName":"Temp","payloadFormat":"Jsonn"}""";
|
|
|
|
MqttTagDefinitionFactory.FromSparkplugTagConfig(Blob, RawPath, out _)
|
|
.ShouldBeTrue("the Sparkplug reader never consults payloadFormat");
|
|
MqttTagDefinitionFactory.FromTagConfig(Blob, RawPath, out _)
|
|
.ShouldBeFalse("the Plain reader reads payloadFormat strictly (and this blob has no topic)");
|
|
|
|
var warnings = MqttTagDefinitionFactory.Inspect(Blob);
|
|
warnings.ShouldContain(w => w.Contains("payloadFormat") && w.Contains("Jsonn"));
|
|
warnings.ShouldContain(w => w.Contains("Plain mode"), "the advisory must name the mode dependency");
|
|
|
|
// The verdict itself is unchanged — this is an advisory, not a rejection. A rejection would name
|
|
// the tag as unresolvable, which is false for every Sparkplug deployment.
|
|
warnings.ShouldNotContain(w => w.Contains("rejected by the driver's tag reader"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The falsification control for the rule above: the SAME invalid <c>payloadFormat</c> on a blob
|
|
/// the union REFUSES is a rejection, not an advisory — so the advisory branch cannot be a blanket
|
|
/// "payloadFormat is always advisory", which would under-report a Plain tag that is dead in every
|
|
/// mode. The class a key lands in is decided by the accept gate, never by the key.
|
|
/// </summary>
|
|
[Fact]
|
|
public void The_same_invalid_payloadFormat_on_a_rejected_blob_is_a_rejection()
|
|
{
|
|
const string Blob = """{"topic":"plant/line1/temp","payloadFormat":"Jsonn"}""";
|
|
|
|
EitherModeAccepts(Blob).ShouldBeFalse("Plain refuses the enum, Sparkplug has no binding tuple");
|
|
|
|
var warnings = MqttTagDefinitionFactory.Inspect(Blob);
|
|
warnings.ShouldContain(w => w.Contains("payloadFormat") && w.Contains("Jsonn"));
|
|
warnings.ShouldNotContain(w => w.Contains("Plain mode"), "an unserved tag is not a mode-dependent case");
|
|
}
|
|
|
|
// ---- The two authoring-only rules the deploy gate used to pass over in silence ----
|
|
|
|
/// <summary>
|
|
/// <c>MqttTagConfigModel.ValidatePlain</c> refuses a wildcard <c>writeTopic</c> outright, but the
|
|
/// typed editor is only ONE authoring path — the raw-JSON textarea, CSV import and the API all
|
|
/// reach the deploy gate directly. A wildcard cannot be published to at all (MQTT-3.3.2-2), so the
|
|
/// tag reads perfectly while every write is refused; that has to be said somewhere the operator
|
|
/// sees it, and an ADVISORY is the right class because the blob IS accepted.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_wildcard_write_topic_is_advised_on_an_accepted_blob()
|
|
{
|
|
const string Blob = """{"topic":"plant/line1/temp","writeTopic":"plant/+/set"}""";
|
|
|
|
EitherModeAccepts(Blob).ShouldBeTrue("the tag still READS fine — only the write is unaddressable");
|
|
MqttTagDefinitionFactory.Inspect(Blob)
|
|
.ShouldContain(w => w.Contains("writeTopic") && w.Contains("wildcard"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A Sparkplug group / edge-node / device id is a single MQTT topic segment, so a decoded incoming
|
|
/// id can never contain <c>/</c>, <c>+</c> or <c>#</c> — an authored one that does is a binding
|
|
/// that can never resolve, and the mapper accepts it (it validates presence, not shape). The
|
|
/// AdminUI model calls this out in words and refuses the save; the deploy gate said nothing.
|
|
/// </summary>
|
|
/// <param name="key">The key the advisory must name.</param>
|
|
/// <param name="blob">The tag config carrying an unbindable id.</param>
|
|
[Theory]
|
|
[InlineData("groupId", """{"groupId":"Ot/Sim","edgeNodeId":"EdgeA","metricName":"T"}""")]
|
|
[InlineData("edgeNodeId", """{"groupId":"OtSim","edgeNodeId":"Edge+A","metricName":"T"}""")]
|
|
[InlineData("deviceId", """{"groupId":"OtSim","edgeNodeId":"EdgeA","deviceId":"F#1","metricName":"T"}""")]
|
|
public void A_sparkplug_id_that_is_not_a_topic_segment_is_advised(string key, string blob)
|
|
{
|
|
EitherModeAccepts(blob).ShouldBeTrue("the reader accepts it — which is exactly why it must be advised");
|
|
MqttTagDefinitionFactory.Inspect(blob).ShouldContain(w => w.Contains(key));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The falsification control for the rule above: <c>metricName</c> is NOT a topic segment — it
|
|
/// rides in the payload — and Sparkplug's own canonical names use <c>/</c>. Advising it would
|
|
/// report every conformant <c>Node Control/Rebirth</c> tag as broken.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_metric_name_containing_a_slash_is_never_advised()
|
|
=> MqttTagDefinitionFactory
|
|
.Inspect("""{"groupId":"OtSim","edgeNodeId":"EdgeA","metricName":"Node Control/Rebirth"}""")
|
|
.ShouldBeEmpty();
|
|
|
|
/// <summary>
|
|
/// Both gates must keep agreeing on the VERDICT (Gitea #557 item 2). The #554 authoring consult in
|
|
/// <c>MqttTagConfigModel</c> asks the identical union and only words a REFUSAL, so an advisory can
|
|
/// never leak into the modal as a blocking error. Re-stated here as the union itself, since the
|
|
/// AdminUI model is not referenced from this project.
|
|
/// </summary>
|
|
[Fact]
|
|
public void The_authoring_consults_union_is_the_same_union_the_deploy_gate_uses()
|
|
{
|
|
const string Blob =
|
|
"""{"groupId":"OtOpcUaSim","edgeNodeId":"EdgeA","metricName":"Temp","payloadFormat":"Jsonn"}""";
|
|
|
|
// Both gates read this verdict; the deploy gate's non-empty inspection above does not change it.
|
|
EitherModeAccepts(Blob).ShouldBeTrue();
|
|
MqttTagDefinitionFactory.Inspect(Blob).ShouldNotBeEmpty("advisories are allowed on an accepted blob");
|
|
}
|
|
}
|