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; /// Unit tests for — 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. 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.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() { 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(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(bespokeSession), }; var universal = new FakeUniversalDriverBrowser { CanBrowsePredicate = (_, _) => true, // universal would also serve it OpenHandler = (_, _, _) => Task.FromResult(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(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( () => service.RootAsync(Guid.NewGuid(), CancellationToken.None)); } [Fact] public async Task RootAsync_invokes_session_Root() { var registry = new BrowseSessionRegistry(); IReadOnlyList 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>(_ => Array.Empty(), ct), }; registry.Register(session); var service = NewService(registry); var sw = System.Diagnostics.Stopwatch.StartNew(); await Should.ThrowAsync( () => 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(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( () => 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)); } [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 } /// 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; /// Whether this fake session advertises the action (a plain-MQTT window does not). public bool RebirthAvailable { get; init; } = true; 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")))); } }