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;
///
/// Parity tests for after the cutover to the
/// shared . 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
/// , no-roles-matched is denied, and the
/// resulting principal/claims keep their exact shape.
///
public sealed class DashboardAuthenticatorTests
{
/// A blank username is rejected without touching the LDAP provider.
[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);
}
/// A blank password is rejected without touching the LDAP provider.
[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);
}
///
/// A failed LDAP outcome (any failure bucket, including )
/// maps to the generic dashboard failure without leaking the raw password.
///
[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);
}
///
/// On success the principal carries the resolved roles, the LDAP-group claims
/// (short RDN names as returned by ), the display
/// name (ClaimTypes.Name), and the username (ClaimTypes.NameIdentifier), under the
/// dashboard authentication scheme.
///
[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(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(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 groupClaims = principal.FindAll(
DashboardAuthenticationDefaults.LdapGroupClaimType)
.Select(claim => claim.Value)
.ToList();
Assert.Equal(["GwAdmin", "GwReader"], groupClaims);
}
///
/// 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.
///
[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(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 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 groups = principal
.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType)
.Select(c => c.Value)
.ToList();
Assert.Equal(["GwAdmin", "GwReader"], groups);
}
///
/// 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).
///
[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);
}
///
/// Direct-injection path only (review C1): when an
/// 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.
///
/// This is NOT the real production flow. The shared ZB.MOM.WW.Auth.Ldap
/// 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. GwAdmin), not the DN. This test
/// uses a fake service to exercise only the authenticator's own pass-through of group
/// values; see
/// for the realistic (already-short) group shape.
///
///
[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(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);
}
/// The (already-trimmed) username from the LDAP result flows onto the principal.
[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(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 groupToRole)
{
GatewayOptions options = new()
{
Dashboard = new DashboardOptions
{
GroupToRole = groupToRole,
},
};
IGroupRoleMapper roleMapper = new DashboardGroupRoleMapper(Options.Create(options));
return new DashboardAuthenticator(
ldapAuthService,
roleMapper,
NullLogger.Instance);
}
private static Dictionary StandardMapping() => new(StringComparer.OrdinalIgnoreCase)
{
["GwAdmin"] = DashboardRoles.Admin,
["GwReader"] = DashboardRoles.Viewer,
};
///
/// Fake returning a fixed result, recording whether it was
/// invoked and with what username so the authenticator's pre-checks and trimming can be asserted.
///
private sealed class FakeLdapAuthService(LdapAuthResult result) : ILdapAuthService
{
public bool WasCalled { get; private set; }
public string? LastUsername { get; private set; }
public Task AuthenticateAsync(string username, string password, CancellationToken ct)
{
WasCalled = true;
LastUsername = username;
return Task.FromResult(result);
}
}
}