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:
+115
-4
@@ -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"))));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user