// Live user-bind identity test (Auth-007, scadaproj#40) — opt-in only.
//
// Start the throwaway fixture (NOT the shared rig — it needs users that can search):
// cd tests/ZB.MOM.WW.Auth.Ldap.Tests/Integration/glauth-userbind && docker compose up -d
// Run:
// ZB_LDAP_USERBIND_IT=1 dotnet test tests/ZB.MOM.WW.Auth.Ldap.Tests \
// --filter "FullyQualifiedName~GLAuthUserBindIdentityTests"
// Optional: ZB_LDAP_USERBIND_SERVER (default localhost), ZB_LDAP_USERBIND_PORT (default 3897).
//
// GLAuth does not implement RFC 4532 Who-am-I (it answers protocol error 2), so these tests also
// prove the DN-template fallback path against a real directory.
using System.Net.Sockets;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
namespace ZB.MOM.WW.Auth.Ldap.Tests.Integration;
public sealed class GLAuthUserBindIdentityTests
{
private static LdapOptions Options(string template, string userNameAttribute)
{
var server = Environment.GetEnvironmentVariable("ZB_LDAP_USERBIND_SERVER") ?? "localhost";
var port = int.TryParse(Environment.GetEnvironmentVariable("ZB_LDAP_USERBIND_PORT"), out var p) ? p : 3897;
try
{
using var tcp = new TcpClient();
if (!tcp.ConnectAsync(server, port).Wait(TimeSpan.FromSeconds(3)))
Skip.If(true, $"GLAuth fixture not reachable at {server}:{port}.");
}
catch (Exception ex)
{
Skip.If(true, $"GLAuth fixture not reachable at {server}:{port}: {ex.Message}");
}
return new LdapOptions
{
Server = server,
Port = port,
Transport = LdapTransport.None,
AllowInsecure = true,
SearchBase = "dc=zb,dc=local",
BindMode = LdapBindMode.User,
UserBindTemplate = template,
UserNameAttribute = userNameAttribute,
GroupAttribute = "memberOf",
ConnectionTimeoutMs = 3000,
};
}
///
/// mallory binds with her own password; (mail=mallory) finds the boss entry, which
/// is in Admins. Before the fix this signed mallory in with boss's groups.
///
[SkippableFact]
public async Task BoundAccount_DifferentFromTheSearchedEntry_IsRefused()
{
Skip.IfNot(Environment.GetEnvironmentVariable("ZB_LDAP_USERBIND_IT") == "1",
"Set ZB_LDAP_USERBIND_IT=1 and start Integration/glauth-userbind to run.");
var svc = new LdapAuthService(Options("cn={0},ou=Users,dc=zb,dc=local", "mail"));
var result = await svc.AuthenticateAsync("mallory", "password", CancellationToken.None);
Assert.False(result.Succeeded, $"signed in as {result.Username} with [{string.Join(",", result.Groups)}]");
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
Assert.DoesNotContain("Admins", result.Groups);
}
/// Positive control: the same mechanism admits a user whose bind DN is the entry's DN.
[SkippableFact]
public async Task BoundAccount_SameAsTheSearchedEntry_Succeeds()
{
Skip.IfNot(Environment.GetEnvironmentVariable("ZB_LDAP_USERBIND_IT") == "1",
"Set ZB_LDAP_USERBIND_IT=1 and start Integration/glauth-userbind to run.");
// GLAuth reports search DNs as cn=,ou=,ou=users,; it also accepts that
// form as a bind DN, so this template binds the exact DN the search returns.
var svc = new LdapAuthService(Options("cn={0},ou=Users,ou=users,dc=zb,dc=local", "cn"));
var result = await svc.AuthenticateAsync("boss", "password", CancellationToken.None);
Assert.True(result.Succeeded, $"failed: {result.Failure}");
Assert.Equal("boss", result.Username);
Assert.Contains("Admins", result.Groups);
}
}