From 64ec7aa43a696d9a32bf9e5e9b57a37088476db3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 22:51:16 -0400 Subject: [PATCH] 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 --- .../Browsing/IBrowseSession.cs | 44 ++ .../MqttBrowseSession.cs | 658 ++++++++++++++++-- .../MqttDriverBrowser.cs | 65 +- .../Browsing/BrowserSessionService.cs | 67 +- .../Browsing/IBrowserSessionService.cs | 21 + .../MqttBrowseSessionTests.cs | 384 +++++++++- .../Browsing/BrowserSessionServiceTests.cs | 119 +++- 7 files changed, 1263 insertions(+), 95 deletions(-) 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")))); + } }