513fa1aa16
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
189 lines
8.4 KiB
C#
189 lines
8.4 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() };
|
|
}
|
|
|
|
/// <summary>Verifies that no user is returned when the disable-login option is off.</summary>
|
|
[Fact]
|
|
public void TryDisableLoginUser_WhenDisabled_ReturnsNull()
|
|
{
|
|
var context = ContextWith(new AuthDisableLoginOptions { DisableLogin = false });
|
|
|
|
Assert.Null(ManagementAuthenticator.TryDisableLoginUser(context));
|
|
}
|
|
|
|
/// <summary>Verifies that no user is returned when no disable-login options are registered.</summary>
|
|
[Fact]
|
|
public void TryDisableLoginUser_WhenNoOptionsRegistered_ReturnsNull()
|
|
{
|
|
var context = new DefaultHttpContext
|
|
{
|
|
RequestServices = new ServiceCollection().BuildServiceProvider(),
|
|
};
|
|
|
|
Assert.Null(ManagementAuthenticator.TryDisableLoginUser(context));
|
|
}
|
|
|
|
/// <summary>Verifies that an enabled disable-login option returns a dev user holding all roles system-wide.</summary>
|
|
[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
|
|
}
|
|
|
|
/// <summary>Verifies that an enabled option with a blank user falls back to the multi-role user.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that authentication bypasses Basic auth and returns the dev user when login is disabled.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that authentication returns unauthorized when login is enabled and no header is sent.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that a locked-out key returns HTTP 429 without attempting an LDAP bind.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>Verifies that a failed LDAP bind records a failure against the lockout tracker.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[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"));
|
|
}
|
|
}
|