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

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

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

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

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 22:51:16 -04:00
parent 6d7a458c4d
commit 64ec7aa43a
7 changed files with 1263 additions and 95 deletions
@@ -3,6 +3,12 @@ using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser;
// See SparkplugTopicTests.cs for why this alias is redeclared locally in every consuming project
// rather than inherited: `SparkplugDataType` is a `global using` alias for the generated
// `Org.Eclipse.Tahu.Protobuf.DataType` (declared in Contracts/SparkplugDataType.cs), and `global using`
// scope does not cross a ProjectReference.
using SparkplugDataType = Org.Eclipse.Tahu.Protobuf.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
@@ -375,16 +381,6 @@ public sealed class MqttBrowseSessionTests
browser.DriverType.ShouldBe("Mqtt");
}
[Fact]
public async Task OpenAsync_SparkplugMode_FailsFastWithoutConnecting()
{
var browser = new MqttDriverBrowser();
const string cfg = """{"host":"broker.invalid","port":8883,"mode":"SparkplugB"}""";
await Should.ThrowAsync<NotSupportedException>(
async () => await browser.OpenAsync(cfg, TestContext.Current.CancellationToken));
}
[Theory]
[InlineData(null, "#")]
[InlineData("", "#")]
@@ -434,4 +430,372 @@ public sealed class MqttBrowseSessionTests
var opts = new MqttDriverOptions { ConnectTimeoutSeconds = configured };
MqttDriverBrowser.OpenBudget(opts).ShouldBe(TimeSpan.FromSeconds(expected));
}
// ------------------------------------------------------- Sparkplug: the Last-Will landmine
[Fact]
public void MqttDriverOptions_CarryNothingWillShaped_SoABrowseSessionCanNeverFireAnNdeath()
{
// THE landmine (see the marked block on MqttDriverBrowser.ToBrowseOptions). A Sparkplug
// NDEATH is a Last Will: it is published by the BROKER, not by us, so it is invisible to
// PublishCountForTest — and any ungraceful end to a browse session would fire an NDEATH
// under the plant's real edge-node identity. ToBrowseOptions is where a will must be
// stripped; today there is nothing to strip, and this test is what makes that a checked
// fact rather than a claim: adding a will-shaped property anywhere in the options graph
// reddens it and forces the author to handle it in ToBrowseOptions.
static IEnumerable<string> Names(Type t) => t.GetProperties().Select(p => p.Name);
var suspects = Names(typeof(MqttDriverOptions))
.Concat(Names(typeof(MqttSparkplugOptions)))
.Concat(Names(typeof(MqttPlainOptions)))
.Where(n => n.Contains("will", StringComparison.OrdinalIgnoreCase)
|| n.Contains("testament", StringComparison.OrdinalIgnoreCase)
|| n.Contains("death", StringComparison.OrdinalIgnoreCase))
.ToList();
suspects.ShouldBeEmpty();
}
// ------------------------------------------------------- Sparkplug: birth-driven tree
[Fact]
public async Task SparkplugTree_FillsFromObservedBirths_BrowseNeverPublishes()
{
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "Temperature", SparkplugDataType.Float);
var groups = await s.RootAsync(TestContext.Current.CancellationToken);
groups.ShouldContain(n => n.DisplayName == "Plant1");
var nodes = await s.ExpandAsync("Plant1", TestContext.Current.CancellationToken);
nodes.ShouldContain(n => n.DisplayName == "EdgeA");
var devices = await s.ExpandAsync("Plant1/EdgeA", TestContext.Current.CancellationToken);
var device = devices.ShouldHaveSingleItem();
device.DisplayName.ShouldBe("Filler1");
device.Kind.ShouldBe(BrowseNodeKind.Folder);
var metrics = await s.ExpandAsync("Plant1/EdgeA/Filler1", TestContext.Current.CancellationToken);
var metric = metrics.ShouldHaveSingleItem();
metric.DisplayName.ShouldBe("Temperature");
metric.Kind.ShouldBe(BrowseNodeKind.Leaf);
metric.NodeId.ShouldBe("Plant1/EdgeA/Filler1::Temperature");
await s.AttributesAsync(metric.NodeId, TestContext.Current.CancellationToken);
s.PublishCountForTest.ShouldBe(0); // passive — browsing a live plant broker publishes nothing
}
[Fact]
public async Task SparkplugTree_NodeLevelMetrics_HangDirectlyUnderTheEdgeNode()
{
// An NBIRTH metric has no device. It is NOT parked under a synthesised device folder —
// a fake segment would be indistinguishable from a real device of the same name, and the
// authored address tuple carries deviceId = null for exactly these.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeA", device: null, "Node Control/Rebirth", SparkplugDataType.Boolean);
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "Temperature", SparkplugDataType.Float);
var underEdge = await s.ExpandAsync("Plant1/EdgeA", TestContext.Current.CancellationToken);
underEdge.Select(n => n.DisplayName).ShouldBe(["Filler1", "Node Control/Rebirth"]);
var device = underEdge.Single(n => n.DisplayName == "Filler1");
device.Kind.ShouldBe(BrowseNodeKind.Folder);
// A metric name may itself contain '/'; it stays ONE leaf (the metric name is the tag's
// stable binding key, so a split would invite committing a partial name).
var nodeMetric = underEdge.Single(n => n.DisplayName == "Node Control/Rebirth");
nodeMetric.Kind.ShouldBe(BrowseNodeKind.Leaf);
nodeMetric.NodeId.ShouldBe("Plant1/EdgeA::Node Control/Rebirth");
(await s.ExpandAsync(nodeMetric.NodeId, TestContext.Current.CancellationToken)).ShouldBeEmpty();
}
[Fact]
public async Task SparkplugTree_ADeviceAndANodeMetricSharingALabel_AreBothKept()
{
// The two are siblings under the edge node and CAN share a label. They are distinct nodes with
// distinct ids, so neither may displace the other — a tree keyed by label would silently drop
// whichever arrived second, and the picker would offer a folder where a metric should be.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeA", "Temp", "Value", SparkplugDataType.Float); // device "Temp"
s.ObserveBirthForTest("Plant1", "EdgeA", null, "Temp", SparkplugDataType.Float); // metric "Temp"
var children = await s.ExpandAsync("Plant1/EdgeA", TestContext.Current.CancellationToken);
children.Count.ShouldBe(2);
children.ShouldContain(n => n.NodeId == "Plant1/EdgeA/Temp" && n.Kind == BrowseNodeKind.Folder);
children.ShouldContain(n => n.NodeId == "Plant1/EdgeA::Temp" && n.Kind == BrowseNodeKind.Leaf);
(await s.AttributesAsync("Plant1/EdgeA::Temp", TestContext.Current.CancellationToken))
.ShouldHaveSingleItem().Name.ShouldBe("Temp");
}
[Fact]
public async Task SparkplugTree_EmptyBirth_StillRendersItsDeviceAsAFolder()
{
// A DBIRTH carrying no metrics leaves a childless device node; it must not masquerade as a
// committable metric leaf.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveEmptyBirthForTest("Plant1", "EdgeA", "Filler1");
var device = (await s.ExpandAsync("Plant1/EdgeA", TestContext.Current.CancellationToken))
.ShouldHaveSingleItem();
device.DisplayName.ShouldBe("Filler1");
device.Kind.ShouldBe(BrowseNodeKind.Folder);
}
[Fact]
public async Task SparkplugObserve_NonBirthMessages_BuildNoTree()
{
// Only NBIRTH/DBIRTH are self-describing. A DDATA metric carries an alias and no name at
// all, so anything built from one would be a tree of unnamed nodes.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveRawForTest("spBv1.0/Plant1/DDATA/EdgeA/Filler1", [0x08, 0x01]);
s.ObserveRawForTest("spBv1.0/Plant1/NCMD/EdgeA", [0x08, 0x01]);
s.ObserveRawForTest("spBv1.0/Plant1/NDEATH/EdgeA", [0x08, 0x01]);
s.ObserveRawForTest("spBv1.0/STATE/host1", "ONLINE"u8.ToArray());
s.ObserveRawForTest("some/plain/topic", "23.5"u8.ToArray()); // not Sparkplug at all
(await s.RootAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty();
s.PublishCountForTest.ShouldBe(0);
}
[Fact]
public void SparkplugObserve_GarbageBirthPayload_IsIgnoredAndNeverThrows()
{
// Runs on MQTTnet's dispatcher thread behind an unauthenticated firehose.
var s = new MqttBrowseSession(MqttMode.SparkplugB);
Should.NotThrow(() => s.ObserveRawForTest("spBv1.0/Plant1/NBIRTH/EdgeA", [0xFF, 0xFF, 0xFF, 0xFF]));
Should.NotThrow(() => s.ObserveRawForTest("spBv1.0/Plant1/NBIRTH/EdgeA", []));
}
// ------------------------------------------------------- Sparkplug: AttributesAsync
[Fact]
public async Task SparkplugAttributes_AreBirthDerived_NotPayloadSniffed()
{
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "Temperature", SparkplugDataType.Float);
var a = (await s.AttributesAsync(
"Plant1/EdgeA/Filler1::Temperature", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
a.Name.ShouldBe("Temperature");
a.DriverDataType.ShouldBe(nameof(Core.Abstractions.DriverDataType.Float32)); // from the birth
a.IsArray.ShouldBeFalse();
a.SecurityClass.ShouldContain("DBIRTH");
a.SecurityClass.ShouldContain("Float");
// The plain-mode payload-snippet machinery must not run: a Sparkplug body is protobuf, and
// sniffing it would report "(N bytes, binary)" for every metric in the plant.
a.SecurityClass.ShouldNotContain("binary");
a.SecurityClass.ShouldNotContain("bytes");
}
[Fact]
public async Task SparkplugAttributes_ArrayMetric_ReportsElementTypePlusIsArray()
{
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeA", null, "Profile", SparkplugDataType.Int32Array);
var a = (await s.AttributesAsync(
"Plant1/EdgeA::Profile", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
a.DriverDataType.ShouldBe(nameof(Core.Abstractions.DriverDataType.Int32)); // element type
a.IsArray.ShouldBeTrue();
}
[Fact]
public async Task SparkplugAttributes_UnsupportedDataType_IsReportedNotDefaulted()
{
// ToDriverDataType() returns null for DataSet/Template/PropertySet/PropertySetList/Unknown.
// A null must never be coerced into a guessed DriverDataType — the operator has to see that
// the metric is not bindable.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeA", null, "Curve", SparkplugDataType.DataSet);
var a = (await s.AttributesAsync(
"Plant1/EdgeA::Curve", TestContext.Current.CancellationToken)).ShouldHaveSingleItem();
Enum.TryParse<Core.Abstractions.DriverDataType>(a.DriverDataType, ignoreCase: true, out _)
.ShouldBeFalse(); // not a guessed type — unmappable, and visibly so
a.SecurityClass.ShouldContain("unsupported");
}
[Fact]
public async Task SparkplugAttributes_ForAFolderNode_AreEmpty()
{
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "Temperature", SparkplugDataType.Float);
(await s.AttributesAsync("Plant1", TestContext.Current.CancellationToken)).ShouldBeEmpty();
(await s.AttributesAsync("Plant1/EdgeA", TestContext.Current.CancellationToken)).ShouldBeEmpty();
(await s.AttributesAsync("Plant1/EdgeA/Filler1", TestContext.Current.CancellationToken)).ShouldBeEmpty();
(await s.AttributesAsync("no/such/node", TestContext.Current.CancellationToken)).ShouldBeEmpty();
}
// ------------------------------------------------------- Sparkplug: the one sanctioned publish
[Fact]
public async Task RequestRebirthAsync_ScopedToNode_PublishesOneNcmd()
{
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "T", SparkplugDataType.Float);
var published = await s.RequestRebirthAsync("Plant1/EdgeA", TestContext.Current.CancellationToken);
published.ShouldBe(1);
s.PublishCountForTest.ShouldBe(1);
s.PublishedTopicsForTest.ShouldBe(["spBv1.0/Plant1/NCMD/EdgeA"]);
}
[Fact]
public async Task RequestRebirthAsync_ForANeverSeenEdgeNode_StillPublishes()
{
// A node that has never birthed is the PRIME rebirth target — refusing to address one
// because the observation window never saw it would defeat the whole feature.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
(await s.RequestRebirthAsync("Plant1/EdgeGhost", TestContext.Current.CancellationToken)).ShouldBe(1);
s.PublishedTopicsForTest.ShouldBe(["spBv1.0/Plant1/NCMD/EdgeGhost"]);
}
[Fact]
public async Task RequestRebirthAsync_FromADeviceOrMetricNodeId_ResolvesUpToItsEdgeNode()
{
// NCMD is node-addressed; there is no device-scoped rebirth. Selecting a metric and
// clicking Request-rebirth must address the metric's OWNING edge node — resolved from the
// tuple stored on the node, never re-parsed out of the id string.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "T", SparkplugDataType.Float);
(await s.RequestRebirthAsync("Plant1/EdgeA/Filler1", TestContext.Current.CancellationToken)).ShouldBe(1);
(await s.RequestRebirthAsync("Plant1/EdgeA/Filler1::T", TestContext.Current.CancellationToken)).ShouldBe(1);
s.PublishedTopicsForTest.ShouldBe(["spBv1.0/Plant1/NCMD/EdgeA", "spBv1.0/Plant1/NCMD/EdgeA"]);
}
[Fact]
public async Task RequestRebirthAsync_GroupScope_PublishesExactlyOneNcmdPerObservedEdgeNode()
{
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
s.ObserveBirthForTest("Plant1", "EdgeB", null, "T", SparkplugDataType.Float);
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler1", "T", SparkplugDataType.Float);
s.ObserveBirthForTest("Plant1", "EdgeA", "Filler2", "T", SparkplugDataType.Float); // same node
s.ObserveBirthForTest("Plant2", "EdgeZ", null, "T", SparkplugDataType.Float); // other group
var published = await s.RequestRebirthAsync("Plant1", TestContext.Current.CancellationToken);
published.ShouldBe(2);
s.PublishCountForTest.ShouldBe(2);
s.PublishedTopicsForTest.ShouldBe(["spBv1.0/Plant1/NCMD/EdgeA", "spBv1.0/Plant1/NCMD/EdgeB"]);
}
[Fact]
public async Task RequestRebirthAsync_GroupScope_BeyondTheCap_PublishesNothingAtAll()
{
// A 200-node group must not emit 200 NCMDs. The refusal is all-or-nothing: a partial fan-out
// would leave the operator unable to say which half of the plant was asked to rebirth.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
for (var i = 0; i <= MqttBrowseSession.MaxGroupRebirthNodes; i++)
s.ObserveBirthForTest("Plant1", $"Edge{i:D3}", null, "T", SparkplugDataType.Float);
var ex = await Should.ThrowAsync<InvalidOperationException>(
async () => await s.RequestRebirthAsync("Plant1", TestContext.Current.CancellationToken));
ex.Message.ShouldContain(MqttBrowseSession.MaxGroupRebirthNodes.ToString());
s.PublishCountForTest.ShouldBe(0);
}
[Fact]
public async Task RequestRebirthAsync_GroupScope_TruncatedBeforeAnyEdgeNode_PublishesNothing()
{
// The node cap can leave a group node with no edge node recorded beneath it. A group that
// resolves to zero targets is refused rather than quietly succeeding with a count of 0 — the
// operator clicked Request-rebirth and is entitled to know nothing was sent.
await using var s = new MqttBrowseSession(MqttMode.SparkplugB, nodeCap: 1);
s.ObserveBirthForTest("Plant1", "EdgeA", null, "T", SparkplugDataType.Float);
s.Truncated.ShouldBeTrue();
(await s.ExpandAsync("Plant1", TestContext.Current.CancellationToken)).ShouldBeEmpty();
await Should.ThrowAsync<InvalidOperationException>(
async () => await s.RequestRebirthAsync("Plant1", TestContext.Current.CancellationToken));
s.PublishCountForTest.ShouldBe(0);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("Plant1")] // a group this window has never observed
[InlineData("Plant1/EdgeA/Filler1/Extra")] // unknown, and not resolvable to an edge node
public async Task RequestRebirthAsync_UnusableScope_PublishesNothing(string scope)
{
await using var s = new MqttBrowseSession(MqttMode.SparkplugB);
await Should.ThrowAsync<ArgumentException>(
async () => await s.RequestRebirthAsync(scope, TestContext.Current.CancellationToken));
s.PublishCountForTest.ShouldBe(0);
}
[Fact]
public async Task RequestRebirthAsync_InPlainMode_IsRefused()
{
// P1's guarantee is unconditional: a plain-topic browse window publishes nothing, ever.
await using var s = new MqttBrowseSession(MqttMode.Plain);
s.ObserveTopicForTest("factory/oven/temp", "1");
await Should.ThrowAsync<NotSupportedException>(
async () => await s.RequestRebirthAsync("Plant1/EdgeA", TestContext.Current.CancellationToken));
s.PublishCountForTest.ShouldBe(0);
}
[Fact]
public async Task RequestRebirthAsync_AfterDispose_Throws()
{
var s = new MqttBrowseSession(MqttMode.SparkplugB);
await s.DisposeAsync();
await Should.ThrowAsync<ObjectDisposedException>(
async () => await s.RequestRebirthAsync("Plant1/EdgeA", TestContext.Current.CancellationToken));
s.PublishCountForTest.ShouldBe(0);
}
// ------------------------------------------------------- Sparkplug: the browser
[Fact]
public async Task OpenAsync_SparkplugMode_NoLongerRefuses_ButStillReachesTheBroker()
{
// Task 23 unseals the Sparkplug branch. The open still has to CONNECT, so an unreachable
// broker fails the way any other open does — what must NOT come back is the Task-10
// NotSupportedException seal.
var browser = new MqttDriverBrowser();
const string cfg = """
{"host":"broker.invalid","port":8883,"mode":"SparkplugB","connectTimeoutSeconds":5,
"sparkplug":{"groupId":"Plant1"}}
""";
var ex = await Should.ThrowAsync<Exception>(
async () => await browser.OpenAsync(cfg, TestContext.Current.CancellationToken));
ex.ShouldNotBeOfType<NotSupportedException>();
}
[Theory]
[InlineData("Plant1", "spBv1.0/Plant1/#")]
[InlineData("", "spBv1.0/#")] // group not authored yet — discover every group
[InlineData(" ", "spBv1.0/#")]
public void BuildRootFilter_SparkplugMode_ScopesToTheSparkplugNamespace(string groupId, string expected)
{
var opts = new MqttDriverOptions
{
Mode = MqttMode.SparkplugB,
Sparkplug = new MqttSparkplugOptions { GroupId = groupId },
Plain = new MqttPlainOptions { TopicPrefix = "ignored/in/sparkplug/mode" },
};
MqttDriverBrowser.BuildRootFilter(opts).ShouldBe(expected);
}
[Fact]
public void BuildRootFilter_SparkplugMode_WithNoSparkplugSection_DiscoversEveryGroup()
{
var opts = new MqttDriverOptions { Mode = MqttMode.SparkplugB, Sparkplug = null };
MqttDriverBrowser.BuildRootFilter(opts).ShouldBe("spBv1.0/#");
}
}
@@ -1,3 +1,6 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
@@ -7,17 +10,23 @@ using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Browsing;
/// <summary>Unit tests for <see cref="BrowserSessionService"/> — driver-type dispatch
/// on open, NotFound semantics on unknown tokens, exception swallowing, and per-call
/// timeout enforcement.</summary>
/// on open, NotFound semantics on unknown tokens, exception swallowing, per-call
/// timeout enforcement, and the DriverOperator gate on the one write-capable member.</summary>
public sealed class BrowserSessionServiceTests
{
private static BrowserSessionService NewService(
BrowseSessionRegistry registry, params IDriverBrowser[] browsers) =>
new(browsers, registry, NullLogger<BrowserSessionService>.Instance, new FakeUniversalDriverBrowser());
NewService(registry, new FakeUniversalDriverBrowser(), browsers);
private static BrowserSessionService NewService(
BrowseSessionRegistry registry, IUniversalDriverBrowser universal, params IDriverBrowser[] browsers) =>
new(browsers, registry, NullLogger<BrowserSessionService>.Instance, universal);
new(browsers, registry, NullLogger<BrowserSessionService>.Instance, universal,
new FakeAuthorizationService(succeeds: true), new FakeAuthenticationStateProvider());
private static BrowserSessionService NewServiceWithAuthz(
BrowseSessionRegistry registry, bool authorized) =>
new([], registry, NullLogger<BrowserSessionService>.Instance, new FakeUniversalDriverBrowser(),
new FakeAuthorizationService(authorized), new FakeAuthenticationStateProvider());
[Fact]
public async Task OpenAsync_unknown_driver_type_returns_Ok_false_with_message()
@@ -226,4 +235,106 @@ public sealed class BrowserSessionServiceTests
// Unknown token is a no-op.
await Should.NotThrowAsync(() => service.CloseAsync(Guid.NewGuid()));
}
// ---------------------------------------------------- RequestRebirthAsync: the one write path
[Fact]
public async Task RequestRebirthAsync_WithoutDriverOperator_IsRefusedAndNeverReachesTheSession()
{
// A publishing action that skips authz is a security bug, not a style issue: this one makes a
// live plant broker receive a command. The refusal must happen BEFORE the session is touched.
var registry = new BrowseSessionRegistry();
var session = new FakeRebirthCapableBrowseSession();
registry.Register(session);
var service = NewServiceWithAuthz(registry, authorized: false);
await Should.ThrowAsync<UnauthorizedAccessException>(
() => service.RequestRebirthAsync(session.Token, "Plant1/EdgeA", CancellationToken.None));
session.Scopes.ShouldBeEmpty();
}
[Fact]
public async Task RequestRebirthAsync_WithDriverOperator_DispatchesTheScopeAndReturnsTheCount()
{
var registry = new BrowseSessionRegistry();
var session = new FakeRebirthCapableBrowseSession { Published = 2 };
registry.Register(session);
var service = NewServiceWithAuthz(registry, authorized: true);
var published = await service.RequestRebirthAsync(session.Token, "Plant1", CancellationToken.None);
published.ShouldBe(2);
session.Scopes.ShouldBe(["Plant1"]);
}
[Fact]
public async Task RequestRebirthAsync_OnANonRebirthCapableSession_IsNotSupported()
{
var registry = new BrowseSessionRegistry();
var session = new FakeBrowseSession();
registry.Register(session);
var service = NewServiceWithAuthz(registry, authorized: true);
await Should.ThrowAsync<NotSupportedException>(
() => service.RequestRebirthAsync(session.Token, "Plant1/EdgeA", CancellationToken.None));
}
[Fact]
public async Task RequestRebirthAsync_UnknownToken_ThrowsBrowseSessionNotFound()
{
var registry = new BrowseSessionRegistry();
var service = NewServiceWithAuthz(registry, authorized: true);
await Should.ThrowAsync<BrowseSessionNotFoundException>(
() => service.RequestRebirthAsync(Guid.NewGuid(), "Plant1/EdgeA", CancellationToken.None));
}
/// <summary>A browse session that records the rebirth scopes it was asked for.</summary>
private sealed class FakeRebirthCapableBrowseSession : IRebirthCapableBrowseSession
{
public Guid Token { get; } = Guid.NewGuid();
public DateTime LastUsedUtc { get; } = DateTime.UtcNow;
public int Published { get; init; } = 1;
public List<string> Scopes { get; } = [];
public Task<int> RequestRebirthAsync(string scope, CancellationToken cancellationToken)
{
Scopes.Add(scope);
return Task.FromResult(Published);
}
public Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<BrowseNode>>([]);
public Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<BrowseNode>>([]);
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken) =>
Task.FromResult<IReadOnlyList<AttributeInfo>>([]);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <summary>An <see cref="IAuthorizationService"/> whose verdict the test dictates.</summary>
private sealed class FakeAuthorizationService(bool succeeds) : IAuthorizationService
{
public Task<AuthorizationResult> AuthorizeAsync(
ClaimsPrincipal user, object? resource, IEnumerable<IAuthorizationRequirement> requirements) =>
Task.FromResult(succeeds ? AuthorizationResult.Success() : AuthorizationResult.Failed());
public Task<AuthorizationResult> AuthorizeAsync(ClaimsPrincipal user, object? resource, string policyName) =>
Task.FromResult(succeeds ? AuthorizationResult.Success() : AuthorizationResult.Failed());
}
/// <summary>A fixed authenticated identity for the circuit under test.</summary>
private sealed class FakeAuthenticationStateProvider : AuthenticationStateProvider
{
public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
Task.FromResult(new AuthenticationState(
new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Name, "tester")], "test"))));
}
}