Files
scadaproj/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/LdapAuthServiceFailureTests.cs
T

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