Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAuthenticatorTests.cs
T
Joseph Doherty 7e1af37eb1 feat(auth): MxGateway dashboard adopt ZbClaimTypes + ZbCookieDefaults, keep cookie name (Task 1.5)
- DashboardAuthenticator.CreatePrincipal: emit ZbClaimTypes.Username ("zb:username") with
  the login username, ZbClaimTypes.DisplayName ("zb:displayname") with the display name,
  ZbClaimTypes.Name (== ClaimTypes.Name) for Identity.Name resolution, ZbClaimTypes.Role
  (== ClaimTypes.Role) for IsInRole/[Authorize]. Keep ClaimTypes.NameIdentifier for back-compat
  read-sites; keep mxgateway:ldap_group unchanged (MxGateway-specific, no ZbClaimType for groups).
  ClaimsIdentity built with nameType=ZbClaimTypes.Name, roleType=ZbClaimTypes.Role.
- DashboardServiceCollectionExtensions.AddGatewayDashboard: route cookie hardening through
  ZbCookieDefaults.Apply(requireHttps:true, idleTimeout:8h); set cookie name/path/redirects
  after Apply; PostConfigure still overrides SecurePolicy per RequireHttpsCookie setting.
- DashboardAuthenticatorTests: add AuthenticateAsync_Success_EmitsCanonicalZbClaims asserting
  zb:username, zb:displayname, ZbClaimTypes.Role per role, Identity.Name, and ldap_group preserved.
2026-06-02 06:10:48 -04:00

311 lines
13 KiB
C#

using System.Security.Claims;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.Auth.AspNetCore;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
/// <summary>
/// Parity tests for <see cref="DashboardAuthenticator"/> after the cutover to the
/// shared <see cref="ILdapAuthService"/>. The library now owns connect/bind/search
/// (covered by its own tests); these tests fake the shared service to return known
/// groups and assert the dashboard-specific policy is unchanged: groups → roles via
/// <see cref="IGroupRoleMapper{TRole}"/>, no-roles-matched is denied, and the
/// resulting principal/claims keep their exact shape.
/// </summary>
public sealed class DashboardAuthenticatorTests
{
/// <summary>A blank username is rejected without touching the LDAP provider.</summary>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task AuthenticateAsync_BlankUsername_FailsWithoutCallingLdap(string? username)
{
FakeLdapAuthService ldap = new(LdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
username,
"password",
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Null(result.Principal);
Assert.False(ldap.WasCalled);
}
/// <summary>A blank password is rejected without touching the LDAP provider.</summary>
[Fact]
public async Task AuthenticateAsync_BlankPassword_FailsWithoutCallingLdap()
{
FakeLdapAuthService ldap = new(LdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"admin",
" ",
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Null(result.Principal);
Assert.False(ldap.WasCalled);
}
/// <summary>
/// A failed LDAP outcome (any failure bucket, including <see cref="LdapAuthFailure.Disabled"/>)
/// maps to the generic dashboard failure without leaking the raw password.
/// </summary>
[Theory]
[InlineData(LdapAuthFailure.Disabled)]
[InlineData(LdapAuthFailure.BadCredentials)]
[InlineData(LdapAuthFailure.UserNotFound)]
[InlineData(LdapAuthFailure.ServiceAccountBindFailed)]
public async Task AuthenticateAsync_LdapFailure_ReturnsFailureWithoutRawCredentials(
LdapAuthFailure failure)
{
FakeLdapAuthService ldap = new(LdapAuthResult.Fail(failure));
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"admin",
"admin123",
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Null(result.Principal);
Assert.DoesNotContain("admin123", result.FailureMessage, StringComparison.Ordinal);
}
/// <summary>
/// On success the principal carries the resolved roles, the LDAP-group claims
/// (short RDN names as returned by <see cref="ILdapAuthService"/>), the display
/// name (ClaimTypes.Name), and the username (ClaimTypes.NameIdentifier), under the
/// dashboard authentication scheme.
/// </summary>
[Fact]
public async Task AuthenticateAsync_Success_BuildsPrincipalWithExpectedClaims()
{
FakeLdapAuthService ldap = new(LdapAuthResult.Success(
username: "admin",
displayName: "Administrator",
groups: ["GwAdmin", "GwReader"]));
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"admin",
"admin123",
CancellationToken.None);
Assert.True(result.Succeeded);
ClaimsPrincipal principal = Assert.IsType<ClaimsPrincipal>(result.Principal);
Assert.Equal("admin", principal.FindFirst(ClaimTypes.NameIdentifier)?.Value);
Assert.Equal("Administrator", principal.FindFirst(ClaimTypes.Name)?.Value);
// Identity is built with the dashboard scheme and Name/Role claim types so
// IsInRole and Identity.Name keep working.
ClaimsIdentity identity = Assert.IsType<ClaimsIdentity>(principal.Identity);
Assert.Equal(DashboardAuthenticationDefaults.AuthenticationScheme, identity.AuthenticationType);
Assert.Equal("Administrator", identity.Name);
// Both groups mapped → both roles present.
Assert.True(principal.IsInRole(DashboardRoles.Admin));
Assert.True(principal.IsInRole(DashboardRoles.Viewer));
// LDAP groups (already short RDN names from the service) are surfaced under
// the dedicated group claim type.
IReadOnlyList<string> groupClaims = principal.FindAll(
DashboardAuthenticationDefaults.LdapGroupClaimType)
.Select(claim => claim.Value)
.ToList();
Assert.Equal(["GwAdmin", "GwReader"], groupClaims);
}
/// <summary>
/// Task 1.5: the principal emits canonical ZbClaimTypes.Username ("zb:username") with
/// the login username, ZbClaimTypes.Role (= ClaimTypes.Role) for each resolved role, and
/// ZbClaimTypes.DisplayName ("zb:displayname") with the display name — while keeping
/// ClaimTypes.NameIdentifier, ClaimTypes.Name, and mxgateway:ldap_group claims intact.
/// </summary>
[Fact]
public async Task AuthenticateAsync_Success_EmitsCanonicalZbClaims()
{
FakeLdapAuthService ldap = new(LdapAuthResult.Success(
username: "jsmith",
displayName: "John Smith",
groups: ["GwAdmin", "GwReader"]));
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"jsmith",
"pw",
CancellationToken.None);
Assert.True(result.Succeeded);
ClaimsPrincipal principal = Assert.IsType<ClaimsPrincipal>(result.Principal);
// ZbClaimTypes.Username ("zb:username") carries the login username.
Assert.Equal("jsmith", principal.FindFirstValue(ZbClaimTypes.Username));
// ZbClaimTypes.DisplayName ("zb:displayname") carries the display name.
Assert.Equal("John Smith", principal.FindFirstValue(ZbClaimTypes.DisplayName));
// ZbClaimTypes.Role (= ClaimTypes.Role) is used for each role claim.
IReadOnlyList<string> roleClaims = principal
.FindAll(ZbClaimTypes.Role)
.Select(c => c.Value)
.OrderBy(r => r)
.ToList();
Assert.Contains(DashboardRoles.Admin, roleClaims);
Assert.Contains(DashboardRoles.Viewer, roleClaims);
// IsInRole still works (identity built with roleType = ZbClaimTypes.Role).
Assert.True(principal.IsInRole(DashboardRoles.Admin));
Assert.True(principal.IsInRole(DashboardRoles.Viewer));
// ClaimTypes.Name still resolves as Identity.Name (nameType = ZbClaimTypes.Name = ClaimTypes.Name).
Assert.Equal("John Smith", principal.Identity?.Name);
// mxgateway:ldap_group claims are preserved.
IReadOnlyList<string> groups = principal
.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType)
.Select(c => c.Value)
.ToList();
Assert.Equal(["GwAdmin", "GwReader"], groups);
}
/// <summary>
/// When the user authenticates but none of their groups map to a dashboard role,
/// the login is denied (the long-standing "no roles matched → denied" rule).
/// </summary>
[Fact]
public async Task AuthenticateAsync_NoRolesMatched_DeniesLogin()
{
FakeLdapAuthService ldap = new(LdapAuthResult.Success(
username: "nobody",
displayName: "No Body",
groups: ["SomeUnmappedGroup"]));
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"nobody",
"pw",
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Null(result.Principal);
}
/// <summary>
/// Direct-injection path only (review C1): when an <see cref="ILdapAuthService"/>
/// implementation hands the authenticator a full distinguished name as a group, the
/// mapper's leading-RDN fallback still resolves the role, and whatever string the
/// service supplied is surfaced verbatim on the group claim.
/// <para>
/// This is NOT the real production flow. The shared <c>ZB.MOM.WW.Auth.Ldap</c>
/// provider strips each group DN to its short RDN name before returning it, so the
/// authenticator never receives a full DN from the real library and the group claim
/// in production carries the short name (e.g. <c>GwAdmin</c>), not the DN. This test
/// uses a fake service to exercise only the authenticator's own pass-through of group
/// values; see <see cref="AuthenticateAsync_Success_BuildsPrincipalWithExpectedClaims"/>
/// for the realistic (already-short) group shape.
/// </para>
/// </summary>
[Fact]
public async Task AuthenticateAsync_GroupAsDistinguishedNameFromService_ResolvesRoleAndSurfacesServiceValue()
{
const string groupDn = "ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local";
FakeLdapAuthService ldap = new(LdapAuthResult.Success(
username: "admin",
displayName: "admin",
groups: [groupDn]));
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"admin",
"admin123",
CancellationToken.None);
Assert.True(result.Succeeded);
ClaimsPrincipal principal = Assert.IsType<ClaimsPrincipal>(result.Principal);
// Role resolves via the leading-RDN fallback in DashboardGroupRoleMapping.
Assert.True(principal.IsInRole(DashboardRoles.Admin));
// The authenticator surfaces the value the (fake) service returned, verbatim.
// With the real library this value would already be the short RDN ("GwAdmin").
Assert.Contains(
principal.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType),
claim => claim.Value == groupDn);
}
/// <summary>The (already-trimmed) username from the LDAP result flows onto the principal.</summary>
[Fact]
public async Task AuthenticateAsync_UsesUsernameFromLdapResult()
{
FakeLdapAuthService ldap = new(LdapAuthResult.Success(
username: "canonical",
displayName: "Canonical User",
groups: ["GwReader"]));
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
" canonical ",
"pw",
CancellationToken.None);
Assert.True(result.Succeeded);
ClaimsPrincipal principal = Assert.IsType<ClaimsPrincipal>(result.Principal);
Assert.Equal("canonical", principal.FindFirst(ClaimTypes.NameIdentifier)?.Value);
// The authenticator trims before delegating, so the provider sees the canonical value.
Assert.Equal("canonical", ldap.LastUsername);
}
private static DashboardAuthenticator CreateAuthenticator(
ILdapAuthService ldapAuthService,
Dictionary<string, string> groupToRole)
{
GatewayOptions options = new()
{
Dashboard = new DashboardOptions
{
GroupToRole = groupToRole,
},
};
IGroupRoleMapper<string> roleMapper = new DashboardGroupRoleMapper(Options.Create(options));
return new DashboardAuthenticator(
ldapAuthService,
roleMapper,
NullLogger<DashboardAuthenticator>.Instance);
}
private static Dictionary<string, string> StandardMapping() => new(StringComparer.OrdinalIgnoreCase)
{
["GwAdmin"] = DashboardRoles.Admin,
["GwReader"] = DashboardRoles.Viewer,
};
/// <summary>
/// Fake <see cref="ILdapAuthService"/> returning a fixed result, recording whether it was
/// invoked and with what username so the authenticator's pre-checks and trimming can be asserted.
/// </summary>
private sealed class FakeLdapAuthService(LdapAuthResult result) : ILdapAuthService
{
public bool WasCalled { get; private set; }
public string? LastUsername { get; private set; }
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct)
{
WasCalled = true;
LastUsername = username;
return Task.FromResult(result);
}
}
}