Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowserSessionServiceTests.cs
T
Joseph Doherty 64ec7aa43a 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
2026-07-24 22:51:16 -04:00

341 lines
14 KiB
C#

using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
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, 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) =>
NewService(registry, new FakeUniversalDriverBrowser(), browsers);
private static BrowserSessionService NewService(
BrowseSessionRegistry registry, IUniversalDriverBrowser universal, params IDriverBrowser[] browsers) =>
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()
{
var registry = new BrowseSessionRegistry();
var service = NewService(registry, new FakeDriverBrowser("Known"));
var result = await service.OpenAsync("Unknown", "{}", CancellationToken.None);
result.Ok.ShouldBeFalse();
result.Token.ShouldBe(Guid.Empty);
result.Message.ShouldNotBeNull();
result.Message!.ShouldContain("Unknown");
}
[Fact]
public async Task OpenAsync_happy_path_returns_token_and_registers()
{
var registry = new BrowseSessionRegistry();
var session = new FakeBrowseSession();
var browser = new FakeDriverBrowser("Galaxy")
{
OpenHandler = (_, _) => Task.FromResult<IBrowseSession>(session),
};
var service = NewService(registry, browser);
var result = await service.OpenAsync("Galaxy", "{}", CancellationToken.None);
result.Ok.ShouldBeTrue();
result.Message.ShouldBeNull();
result.Token.ShouldNotBe(Guid.Empty);
result.Token.ShouldBe(session.Token);
registry.TryGet(result.Token, out var registered).ShouldBeTrue();
registered.ShouldBeSameAs((IBrowseSession)session);
}
[Fact]
public async Task OpenAsync_swallows_driver_throws_returns_Ok_false()
{
var registry = new BrowseSessionRegistry();
var browser = new FakeDriverBrowser("Galaxy")
{
OpenHandler = (_, _) => throw new InvalidOperationException("boom"),
};
var service = NewService(registry, browser);
var result = await service.OpenAsync("Galaxy", "{}", CancellationToken.None);
result.Ok.ShouldBeFalse();
result.Token.ShouldBe(Guid.Empty);
result.Message.ShouldNotBeNull();
result.Message!.ShouldContain("boom");
}
[Fact]
public async Task Open_BespokeBrowserWinsOverUniversal()
{
var registry = new BrowseSessionRegistry();
var bespokeSession = new FakeBrowseSession();
var bespoke = new FakeDriverBrowser("AbCip")
{
OpenHandler = (_, _) => Task.FromResult<IBrowseSession>(bespokeSession),
};
var universal = new FakeUniversalDriverBrowser
{
CanBrowsePredicate = (_, _) => true, // universal would also serve it
OpenHandler = (_, _, _) => Task.FromResult<IBrowseSession>(new FakeBrowseSession()),
};
var service = NewService(registry, universal, bespoke);
var result = await service.OpenAsync("AbCip", "{}", CancellationToken.None);
result.Ok.ShouldBeTrue();
result.Token.ShouldBe(bespokeSession.Token); // bespoke session, not the universal one
}
[Fact]
public async Task Open_FallsBackToUniversal_WhenNoBespokeAndCanBrowse()
{
var registry = new BrowseSessionRegistry();
var universalSession = new FakeBrowseSession();
var universal = new FakeUniversalDriverBrowser
{
CanBrowsePredicate = (t, _) => t == "AbCip",
OpenHandler = (_, _, _) => Task.FromResult<IBrowseSession>(universalSession),
};
var service = NewService(registry, universal); // no bespoke browsers
var result = await service.OpenAsync("AbCip", "{}", CancellationToken.None);
result.Ok.ShouldBeTrue();
result.Token.ShouldBe(universalSession.Token);
registry.TryGet(result.Token, out _).ShouldBeTrue();
}
[Fact]
public async Task Open_NoBespokeAndCannotBrowse_ReturnsNoBrowserMessage()
{
var registry = new BrowseSessionRegistry();
var service = NewService(registry, new FakeUniversalDriverBrowser()); // CanBrowse=false
var result = await service.OpenAsync("Modbus", "{}", CancellationToken.None);
result.Ok.ShouldBeFalse();
result.Token.ShouldBe(Guid.Empty);
result.Message!.ShouldContain("Modbus");
}
[Fact]
public async Task Open_UniversalThrow_ReturnsOkFalseWithMessage()
{
var registry = new BrowseSessionRegistry();
var universal = new FakeUniversalDriverBrowser
{
CanBrowsePredicate = (_, _) => true,
OpenHandler = (_, _, _) => throw new InvalidOperationException("universal-boom"),
};
var service = NewService(registry, universal);
var result = await service.OpenAsync("AbCip", "{}", CancellationToken.None);
result.Ok.ShouldBeFalse();
result.Message!.ShouldContain("universal-boom");
}
[Fact]
public void CanBrowse_TrueForBespokeType_TrueForUniversalType_FalseOtherwise()
{
var registry = new BrowseSessionRegistry();
var universal = new FakeUniversalDriverBrowser { CanBrowsePredicate = (t, _) => t == "AbCip" };
var service = NewService(registry, universal, new FakeDriverBrowser("Galaxy"));
service.CanBrowse("Galaxy", "{}").ShouldBeTrue(); // bespoke registered
service.CanBrowse("AbCip", "{}").ShouldBeTrue(); // universal can serve
service.CanBrowse("Modbus", "{}").ShouldBeFalse(); // neither
}
[Fact]
public async Task RootAsync_unknown_token_throws_BrowseSessionNotFoundException()
{
var registry = new BrowseSessionRegistry();
var service = NewService(registry);
await Should.ThrowAsync<BrowseSessionNotFoundException>(
() => service.RootAsync(Guid.NewGuid(), CancellationToken.None));
}
[Fact]
public async Task RootAsync_invokes_session_Root()
{
var registry = new BrowseSessionRegistry();
IReadOnlyList<BrowseNode> expected = new[]
{
new BrowseNode("ns=2;s=A", "A", BrowseNodeKind.Folder, true),
new BrowseNode("ns=2;s=B", "B", BrowseNodeKind.Leaf, false),
};
var session = new FakeBrowseSession { RootHandler = _ => Task.FromResult(expected) };
registry.Register(session);
var service = NewService(registry);
var actual = await service.RootAsync(session.Token, CancellationToken.None);
actual.ShouldBe(expected);
}
[Fact]
public async Task RootAsync_enforces_PerCallTimeout()
{
var registry = new BrowseSessionRegistry();
var session = new FakeBrowseSession
{
// Awaits the linked CTS — the service must cancel it via PerCallTimeout (20s).
RootHandler = ct => Task.Delay(TimeSpan.FromSeconds(40), ct)
.ContinueWith<IReadOnlyList<BrowseNode>>(_ => Array.Empty<BrowseNode>(), ct),
};
registry.Register(session);
var service = NewService(registry);
var sw = System.Diagnostics.Stopwatch.StartNew();
await Should.ThrowAsync<OperationCanceledException>(
() => service.RootAsync(session.Token, CancellationToken.None));
sw.Stop();
// Real timeout is 20s; allow generous slack but cap well below the 40s task delay.
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(35));
}
[Fact]
public async Task CloseAsync_removes_and_disposes_session()
{
var registry = new BrowseSessionRegistry();
var session = new FakeBrowseSession();
var browser = new FakeDriverBrowser("Galaxy")
{
OpenHandler = (_, _) => Task.FromResult<IBrowseSession>(session),
};
var service = NewService(registry, browser);
var opened = await service.OpenAsync("Galaxy", "{}", CancellationToken.None);
opened.Ok.ShouldBeTrue();
await service.CloseAsync(opened.Token);
registry.TryGet(opened.Token, out _).ShouldBeFalse();
session.Disposed.ShouldBeTrue();
// 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"))));
}
}