feat(mqtt): Sparkplug birth-driven browser tree + scoped Request-rebirth action

Unseals the Sparkplug branch of the MQTT address-picker browser that Task 10
deliberately sealed shut, and adds the first (and only) sanctioned publish on a
browse session.

- MqttBrowseSession serves Group -> EdgeNode -> [Device] -> Metric in Sparkplug
  mode, decoded from observed NBIRTH/DBIRTH certificates (the only Sparkplug
  messages that name their metrics). Node-level metrics hang directly under
  their edge node -- no synthesised device folder, because the authored address
  tuple genuinely has deviceId = null for them. Metric node ids use a '::'
  separator: a metric name may contain '/', so slash-joining would make a
  node-level metric indistinguishable from a device folder.
- AttributesAsync is purely birth-derived in Sparkplug mode. The plain path's
  UTF-8 inference / payload-snippet machinery does not run and no payload bytes
  are retained: a Sparkplug body is protobuf and would be reported as
  "(N bytes, binary)" for every metric in the plant. A datatype ToDriverDataType
  cannot map is reported as the Sparkplug type name, never coerced.
- RequestRebirthAsync(scope) is the one publishing member, reached only by an
  explicit operator action. It routes through the counted PublishAsync
  chokepoint via a private RebirthTransport adapter, so PublishCountForTest
  still proves that OpenAsync/RootAsync/ExpandAsync/AttributesAsync/Observe/
  DisposeAsync publish nothing -- now in both modes. A device or metric scope
  resolves up to its owning edge node (NCMD is node-addressed); group scope
  enumerates observed edge nodes and is refused whole past
  MaxGroupRebirthNodes = 32.
- BrowserSessionService.RequestRebirthAsync gates on the same DriverOperator
  policy that gates the picker, evaluated server-side where the action happens
  rather than only at render time, fails closed, and Info-logs the scope and the
  published count. Exposed through the new IRebirthCapableBrowseSession contract
  so "does this session write?" stays a type question.
- ToBrowseOptions' Last-Will landmine re-verified: MqttDriverOptions still
  carries nothing will-shaped (an NDEATH is a broker-published will and would be
  invisible to PublishCountForTest), now pinned by a reflection guard.

Falsifiability: injecting a publish into RootAsync reddens the Sparkplug and
plain passivity tests; double-publishing per target reddens all four
one-NCMD-per-node assertions. 572/572 MQTT tests, 24/24 AdminUI browsing tests.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 22:51:16 -04:00
parent 6d7a458c4d
commit 64ec7aa43a
7 changed files with 1263 additions and 95 deletions
@@ -35,3 +35,47 @@ public interface IBrowseSession : IAsyncDisposable
/// <returns>The attributes of the node.</returns>
Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken);
}
/// <summary>
/// An <see cref="IBrowseSession"/> whose protocol offers an explicit, operator-triggered
/// <b>re-announce</b> action: a request that the remote peer republish its self-description so the
/// observation window can fill without waiting for the next natural announcement. Implemented
/// today only by the MQTT/Sparkplug browser, whose tree is built from observed NBIRTH/DBIRTH
/// certificates.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is the only interface in the browse contract that causes an outbound message.</b>
/// Every other browse member is strictly read-only, and for the MQTT browser that read-only
/// property is load-bearing: a picker opened by an operator runs against a live production
/// broker. It is a separate interface, rather than an optional member on
/// <see cref="IBrowseSession"/>, precisely so "does this session write?" stays a type
/// question a caller cannot forget to ask.
/// </para>
/// <para>
/// <b>Callers must authorize before invoking it.</b> The AdminUI routes it through
/// <c>BrowserSessionService.RequestRebirthAsync</c>, which enforces the same
/// <c>DriverOperator</c> policy that gates the picker's Browse affordance — an implementation
/// cannot check that itself, since it holds no user identity.
/// </para>
/// </remarks>
public interface IRebirthCapableBrowseSession : IBrowseSession
{
/// <summary>
/// Asks the addressed remote peer(s) to re-announce themselves.
/// </summary>
/// <param name="scope">
/// The protocol-specific target. For Sparkplug: a browse <c>NodeId</c> from this session's own
/// tree (group, edge node, device or metric — resolved up to the owning edge node), or a bare
/// <c>{group}/{edgeNode}</c> pair for a node that has not been observed yet.
/// </param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The number of request messages published.</returns>
/// <exception cref="ArgumentException"><paramref name="scope"/> is empty or unusable.</exception>
/// <exception cref="InvalidOperationException">
/// The scope resolves to no target, or to more targets than the implementation will fan out to
/// in one action. Nothing is published in either case.
/// </exception>
/// <exception cref="NotSupportedException">This session's mode has no re-announce action.</exception>
Task<int> RequestRebirthAsync(string scope, CancellationToken cancellationToken);
}
@@ -1,10 +1,19 @@
using System.Collections.Concurrent;
using System.Globalization;
using System.Text;
using Google.Protobuf;
using Microsoft.Extensions.Logging;
using MQTTnet;
using Org.Eclipse.Tahu.Protobuf;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
// See SparkplugTopicTests.cs for why this alias is redeclared locally in every consuming project
// rather than inherited: `SparkplugDataType` is a `global using` alias for the generated
// `Org.Eclipse.Tahu.Protobuf.DataType` (declared in Contracts/SparkplugDataType.cs), and `global using`
// scope does not cross a ProjectReference.
using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser;
@@ -24,8 +33,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser;
/// by an operator runs against a <i>live production broker</i>; if browse published anything,
/// opening an address picker could inject messages into a running plant. Every outbound
/// message must route through the single <see cref="PublishAsync"/> seam, which counts its
/// calls into <see cref="PublishCountForTest"/> so the guarantee is assertable. In P1 nothing
/// calls it; P2's operator-triggered Sparkplug rebirth (Task 23) is the one intended exception.
/// calls into <see cref="PublishCountForTest"/> so the guarantee is assertable.
/// <see cref="RequestRebirthAsync"/> — an explicit operator action, never a browse step — is
/// the <b>one and only</b> caller of that seam. <see cref="RootAsync"/>,
/// <see cref="ExpandAsync"/>, <see cref="AttributesAsync"/>, <see cref="Observe"/> and
/// <see cref="DisposeAsync"/> must never reach it, in either mode.
/// </para>
/// <para>
/// <b>Sparkplug mode serves a different tree, from the same passive window.</b> Instead of raw
/// <c>spBv1.0/…</c> topic segments — which look like a metric tree but are not one — the
/// session decodes observed NBIRTH/DBIRTH certificates into
/// Group → EdgeNode → [Device] → Metric, because a birth is the only Sparkplug message that
/// is self-describing (a DATA metric carries an alias and no name at all). The same node cap,
/// the same identifier bounds and the same "publishes nothing" guarantee apply.
/// </para>
/// <para>
/// Observations arrive on MQTTnet's dispatcher thread while the picker calls
@@ -33,7 +53,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser;
/// every mutation is lock-free.
/// </para>
/// </summary>
internal sealed class MqttBrowseSession : IBrowseSession
internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
{
/// <summary>Default cap on recorded nodes; a busy broker's <c>#</c> firehose is unbounded.</summary>
internal const int DefaultNodeCap = 50_000;
@@ -69,6 +89,39 @@ internal sealed class MqttBrowseSession : IBrowseSession
/// <summary>Sentinel node id of the "oversized topics rejected" marker.</summary>
internal const string OversizedNodeId = "__oversized__";
/// <summary>
/// Separates a metric name from its owning group/edge-node/device path in a Sparkplug metric's
/// node id. A group/edge-node/device id is a single MQTT topic segment and therefore cannot
/// contain <c>/</c>, but a <b>metric name</b> routinely does (<c>Node Control/Rebirth</c>,
/// <c>Properties/Serial Number</c>) — so slash-joining would make a node-level metric
/// <c>Plant1/EdgeA/Temperature</c> indistinguishable from a device folder of the same shape,
/// and the picker would commit the wrong address. Nothing re-parses this: it exists so the ids
/// the tree hands out are unambiguous, while every rebirth target is read from the tuple stored
/// on the node itself.
/// </summary>
internal const string MetricSeparator = "::";
/// <summary>
/// Ceiling on a Sparkplug birth body this session will decode. The plain path bounds its work
/// at <see cref="PayloadInspectMaxBytes"/>, but a birth must be parsed <i>whole</i> to yield its
/// metric list, so the bound has to be on the message instead. Mirrors
/// <c>MqttDriverOptions.MaxPayloadBytes</c>' 1 MiB default: the protobuf parse runs on MQTTnet's
/// shared dispatcher thread, so an unbounded one is unbounded work per message, forever.
/// </summary>
internal const int MaxBirthPayloadBytes = 1024 * 1024;
/// <summary>
/// Cap on how many edge nodes one group-scoped <see cref="RequestRebirthAsync"/> will address.
/// A rebirth makes an edge node republish its entire metric set, so fanning one operator click
/// across a 200-node group is a self-inflicted broker storm. Past this the call is refused
/// <b>whole</b> — a partial fan-out would leave the operator unable to say which half of the
/// plant was asked to rebirth.
/// </summary>
internal const int MaxGroupRebirthNodes = 32;
/// <summary>Bound on the recorded published-topic trail (a test/diagnostic aid, not a log).</summary>
private const int PublishTrailMax = 64;
/// <summary>
/// Throw-on-invalid so genuinely binary payloads are reported as such rather than silently
/// rendered as a field of replacement characters. Cached: allocating an encoding per message
@@ -89,6 +142,17 @@ internal sealed class MqttBrowseSession : IBrowseSession
/// <summary>First-segment nodes, keyed by segment.</summary>
private readonly ConcurrentDictionary<string, TopicNode> _roots = new(StringComparer.Ordinal);
/// <summary>
/// The adapter that lets <see cref="RebirthRequester"/> reach this session's client
/// <i>through</i> <see cref="PublishAsync"/>. A private nested type rather than an interface on
/// the session itself, so <see cref="PublishAsync"/> stays private and the counted chokepoint
/// cannot be bypassed by a future caller holding the session.
/// </summary>
private readonly RebirthTransport _rebirthTransport;
/// <summary>Topics this session has published, newest last; bounded by <see cref="PublishTrailMax"/>.</summary>
private readonly ConcurrentQueue<string> _publishedTopics = new();
private int _disposed;
private long _lastUsedTicksUtc = DateTime.UtcNow.Ticks;
private int _nodeCount;
@@ -117,14 +181,19 @@ internal sealed class MqttBrowseSession : IBrowseSession
_client = client;
_logger = logger;
_nodeCap = nodeCap;
_rebirthTransport = new RebirthTransport(this);
}
/// <summary>
/// Number of messages this session has published. <b>Always 0 in P1</b> — the assertion that
/// browsing a live plant broker is strictly read-only.
/// Number of messages this session has published — the assertion that browsing a live plant
/// broker is strictly read-only. <b>Always 0 unless <see cref="RequestRebirthAsync"/> was
/// explicitly invoked</b>: no browse path in either mode reaches the publish seam.
/// </summary>
internal int PublishCountForTest => Volatile.Read(ref _publishCount);
/// <summary>The topics this session published, in order — the discriminating view of the above.</summary>
internal IReadOnlyList<string> PublishedTopicsForTest => [.. _publishedTopics];
/// <summary>True once the node cap stopped recording; surfaced as a marker node from <see cref="RootAsync"/>.</summary>
internal bool Truncated => Volatile.Read(ref _truncated) != 0;
@@ -168,7 +237,9 @@ internal sealed class MqttBrowseSession : IBrowseSession
{
nodes.Add(new BrowseNode(
OversizedNodeId,
"⚠ Some topics were too long to show — use manual entry for those",
Mode == MqttMode.SparkplugB
? "⚠ Some metrics were unusable (oversized or malformed names) — use manual entry for those"
: "⚠ Some topics were too long to show — use manual entry for those",
BrowseNodeKind.Folder,
HasChildrenHint: false));
}
@@ -196,27 +267,82 @@ internal sealed class MqttBrowseSession : IBrowseSession
/// <inheritdoc />
/// <remarks>
/// A topic has no attribute model — MQTT carries an opaque payload. The single synthetic
/// attribute returned here exists to help the operator confirm they picked the right topic:
/// <see cref="AttributeInfo.DriverDataType"/> carries the type <i>inferred</i> from the last
/// observed payload (the authored tag's own <c>dataType</c> remains the authority — inference
/// is the brittle fallback, per design §4), and <see cref="AttributeInfo.SecurityClass"/>
/// carries that payload's snippet. The <c>SecurityClass</c> slot is reused deliberately: it is
/// the picker's free-text descriptor line (rendered as
/// <c>{DriverDataType} · {SecurityClass}</c>) and MQTT has no security classes of its own.
/// Returns empty for a synthesised path segment nothing was ever published to.
/// <para>
/// <b>Plain mode.</b> A topic has no attribute model — MQTT carries an opaque payload. The
/// single synthetic attribute returned here exists to help the operator confirm they picked
/// the right topic: <see cref="AttributeInfo.DriverDataType"/> carries the type
/// <i>inferred</i> from the last observed payload (the authored tag's own <c>dataType</c>
/// remains the authority — inference is the brittle fallback, per design §4), and
/// <see cref="AttributeInfo.SecurityClass"/> carries that payload's snippet. The
/// <c>SecurityClass</c> slot is reused deliberately: it is the picker's free-text descriptor
/// line (rendered as <c>{DriverDataType} · {SecurityClass}</c>) and MQTT has no security
/// classes of its own. Returns empty for a synthesised path segment nothing was ever
/// published to.
/// </para>
/// <para>
/// <b>Sparkplug mode is purely birth-derived — no payload is ever sniffed.</b> A Sparkplug
/// metric <i>declares</i> its own type in the birth certificate, so there is nothing to
/// infer; and the plain path's UTF-8 inference / snippet machinery is not merely redundant
/// here but actively wrong, because a Sparkplug body is protobuf and would be reported as
/// <c>(N bytes, binary)</c> for every metric in the plant. This session therefore retains no
/// payload bytes at all in Sparkplug mode: the attribute carries the metric name, the
/// <see cref="DriverDataType"/> the birth declared (element type for an array variant, with
/// <see cref="AttributeInfo.IsArray"/> carrying the array bit), and a descriptor built from
/// the birth. A datatype this driver does not support in v1 is reported as such and
/// <b>never</b> coerced into a guessed <see cref="DriverDataType"/>.
/// </para>
/// </remarks>
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(Disposed, this);
IReadOnlyList<AttributeInfo> attrs =
_byPath.TryGetValue(nodeId ?? "", out var node) && node.Observed is { } observed
? [new AttributeInfo(node.Segment, observed.DataType.ToString(), IsArray: false, observed.Snippet)]
: [];
IReadOnlyList<AttributeInfo> attrs = [];
if (_byPath.TryGetValue(nodeId ?? "", out var node))
{
if (node.Metric is { } metric)
{
attrs = [DescribeMetric(metric)];
}
else if (node.Observed is { } observed)
{
attrs = [new AttributeInfo(node.Segment, observed.DataType.ToString(), IsArray: false, observed.Snippet)];
}
}
Touch();
return Task.FromResult(attrs);
}
/// <summary>
/// Projects one observed Sparkplug birth metric into the picker's attribute row.
/// </summary>
/// <param name="metric">The metric as its birth certificate declared it.</param>
/// <returns>The attribute row.</returns>
/// <remarks>
/// <see cref="SparkplugDataTypeExtensions.ToDriverDataType"/> returns <see langword="null"/> for
/// the five v1-unsupported datatypes (<c>Unknown</c>, <c>DataSet</c>, <c>Template</c>,
/// <c>PropertySet</c>, <c>PropertySetList</c>), and that null is passed through as the Sparkplug
/// type's own name rather than defaulted. None of those five names parses as a
/// <see cref="DriverDataType"/>, so the AdminUI's browse-commit mapper falls back to its
/// declared default instead of silently binding a metric this driver cannot serve — and the
/// descriptor line says so in words.
/// </remarks>
private static AttributeInfo DescribeMetric(SparkplugMetricInfo metric)
{
var mapped = metric.DataType?.ToDriverDataType();
var sparkplugName = metric.DataType?.ToString() ?? nameof(SparkplugDataType.Unknown);
var descriptor = new StringBuilder(metric.BirthType.ToString()).Append(" · ").Append(sparkplugName);
if (metric.Alias is { } alias) descriptor.Append(" · alias ").Append(alias);
if (mapped is null) descriptor.Append(" · unsupported in v1 — not bindable");
return new AttributeInfo(
metric.Name,
mapped?.ToString() ?? sparkplugName,
metric.DataType?.IsSparkplugArray() ?? false,
descriptor.ToString());
}
/// <summary>
/// Best-effort teardown. Safe on a session that was never opened (test seam, or an
/// <c>OpenAsync</c> that failed between constructing the client and connecting it) and
@@ -249,16 +375,35 @@ internal sealed class MqttBrowseSession : IBrowseSession
}
/// <summary>
/// Records one observed topic (and its payload) into the accumulating tree. Called from
/// MQTTnet's dispatcher thread, so it does no I/O, takes no lock, and never throws — a
/// throwing handler would stall the client's pump.
/// Records one observed message into the accumulating tree, in whichever shape
/// <see cref="Mode"/> selects. Called from MQTTnet's dispatcher thread, so it does no I/O,
/// takes no lock, and never throws — a throwing handler would stall the client's pump. It also
/// never publishes: this is an observation seam, and the browser's read-only guarantee depends
/// on nothing here reaching <see cref="PublishAsync"/>.
/// </summary>
/// <param name="topic">The topic the message arrived on.</param>
/// <param name="payload">The message body; used only for type inference and the display snippet.</param>
/// <param name="payload">The message body.</param>
internal void Observe(string topic, ReadOnlySpan<byte> payload)
{
if (Disposed || string.IsNullOrWhiteSpace(topic)) return;
if (Mode == MqttMode.SparkplugB)
{
ObserveSparkplugBirth(topic, payload);
return;
}
ObservePlainTopic(topic, payload);
}
/// <summary>
/// Plain mode: records the topic as a path in the segment tree, decorated with the payload's
/// inferred type and display snippet.
/// </summary>
/// <param name="topic">The topic the message arrived on.</param>
/// <param name="payload">The message body; used only for type inference and the display snippet.</param>
private void ObservePlainTopic(string topic, ReadOnlySpan<byte> payload)
{
if (topic.Length > MaxTopicChars)
{
Volatile.Write(ref _oversized, 1);
@@ -285,28 +430,170 @@ internal sealed class MqttBrowseSession : IBrowseSession
{
path = path.Length == 0 ? segment : path + "/" + segment;
if (!level.TryGetValue(segment, out var child))
node = GetOrCreate(level, path, segment, kind: null);
if (node is null) return; // node cap tripped; already-recorded topics keep serving
level = node.Children;
}
node?.Record(InferPayload(payload));
}
/// <summary>
/// Sparkplug mode: decodes an observed <b>birth certificate</b> into the
/// Group → EdgeNode → [Device] → Metric tree.
/// </summary>
/// <param name="topic">The topic the message arrived on.</param>
/// <param name="payload">The Sparkplug-B protobuf body.</param>
/// <remarks>
/// <para>
/// <b>Births only.</b> NBIRTH/DBIRTH are the only Sparkplug messages that name their
/// metrics; a DATA metric carries an alias and no name at all (that is the whole point of
/// the alias table), so a tree built from DATA would be a tree of unnamed nodes. NDEATH,
/// DDEATH, NCMD, DCMD and STATE carry no metric model worth browsing, and everything that
/// is not a Sparkplug topic at all is ignored outright rather than falling back to the
/// plain segment tree — a raw <c>spBv1.0/…</c> tree looks like a metric tree but is not one.
/// </para>
/// <para>
/// <b>Node-level metrics hang directly under their edge node</b>, not under a synthesised
/// "(node)" device folder. The authored address tuple is
/// <c>(group, edgeNode, device?, metric)</c> with <c>device</c> genuinely absent for these,
/// so an invented folder segment would be indistinguishable from a real device of that
/// name — and the picker would commit an address that does not exist. Devices render as
/// folders and metrics as leaves, so the two stay distinguishable side by side.
/// </para>
/// </remarks>
private void ObserveSparkplugBirth(string topic, ReadOnlySpan<byte> payload)
{
if (!SparkplugTopic.TryParse(topic, out var parsed)) return;
if (parsed.Type is not (SparkplugMessageType.NBIRTH or SparkplugMessageType.DBIRTH)) return;
// The bound has to be on the whole message: unlike a plain payload, a birth must be parsed
// entire to yield its metric list, and the parse runs on the shared dispatcher thread.
if (payload.Length > MaxBirthPayloadBytes)
{
Volatile.Write(ref _oversized, 1);
return;
}
// Never throws, for any input — see the SparkplugCodec type remarks.
if (!SparkplugCodec.TryDecode(payload, out var decoded)) return;
var groupId = parsed.GroupId!;
var edgeNodeId = parsed.EdgeNodeId!;
var deviceId = parsed.DeviceId;
if (!IsUsableLabel(groupId) || !IsUsableLabel(edgeNodeId) || (deviceId is not null && !IsUsableLabel(deviceId)))
{
Volatile.Write(ref _oversized, 1);
return;
}
var scope = new SparkplugScope(groupId, edgeNodeId);
var group = GetOrCreate(_roots, groupId, groupId, BrowseNodeKind.Folder, new SparkplugScope(groupId, null));
if (group is null) return;
var edgePath = groupId + "/" + edgeNodeId;
var edge = GetOrCreate(group.Children, edgePath, edgeNodeId, BrowseNodeKind.Folder, scope);
if (edge is null) return;
var owner = edge;
var ownerPath = edgePath;
if (deviceId is not null)
{
ownerPath = edgePath + "/" + deviceId;
owner = GetOrCreate(edge.Children, ownerPath, deviceId, BrowseNodeKind.Folder, scope);
if (owner is null) return;
}
foreach (var metric in decoded.Metrics)
{
// A birth is REQUIRED to name every metric it carries; one that does not is not something
// a picker can commit an address for.
var name = metric.Name;
if (name is null || !IsUsableLabel(name))
{
if (name is not null) Volatile.Write(ref _oversized, 1);
continue;
}
var metricPath = ownerPath + MetricSeparator + name;
if (metricPath.Length > MaxTopicChars)
{
Volatile.Write(ref _oversized, 1);
continue;
}
var node = GetOrCreate(owner.Children, metricPath, name, BrowseNodeKind.Leaf, scope);
if (node is null) return; // node cap tripped
node.RecordMetric(new SparkplugMetricInfo(name, metric.DataType, parsed.Type, metric.Alias));
}
}
/// <summary>
/// Finds or creates one tree node under <paramref name="level"/>, honouring the node cap.
/// </summary>
/// <param name="level">The child map to place the node in.</param>
/// <param name="path">The node's full path — its browse node id, and its <see cref="_byPath"/> key.</param>
/// <param name="segment">The node's own label.</param>
/// <param name="kind">
/// An explicit browse kind, or <see langword="null"/> to let <see cref="Project"/> infer it from
/// whether the node has children. Sparkplug sets it explicitly: a device whose birth carried no
/// metrics is childless but is still a folder, and must not masquerade as a committable metric.
/// </param>
/// <param name="scope">The Sparkplug scope to stamp on a newly created node, if any.</param>
/// <returns>The node, or <see langword="null"/> when the node cap stopped recording.</returns>
private TopicNode? GetOrCreate(
ConcurrentDictionary<string, TopicNode> level,
string path,
string segment,
BrowseNodeKind? kind,
SparkplugScope? scope = null)
{
// Keyed by full path, not by label. In plain mode the two are equivalent (a path is its
// parent's path plus its label), but in Sparkplug mode a device and an edge-node-level metric
// are siblings that can share a label — `Plant1/EdgeA/Temp` and `Plant1/EdgeA::Temp` are
// different nodes, and keying by label would silently drop whichever arrived second.
if (level.TryGetValue(path, out var existing)) return existing;
if (Volatile.Read(ref _nodeCount) >= _nodeCap)
{
Volatile.Write(ref _truncated, 1);
return; // stop recording; already-recorded topics keep serving
return null;
}
var created = new TopicNode(path, segment);
child = level.GetOrAdd(segment, created);
var created = new TopicNode(path, segment, kind, scope);
var child = level.GetOrAdd(path, created);
if (ReferenceEquals(child, created))
{
Interlocked.Increment(ref _nodeCount);
_byPath[path] = child;
}
return child;
}
node = child;
level = child.Children;
/// <summary>
/// Whether an identifier off the wire is usable as a tree label. Length-bounded like a topic
/// segment, and control-character-free: a metric name is arbitrary UTF-8 chosen by the
/// publisher, and an embedded newline or ANSI escape would break the picker's single-line rows.
/// Unlike the payload snippet — which is scrubbed — a name is <b>rejected</b> rather than
/// cleaned, because a scrubbed name is a different name and the picker would commit it as the
/// tag's binding key.
/// </summary>
/// <param name="label">The candidate label.</param>
/// <returns><see langword="true"/> when the label may be recorded.</returns>
private static bool IsUsableLabel(string label)
{
if (label.Length == 0 || label.Length > MaxSegmentChars) return false;
foreach (var c in label)
{
if (char.IsControl(c)) return false;
}
node?.Record(InferPayload(payload));
return true;
}
/// <summary>Test seam over <see cref="Observe"/> that takes the payload as text.</summary>
@@ -315,11 +602,189 @@ internal sealed class MqttBrowseSession : IBrowseSession
internal void ObserveTopicForTest(string topic, string? payload = null) =>
Observe(topic, payload is null ? ReadOnlySpan<byte>.Empty : Encoding.UTF8.GetBytes(payload));
/// <summary>Test seam over <see cref="Observe"/> that takes raw wire bytes.</summary>
/// <param name="topic">The observed topic.</param>
/// <param name="payload">The raw message body.</param>
internal void ObserveRawForTest(string topic, byte[] payload) => Observe(topic, payload);
/// <summary>
/// <b>The one and only outbound-message seam.</b> Nothing in P1 calls it — browse against a
/// live plant broker is strictly read-only, and <see cref="PublishCountForTest"/> is the
/// assertion of that. P2's Sparkplug <c>RequestRebirthAsync</c> (Task 23) is the single
/// explicitly-operator-triggered exception and <b>must route through here</b> rather than
/// Test seam that feeds one metric's birth certificate through the <b>real</b> observe path —
/// it encodes an actual Sparkplug-B payload and hands it to <see cref="Observe"/>, so the topic
/// parse, the protobuf decode and the tree build are all exercised rather than mocked around.
/// </summary>
/// <param name="groupId">The Sparkplug group id.</param>
/// <param name="edgeNodeId">The Sparkplug edge-node id.</param>
/// <param name="device">The device id, or <see langword="null"/> for an edge-node-level metric (NBIRTH).</param>
/// <param name="metricName">The metric name the birth declares.</param>
/// <param name="dataType">The datatype the birth declares.</param>
/// <param name="alias">The metric alias the birth declares, if any.</param>
internal void ObserveBirthForTest(
string groupId,
string edgeNodeId,
string? device,
string metricName,
SparkplugDataType dataType,
ulong? alias = null)
{
var metric = new Payload.Types.Metric { Name = metricName, Datatype = (uint)dataType };
if (alias is { } a) metric.Alias = a;
ObserveBirth(groupId, edgeNodeId, device, metric);
}
/// <summary>Test seam feeding a birth certificate that declares no metrics at all.</summary>
/// <param name="groupId">The Sparkplug group id.</param>
/// <param name="edgeNodeId">The Sparkplug edge-node id.</param>
/// <param name="device">The device id, or <see langword="null"/> for an NBIRTH.</param>
internal void ObserveEmptyBirthForTest(string groupId, string edgeNodeId, string? device) =>
ObserveBirth(groupId, edgeNodeId, device, metric: null);
/// <summary>Encodes and observes one birth certificate.</summary>
/// <param name="groupId">The Sparkplug group id.</param>
/// <param name="edgeNodeId">The Sparkplug edge-node id.</param>
/// <param name="device">The device id, or <see langword="null"/> for an NBIRTH.</param>
/// <param name="metric">The single metric the birth carries, or <see langword="null"/> for none.</param>
private void ObserveBirth(string groupId, string edgeNodeId, string? device, Payload.Types.Metric? metric)
{
var type = device is null ? SparkplugMessageType.NBIRTH : SparkplugMessageType.DBIRTH;
var topic = SparkplugTopic.Format(groupId, type, edgeNodeId, device);
var payload = new Payload { Seq = 0 };
if (metric is not null) payload.Metrics.Add(metric);
Observe(topic, payload.ToByteArray());
}
/// <inheritdoc />
/// <remarks>
/// <para>
/// <b>The one browse-session member that publishes, and it is never on a browse path.</b>
/// Only an explicit operator click reaches it — <see cref="OpenAsync"/>-equivalent setup,
/// <see cref="RootAsync"/>, <see cref="ExpandAsync"/>, <see cref="AttributesAsync"/>,
/// <see cref="Observe"/> and <see cref="DisposeAsync"/> must never call it, and
/// <see cref="PublishCountForTest"/> is what makes that assertable rather than aspirational.
/// Authorization is the caller's job (<c>BrowserSessionService</c> enforces
/// <c>DriverOperator</c>): this type holds no user identity.
/// </para>
/// <para>
/// <b>Scope resolution never re-parses an id string when it does not have to.</b> A scope
/// that names a node in this session's own tree resolves through the
/// <c>(group, edgeNode)</c> tuple stamped on that node — so selecting a device or a metric
/// addresses its <i>owning edge node</i>, which is the only thing an NCMD can address
/// (Sparkplug has no device-scoped rebirth). A two-segment <c>{group}/{edgeNode}</c> scope
/// is honoured even when the window has never observed that node: a node that has not
/// birthed is the <i>prime</i> rebirth target, and refusing it would defeat the feature.
/// </para>
/// <para>
/// <b>Group scope is bounded and all-or-nothing</b> — see
/// <see cref="MaxGroupRebirthNodes"/>.
/// </para>
/// </remarks>
public async Task<int> RequestRebirthAsync(string scope, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(Disposed, this);
if (Mode != MqttMode.SparkplugB)
{
throw new NotSupportedException(
"A rebirth request is a Sparkplug B action; a plain-MQTT browse window publishes nothing, ever.");
}
ArgumentException.ThrowIfNullOrWhiteSpace(scope);
var targets = ResolveRebirthTargets(scope);
foreach (var (groupId, edgeNodeId) in targets)
{
// QoS 0, retain false, bounded deadline — all owned by RebirthRequester, which reaches the
// client only through this session's counted PublishAsync chokepoint.
await RebirthRequester
.RequestAsync(_rebirthTransport, groupId, edgeNodeId, cancellationToken)
.ConfigureAwait(false);
}
Touch();
return targets.Count;
}
/// <summary>
/// Resolves a rebirth scope to the edge nodes an NCMD should be addressed to. Pure — it
/// publishes nothing, so an over-wide or unresolvable scope is refused before any message
/// leaves.
/// </summary>
/// <param name="scope">The scope, as documented on <see cref="RequestRebirthAsync"/>.</param>
/// <returns>The distinct <c>(group, edgeNode)</c> targets, ordered for determinism.</returns>
/// <exception cref="ArgumentException">The scope is not resolvable to any edge node.</exception>
/// <exception cref="InvalidOperationException">
/// A group scope named no observed edge node, or more than <see cref="MaxGroupRebirthNodes"/>.
/// </exception>
private IReadOnlyList<(string GroupId, string EdgeNodeId)> ResolveRebirthTargets(string scope)
{
// 1. A node from this session's own tree — the normal path. The target comes off the stored
// tuple, never off the id string.
if (_byPath.TryGetValue(scope, out var node) && node.Scope is { } nodeScope)
{
if (nodeScope.EdgeNodeId is { } edgeNodeId) return [(nodeScope.GroupId, edgeNodeId)];
return EnumerateGroup(nodeScope.GroupId, node);
}
var segments = scope.Split('/');
// 2. A group that is a root of this tree (identical to case 1 for an observed group, and
// reached only when the group node itself was evicted by the node cap).
if (segments.Length == 1 && _roots.TryGetValue(segments[0], out var group))
{
return EnumerateGroup(segments[0], group);
}
// 3. A bare {group}/{edgeNode} the window has never seen — the prime rebirth target.
if (segments.Length == 2 && IsUsableLabel(segments[0]) && IsUsableLabel(segments[1]))
{
return [(segments[0], segments[1])];
}
throw new ArgumentException(
$"'{scope}' does not name a Sparkplug group or edge node in this browse session; "
+ "select a node in the tree, or address one as '{group}/{edgeNode}'.",
nameof(scope));
}
/// <summary>Enumerates the observed edge nodes of one group, bounded by <see cref="MaxGroupRebirthNodes"/>.</summary>
/// <param name="groupId">The group id.</param>
/// <param name="group">The group's tree node.</param>
/// <returns>The group's edge-node targets, ordered by edge-node id.</returns>
private static IReadOnlyList<(string GroupId, string EdgeNodeId)> EnumerateGroup(string groupId, TopicNode group)
{
var edgeNodes = group.Children.Values
.Select(n => n.Scope?.EdgeNodeId)
.Where(id => id is not null)
.Select(id => id!)
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal)
.ToList();
if (edgeNodes.Count == 0)
{
throw new InvalidOperationException(
$"No edge node has been observed in Sparkplug group '{groupId}', so there is nothing to "
+ "address; select an edge node, or address one as '{group}/{edgeNode}'.");
}
if (edgeNodes.Count > MaxGroupRebirthNodes)
{
throw new InvalidOperationException(
$"Sparkplug group '{groupId}' has {edgeNodes.Count} observed edge nodes, more than the "
+ $"{MaxGroupRebirthNodes} one request will fan out to; select a single edge node instead. "
+ "Nothing was published.");
}
return [.. edgeNodes.Select(id => (groupId, id))];
}
/// <summary>
/// <b>The one and only outbound-message seam.</b> Every browse path is strictly read-only, and
/// <see cref="PublishCountForTest"/> is the assertion of that;
/// <see cref="RequestRebirthAsync"/> — an explicit operator action — is the single sanctioned
/// caller. Anything that ever needs to publish <b>must route through here</b> rather than
/// touching <see cref="_client"/> directly, or the guarantee stops being checkable.
/// </summary>
/// <param name="message">The message to publish.</param>
@@ -328,6 +793,10 @@ internal sealed class MqttBrowseSession : IBrowseSession
private async Task PublishAsync(MqttApplicationMessage message, CancellationToken cancellationToken)
{
Interlocked.Increment(ref _publishCount);
_publishedTopics.Enqueue(message.Topic);
while (_publishedTopics.Count > PublishTrailMax) _publishedTopics.TryDequeue(out _);
if (_client is { } client)
{
await client.PublishAsync(message, cancellationToken).ConfigureAwait(false);
@@ -454,15 +923,19 @@ internal sealed class MqttBrowseSession : IBrowseSession
.OrderBy(n => n.Segment, StringComparer.Ordinal)
.Select(n =>
{
// A childless node is always the terminal segment of a topic that actually published,
// so it is the committable address. A node that both published and has children below
// it renders as a folder — MQTT allows that shape and the picker must stay expandable.
// Plain mode infers: a childless node is always the terminal segment of a topic that
// actually published, so it is the committable address, while a node that both
// published and has children below it renders as a folder — MQTT allows that shape and
// the picker must stay expandable. Sparkplug states the kind outright, because a
// device whose birth carried no metrics is childless and must still not be offered as
// a committable metric.
var hasChildren = !n.Children.IsEmpty;
var kind = n.Kind ?? (hasChildren ? BrowseNodeKind.Folder : BrowseNodeKind.Leaf);
return new BrowseNode(
n.Path,
n.Segment,
hasChildren ? BrowseNodeKind.Folder : BrowseNodeKind.Leaf,
hasChildren);
kind,
hasChildren || kind == BrowseNodeKind.Folder);
})
.ToList();
@@ -473,29 +946,104 @@ internal sealed class MqttBrowseSession : IBrowseSession
/// <param name="Snippet">A bounded, display-safe rendering of that payload.</param>
private sealed record ObservedValue(DriverDataType DataType, string Snippet);
/// <summary>One segment of the observed topic space.</summary>
private sealed class TopicNode(string path, string segment)
/// <summary>
/// The Sparkplug identity a tree node belongs to. Carried explicitly on the node so a rebirth
/// target is <b>read</b>, never parsed back out of a node-id string — the same discipline the
/// v3 address space applies to <c>AddressSpaceRealm</c>.
/// </summary>
/// <param name="GroupId">The Sparkplug group id.</param>
/// <param name="EdgeNodeId">
/// The owning edge-node id, or <see langword="null"/> on a group node (which addresses every
/// edge node beneath it).
/// </param>
private sealed record SparkplugScope(string GroupId, string? EdgeNodeId);
/// <summary>One metric, exactly as a birth certificate declared it. No payload bytes are retained.</summary>
/// <param name="Name">The metric name — the tag's stable binding key across rebirths.</param>
/// <param name="DataType">
/// The declared Sparkplug datatype, or <see langword="null"/> when the birth omitted it (which a
/// conformant birth never does; kept nullable because the decoder preserves absence rather than
/// defaulting it).
/// </param>
/// <param name="BirthType">Which birth declared it — <c>NBIRTH</c> or <c>DBIRTH</c>.</param>
/// <param name="Alias">The alias the birth assigned, if any. Display only: aliases are per-birth
/// and may be reused for a different metric after a rebirth, so nothing binds by them.</param>
private sealed record SparkplugMetricInfo(
string Name,
SparkplugDataType? DataType,
SparkplugMessageType BirthType,
ulong? Alias);
/// <summary>One node of the observed tree — a topic segment in plain mode, a group / edge node /
/// device / metric in Sparkplug mode.</summary>
private sealed class TopicNode(string path, string segment, BrowseNodeKind? kind = null, SparkplugScope? scope = null)
{
/// <summary>Full slash-joined topic path — the node id the picker round-trips.</summary>
/// <summary>Full path — the node id the picker round-trips.</summary>
public string Path { get; } = path;
/// <summary>This node's own topic segment — the display label.</summary>
/// <summary>This node's own label.</summary>
public string Segment { get; } = segment;
/// <summary>Child segments, keyed by segment name.</summary>
/// <summary>
/// An explicit browse kind, or <see langword="null"/> to infer it from the child count.
/// Set in Sparkplug mode only; see <see cref="Project"/>.
/// </summary>
public BrowseNodeKind? Kind { get; } = kind;
/// <summary>The Sparkplug identity this node belongs to; <see langword="null"/> in plain mode.</summary>
public SparkplugScope? Scope { get; } = scope;
/// <summary>Child nodes, keyed by their full path (see <see cref="GetOrCreate"/>).</summary>
public ConcurrentDictionary<string, TopicNode> Children { get; } = new(StringComparer.Ordinal);
/// <summary>
/// The last payload observed on exactly this topic, or <see langword="null"/> when this is
/// a synthesised intermediate segment nothing published to. Written by the dispatcher
/// thread and read by the UI thread, so the whole record is swapped as one reference.
/// Plain mode only — Sparkplug retains no payload bytes.
/// </summary>
public ObservedValue? Observed => Volatile.Read(ref _observed);
/// <summary>
/// The birth-declared metric this node is, or <see langword="null"/> when it is a folder
/// (or a plain-mode topic segment). Swapped as one reference for the same reason as
/// <see cref="Observed"/> — a rebirth republishes the whole set, redeclaring types.
/// </summary>
public SparkplugMetricInfo? Metric => Volatile.Read(ref _metric);
private ObservedValue? _observed;
private SparkplugMetricInfo? _metric;
/// <summary>Publishes the latest observation for this topic.</summary>
/// <param name="value">The inferred type + snippet to record.</param>
public void Record(ObservedValue value) => Volatile.Write(ref _observed, value);
/// <summary>Publishes the latest birth declaration for this metric.</summary>
/// <param name="value">The metric as the most recent birth declared it.</param>
public void RecordMetric(SparkplugMetricInfo value) => Volatile.Write(ref _metric, value);
}
/// <summary>
/// Adapts <see cref="RebirthRequester"/>'s publish seam onto this session's single counted
/// <see cref="PublishAsync"/> chokepoint. A private nested type on purpose: making the session
/// itself an <see cref="IMqttPublishTransport"/> would hand anyone holding it a general
/// publish-anything capability, and the read-only guarantee rests on there being exactly one
/// way out.
/// </summary>
/// <param name="owner">The session whose client the message is published on.</param>
private sealed class RebirthTransport(MqttBrowseSession owner) : IMqttPublishTransport
{
/// <inheritdoc />
public Task PublishAsync(string topic, byte[] payload, int qos, bool retain, CancellationToken cancellationToken)
{
var message = new MqttApplicationMessageBuilder()
.WithTopic(topic)
.WithPayload(payload)
.WithQualityOfServiceLevel((MQTTnet.Protocol.MqttQualityOfServiceLevel)Math.Clamp(qos, 0, 2))
.WithRetainFlag(retain)
.Build();
return owner.PublishAsync(message, cancellationToken);
}
}
}
@@ -14,10 +14,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser;
/// window against the broker named by the form's JSON.
/// <para>
/// MQTT has no discovery protocol, so there is nothing to enumerate — the browser subscribes
/// to the wildcard (<c>#</c>, or <c>{topicPrefix}/#</c>) and lets
/// <see cref="MqttBrowseSession"/> accumulate whatever arrives. Nothing on any browse path
/// publishes: an operator opening a picker must not be able to inject a message into a
/// running plant.
/// to a wildcard (<c>#</c> / <c>{topicPrefix}/#</c> in plain mode, <c>spBv1.0/{groupId}/#</c>
/// in Sparkplug mode) and lets <see cref="MqttBrowseSession"/> accumulate whatever arrives:
/// a topic segment tree in plain mode, a Group → EdgeNode → Device → Metric tree decoded from
/// observed birth certificates in Sparkplug mode. Nothing on any browse path publishes — in
/// either mode: an operator opening a picker must not be able to inject a message into a
/// running plant. The session's operator-triggered <c>RequestRebirthAsync</c> is the sole
/// sanctioned publish, and it is never reached by <c>OpenAsync</c> or by any browse call.
/// </para>
/// <para>
/// <b>Layering note.</b> Unlike every other bespoke browser here, this project references the
@@ -68,17 +71,6 @@ public sealed class MqttDriverBrowser : IDriverBrowser
var opts = JsonSerializer.Deserialize<MqttDriverOptions>(configJson, MqttJson.Options)
?? throw new InvalidOperationException("Mqtt options deserialized to null.");
if (opts.Mode == MqttMode.SparkplugB)
{
// P2 seam (Task 23): Sparkplug browse is a Group → EdgeNode → Device → Metric tree built
// from observed NBIRTH/DBIRTH payloads, with an operator-triggered rebirth request as the
// one sanctioned publish. Failing fast beats serving a raw spBv1.0 topic tree that looks
// like a metric tree but is not one.
throw new NotSupportedException(
"Sparkplug B browse (Group → EdgeNode → Device → Metric) is not available yet; " +
"the MQTT browser currently observes plain topics only. Author Sparkplug tags manually.");
}
var filter = BuildRootFilter(opts);
var suffix = BuildBrowseClientIdSuffix();
var clientOptions = MqttConnection.BuildClientOptions(ToBrowseOptions(opts), suffix, _logger);
@@ -111,8 +103,9 @@ public sealed class MqttDriverBrowser : IDriverBrowser
await client.SubscribeAsync(subscribeOptions, openCts.Token).ConfigureAwait(false);
_logger.LogInformation(
"AdminUI MQTT browse session opened against {Host}:{Port} observing '{Filter}' (read-only).",
opts.Host, opts.Port, filter);
"AdminUI MQTT browse session opened against {Host}:{Port} in {Mode} mode observing "
+ "'{Filter}' (read-only).",
opts.Host, opts.Port, opts.Mode, filter);
return session;
}
@@ -124,13 +117,30 @@ public sealed class MqttDriverBrowser : IDriverBrowser
}
/// <summary>
/// The wildcard the observation window subscribes to: the whole broker (<c>#</c>) unless the
/// config scopes it with a Plain-mode topic prefix.
/// The wildcard the observation window subscribes to.
/// <para>
/// <b>Plain mode:</b> the whole broker (<c>#</c>) unless the config scopes it with a
/// topic prefix.
/// </para>
/// <para>
/// <b>Sparkplug mode:</b> the Sparkplug namespace only —
/// <c>spBv1.0/{groupId}/#</c>, matching the group the deployed driver itself subscribes
/// (design §3.1), or <c>spBv1.0/#</c> when no group has been authored yet, so the picker
/// can discover which groups exist. The Plain-mode topic prefix is deliberately ignored:
/// it is a different mode's key, and honouring it would silently narrow the window to a
/// prefix that cannot match a Sparkplug topic at all.
/// </para>
/// </summary>
/// <param name="options">The browse configuration.</param>
/// <returns>The MQTT topic filter to subscribe.</returns>
internal static string BuildRootFilter(MqttDriverOptions options)
{
if (options.Mode == MqttMode.SparkplugB)
{
var groupId = (options.Sparkplug?.GroupId ?? string.Empty).Trim();
return groupId.Length == 0 ? "spBv1.0/#" : $"spBv1.0/{groupId}/#";
}
var prefix = (options.Plain?.TopicPrefix ?? string.Empty).Trim();
if (prefix.Length == 0) return "#";
if (!prefix.EndsWith('/')) prefix += "/";
@@ -151,13 +161,18 @@ public sealed class MqttDriverBrowser : IDriverBrowser
/// forced: a transient browse identity must not leave queued-message state behind on the
/// broker after the picker closes.
/// <para>
/// ⚠ <b>P2 landmine — a Last Will would break the read-only guarantee from outside it.</b>
/// <c>MqttDriverOptions</c> carries no will today, so there is nothing to strip. When the
/// Sparkplug tasks add one (NDEATH <i>is</i> a will message), it MUST be cleared here: a
/// will is published by the <i>broker</i>, not by us, so it is invisible to
/// <see cref="MqttBrowseSession.PublishCountForTest"/> — and any ungraceful end to a browse
/// ⚠ <b>THE landmine — a Last Will would break the read-only guarantee from outside it.</b>
/// <c>MqttDriverOptions</c> (and <c>MqttSparkplugOptions</c>) carry no will, <b>re-verified
/// when Task 23 unsealed Sparkplug browse</b>, so there is nothing to strip and the
/// projection stays a single <c>CleanSession</c> override. If a will is ever added — NDEATH
/// <i>is</i> a will message — it MUST be cleared here: a will is published by the
/// <i>broker</i>, not by us, so it is invisible to
/// <see cref="MqttBrowseSession.PublishCountForTest"/>, and any ungraceful end to a browse
/// session (crash, dropped link, the disconnect deadline expiring) would then fire an
/// NDEATH under the plant's own edge-node identity.
/// NDEATH under the plant's own edge-node identity — killing a live Sparkplug node because
/// an operator opened an address picker. <c>MqttBrowseSessionTests</c>'
/// <c>MqttDriverOptions_CarryNothingWillShaped_…</c> is the reflection guard that turns that
/// "there is nothing to strip" into a checked fact rather than a claim.
/// </para>
/// </summary>
/// <param name="options">The authored configuration.</param>
@@ -1,5 +1,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Security.Auth;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
@@ -10,11 +13,19 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
/// expand/attributes call in a 20-second linked CTS so a stuck driver cannot
/// stall the UI indefinitely.
/// </summary>
/// <param name="browsers">The bespoke per-driver browsers.</param>
/// <param name="registry">The open-session registry.</param>
/// <param name="logger">The logger.</param>
/// <param name="universalBrowser">The Discover-backed fallback browser.</param>
/// <param name="authorizationService">Policy evaluator for <see cref="RequestRebirthAsync"/>.</param>
/// <param name="authenticationStateProvider">Supplies the calling circuit's user.</param>
public sealed class BrowserSessionService(
IEnumerable<IDriverBrowser> browsers,
BrowseSessionRegistry registry,
ILogger<BrowserSessionService> logger,
IUniversalDriverBrowser universalBrowser) : IBrowserSessionService
IUniversalDriverBrowser universalBrowser,
IAuthorizationService authorizationService,
AuthenticationStateProvider authenticationStateProvider) : IBrowserSessionService
{
/// <summary>Upper bound on a single root/expand/attributes call.</summary>
public static readonly TimeSpan PerCallTimeout = TimeSpan.FromSeconds(20);
@@ -75,6 +86,60 @@ public sealed class BrowserSessionService(
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(Guid token, string nodeId, CancellationToken ct) =>
InvokeAsync<IReadOnlyList<AttributeInfo>>(token, ct, (s, c) => s.AttributesAsync(nodeId, c));
/// <inheritdoc />
/// <remarks>
/// <para>
/// <b>The authorization check is the point of routing this through the service at all.</b>
/// Every other member here is read-only; this one causes the browse session to publish onto
/// a live plant broker, so it is gated on the same <c>DriverOperator</c> policy the picker
/// bodies evaluate before rendering their Browse affordance. A render-time check alone is
/// not a control — it decides what a page draws, not what a circuit may invoke — so the
/// check is made here, where the action actually happens, and it fails closed.
/// </para>
/// <para>
/// Deliberately <b>not</b> wrapped in <see cref="PerCallTimeout"/>-style swallowing: unlike
/// a failed expand, a failed or refused rebirth is something the operator must see, so the
/// exception propagates to the caller for display.
/// </para>
/// </remarks>
public async Task<int> RequestRebirthAsync(Guid token, string scope, CancellationToken ct)
{
if (!registry.TryGet(token, out var session))
throw new BrowseSessionNotFoundException(token);
if (session is not IRebirthCapableBrowseSession rebirthable)
{
throw new NotSupportedException(
$"Browse session {token} ({session.GetType().Name}) has no re-announce action.");
}
var state = await authenticationStateProvider.GetAuthenticationStateAsync().ConfigureAwait(false);
var authorized = await authorizationService
.AuthorizeAsync(state.User, resource: null, AdminUiPolicies.DriverOperator)
.ConfigureAwait(false);
if (!authorized.Succeeded)
{
logger.LogWarning(
"Rebirth request REFUSED for scope '{Scope}' on browse session {Token}: caller does not "
+ "satisfy the {Policy} policy.", scope, token, AdminUiPolicies.DriverOperator);
throw new UnauthorizedAccessException(
$"Requesting a rebirth requires the {AdminUiPolicies.DriverOperator} policy.");
}
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(PerCallTimeout);
var published = await rebirthable.RequestRebirthAsync(scope, cts.Token).ConfigureAwait(false);
logger.LogInformation(
"Rebirth requested by {User} for scope '{Scope}' on browse session {Token}: {Published} "
+ "message(s) published.",
state.User.Identity?.Name ?? "(anonymous)", scope, token, published);
return published;
}
/// <inheritdoc />
public async Task CloseAsync(Guid token)
{
@@ -50,6 +50,27 @@ public interface IBrowserSessionService
/// <returns>The attributes of the node.</returns>
Task<IReadOnlyList<AttributeInfo>> AttributesAsync(Guid token, string nodeId, CancellationToken ct);
/// <summary>
/// Asks the remote peer(s) addressed by <paramref name="scope"/> to re-announce themselves, on
/// a session whose driver offers that action (today: MQTT in Sparkplug B mode, where it is a
/// Sparkplug rebirth-request NCMD).
/// </summary>
/// <param name="token">Registry handle for the open browse session.</param>
/// <param name="scope">The driver-specific target — for Sparkplug, a browse node id from the
/// session's own tree, or a bare <c>{group}/{edgeNode}</c>.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>The number of request messages published.</returns>
/// <remarks>
/// <b>The only browse operation that writes to the device network</b>, and therefore the only
/// one carrying an authorization check: the caller must satisfy the same
/// <c>DriverOperator</c> policy that gates the picker's Browse affordance. Everything else on
/// this facade is read-only.
/// </remarks>
/// <exception cref="BrowseSessionNotFoundException">The token is unknown (or was reaped).</exception>
/// <exception cref="UnauthorizedAccessException">The caller is not a DriverOperator.</exception>
/// <exception cref="NotSupportedException">This session's driver has no re-announce action.</exception>
Task<int> RequestRebirthAsync(Guid token, string scope, CancellationToken ct);
/// <summary>Removes the session from the registry and disposes it. No-op for unknown tokens.</summary>
/// <param name="token">Registry handle for the browse session to close.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
@@ -3,6 +3,12 @@ using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser;
// See SparkplugTopicTests.cs for why this alias is redeclared locally in every consuming project
// rather than inherited: `SparkplugDataType` is a `global using` alias for the generated
// `Org.Eclipse.Tahu.Protobuf.DataType` (declared in Contracts/SparkplugDataType.cs), and `global using`
// scope does not cross a ProjectReference.
using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
@@ -375,16 +381,6 @@ public sealed class MqttBrowseSessionTests
browser.DriverType.ShouldBe("Mqtt");
}
[Fact]
public async Task OpenAsync_SparkplugMode_FailsFastWithoutConnecting()
{
var browser = new MqttDriverBrowser();
const string cfg = """{"host":"broker.invalid","port":8883,"mode":"SparkplugB"}""";
await Should.ThrowAsync<NotSupportedException>(
async () => await browser.OpenAsync(cfg, TestContext.Current.CancellationToken));
}
[Theory]
[InlineData(null, "#")]
[InlineData("", "#")]
@@ -434,4 +430,372 @@ public sealed class MqttBrowseSessionTests
var opts = new MqttDriverOptions { ConnectTimeoutSeconds = configured };
MqttDriverBrowser.OpenBudget(opts).ShouldBe(TimeSpan.FromSeconds(expected));
}
// ------------------------------------------------------- Sparkplug: the Last-Will landmine
[Fact]
public void MqttDriverOptions_CarryNothingWillShaped_SoABrowseSessionCanNeverFireAnNdeath()
{
// THE landmine (see the marked block on MqttDriverBrowser.ToBrowseOptions). A Sparkplug
// NDEATH is a Last Will: it is published by the BROKER, not by us, so it is invisible to
// PublishCountForTest — and any ungraceful end to a browse session would fire an NDEATH
// under the plant's real edge-node identity. ToBrowseOptions is where a will must be
// stripped; today there is nothing to strip, and this test is what makes that a checked
// fact rather than a claim: adding a will-shaped property anywhere in the options graph
// reddens it and forces the author to handle it in ToBrowseOptions.
static IEnumerable<string> Names(Type t) => t.GetProperties().Select(p => p.Name);
var suspects = Names(typeof(MqttDriverOptions))
.Concat(Names(typeof(MqttSparkplugOptions)))
.Concat(Names(typeof(MqttPlainOptions)))
.Where(n => n.Contains("will", StringComparison.OrdinalIgnoreCase)
|| n.Contains("testament", StringComparison.OrdinalIgnoreCase)
|| n.Contains("death", StringComparison.OrdinalIgnoreCase))
.ToList();
suspects.ShouldBeEmpty();
}
// ------------------------------------------------------- Sparkplug: birth-driven tree
[Fact]
public async Task SparkplugTree_FillsFromObservedBirths_BrowseNeverPublishes()
{
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "Temperature", SparkplugDataType.Float);
var groups = await s.RootAsync(TestContext.Current.CancellationToken);
groups.ShouldContain(n => n.DisplayName == "Plant1");
var nodes = await s.ExpandAsync("Plant1", TestContext.Current.CancellationToken);
nodes.ShouldContain(n => n.DisplayName == "EdgeA");
var devices = await s.ExpandAsync("Plant1/EdgeA", TestContext.Current.CancellationToken);
var device = devices.ShouldHaveSingleItem();
device.DisplayName.ShouldBe("Filler1");
device.Kind.ShouldBe(BrowseNodeKind.Folder);
var metrics = await s.ExpandAsync("Plant1/EdgeA/Filler1", TestContext.Current.CancellationToken);
var metric = metrics.ShouldHaveSingleItem();
metric.DisplayName.ShouldBe("Temperature");
metric.Kind.ShouldBe(BrowseNodeKind.Leaf);
metric.NodeId.ShouldBe("Plant1/EdgeA/Filler1::Temperature");
await s.AttributesAsync(metric.NodeId, TestContext.Current.CancellationToken);
s.PublishCountForTest.ShouldBe(0); // passive — browsing a live plant broker publishes nothing
}
[Fact]
public async Task SparkplugTree_NodeLevelMetrics_HangDirectlyUnderTheEdgeNode()
{
// An NBIRTH metric has no device. It is NOT parked under a synthesised device folder —
// a fake segment would be indistinguishable from a real device of the same name, and the
// authored address tuple carries deviceId = null for exactly these.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeA", device: null, "Node Control/Rebirth", SparkplugDataType.Boolean);
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "Temperature", SparkplugDataType.Float);
var underEdge = await s.ExpandAsync("Plant1/EdgeA", TestContext.Current.CancellationToken);
underEdge.Select(n => n.DisplayName).ShouldBe(["Filler1", "Node Control/Rebirth"]);
var device = underEdge.Single(n => n.DisplayName == "Filler1");
device.Kind.ShouldBe(BrowseNodeKind.Folder);
// A metric name may itself contain '/'; it stays ONE leaf (the metric name is the tag's
// stable binding key, so a split would invite committing a partial name).
var nodeMetric = underEdge.Single(n => n.DisplayName == "Node Control/Rebirth");
nodeMetric.Kind.ShouldBe(BrowseNodeKind.Leaf);
nodeMetric.NodeId.ShouldBe("Plant1/EdgeA::Node Control/Rebirth");
(await s.ExpandAsync(nodeMetric.NodeId, TestContext.Current.CancellationToken)).ShouldBeEmpty();
}
[Fact]
public async Task SparkplugTree_ADeviceAndANodeMetricSharingALabel_AreBothKept()
{
// The two are siblings under the edge node and CAN share a label. They are distinct nodes with
// distinct ids, so neither may displace the other — a tree keyed by label would silently drop
// whichever arrived second, and the picker would offer a folder where a metric should be.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeA", "Temp", "Value", SparkplugDataType.Float); // device "Temp"
s.ObserveBirthForTest("Plant1", "EdgeA", null, "Temp", SparkplugDataType.Float); // metric "Temp"
var children = await s.ExpandAsync("Plant1/EdgeA", TestContext.Current.CancellationToken);
children.Count.ShouldBe(2);
children.ShouldContain(n => n.NodeId == "Plant1/EdgeA/Temp" && n.Kind == BrowseNodeKind.Folder);
children.ShouldContain(n => n.NodeId == "Plant1/EdgeA::Temp" && n.Kind == BrowseNodeKind.Leaf);
(await s.AttributesAsync("Plant1/EdgeA::Temp", TestContext.Current.CancellationToken))
.ShouldHaveSingleItem().Name.ShouldBe("Temp");
}
[Fact]
public async Task SparkplugTree_EmptyBirth_StillRendersItsDeviceAsAFolder()
{
// A DBIRTH carrying no metrics leaves a childless device node; it must not masquerade as a
// committable metric leaf.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveEmptyBirthForTest("Plant1", "EdgeA", "Filler1");
var device = (await s.ExpandAsync("Plant1/EdgeA", TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
device.DisplayName.ShouldBe("Filler1");
device.Kind.ShouldBe(BrowseNodeKind.Folder);
}
[Fact]
public async Task SparkplugObserve_NonBirthMessages_BuildNoTree()
{
// Only NBIRTH/DBIRTH are self-describing. A DDATA metric carries an alias and no name at
// all, so anything built from one would be a tree of unnamed nodes.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveRawForTest("spBv1.0/Plant1/DDATA/EdgeA/Filler1", [0x08, 0x01]);
s.ObserveRawForTest("spBv1.0/Plant1/NCMD/EdgeA", [0x08, 0x01]);
s.ObserveRawForTest("spBv1.0/Plant1/NDEATH/EdgeA", [0x08, 0x01]);
s.ObserveRawForTest("spBv1.0/STATE/host1", "ONLINE"u8.ToArray());
s.ObserveRawForTest("some/plain/topic", "23.5"u8.ToArray()); // not Sparkplug at all
(await s.RootAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty();
s.PublishCountForTest.ShouldBe(0);
}
[Fact]
public void SparkplugObserve_GarbageBirthPayload_IsIgnoredAndNeverThrows()
{
// Runs on MQTTnet's dispatcher thread behind an unauthenticated firehose.
var s = new MqttBrowseSession(MqttMode.SparkplugB);
Should.NotThrow(() => s.ObserveRawForTest("spBv1.0/Plant1/NBIRTH/EdgeA", [0xFF, 0xFF, 0xFF, 0xFF]));
Should.NotThrow(() => s.ObserveRawForTest("spBv1.0/Plant1/NBIRTH/EdgeA", []));
}
// ------------------------------------------------------- Sparkplug: AttributesAsync
[Fact]
public async Task SparkplugAttributes_AreBirthDerived_NotPayloadSniffed()
{
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "Temperature", SparkplugDataType.Float);
var a = (await s.AttributesAsync(
"Plant1/EdgeA/Filler1::Temperature", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
a.Name.ShouldBe("Temperature");
a.DriverDataType.ShouldBe(nameof(Core.Abstractions.DriverDataType.Float32)); // from the birth
a.IsArray.ShouldBeFalse();
a.SecurityClass.ShouldContain("DBIRTH");
a.SecurityClass.ShouldContain("Float");
// The plain-mode payload-snippet machinery must not run: a Sparkplug body is protobuf, and
// sniffing it would report "(N bytes, binary)" for every metric in the plant.
a.SecurityClass.ShouldNotContain("binary");
a.SecurityClass.ShouldNotContain("bytes");
}
[Fact]
public async Task SparkplugAttributes_ArrayMetric_ReportsElementTypePlusIsArray()
{
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeA", null, "Profile", SparkplugDataType.Int32Array);
var a = (await s.AttributesAsync(
"Plant1/EdgeA::Profile", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
a.DriverDataType.ShouldBe(nameof(Core.Abstractions.DriverDataType.Int32)); // element type
a.IsArray.ShouldBeTrue();
}
[Fact]
public async Task SparkplugAttributes_UnsupportedDataType_IsReportedNotDefaulted()
{
// ToDriverDataType() returns null for DataSet/Template/PropertySet/PropertySetList/Unknown.
// A null must never be coerced into a guessed DriverDataType — the operator has to see that
// the metric is not bindable.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeA", null, "Curve", SparkplugDataType.DataSet);
var a = (await s.AttributesAsync(
"Plant1/EdgeA::Curve", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
Enum.TryParse<Core.Abstractions.DriverDataType>(a.DriverDataType, ignoreCase: true, out _)
.ShouldBeFalse(); // not a guessed type — unmappable, and visibly so
a.SecurityClass.ShouldContain("unsupported");
}
[Fact]
public async Task SparkplugAttributes_ForAFolderNode_AreEmpty()
{
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "Temperature", SparkplugDataType.Float);
(await s.AttributesAsync("Plant1", TestContext.Current.CancellationToken)).ShouldBeEmpty();
(await s.AttributesAsync("Plant1/EdgeA", TestContext.Current.CancellationToken)).ShouldBeEmpty();
(await s.AttributesAsync("Plant1/EdgeA/Filler1", TestContext.Current.CancellationToken)).ShouldBeEmpty();
(await s.AttributesAsync("no/such/node", TestContext.Current.CancellationToken)).ShouldBeEmpty();
}
// ------------------------------------------------------- Sparkplug: the one sanctioned publish
[Fact]
public async Task RequestRebirthAsync_ScopedToNode_PublishesOneNcmd()
{
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "T", SparkplugDataType.Float);
var published = await s.RequestRebirthAsync("Plant1/EdgeA", TestContext.Current.CancellationToken);
published.ShouldBe(1);
s.PublishCountForTest.ShouldBe(1);
s.PublishedTopicsForTest.ShouldBe(["spBv1.0/Plant1/NCMD/EdgeA"]);
}
[Fact]
public async Task RequestRebirthAsync_ForANeverSeenEdgeNode_StillPublishes()
{
// A node that has never birthed is the PRIME rebirth target — refusing to address one
// because the observation window never saw it would defeat the whole feature.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
(await s.RequestRebirthAsync("Plant1/EdgeGhost", TestContext.Current.CancellationToken)).ShouldBe(1);
s.PublishedTopicsForTest.ShouldBe(["spBv1.0/Plant1/NCMD/EdgeGhost"]);
}
[Fact]
public async Task RequestRebirthAsync_FromADeviceOrMetricNodeId_ResolvesUpToItsEdgeNode()
{
// NCMD is node-addressed; there is no device-scoped rebirth. Selecting a metric and
// clicking Request-rebirth must address the metric's OWNING edge node — resolved from the
// tuple stored on the node, never re-parsed out of the id string.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "T", SparkplugDataType.Float);
(await s.RequestRebirthAsync("Plant1/EdgeA/Filler1", TestContext.Current.CancellationToken)).ShouldBe(1);
(await s.RequestRebirthAsync("Plant1/EdgeA/Filler1::T", TestContext.Current.CancellationToken)).ShouldBe(1);
s.PublishedTopicsForTest.ShouldBe(["spBv1.0/Plant1/NCMD/EdgeA", "spBv1.0/Plant1/NCMD/EdgeA"]);
}
[Fact]
public async Task RequestRebirthAsync_GroupScope_PublishesExactlyOneNcmdPerObservedEdgeNode()
{
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeB", null, "T", SparkplugDataType.Float);
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "T", SparkplugDataType.Float);
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler2", "T", SparkplugDataType.Float); // same node
s.ObserveBirthForTest("Plant2", "EdgeZ", null, "T", SparkplugDataType.Float); // other group
var published = await s.RequestRebirthAsync("Plant1", TestContext.Current.CancellationToken);
published.ShouldBe(2);
s.PublishCountForTest.ShouldBe(2);
s.PublishedTopicsForTest.ShouldBe(["spBv1.0/Plant1/NCMD/EdgeA", "spBv1.0/Plant1/NCMD/EdgeB"]);
}
[Fact]
public async Task RequestRebirthAsync_GroupScope_BeyondTheCap_PublishesNothingAtAll()
{
// A 200-node group must not emit 200 NCMDs. The refusal is all-or-nothing: a partial fan-out
// would leave the operator unable to say which half of the plant was asked to rebirth.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
for (var i = 0; i <= MqttBrowseSession.MaxGroupRebirthNodes; i++)
s.ObserveBirthForTest("Plant1", $"Edge{i:D3}", null, "T", SparkplugDataType.Float);
var ex = await Should.ThrowAsync<InvalidOperationException>(
async () => await s.RequestRebirthAsync("Plant1", TestContext.Current.CancellationToken));
ex.Message.ShouldContain(MqttBrowseSession.MaxGroupRebirthNodes.ToString());
s.PublishCountForTest.ShouldBe(0);
}
[Fact]
public async Task RequestRebirthAsync_GroupScope_TruncatedBeforeAnyEdgeNode_PublishesNothing()
{
// The node cap can leave a group node with no edge node recorded beneath it. A group that
// resolves to zero targets is refused rather than quietly succeeding with a count of 0 — the
// operator clicked Request-rebirth and is entitled to know nothing was sent.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB, nodeCap: 1);
s.ObserveBirthForTest("Plant1", "EdgeA", null, "T", SparkplugDataType.Float);
s.Truncated.ShouldBeTrue();
(await s.ExpandAsync("Plant1", TestContext.Current.CancellationToken)).ShouldBeEmpty();
await Should.ThrowAsync<InvalidOperationException>(
async () => await s.RequestRebirthAsync("Plant1", TestContext.Current.CancellationToken));
s.PublishCountForTest.ShouldBe(0);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("Plant1")] // a group this window has never observed
[InlineData("Plant1/EdgeA/Filler1/Extra")] // unknown, and not resolvable to an edge node
public async Task RequestRebirthAsync_UnusableScope_PublishesNothing(string scope)
{
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()
{
// P1's guarantee is unconditional: a plain-topic browse window publishes nothing, ever.
await using var s = new MqttBrowseSession(MqttMode.Plain);
s.ObserveTopicForTest("factory/oven/temp", "1");
await Should.ThrowAsync<NotSupportedException>(
async () => await s.RequestRebirthAsync("Plant1/EdgeA", TestContext.Current.CancellationToken));
s.PublishCountForTest.ShouldBe(0);
}
[Fact]
public async Task RequestRebirthAsync_AfterDispose_Throws()
{
var s = new MqttBrowseSession(MqttMode.SparkplugB);
await s.DisposeAsync();
await Should.ThrowAsync<ObjectDisposedException>(
async () => await s.RequestRebirthAsync("Plant1/EdgeA", TestContext.Current.CancellationToken));
s.PublishCountForTest.ShouldBe(0);
}
// ------------------------------------------------------- Sparkplug: the browser
[Fact]
public async Task OpenAsync_SparkplugMode_NoLongerRefuses_ButStillReachesTheBroker()
{
// Task 23 unseals the Sparkplug branch. The open still has to CONNECT, so an unreachable
// broker fails the way any other open does — what must NOT come back is the Task-10
// NotSupportedException seal.
var browser = new MqttDriverBrowser();
const string cfg = """
{"host":"broker.invalid","port":8883,"mode":"SparkplugB","connectTimeoutSeconds":5,
"sparkplug":{"groupId":"Plant1"}}
""";
var ex = await Should.ThrowAsync<Exception>(
async () => await browser.OpenAsync(cfg, TestContext.Current.CancellationToken));
ex.ShouldNotBeOfType<NotSupportedException>();
}
[Theory]
[InlineData("Plant1", "spBv1.0/Plant1/#")]
[InlineData("", "spBv1.0/#")] // group not authored yet — discover every group
[InlineData(" ", "spBv1.0/#")]
public void BuildRootFilter_SparkplugMode_ScopesToTheSparkplugNamespace(string groupId, string expected)
{
var opts = new MqttDriverOptions
{
Mode = MqttMode.SparkplugB,
Sparkplug = new MqttSparkplugOptions { GroupId = groupId },
Plain = new MqttPlainOptions { TopicPrefix = "ignored/in/sparkplug/mode" },
};
MqttDriverBrowser.BuildRootFilter(opts).ShouldBe(expected);
}
[Fact]
public void BuildRootFilter_SparkplugMode_WithNoSparkplugSection_DiscoversEveryGroup()
{
var opts = new MqttDriverOptions { Mode = MqttMode.SparkplugB, Sparkplug = null };
MqttDriverBrowser.BuildRootFilter(opts).ShouldBe("spBv1.0/#");
}
}
@@ -1,3 +1,6 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
@@ -7,17 +10,23 @@ using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
/// <summary>Unit tests for <see cref="BrowserSessionService"/> — driver-type dispatch
/// on open, NotFound semantics on unknown tokens, exception swallowing, and per-call
/// timeout enforcement.</summary>
/// on open, NotFound semantics on unknown tokens, exception swallowing, per-call
/// timeout enforcement, and the DriverOperator gate on the one write-capable member.</summary>
public sealed class BrowserSessionServiceTests
{
private static BrowserSessionService NewService(
BrowseSessionRegistry registry, params IDriverBrowser[] browsers) =>
new(browsers, registry, NullLogger<BrowserSessionService>.Instance, new FakeUniversalDriverBrowser());
NewService(registry, new FakeUniversalDriverBrowser(), browsers);
private static BrowserSessionService NewService(
BrowseSessionRegistry registry, IUniversalDriverBrowser universal, params IDriverBrowser[] browsers) =>
new(browsers, registry, NullLogger<BrowserSessionService>.Instance, universal);
new(browsers, registry, NullLogger<BrowserSessionService>.Instance, universal,
new FakeAuthorizationService(succeeds: true), new FakeAuthenticationStateProvider());
private static BrowserSessionService NewServiceWithAuthz(
BrowseSessionRegistry registry, bool authorized) =>
new([], registry, NullLogger<BrowserSessionService>.Instance, new FakeUniversalDriverBrowser(),
new FakeAuthorizationService(authorized), new FakeAuthenticationStateProvider());
[Fact]
public async Task OpenAsync_unknown_driver_type_returns_Ok_false_with_message()
@@ -226,4 +235,106 @@ public sealed class BrowserSessionServiceTests
// Unknown token is a no-op.
await Should.NotThrowAsync(() => service.CloseAsync(Guid.NewGuid()));
}
// ---------------------------------------------------- RequestRebirthAsync: the one write path
[Fact]
public async Task RequestRebirthAsync_WithoutDriverOperator_IsRefusedAndNeverReachesTheSession()
{
// A publishing action that skips authz is a security bug, not a style issue: this one makes a
// live plant broker receive a command. The refusal must happen BEFORE the session is touched.
var registry = new BrowseSessionRegistry();
var session = new FakeRebirthCapableBrowseSession();
registry.Register(session);
var service = NewServiceWithAuthz(registry, authorized: false);
await Should.ThrowAsync<UnauthorizedAccessException>(
() => service.RequestRebirthAsync(session.Token, "Plant1/EdgeA", CancellationToken.None));
session.Scopes.ShouldBeEmpty();
}
[Fact]
public async Task RequestRebirthAsync_WithDriverOperator_DispatchesTheScopeAndReturnsTheCount()
{
var registry = new BrowseSessionRegistry();
var session = new FakeRebirthCapableBrowseSession { Published = 2 };
registry.Register(session);
var service = NewServiceWithAuthz(registry, authorized: true);
var published = await service.RequestRebirthAsync(session.Token, "Plant1", CancellationToken.None);
published.ShouldBe(2);
session.Scopes.ShouldBe(["Plant1"]);
}
[Fact]
public async Task RequestRebirthAsync_OnANonRebirthCapableSession_IsNotSupported()
{
var registry = new BrowseSessionRegistry();
var session = new FakeBrowseSession();
registry.Register(session);
var service = NewServiceWithAuthz(registry, authorized: true);
await Should.ThrowAsync<NotSupportedException>(
() => service.RequestRebirthAsync(session.Token, "Plant1/EdgeA", CancellationToken.None));
}
[Fact]
public async Task RequestRebirthAsync_UnknownToken_ThrowsBrowseSessionNotFound()
{
var registry = new BrowseSessionRegistry();
var service = NewServiceWithAuthz(registry, authorized: true);
await Should.ThrowAsync<BrowseSessionNotFoundException>(
() => service.RequestRebirthAsync(Guid.NewGuid(), "Plant1/EdgeA", CancellationToken.None));
}
/// <summary>A browse session that records the rebirth scopes it was asked for.</summary>
private sealed class FakeRebirthCapableBrowseSession : IRebirthCapableBrowseSession
{
public Guid Token { get; } = Guid.NewGuid();
public DateTime LastUsedUtc { get; } = DateTime.UtcNow;
public int Published { get; init; } = 1;
public List<string> Scopes { get; } = [];
public Task<int> RequestRebirthAsync(string scope, CancellationToken cancellationToken)
{
Scopes.Add(scope);
return Task.FromResult(Published);
}
public Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<BrowseNode>>([]);
public Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<BrowseNode>>([]);
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<AttributeInfo>>([]);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <summary>An <see cref="IAuthorizationService"/> whose verdict the test dictates.</summary>
private sealed class FakeAuthorizationService(bool succeeds) : IAuthorizationService
{
public Task<AuthorizationResult> AuthorizeAsync(
ClaimsPrincipal user, object? resource, IEnumerable<IAuthorizationRequirement> requirements) =>
Task.FromResult(succeeds ? AuthorizationResult.Success() : AuthorizationResult.Failed());
public Task<AuthorizationResult> AuthorizeAsync(ClaimsPrincipal user, object? resource, string policyName) =>
Task.FromResult(succeeds ? AuthorizationResult.Success() : AuthorizationResult.Failed());
}
/// <summary>A fixed authenticated identity for the circuit under test.</summary>
private sealed class FakeAuthenticationStateProvider : AuthenticationStateProvider
{
public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
Task.FromResult(new AuthenticationState(
new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Name, "tester")], "test"))));
}
}