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); } } }