621a9e5dc2
FromTagConfig and FromSparkplugTagConfig each carried a framing pre-check
(`tagConfig[0] != '{'`) evaluated on the UNTRIMMED string, so a TagConfig blob
with leading whitespace was refused by MQTT alone and the tag read
BadNodeIdUnknown forever — while the other six structural-parse drivers
(Modbus, S7, AbCip, AbLegacy, TwinCAT, FOCAS) hand the identical blob to
JsonDocument.Parse, which skips leading whitespace per the JSON spec.
The clause is dropped (the blank guard stays). JsonDocument.Parse plus the
root-ValueKind check now decide framing, exactly as for the six siblings: an
array/number/string root fails ValueKind, non-JSON is a JsonException. The gate
was a validator, not a discriminator — no caller (MqttSubscriptionManager,
SparkplugIngestor, MqttTagConfigModel, Inspect) used the first byte to pick a
format — so verdicts are identical for every input except whitespace-padded
JSON objects, which now get their content's verdict.
Inspect needs no logic change (it has trimmed since #556 and hands the RAW
reference to the mappers), but its comments claimed the padded-blob refusal was
production-reachable; rewritten. That shape was also MQTT's only vehicle for the
catch-all backstop, which is now unreachable-today like FOCAS's — so its test is
replaced by the same count-relation pin, and the backstop code stays as
defence-in-depth.
Tests: both mappers pinned by padded-vs-unpadded DEFINITION equality (a record,
so field-wise), padded non-object/malformed negative controls, an Inspect
verdict+inspection parity theory over the accepted AND rejected corpus with an
anti-vacuity control, and a padded MQTT corpus entry in the AdminUI
authoring/runtime parity suite.
272 lines
15 KiB
C#
272 lines
15 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");
|
|
}
|
|
|
|
/// <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");
|
|
}
|
|
}
|