Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Browsing/BrowserSessionServiceTests.cs
Joseph Doherty 8d9155682d fix(mqtt): browse-commit writes a bindable address + a Request-rebirth affordance
Two gaps the Task 23/24 review found, both blocking Task 26's live gate.

Gap 1 — browse-commit produced a silently dead MQTT tag. RawBrowseCommitMapper
had no `Mqtt` case, so a committed leaf fell through to the generic
`{"address": …}` key. Neither MqttTagDefinitionFactory entry point reads
`address`: the tag deployed clean and reported BadNodeIdUnknown forever, with
no signal at commit time. Predates Task 23 (Plain was affected too), but Task 23
built the Sparkplug metric tree precisely so an operator could browse and commit
a binding.

The address is a DESCRIPTOR, not a reference string, and it cannot be recovered
from the browse node id: `{group}/{node}[/{device}]::{metric}` where a metric
name legitimately contains `/` (`Node Control/Rebirth`) — the ambiguity
MetricSeparator's remarks already name. So the session STATES it, via a new
`AttributeInfo.AddressFields` seam, and the mapper reads it. Which keys are
emitted is also how the mapper learns Plain vs Sparkplug — the driver type
reaching it is just `Mqtt`, and the mode lives on the driver config. Key names
are single-sourced in the new `MqttTagConfigKeys` (producer, mapper, factory),
with the literals pinned by a test so a symmetric rename cannot silently unbind
already-persisted blobs. A leaf with no stated address is refused at commit in
words rather than committed dead.

Gap 2 — RequestRebirthAsync had no UI. Task 23 shipped it backend-only; Task 26
step 1 assumes the button, and Task 25's runbook notes the picker tree stays
empty until a birth lands, so it is the only way to fill it on demand. Added to
the /raw browse modal: scope = the clicked tree node (device/metric resolve up to
their edge node, group fans out and is refused whole past 32), an explicit
two-click confirm naming the resolved scope and its consequence, the outcome or
the refusal shown rather than swallowed. Offered only for a Sparkplug session
(new `IRebirthCapableBrowseSession.RebirthAvailable` — one session class serves
both modes, so a type test alone would draw a button that can only throw) and
only to a DriverOperator; the server-side gate remains the boundary.

Tests: MQTT 545 → 581, AdminUI 781 → 800. The round-trip tests feed the emitted
blob to the REAL factory and were falsified by mutating the emitted key name.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
2026-07-24 23:43:07 -04:00

365 lines
15 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));
}
[Fact]
public void CanRequestRebirth_IsTrue_OnlyForASessionThatAdvertisesTheAction()
{
// The picker draws its Request-rebirth affordance off this. A session type CAN re-announce
// (MqttBrowseSession implements the interface) while a given INSTANCE cannot — a plain-MQTT
// window publishes nothing, ever — so a type test alone would draw a button that only throws.
var registry = new BrowseSessionRegistry();
var sparkplug = new FakeRebirthCapableBrowseSession();
var plain = new FakeRebirthCapableBrowseSession { RebirthAvailable = false };
var plainOldSession = new FakeBrowseSession();
registry.Register(sparkplug);
registry.Register(plain);
registry.Register(plainOldSession);
var service = NewServiceWithAuthz(registry, authorized: true);
service.CanRequestRebirth(sparkplug.Token).ShouldBeTrue();
service.CanRequestRebirth(plain.Token).ShouldBeFalse();
service.CanRequestRebirth(plainOldSession.Token).ShouldBeFalse();
service.CanRequestRebirth(Guid.NewGuid()).ShouldBeFalse(); // unknown/reaped token
}
/// <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;
/// <summary>Whether this fake session advertises the action (a plain-MQTT window does not).</summary>
public bool RebirthAvailable { get; init; } = true;
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"))));
}
}