7af36fd5dc
Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
177 lines
7.2 KiB
C#
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"));
|
|
}
|
|
}
|