using System.Net; using System.Text; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Connections.Features; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using NSubstitute; using ZB.MOM.WW.Auth.Abstractions.Ldap; using ZB.MOM.WW.ScadaBridge.Communication; using ZB.MOM.WW.ScadaBridge.Communication.Grpc; using ZB.MOM.WW.ScadaBridge.ManagementService; using ZB.MOM.WW.ScadaBridge.Security; using ZB.MOM.WW.ScadaBridge.Security.Auth; namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; /// /// Tests for per-instance site-scope authorization /// (finding ManagementService-003). /// public class DebugStreamHubTests { [Fact] public void IsInstanceAccessAllowed_SiteScopedUser_InScopeInstance_Allowed() { var allowed = DebugStreamHub.IsInstanceAccessAllowed( roles: new[] { "Deployer" }, permittedSiteIds: new[] { "1", "2" }, instanceSiteId: 2); Assert.True(allowed); } [Fact] public void IsInstanceAccessAllowed_SiteScopedUser_OutOfScopeInstance_Denied() { var allowed = DebugStreamHub.IsInstanceAccessAllowed( roles: new[] { "Deployer" }, permittedSiteIds: new[] { "1", "2" }, instanceSiteId: 99); Assert.False(allowed); } [Fact] public void IsInstanceAccessAllowed_SystemWideDeployment_AnySiteAllowed() { // Empty permitted set == system-wide Deployer. var allowed = DebugStreamHub.IsInstanceAccessAllowed( roles: new[] { "Deployer" }, permittedSiteIds: Array.Empty(), instanceSiteId: 99); Assert.True(allowed); } [Fact] public void IsInstanceAccessAllowed_AdminRole_BypassesSiteScope() { var allowed = DebugStreamHub.IsInstanceAccessAllowed( roles: new[] { "Administrator", "Deployer" }, permittedSiteIds: new[] { "1" }, instanceSiteId: 99); Assert.True(allowed); } [Fact] public void IsInstanceAccessAllowed_AdminRoleCheck_IsCaseInsensitive() { var allowed = DebugStreamHub.IsInstanceAccessAllowed( roles: new[] { "administrator" }, permittedSiteIds: new[] { "1" }, instanceSiteId: 99); Assert.True(allowed); } // --- OnConnectedAsync delegates to ManagementAuthenticator (arch-review R2 N1) ------------ private readonly ILdapAuthService _ldap = Substitute.For(); private readonly ServiceProvider _provider; private readonly LoginThrottle _throttle; public DebugStreamHubTests() { var services = new ServiceCollection(); services.AddSingleton>( Options.Create(new AuthDisableLoginOptions { DisableLogin = false })); services.AddSingleton>(Options.Create(new SecurityOptions())); services.AddSingleton(TimeProvider.System); services.AddSingleton(); services.AddSingleton(_ldap); services.AddLogging(); _provider = services.BuildServiceProvider(); _throttle = _provider.GetRequiredService(); } private async Task ConnectAsync(string username, string password) { var http = new DefaultHttpContext { RequestServices = _provider }; var creds = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")); http.Request.Headers.Authorization = $"Basic {creds}"; http.Connection.RemoteIpAddress = IPAddress.Parse("10.0.0.1"); var debugStreamService = new DebugStreamService( new CommunicationService(Options.Create(new CommunicationOptions()), NullLogger.Instance), new ServiceCollection().BuildServiceProvider(), new SiteStreamGrpcClientFactory(NullLoggerFactory.Instance), NullLogger.Instance); var hubContext = Substitute.For>(); var hub = new DebugStreamHub(debugStreamService, hubContext, NullLogger.Instance); var ctx = new TestHubCallerContext(http); hub.Context = ctx; await hub.OnConnectedAsync(); return ctx; } [Fact] public async Task OnConnectedAsync_FailedBinds_FeedTheSharedLoginThrottle() { // 5 failed hub connects for alice@10.0.0.1 must arm the same lockout the // /management and /auth surfaces consult (arch-review R2 N1). _ldap.AuthenticateAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(LdapAuthResult.Fail(LdapAuthFailure.BadCredentials)); for (var i = 0; i < 5; i++) await ConnectAsync("alice", "wrong"); // each Aborted Assert.True(_throttle.IsLockedOut("alice", "10.0.0.1")); } [Fact] public async Task OnConnectedAsync_LockedOutKey_AbortsWithoutContactingLdap() { for (var i = 0; i < 5; i++) _throttle.RecordFailure("alice", "10.0.0.1"); _ldap.ClearReceivedCalls(); var ctx = await ConnectAsync("alice", "whatever"); Assert.True(ctx.Aborted); await _ldap.DidNotReceiveWithAnyArgs().AuthenticateAsync(default!, default!, default); } private sealed class TestHubCallerContext : HubCallerContext { private readonly FeatureCollection _features = new(); public TestHubCallerContext(HttpContext http) => _features.Set(new TestHttpContextFeature { HttpContext = http }); public bool Aborted { get; private set; } public override string ConnectionId => "test-conn"; public override string? UserIdentifier => null; public override System.Security.Claims.ClaimsPrincipal? User => null; public override IDictionary Items { get; } = new Dictionary(); public override IFeatureCollection Features => _features; public override CancellationToken ConnectionAborted => CancellationToken.None; public override void Abort() => Aborted = true; private sealed class TestHttpContextFeature : IHttpContextFeature { public HttpContext? HttpContext { get; set; } } } }