Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementAuthenticatorTests.cs
T

177 lines
7.2 KiB
C#

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;
/// <summary>
/// Tests for the dev/test <c>DisableLogin</c> bypass on the management/CLI auth surface
/// (<see cref="ManagementAuthenticator"/>). 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.
/// </summary>
public class ManagementAuthenticatorTests
{
private static HttpContext ContextWith(AuthDisableLoginOptions options)
{
var services = new ServiceCollection();
services.AddSingleton<IOptions<AuthDisableLoginOptions>>(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) -------------------------------------------
/// <summary>
/// Builds a real Basic-Auth request context wired with a <see cref="LoginThrottle"/>
/// (over the injected <paramref name="clock"/>) and the supplied LDAP mock, with the given
/// remote IP. Returns the context plus the resolvable throttle so tests can seed failures.
/// </summary>
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<IOptions<AuthDisableLoginOptions>>(
Options.Create(new AuthDisableLoginOptions { DisableLogin = false }));
services.AddSingleton<IOptions<SecurityOptions>>(Options.Create(new SecurityOptions()));
services.AddSingleton(clock);
services.AddSingleton<LoginThrottle>();
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<LoginThrottle>());
}
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<ILdapAuthService>();
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<ILdapAuthService>();
ldap.AuthenticateAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.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"));
}
}