test(ldap): align DashboardLdapLiveTests fixtures with the shared directory (NEXT-06)

The suite's fixtures had drifted from the shared GLAuth config, so a green run
proved nothing about the service-account bind: the only success-path test used
admin/admin123, but the directory's admin carries the standard dev password, and
the "not an admin" test used a readonly user that does not exist there at all --
it passed via the user-not-found branch rather than the group-missing branch it
names.

Realign to real users from scadaproj/infra/glauth/config.toml: admin/password
(othergroups include GwAdmin, gid 5610) for the success path, and
gw-viewer/password (GwReader only, gid 5611) for the bind-succeeds-but-no-role
path. Both are published dev credentials documented in glauth.md, not secrets.

The gw-viewer test drops its old no-leak assertion on the credential literal:
the real password is the word "password", which legitimately occurs in the
generic denial text, so the check would fail for the wrong reason. The no-leak
property is still covered with a distinctive literal by the wrong-password test.
In its place the test now asserts the property this fixture is uniquely able to
prove -- an authorization failure must be reported with the same message as an
authentication failure, so it cannot be used to enumerate valid accounts.

appsettings ships Server=localhost, so document the MxGateway__Ldap__Server
override the suite needs to reach the shared GLAuth alongside the existing
MXGATEWAY_RUN_LIVE_LDAP_TESTS and ServiceAccountPassword variables.

Verified live: Failed: 0, Passed: 5 against 10.100.0.35:3893.
This commit is contained in:
Joseph Doherty
2026-08-07 10:03:10 -04:00
parent 3d991d2160
commit de67b45d04
3 changed files with 77 additions and 25 deletions
@@ -14,7 +14,19 @@ namespace ZB.MOM.WW.MxGateway.IntegrationTests;
[Trait("Category", "LiveLdap")]
public sealed class DashboardLdapLiveTests
{
/// <summary>Verifies that an admin user in the GwAdmin group authenticates successfully.</summary>
/// <summary>
/// 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 <c>glauth.md</c> and <c>scadaproj/infra/glauth/config.toml</c>),
/// not a secret — unlike the service-account bind password, which is never in source and must
/// arrive via <c>MxGateway__Ldap__ServiceAccountPassword</c>.
/// </summary>
private const string SharedDirectoryPassword = "password";
/// <summary>
/// Verifies that <c>admin</c> — a shared-directory user whose <c>othergroups</c> include
/// GwAdmin (gid 5610) — authenticates successfully and is granted the Admin dashboard role.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveLdapFact]
public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds()
@@ -23,7 +35,7 @@ public sealed class DashboardLdapLiveTests
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"admin",
"admin123",
SharedDirectoryPassword,
CancellationToken.None);
Assert.True(result.Succeeded);
@@ -38,21 +50,43 @@ public sealed class DashboardLdapLiveTests
&& claim.Value == DashboardRoles.Admin);
}
/// <summary>Verifies that a readonly user without GwAdmin group fails to authenticate.</summary>
/// <summary>
/// Verifies that <c>gw-viewer</c> — 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.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveLdapFact]
public async Task AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails()
public async Task AuthenticateAsync_ViewerMissingGwAdminGroup_FailsIndistinguishably()
{
DashboardAuthenticator authenticator = CreateAuthenticator();
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"readonly",
"readonly123",
"gw-viewer",
SharedDirectoryPassword,
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Null(result.Principal);
Assert.DoesNotContain("readonly123", result.FailureMessage, StringComparison.Ordinal);
// 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);
}
/// <summary>Verifies that authentication with wrong password fails without leaking the password.</summary>
@@ -98,9 +132,11 @@ public sealed class DashboardLdapLiveTests
[LiveLdapFact]
public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
{
// Exercises the connect-failure path: a closed loopback port produces a
// connection error that the shared LdapAuthService must absorb into a Fail
// result rather than propagating an exception to the dashboard.
// 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.
@@ -109,7 +145,7 @@ public sealed class DashboardLdapLiveTests
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"admin",
"admin123",
SharedDirectoryPassword,
CancellationToken.None);
Assert.False(result.Succeeded);
@@ -147,8 +183,10 @@ public sealed class DashboardLdapLiveTests
/// <see cref="LibraryLdapOptions.ConnectionTimeoutMs"/>, 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
/// <c>appsettings.json</c> seeds the dev directory connection (localhost:3893,
/// plaintext, AllowInsecure).
/// <c>appsettings.json</c> seeds the dev directory connection (port 3893, plaintext,
/// AllowInsecure) but ships <c>Server=localhost</c>, so a run against the shared GLAuth
/// needs the <c>MxGateway__Ldap__Server=10.100.0.35</c> environment override that the
/// <c>AddEnvironmentVariables()</c> layer below applies.
/// </summary>
private static LibraryLdapOptions LibraryOptions()
{