using System.Text; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NSubstitute; using ZB.MOM.WW.Auth.Abstractions.Ldap; 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 the dev/test DisableLogin bypass on the management/CLI auth surface /// (). The bypass lets the HTTP-Basic CLI surfaces /// authenticate without LDAP when login is disabled — mirroring the interactive cookie /// handler — so a login-disabled deployment is not locked out of the CLI. /// public class ManagementAuthenticatorTests { private static HttpContext ContextWith(AuthDisableLoginOptions options) { var services = new ServiceCollection(); services.AddSingleton>(Options.Create(options)); return new DefaultHttpContext { RequestServices = services.BuildServiceProvider() }; } [Fact] public void TryDisableLoginUser_WhenDisabled_ReturnsNull() { var context = ContextWith(new AuthDisableLoginOptions { DisableLogin = false }); Assert.Null(ManagementAuthenticator.TryDisableLoginUser(context)); } [Fact] public void TryDisableLoginUser_WhenNoOptionsRegistered_ReturnsNull() { var context = new DefaultHttpContext { RequestServices = new ServiceCollection().BuildServiceProvider(), }; Assert.Null(ManagementAuthenticator.TryDisableLoginUser(context)); } [Fact] public void TryDisableLoginUser_WhenEnabled_ReturnsDevUserWithAllRolesSystemWide() { var context = ContextWith(new AuthDisableLoginOptions { DisableLogin = true, User = "ci-bot" }); var user = ManagementAuthenticator.TryDisableLoginUser(context); Assert.NotNull(user); Assert.Equal("ci-bot", user!.Username); Assert.Equal("ci-bot", user.DisplayName); Assert.Equal(Roles.All, user.Roles); Assert.Empty(user.PermittedSiteIds); // empty == system-wide, mirrors AutoLoginAuthenticationHandler } [Fact] public void TryDisableLoginUser_WhenEnabledWithBlankUser_FallsBackToMultiRole() { var context = ContextWith(new AuthDisableLoginOptions { DisableLogin = true, User = " " }); var user = ManagementAuthenticator.TryDisableLoginUser(context); Assert.NotNull(user); Assert.Equal("multi-role", user!.Username); } [Fact] public async Task AuthenticateAsync_WhenDisabled_BypassesBasicAuthAndReturnsDevUser() { // No Authorization header and no ILdapAuthService registered: the bypass must short-circuit // before either is consulted. var context = ContextWith(new AuthDisableLoginOptions { DisableLogin = true }); var outcome = await ManagementAuthenticator.AuthenticateAsync(context); Assert.Null(outcome.Failure); Assert.NotNull(outcome.User); Assert.Equal("multi-role", outcome.User!.Username); Assert.Equal(Roles.All, outcome.User.Roles); } [Fact] public async Task AuthenticateAsync_WhenEnabledWithNoHeader_ReturnsUnauthorized() { // Login NOT disabled and no Basic header: the real auth path rejects with a 401 result // before any LDAP lookup. var context = ContextWith(new AuthDisableLoginOptions { DisableLogin = false }); var outcome = await ManagementAuthenticator.AuthenticateAsync(context); Assert.Null(outcome.User); Assert.NotNull(outcome.Failure); } // --- Login-throttle wiring (arch-review P1/UA5) ------------------------------------------- /// /// Builds a real Basic-Auth request context wired with a /// (over the injected ) and the supplied LDAP mock, with the given /// remote IP. Returns the context plus the resolvable throttle so tests can seed failures. /// private static (HttpContext Context, LoginThrottle Throttle) BasicAuthContext( string username, string password, ILdapAuthService ldap, TimeProvider clock, string remoteIp = "127.0.0.1") { var services = new ServiceCollection(); services.AddSingleton>( Options.Create(new AuthDisableLoginOptions { DisableLogin = false })); services.AddSingleton>(Options.Create(new SecurityOptions())); services.AddSingleton(clock); services.AddSingleton(); services.AddSingleton(ldap); services.AddLogging(); // JsonHttpResult.ExecuteAsync resolves ILoggerFactory var provider = services.BuildServiceProvider(); var context = new DefaultHttpContext { RequestServices = provider }; var creds = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}")); context.Request.Headers.Authorization = $"Basic {creds}"; context.Connection.RemoteIpAddress = System.Net.IPAddress.Parse(remoteIp); context.Response.Body = new MemoryStream(); return (context, provider.GetRequiredService()); } private static async Task<(int Status, string Body)> ExecuteAsync(HttpContext context, IResult result) { await result.ExecuteAsync(context); context.Response.Body.Position = 0; using var reader = new StreamReader(context.Response.Body); var body = await reader.ReadToEndAsync(); return (context.Response.StatusCode, body); } [Fact] public async Task AuthenticateAsync_LockedOutKey_Returns429WithoutLdapBind() { var ldap = Substitute.For(); var (context, throttle) = BasicAuthContext("alice", "wrong", ldap, TimeProvider.System); for (var i = 0; i < 5; i++) throttle.RecordFailure("alice", "127.0.0.1"); var outcome = await ManagementAuthenticator.AuthenticateAsync(context); Assert.Null(outcome.User); Assert.NotNull(outcome.Failure); var (status, body) = await ExecuteAsync(context, outcome.Failure!); Assert.Equal(429, status); Assert.Contains("AUTH_THROTTLED", body); await ldap.DidNotReceiveWithAnyArgs().AuthenticateAsync(default!, default!, default); } [Fact] public async Task AuthenticateAsync_FailedBind_RecordsFailure() { var ldap = Substitute.For(); ldap.AuthenticateAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(LdapAuthResult.Fail(LdapAuthFailure.BadCredentials)); var (context, throttle) = BasicAuthContext("alice", "wrong", ldap, TimeProvider.System); var outcome = await ManagementAuthenticator.AuthenticateAsync(context); // The bind failed → a 401 is returned AND the failure is recorded on the throttle. Assert.Null(outcome.User); Assert.NotNull(outcome.Failure); Assert.False(throttle.IsLockedOut("alice", "127.0.0.1")); for (var i = 0; i < 4; i++) throttle.RecordFailure("alice", "127.0.0.1"); Assert.True(throttle.IsLockedOut("alice", "127.0.0.1")); } }