merge: Mqtt Contracts+Browser code review — wildcard PUBLISH refusal (whole-driver outage path), Sparkplug segment shape rules, rebirth-scope wildcard gate; 12 findings
This commit is contained in:
@@ -854,6 +854,27 @@ public sealed class MqttBrowseSessionTests
|
||||
s.PublishCountForTest.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Plant1/#")]
|
||||
[InlineData("Plant1/+")]
|
||||
[InlineData("+/EdgeA")]
|
||||
[InlineData("#/EdgeA")]
|
||||
public async Task RequestRebirthAsync_WildcardScope_PublishesNothing(string scope)
|
||||
{
|
||||
// SparkplugRebirthScope (the AdminUI's typed input) rejects a wildcard and its own remarks say
|
||||
// it is "advisory, not the gate" because ResolveRebirthTargets "refuses an unusable one BEFORE
|
||||
// any message leaves". That claim has to be true here: an unobserved `{group}/{edgeNode}` scope
|
||||
// is honoured on purpose (a node that has not birthed is the prime rebirth target), and the
|
||||
// label check behind it looked only at length and control characters. A wildcard segment would
|
||||
// therefore have published `spBv1.0/Plant1/NCMD/#` — a PUBLISH the MQTT spec forbids, which a
|
||||
// conformant broker answers by closing the browse connection.
|
||||
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
|
||||
|
||||
await Should.ThrowAsync<ArgumentException>(
|
||||
async () => await s.RequestRebirthAsync(scope, TestContext.Current.CancellationToken));
|
||||
s.PublishCountForTest.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestRebirthAsync_InPlainMode_IsRefused()
|
||||
{
|
||||
|
||||
@@ -252,6 +252,54 @@ public sealed class MqttInspectAcceptGateTests
|
||||
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
|
||||
|
||||
@@ -237,6 +237,58 @@ public sealed class MqttWriteEncoderTests
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user