diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/IBrowseSession.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/IBrowseSession.cs
index cb146c78..258beeb3 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/IBrowseSession.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/IBrowseSession.cs
@@ -35,3 +35,47 @@ public interface IBrowseSession : IAsyncDisposable
/// The attributes of the node.
Task> AttributesAsync(string nodeId, CancellationToken cancellationToken);
}
+
+///
+/// An whose protocol offers an explicit, operator-triggered
+/// re-announce 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.
+///
+///
+///
+/// This is the only interface in the browse contract that causes an outbound message.
+/// 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
+/// , precisely so "does this session write?" stays a type
+/// question a caller cannot forget to ask.
+///
+///
+/// Callers must authorize before invoking it. The AdminUI routes it through
+/// BrowserSessionService.RequestRebirthAsync, which enforces the same
+/// DriverOperator policy that gates the picker's Browse affordance — an implementation
+/// cannot check that itself, since it holds no user identity.
+///
+///
+public interface IRebirthCapableBrowseSession : IBrowseSession
+{
+ ///
+ /// Asks the addressed remote peer(s) to re-announce themselves.
+ ///
+ ///
+ /// The protocol-specific target. For Sparkplug: a browse NodeId from this session's own
+ /// tree (group, edge node, device or metric — resolved up to the owning edge node), or a bare
+ /// {group}/{edgeNode} pair for a node that has not been observed yet.
+ ///
+ /// Cancellation token for the operation.
+ /// The number of request messages published.
+ /// is empty or unusable.
+ ///
+ /// 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.
+ ///
+ /// This session's mode has no re-announce action.
+ Task RequestRebirthAsync(string scope, CancellationToken cancellationToken);
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs
index 3a393310..be3b909b 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttBrowseSession.cs
@@ -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 live production broker; if browse published anything,
/// opening an address picker could inject messages into a running plant. Every outbound
/// message must route through the single seam, which counts its
-/// calls into 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 so the guarantee is assertable.
+/// — an explicit operator action, never a browse step — is
+/// the one and only caller of that seam. ,
+/// , , and
+/// must never reach it, in either mode.
+///
+///
+/// Sparkplug mode serves a different tree, from the same passive window. Instead of raw
+/// spBv1.0/… 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.
///
///
/// 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.
///
///
-internal sealed class MqttBrowseSession : IBrowseSession
+internal sealed class MqttBrowseSession : IRebirthCapableBrowseSession
{
/// Default cap on recorded nodes; a busy broker's # firehose is unbounded.
internal const int DefaultNodeCap = 50_000;
@@ -69,6 +89,39 @@ internal sealed class MqttBrowseSession : IBrowseSession
/// Sentinel node id of the "oversized topics rejected" marker.
internal const string OversizedNodeId = "__oversized__";
+ ///
+ /// 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 /, but a metric name routinely does (Node Control/Rebirth,
+ /// Properties/Serial Number) — so slash-joining would make a node-level metric
+ /// Plant1/EdgeA/Temperature 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.
+ ///
+ internal const string MetricSeparator = "::";
+
+ ///
+ /// Ceiling on a Sparkplug birth body this session will decode. The plain path bounds its work
+ /// at , but a birth must be parsed whole to yield its
+ /// metric list, so the bound has to be on the message instead. Mirrors
+ /// MqttDriverOptions.MaxPayloadBytes' 1 MiB default: the protobuf parse runs on MQTTnet's
+ /// shared dispatcher thread, so an unbounded one is unbounded work per message, forever.
+ ///
+ internal const int MaxBirthPayloadBytes = 1024 * 1024;
+
+ ///
+ /// Cap on how many edge nodes one group-scoped 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
+ /// whole — a partial fan-out would leave the operator unable to say which half of the
+ /// plant was asked to rebirth.
+ ///
+ internal const int MaxGroupRebirthNodes = 32;
+
+ /// Bound on the recorded published-topic trail (a test/diagnostic aid, not a log).
+ private const int PublishTrailMax = 64;
+
///
/// 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
/// First-segment nodes, keyed by segment.
private readonly ConcurrentDictionary _roots = new(StringComparer.Ordinal);
+ ///
+ /// The adapter that lets reach this session's client
+ /// through . A private nested type rather than an interface on
+ /// the session itself, so stays private and the counted chokepoint
+ /// cannot be bypassed by a future caller holding the session.
+ ///
+ private readonly RebirthTransport _rebirthTransport;
+
+ /// Topics this session has published, newest last; bounded by .
+ private readonly ConcurrentQueue _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);
}
///
- /// Number of messages this session has published. Always 0 in P1 — 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. Always 0 unless was
+ /// explicitly invoked: no browse path in either mode reaches the publish seam.
///
internal int PublishCountForTest => Volatile.Read(ref _publishCount);
+ /// The topics this session published, in order — the discriminating view of the above.
+ internal IReadOnlyList PublishedTopicsForTest => [.. _publishedTopics];
+
/// True once the node cap stopped recording; surfaced as a marker node from .
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
///
///
- /// 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:
- /// carries the type inferred from the last
- /// observed payload (the authored tag's own dataType remains the authority — inference
- /// is the brittle fallback, per design §4), and
- /// carries that payload's snippet. The SecurityClass slot is reused deliberately: it is
- /// the picker's free-text descriptor line (rendered as
- /// {DriverDataType} · {SecurityClass}) and MQTT has no security classes of its own.
- /// Returns empty for a synthesised path segment nothing was ever published to.
+ ///
+ /// Plain mode. 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: carries the type
+ /// inferred from the last observed payload (the authored tag's own dataType
+ /// remains the authority — inference is the brittle fallback, per design §4), and
+ /// carries that payload's snippet. The
+ /// SecurityClass slot is reused deliberately: it is the picker's free-text descriptor
+ /// line (rendered as {DriverDataType} · {SecurityClass}) and MQTT has no security
+ /// classes of its own. Returns empty for a synthesised path segment nothing was ever
+ /// published to.
+ ///
+ ///
+ /// Sparkplug mode is purely birth-derived — no payload is ever sniffed. A Sparkplug
+ /// metric declares 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
+ /// (N bytes, binary) 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
+ /// the birth declared (element type for an array variant, with
+ /// 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
+ /// never coerced into a guessed .
+ ///
///
public Task> AttributesAsync(string nodeId, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(Disposed, this);
- IReadOnlyList attrs =
- _byPath.TryGetValue(nodeId ?? "", out var node) && node.Observed is { } observed
- ? [new AttributeInfo(node.Segment, observed.DataType.ToString(), IsArray: false, observed.Snippet)]
- : [];
+
+ IReadOnlyList 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);
}
+ ///
+ /// Projects one observed Sparkplug birth metric into the picker's attribute row.
+ ///
+ /// The metric as its birth certificate declared it.
+ /// The attribute row.
+ ///
+ /// returns for
+ /// the five v1-unsupported datatypes (Unknown, DataSet, Template,
+ /// PropertySet, PropertySetList), and that null is passed through as the Sparkplug
+ /// type's own name rather than defaulted. None of those five names parses as a
+ /// , 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.
+ ///
+ 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());
+ }
+
///
/// Best-effort teardown. Safe on a session that was never opened (test seam, or an
/// OpenAsync that failed between constructing the client and connecting it) and
@@ -249,16 +375,35 @@ internal sealed class MqttBrowseSession : IBrowseSession
}
///
- /// 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
+ /// 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 .
///
/// The topic the message arrived on.
- /// The message body; used only for type inference and the display snippet.
+ /// The message body.
internal void Observe(string topic, ReadOnlySpan payload)
{
if (Disposed || string.IsNullOrWhiteSpace(topic)) return;
+ if (Mode == MqttMode.SparkplugB)
+ {
+ ObserveSparkplugBirth(topic, payload);
+ return;
+ }
+
+ ObservePlainTopic(topic, payload);
+ }
+
+ ///
+ /// Plain mode: records the topic as a path in the segment tree, decorated with the payload's
+ /// inferred type and display snippet.
+ ///
+ /// The topic the message arrived on.
+ /// The message body; used only for type inference and the display snippet.
+ private void ObservePlainTopic(string topic, ReadOnlySpan payload)
+ {
if (topic.Length > MaxTopicChars)
{
Volatile.Write(ref _oversized, 1);
@@ -285,41 +430,361 @@ internal sealed class MqttBrowseSession : IBrowseSession
{
path = path.Length == 0 ? segment : path + "/" + segment;
- if (!level.TryGetValue(segment, out var child))
- {
- if (Volatile.Read(ref _nodeCount) >= _nodeCap)
- {
- Volatile.Write(ref _truncated, 1);
- return; // stop recording; already-recorded topics keep serving
- }
+ node = GetOrCreate(level, path, segment, kind: null);
+ if (node is null) return; // node cap tripped; already-recorded topics keep serving
- var created = new TopicNode(path, segment);
- child = level.GetOrAdd(segment, created);
- if (ReferenceEquals(child, created))
- {
- Interlocked.Increment(ref _nodeCount);
- _byPath[path] = child;
- }
- }
-
- node = child;
- level = child.Children;
+ level = node.Children;
}
node?.Record(InferPayload(payload));
}
+ ///
+ /// Sparkplug mode: decodes an observed birth certificate into the
+ /// Group → EdgeNode → [Device] → Metric tree.
+ ///
+ /// The topic the message arrived on.
+ /// The Sparkplug-B protobuf body.
+ ///
+ ///
+ /// Births only. 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 spBv1.0/… tree looks like a metric tree but is not one.
+ ///
+ ///
+ /// Node-level metrics hang directly under their edge node, not under a synthesised
+ /// "(node)" device folder. The authored address tuple is
+ /// (group, edgeNode, device?, metric) with device 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.
+ ///
+ ///
+ private void ObserveSparkplugBirth(string topic, ReadOnlySpan 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));
+ }
+ }
+
+ ///
+ /// Finds or creates one tree node under , honouring the node cap.
+ ///
+ /// The child map to place the node in.
+ /// The node's full path — its browse node id, and its key.
+ /// The node's own label.
+ ///
+ /// An explicit browse kind, or to let 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.
+ ///
+ /// The Sparkplug scope to stamp on a newly created node, if any.
+ /// The node, or when the node cap stopped recording.
+ private TopicNode? GetOrCreate(
+ ConcurrentDictionary 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 null;
+ }
+
+ 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;
+ }
+
+ ///
+ /// 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 rejected rather than
+ /// cleaned, because a scrubbed name is a different name and the picker would commit it as the
+ /// tag's binding key.
+ ///
+ /// The candidate label.
+ /// when the label may be recorded.
+ 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;
+ }
+
+ return true;
+ }
+
/// Test seam over that takes the payload as text.
/// The observed topic.
/// The message body as text, or for an empty payload.
internal void ObserveTopicForTest(string topic, string? payload = null) =>
Observe(topic, payload is null ? ReadOnlySpan.Empty : Encoding.UTF8.GetBytes(payload));
+ /// Test seam over that takes raw wire bytes.
+ /// The observed topic.
+ /// The raw message body.
+ internal void ObserveRawForTest(string topic, byte[] payload) => Observe(topic, payload);
+
///
- /// The one and only outbound-message seam. Nothing in P1 calls it — browse against a
- /// live plant broker is strictly read-only, and is the
- /// assertion of that. P2's Sparkplug RequestRebirthAsync (Task 23) is the single
- /// explicitly-operator-triggered exception and must route through here rather than
+ /// Test seam that feeds one metric's birth certificate through the real observe path —
+ /// it encodes an actual Sparkplug-B payload and hands it to , so the topic
+ /// parse, the protobuf decode and the tree build are all exercised rather than mocked around.
+ ///
+ /// The Sparkplug group id.
+ /// The Sparkplug edge-node id.
+ /// The device id, or for an edge-node-level metric (NBIRTH).
+ /// The metric name the birth declares.
+ /// The datatype the birth declares.
+ /// The metric alias the birth declares, if any.
+ 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);
+ }
+
+ /// Test seam feeding a birth certificate that declares no metrics at all.
+ /// The Sparkplug group id.
+ /// The Sparkplug edge-node id.
+ /// The device id, or for an NBIRTH.
+ internal void ObserveEmptyBirthForTest(string groupId, string edgeNodeId, string? device) =>
+ ObserveBirth(groupId, edgeNodeId, device, metric: null);
+
+ /// Encodes and observes one birth certificate.
+ /// The Sparkplug group id.
+ /// The Sparkplug edge-node id.
+ /// The device id, or for an NBIRTH.
+ /// The single metric the birth carries, or for none.
+ 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());
+ }
+
+ ///
+ ///
+ ///
+ /// The one browse-session member that publishes, and it is never on a browse path.
+ /// Only an explicit operator click reaches it — -equivalent setup,
+ /// , , ,
+ /// and must never call it, and
+ /// is what makes that assertable rather than aspirational.
+ /// Authorization is the caller's job (BrowserSessionService enforces
+ /// DriverOperator): this type holds no user identity.
+ ///
+ ///
+ /// Scope resolution never re-parses an id string when it does not have to. A scope
+ /// that names a node in this session's own tree resolves through the
+ /// (group, edgeNode) tuple stamped on that node — so selecting a device or a metric
+ /// addresses its owning edge node, which is the only thing an NCMD can address
+ /// (Sparkplug has no device-scoped rebirth). A two-segment {group}/{edgeNode} scope
+ /// is honoured even when the window has never observed that node: a node that has not
+ /// birthed is the prime rebirth target, and refusing it would defeat the feature.
+ ///
+ ///
+ /// Group scope is bounded and all-or-nothing — see
+ /// .
+ ///
+ ///
+ public async Task 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ /// The scope, as documented on .
+ /// The distinct (group, edgeNode) targets, ordered for determinism.
+ /// The scope is not resolvable to any edge node.
+ ///
+ /// A group scope named no observed edge node, or more than .
+ ///
+ 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));
+ }
+
+ /// Enumerates the observed edge nodes of one group, bounded by .
+ /// The group id.
+ /// The group's tree node.
+ /// The group's edge-node targets, ordered by edge-node id.
+ 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))];
+ }
+
+ ///
+ /// The one and only outbound-message seam. Every browse path is strictly read-only, and
+ /// is the assertion of that;
+ /// — an explicit operator action — is the single sanctioned
+ /// caller. Anything that ever needs to publish must route through here rather than
/// touching directly, or the guarantee stops being checkable.
///
/// The message to publish.
@@ -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
/// A bounded, display-safe rendering of that payload.
private sealed record ObservedValue(DriverDataType DataType, string Snippet);
- /// One segment of the observed topic space.
- private sealed class TopicNode(string path, string segment)
+ ///
+ /// The Sparkplug identity a tree node belongs to. Carried explicitly on the node so a rebirth
+ /// target is read, never parsed back out of a node-id string — the same discipline the
+ /// v3 address space applies to AddressSpaceRealm.
+ ///
+ /// The Sparkplug group id.
+ ///
+ /// The owning edge-node id, or on a group node (which addresses every
+ /// edge node beneath it).
+ ///
+ private sealed record SparkplugScope(string GroupId, string? EdgeNodeId);
+
+ /// One metric, exactly as a birth certificate declared it. No payload bytes are retained.
+ /// The metric name — the tag's stable binding key across rebirths.
+ ///
+ /// The declared Sparkplug datatype, or when the birth omitted it (which a
+ /// conformant birth never does; kept nullable because the decoder preserves absence rather than
+ /// defaulting it).
+ ///
+ /// Which birth declared it — NBIRTH or DBIRTH.
+ /// 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.
+ private sealed record SparkplugMetricInfo(
+ string Name,
+ SparkplugDataType? DataType,
+ SparkplugMessageType BirthType,
+ ulong? Alias);
+
+ /// One node of the observed tree — a topic segment in plain mode, a group / edge node /
+ /// device / metric in Sparkplug mode.
+ private sealed class TopicNode(string path, string segment, BrowseNodeKind? kind = null, SparkplugScope? scope = null)
{
- /// Full slash-joined topic path — the node id the picker round-trips.
+ /// Full path — the node id the picker round-trips.
public string Path { get; } = path;
- /// This node's own topic segment — the display label.
+ /// This node's own label.
public string Segment { get; } = segment;
- /// Child segments, keyed by segment name.
+ ///
+ /// An explicit browse kind, or to infer it from the child count.
+ /// Set in Sparkplug mode only; see .
+ ///
+ public BrowseNodeKind? Kind { get; } = kind;
+
+ /// The Sparkplug identity this node belongs to; in plain mode.
+ public SparkplugScope? Scope { get; } = scope;
+
+ /// Child nodes, keyed by their full path (see ).
public ConcurrentDictionary Children { get; } = new(StringComparer.Ordinal);
///
/// The last payload observed on exactly this topic, or 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.
///
public ObservedValue? Observed => Volatile.Read(ref _observed);
+ ///
+ /// The birth-declared metric this node is, or when it is a folder
+ /// (or a plain-mode topic segment). Swapped as one reference for the same reason as
+ /// — a rebirth republishes the whole set, redeclaring types.
+ ///
+ public SparkplugMetricInfo? Metric => Volatile.Read(ref _metric);
+
private ObservedValue? _observed;
+ private SparkplugMetricInfo? _metric;
/// Publishes the latest observation for this topic.
/// The inferred type + snippet to record.
public void Record(ObservedValue value) => Volatile.Write(ref _observed, value);
+
+ /// Publishes the latest birth declaration for this metric.
+ /// The metric as the most recent birth declared it.
+ public void RecordMetric(SparkplugMetricInfo value) => Volatile.Write(ref _metric, value);
+ }
+
+ ///
+ /// Adapts 's publish seam onto this session's single counted
+ /// chokepoint. A private nested type on purpose: making the session
+ /// itself an would hand anyone holding it a general
+ /// publish-anything capability, and the read-only guarantee rests on there being exactly one
+ /// way out.
+ ///
+ /// The session whose client the message is published on.
+ private sealed class RebirthTransport(MqttBrowseSession owner) : IMqttPublishTransport
+ {
+ ///
+ 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);
+ }
}
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs
index 42c049d6..911a87aa 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser/MqttDriverBrowser.cs
@@ -14,10 +14,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser;
/// window against the broker named by the form's JSON.
///
/// MQTT has no discovery protocol, so there is nothing to enumerate — the browser subscribes
-/// to the wildcard (#, or {topicPrefix}/#) and lets
-/// 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 (# / {topicPrefix}/# in plain mode, spBv1.0/{groupId}/#
+/// in Sparkplug mode) and lets 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 RequestRebirthAsync is the sole
+/// sanctioned publish, and it is never reached by OpenAsync or by any browse call.
///
///
/// Layering note. Unlike every other bespoke browser here, this project references the
@@ -68,17 +71,6 @@ public sealed class MqttDriverBrowser : IDriverBrowser
var opts = JsonSerializer.Deserialize(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
}
///
- /// The wildcard the observation window subscribes to: the whole broker (#) unless the
- /// config scopes it with a Plain-mode topic prefix.
+ /// The wildcard the observation window subscribes to.
+ ///
+ /// Plain mode: the whole broker (#) unless the config scopes it with a
+ /// topic prefix.
+ ///
+ ///
+ /// Sparkplug mode: the Sparkplug namespace only —
+ /// spBv1.0/{groupId}/#, matching the group the deployed driver itself subscribes
+ /// (design §3.1), or spBv1.0/# 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.
+ ///
///
/// The browse configuration.
/// The MQTT topic filter to subscribe.
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.
///
- /// ⚠ P2 landmine — a Last Will would break the read-only guarantee from outside it.
- /// MqttDriverOptions carries no will today, so there is nothing to strip. When the
- /// Sparkplug tasks add one (NDEATH is a will message), it MUST be cleared here: a
- /// will is published by the broker, not by us, so it is invisible to
- /// — and any ungraceful end to a browse
+ /// ⚠ THE landmine — a Last Will would break the read-only guarantee from outside it.
+ /// MqttDriverOptions (and MqttSparkplugOptions) carry no will, re-verified
+ /// when Task 23 unsealed Sparkplug browse, so there is nothing to strip and the
+ /// projection stays a single CleanSession override. If a will is ever added — NDEATH
+ /// is a will message — it MUST be cleared here: a will is published by the
+ /// broker, not by us, so it is invisible to
+ /// , 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. MqttBrowseSessionTests'
+ /// MqttDriverOptions_CarryNothingWillShaped_… is the reflection guard that turns that
+ /// "there is nothing to strip" into a checked fact rather than a claim.
///
///
/// The authored configuration.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs
index ffb829db..da01f21c 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/BrowserSessionService.cs
@@ -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.
///
+/// The bespoke per-driver browsers.
+/// The open-session registry.
+/// The logger.
+/// The Discover-backed fallback browser.
+/// Policy evaluator for .
+/// Supplies the calling circuit's user.
public sealed class BrowserSessionService(
IEnumerable browsers,
BrowseSessionRegistry registry,
ILogger logger,
- IUniversalDriverBrowser universalBrowser) : IBrowserSessionService
+ IUniversalDriverBrowser universalBrowser,
+ IAuthorizationService authorizationService,
+ AuthenticationStateProvider authenticationStateProvider) : IBrowserSessionService
{
/// Upper bound on a single root/expand/attributes call.
public static readonly TimeSpan PerCallTimeout = TimeSpan.FromSeconds(20);
@@ -75,6 +86,60 @@ public sealed class BrowserSessionService(
public Task> AttributesAsync(Guid token, string nodeId, CancellationToken ct) =>
InvokeAsync>(token, ct, (s, c) => s.AttributesAsync(nodeId, c));
+ ///
+ ///
+ ///
+ /// The authorization check is the point of routing this through the service at all.
+ /// 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 DriverOperator 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.
+ ///
+ ///
+ /// Deliberately not wrapped in -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.
+ ///
+ ///
+ public async Task 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;
+ }
+
///
public async Task CloseAsync(Guid token)
{
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/IBrowserSessionService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/IBrowserSessionService.cs
index 9e25fbb5..0aa9dc65 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/IBrowserSessionService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Browsing/IBrowserSessionService.cs
@@ -50,6 +50,27 @@ public interface IBrowserSessionService
/// The attributes of the node.
Task> AttributesAsync(Guid token, string nodeId, CancellationToken ct);
+ ///
+ /// Asks the remote peer(s) addressed by 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).
+ ///
+ /// Registry handle for the open browse session.
+ /// The driver-specific target — for Sparkplug, a browse node id from the
+ /// session's own tree, or a bare {group}/{edgeNode}.
+ /// Cancellation token for the operation.
+ /// The number of request messages published.
+ ///
+ /// The only browse operation that writes to the device network, and therefore the only
+ /// one carrying an authorization check: the caller must satisfy the same
+ /// DriverOperator policy that gates the picker's Browse affordance. Everything else on
+ /// this facade is read-only.
+ ///
+ /// The token is unknown (or was reaped).
+ /// The caller is not a DriverOperator.
+ /// This session's driver has no re-announce action.
+ Task RequestRebirthAsync(Guid token, string scope, CancellationToken ct);
+
/// Removes the session from the registry and disposes it. No-op for unknown tokens.
/// Registry handle for the browse session to close.
/// A task that represents the asynchronous operation.
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs
index bff929cc..b2ec5d34 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowseSessionTests.cs
@@ -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;
///
@@ -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(
- 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 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(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(
+ 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(
+ 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(
+ 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(
+ 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(
+ 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(
+ async () => await browser.OpenAsync(cfg, TestContext.Current.CancellationToken));
+ ex.ShouldNotBeOfType();
+ }
+
+ [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/#");
+ }
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowserSessionServiceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowserSessionServiceTests.cs
index 1939dfa4..b3bf8553 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowserSessionServiceTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowserSessionServiceTests.cs
@@ -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;
/// Unit tests for — driver-type dispatch
-/// on open, NotFound semantics on unknown tokens, exception swallowing, and per-call
-/// timeout enforcement.
+/// on open, NotFound semantics on unknown tokens, exception swallowing, per-call
+/// timeout enforcement, and the DriverOperator gate on the one write-capable member.
public sealed class BrowserSessionServiceTests
{
private static BrowserSessionService NewService(
BrowseSessionRegistry registry, params IDriverBrowser[] browsers) =>
- new(browsers, registry, NullLogger.Instance, new FakeUniversalDriverBrowser());
+ NewService(registry, new FakeUniversalDriverBrowser(), browsers);
private static BrowserSessionService NewService(
BrowseSessionRegistry registry, IUniversalDriverBrowser universal, params IDriverBrowser[] browsers) =>
- new(browsers, registry, NullLogger.Instance, universal);
+ new(browsers, registry, NullLogger.Instance, universal,
+ new FakeAuthorizationService(succeeds: true), new FakeAuthenticationStateProvider());
+
+ private static BrowserSessionService NewServiceWithAuthz(
+ BrowseSessionRegistry registry, bool authorized) =>
+ new([], registry, NullLogger.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(
+ () => 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(
+ () => 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(
+ () => service.RequestRebirthAsync(Guid.NewGuid(), "Plant1/EdgeA", CancellationToken.None));
+ }
+
+ /// A browse session that records the rebirth scopes it was asked for.
+ 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 Scopes { get; } = [];
+
+ public Task RequestRebirthAsync(string scope, CancellationToken cancellationToken)
+ {
+ Scopes.Add(scope);
+ return Task.FromResult(Published);
+ }
+
+ public Task> RootAsync(CancellationToken cancellationToken) =>
+ Task.FromResult>([]);
+
+ public Task> ExpandAsync(string nodeId, CancellationToken cancellationToken) =>
+ Task.FromResult>([]);
+
+ public Task> AttributesAsync(string nodeId, CancellationToken cancellationToken) =>
+ Task.FromResult>([]);
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+ }
+
+ /// An whose verdict the test dictates.
+ private sealed class FakeAuthorizationService(bool succeeds) : IAuthorizationService
+ {
+ public Task AuthorizeAsync(
+ ClaimsPrincipal user, object? resource, IEnumerable requirements) =>
+ Task.FromResult(succeeds ? AuthorizationResult.Success() : AuthorizationResult.Failed());
+
+ public Task AuthorizeAsync(ClaimsPrincipal user, object? resource, string policyName) =>
+ Task.FromResult(succeeds ? AuthorizationResult.Success() : AuthorizationResult.Failed());
+ }
+
+ /// A fixed authenticated identity for the circuit under test.
+ private sealed class FakeAuthenticationStateProvider : AuthenticationStateProvider
+ {
+ public override Task GetAuthenticationStateAsync() =>
+ Task.FromResult(new AuthenticationState(
+ new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Name, "tester")], "test"))));
+ }
}