157 lines
5.7 KiB
C#
157 lines
5.7 KiB
C#
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
|
using ZB.MOM.WW.Auth.Ldap;
|
|
|
|
namespace ZB.MOM.WW.Auth.Ldap.Tests;
|
|
|
|
/// <summary>
|
|
/// Task 6 failure-mode tests. These pin the fail-closed contract: every error path returns a
|
|
/// structured <see cref="LdapAuthResult.Fail(LdapAuthFailure)"/>, the method never throws, and
|
|
/// a successful result always carries at least one group.
|
|
/// </summary>
|
|
public class LdapAuthServiceFailureTests
|
|
{
|
|
// Mirrors the happy-path test defaults (insecure plaintext dev transport, service account
|
|
// set, DisplayNameAttribute aligned with the fake's "displayName" key).
|
|
private static LdapOptions Opts() => new()
|
|
{
|
|
Enabled = true,
|
|
Server = "x",
|
|
Port = 3893,
|
|
Transport = LdapTransport.None,
|
|
AllowInsecure = true,
|
|
SearchBase = "dc=x",
|
|
ServiceAccountDn = "cn=svc,dc=x",
|
|
ServiceAccountPassword = "svcpw",
|
|
UserNameAttribute = "cn",
|
|
DisplayNameAttribute = "displayName",
|
|
GroupAttribute = "memberOf",
|
|
};
|
|
|
|
[Fact]
|
|
public async Task BadCredentials_WhenUserBindThrows()
|
|
{
|
|
var fake = new FakeLdapConnection()
|
|
.WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" })
|
|
.ThrowOnUserBind();
|
|
|
|
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
|
|
.AuthenticateAsync("alice", "bad", default);
|
|
|
|
Assert.False(r.Succeeded);
|
|
Assert.Equal(LdapAuthFailure.BadCredentials, r.Failure);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UserNotFound_WhenZeroMatches()
|
|
{
|
|
var fake = new FakeLdapConnection().WithNoMatch();
|
|
|
|
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
|
|
.AuthenticateAsync("ghost", "pw", default);
|
|
|
|
Assert.False(r.Succeeded);
|
|
Assert.Equal(LdapAuthFailure.UserNotFound, r.Failure);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AmbiguousUser_WhenMultipleMatches()
|
|
{
|
|
var fake = new FakeLdapConnection().WithDuplicateMatch();
|
|
|
|
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
|
|
.AuthenticateAsync("alice", "pw", default);
|
|
|
|
Assert.False(r.Succeeded);
|
|
Assert.Equal(LdapAuthFailure.AmbiguousUser, r.Failure);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AmbiguousUser_DoesNotAttemptUserBind()
|
|
{
|
|
var fake = new FakeLdapConnection().WithDuplicateMatch();
|
|
|
|
await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
|
|
.AuthenticateAsync("alice", "pw", default);
|
|
|
|
// Only the service-account bind should have happened; never bind an ambiguous DN.
|
|
Assert.Equal(new[] { "cn=svc,dc=x" }, fake.BoundDns);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GroupLookupFailed_WhenUserHasNoGroups()
|
|
{
|
|
var fake = new FakeLdapConnection()
|
|
.WithUserEntry("cn=alice,dc=x", memberOf: Array.Empty<string>());
|
|
|
|
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
|
|
.AuthenticateAsync("alice", "pw", default);
|
|
|
|
Assert.False(r.Succeeded);
|
|
Assert.Equal(LdapAuthFailure.GroupLookupFailed, r.Failure);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ServiceAccountBindFailed_Distinctly_WhenServiceBindThrows()
|
|
{
|
|
var fake = new FakeLdapConnection()
|
|
.WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" })
|
|
.ThrowOnServiceBind();
|
|
|
|
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
|
|
.AuthenticateAsync("alice", "pw", default);
|
|
|
|
Assert.False(r.Succeeded);
|
|
Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, r.Failure);
|
|
// Distinct from BadCredentials: a service-account problem is a system misconfiguration,
|
|
// not the end user's fault.
|
|
Assert.NotEqual(LdapAuthFailure.BadCredentials, r.Failure);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(null)]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public async Task BadCredentials_WhenUsernameNullOrWhitespace_NoConnectionAttempted(string? username)
|
|
{
|
|
// I4: an empty/whitespace/null username is rejected up front as BadCredentials,
|
|
// before any connection or bind is attempted (and a null can't NRE into the catch-all).
|
|
var fake = new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" });
|
|
|
|
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
|
|
.AuthenticateAsync(username!, "pw", default);
|
|
|
|
Assert.False(r.Succeeded);
|
|
Assert.Equal(LdapAuthFailure.BadCredentials, r.Failure);
|
|
Assert.Null(fake.ConnectArgs); // never connected
|
|
Assert.Empty(fake.BoundDns); // never bound
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Throws_WhenCancellationRequested()
|
|
{
|
|
// I3: a pre-cancelled token is observed at entry, before any work.
|
|
var fake = new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" });
|
|
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
|
|
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
|
() => svc.AuthenticateAsync("alice", "pw", new CancellationToken(canceled: true)));
|
|
|
|
Assert.Null(fake.ConnectArgs); // never connected
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NeverThrows_OnConnectFailure()
|
|
{
|
|
var fake = new FakeLdapConnection()
|
|
.WithUserEntry("cn=alice,dc=x", new[] { "cn=Eng,dc=x" })
|
|
.ThrowOnConnect();
|
|
|
|
var r = await new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake))
|
|
.AuthenticateAsync("alice", "pw", default);
|
|
|
|
Assert.False(r.Succeeded);
|
|
// Directory unreachable is a system-side failure -> bucketed under ServiceAccountBindFailed.
|
|
Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, r.Failure);
|
|
}
|
|
}
|