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. /// public class DebugStreamHubTests { /// Verifies that a site-scoped user is allowed to access an instance within their site scope. [Fact] public void IsInstanceAccessAllowed_SiteScopedUser_InScopeInstance_Allowed() { var allowed = DebugStreamHub.IsInstanceAccessAllowed( roles: new[] { "Deployer" }, permittedSiteIds: new[] { "1", "2" }, instanceSiteId: 2); Assert.True(allowed); } /// Verifies that a site-scoped user is denied access to an instance outside their site scope. [Fact] public void IsInstanceAccessAllowed_SiteScopedUser_OutOfScopeInstance_Denied() { var allowed = DebugStreamHub.IsInstanceAccessAllowed( roles: new[] { "Deployer" }, permittedSiteIds: new[] { "1", "2" }, instanceSiteId: 99); Assert.False(allowed); } /// Verifies that a system-wide deployment user is allowed to access instances at any site. [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); } /// Verifies that the Administrator role bypasses site scoping. [Fact] public void IsInstanceAccessAllowed_AdminRole_BypassesSiteScope() { var allowed = DebugStreamHub.IsInstanceAccessAllowed( roles: new[] { "Administrator", "Deployer" }, permittedSiteIds: new[] { "1" }, instanceSiteId: 99); Assert.True(allowed); } /// Verifies that the Administrator role check is case-insensitive. [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; /// Initializes the test with a substituted LDAP service, a login throttle and a service provider. 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; } /// Verifies that failed binds on connect feed the shared login throttle. /// A task that represents the asynchronous operation. [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")); } /// Verifies that a locked-out key aborts the connection without contacting LDAP. /// A task that represents the asynchronous operation. [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(); /// Initializes the context so that its HTTP context feature returns the given request context. /// The HTTP context exposed through the context feature. public TestHubCallerContext(HttpContext http) => _features.Set(new TestHttpContextFeature { HttpContext = http }); /// Gets a value indicating whether has been called. 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 { /// Gets or sets the HTTP context exposed by the feature. public HttpContext? HttpContext { get; set; } } } }