using System.Security.Claims; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.Abstractions.Ldap; using ZB.MOM.WW.Auth.Ldap; using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Dashboard; using LibraryLdapOptions = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions; namespace ZB.MOM.WW.MxGateway.IntegrationTests; [Collection(LiveResourcesCollection.Name)] [Trait("Category", "LiveLdap")] public sealed class DashboardLdapLiveTests { /// /// The shared dev/test directory issues every human tester the same well-known password, so /// the fixtures name it once rather than repeating a literal that drifts per test. This is a /// published dev credential (see glauth.md and scadaproj/infra/glauth/config.toml), /// not a secret — unlike the service-account bind password, which is never in source and must /// arrive via MxGateway__Ldap__ServiceAccountPassword. /// private const string SharedDirectoryPassword = "password"; /// /// Verifies that admin — a shared-directory user whose othergroups include /// GwAdmin (gid 5610) — authenticates successfully and is granted the Admin dashboard role. /// /// A task that represents the asynchronous operation. [LiveLdapFact] public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds() { DashboardAuthenticator authenticator = CreateAuthenticator(); DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( "admin", SharedDirectoryPassword, CancellationToken.None); Assert.True(result.Succeeded); Assert.NotNull(result.Principal); Assert.Equal("admin", result.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value); Assert.Contains(result.Principal.Claims, claim => claim.Type == DashboardAuthenticationDefaults.LdapGroupClaimType && claim.Value.Contains("GwAdmin", StringComparison.OrdinalIgnoreCase)); Assert.Contains(result.Principal.Claims, claim => claim.Type == ClaimTypes.Role && claim.Value == DashboardRoles.Admin); } /// /// Verifies that gw-viewer — a shared-directory user whose only group is GwReader /// (gid 5611), which this suite's GroupToRole map deliberately leaves unmapped — is denied /// even though its bind succeeds, and that the denial is indistinguishable from the /// unknown-user denial. /// /// A task that represents the asynchronous operation. [LiveLdapFact] public async Task AuthenticateAsync_ViewerMissingGwAdminGroup_FailsIndistinguishably() { DashboardAuthenticator authenticator = CreateAuthenticator(); DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( "gw-viewer", SharedDirectoryPassword, CancellationToken.None); Assert.False(result.Succeeded); Assert.Null(result.Principal); // This test used to assert the failure message did not echo the credential literal. // That check cannot survive the move to the shared directory: the real password is the // word "password", which legitimately occurs in the generic denial text ("The username // or password is invalid, ..."), so the assertion would fail for the wrong reason. The // no-leak property is still covered — with a distinctive literal — by // AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword below. What is // asserted here instead is the property this fixture is actually uniquely able to prove: // an authorization failure (valid credentials, no mapped role) must be reported with the // same message as an authentication failure, so the response cannot be used to enumerate // valid accounts. DashboardAuthenticationResult unknownUserResult = await authenticator.AuthenticateAsync( "no-such-user-9f3c1", "irrelevant-password", CancellationToken.None); Assert.False(string.IsNullOrWhiteSpace(result.FailureMessage)); Assert.Equal(unknownUserResult.FailureMessage, result.FailureMessage); } /// Verifies that authentication with wrong password fails without leaking the password. /// A task that represents the asynchronous operation. [LiveLdapFact] public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword() { // Exercises the user-bind-failure branch: the user exists and the service // account search succeeds, but the candidate bind is rejected. const string wrongPassword = "definitely-not-the-admin-password"; DashboardAuthenticator authenticator = CreateAuthenticator(); DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( "admin", wrongPassword, CancellationToken.None); Assert.False(result.Succeeded); Assert.Null(result.Principal); Assert.DoesNotContain(wrongPassword, result.FailureMessage, StringComparison.Ordinal); } /// Verifies that authentication with unknown username fails. /// A task that represents the asynchronous operation. [LiveLdapFact] public async Task AuthenticateAsync_UnknownUsername_Fails() { // Exercises the user-not-found branch: the service-account search returns no // entry, so no candidate bind is attempted. DashboardAuthenticator authenticator = CreateAuthenticator(); DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( "no-such-user-9f3c1", "irrelevant-password", CancellationToken.None); Assert.False(result.Succeeded); Assert.Null(result.Principal); } /// Verifies that authentication fails gracefully when the server is unreachable. /// A task that represents the asynchronous operation. [LiveLdapFact] public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing() { // Exercises the connect-failure path: overriding only the port keeps whatever host // the run targets (localhost by default, the shared GLAuth under the // MxGateway__Ldap__Server override) while pointing at a port nothing listens on, so // the connection error the shared LdapAuthService must absorb into a Fail result — // rather than propagate as an exception to the dashboard — is reproduced either way. DashboardAuthenticator authenticator = CreateAuthenticator(LibraryOptions() with { // 1 is a reserved port number that no LDAP server listens on. Port = 1, }); DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( "admin", SharedDirectoryPassword, CancellationToken.None); Assert.False(result.Succeeded); Assert.Null(result.Principal); } private static DashboardAuthenticator CreateAuthenticator() => CreateAuthenticator(LibraryOptions()); private static DashboardAuthenticator CreateAuthenticator(LibraryLdapOptions ldapOptions) { GatewayOptions gatewayOptions = new() { Dashboard = new DashboardOptions { GroupToRole = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["GwAdmin"] = DashboardRoles.Admin, }, }, }; return new DashboardAuthenticator( new LdapAuthService(ldapOptions), new DashboardGroupRoleMapper(Options.Create(gatewayOptions)), NullLogger.Instance); } /// /// Builds the shared library by binding the real /// MxGateway:Ldap configuration section the same way production does in /// AddZbLdapAuth(configuration, "MxGateway:Ldap"), rather than hand-copying the /// gateway shadow LdapOptions defaults field by field. /// Binding the section directly onto the shared type means the live tests exercise the /// exact option-binding path production uses, pick up every shared field (including /// , which governs the /// unreachable-server test's timing) at whatever value the operator configured, and /// cannot silently drop a field added to the shared type. The gateway's /// appsettings.json seeds the dev directory connection (port 3893, plaintext, /// AllowInsecure) but ships Server=localhost, so a run against the shared GLAuth /// needs the MxGateway__Ldap__Server=10.100.0.35 environment override that the /// AddEnvironmentVariables() layer below applies. /// private static LibraryLdapOptions LibraryOptions() { string repositoryRoot = IntegrationTestEnvironment.ResolveRepositoryRoot(AppContext.BaseDirectory); string appSettingsPath = Path.Combine( repositoryRoot, "src", "ZB.MOM.WW.MxGateway.Server", "appsettings.json"); IConfiguration configuration = new ConfigurationBuilder() .AddJsonFile(appSettingsPath, optional: false) .AddEnvironmentVariables() .Build(); // Same section production binds in AddZbLdapAuth(configuration, "MxGateway:Ldap"). // Get returns null only when the section is absent; appsettings.json always // carries it, so fall back to shared defaults defensively rather than throw. LibraryLdapOptions options = configuration.GetSection("MxGateway:Ldap").Get() ?? new LibraryLdapOptions(); // appsettings.json now ships the bind password as the unexpanded // "${secret:ldap/mxgateway/bind}" token (resolved at gateway startup by the // pre-host secrets expander, which this bare ConfigurationBuilder does not run). // AddEnvironmentVariables() above lets MxGateway__Ldap__ServiceAccountPassword // override the token with the real password. Fail loud rather than silently // binding with the literal token string, which would make every live test in // this file fail with a confusing LDAP bind error instead of an actionable one. if (string.IsNullOrEmpty(options.ServiceAccountPassword) || options.ServiceAccountPassword.StartsWith("${secret:", StringComparison.Ordinal)) { throw new InvalidOperationException( "Live LDAP tests require the real bind password via the " + "MxGateway__Ldap__ServiceAccountPassword environment variable " + "(appsettings now ships a ${secret:} token that this suite does not expand). " + "Set it before running."); } return options; } }