298 lines
12 KiB
C#
298 lines
12 KiB
C#
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
|
|
|
namespace ZB.MOM.WW.Auth.Ldap.Tests;
|
|
|
|
/// <summary>
|
|
/// Covers the multi-endpoint failover walk: which failures fail over (system-side only),
|
|
/// which are final (any authoritative answer from a healthy directory), how the sticky
|
|
/// endpoint preference moves, and that single-endpoint behaviour is unchanged.
|
|
/// </summary>
|
|
public class LdapAuthServiceFailoverTests
|
|
{
|
|
private static LdapOptions Opts(params string[] fallbacks) => new()
|
|
{
|
|
Enabled = true,
|
|
Server = "dc1",
|
|
Port = 636,
|
|
Transport = LdapTransport.Ldaps,
|
|
SearchBase = "dc=x",
|
|
ServiceAccountDn = "cn=svc,dc=x",
|
|
ServiceAccountPassword = "svcpw",
|
|
FallbackServers = fallbacks,
|
|
};
|
|
|
|
private static FakeLdapConnection HealthyWithUser() =>
|
|
new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=ops,dc=x" });
|
|
|
|
// ---- failover triggers (system-side) ----
|
|
|
|
[Fact]
|
|
public async Task ConnectFailure_FailsOverToFallback_AndSucceeds()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
new FakeLdapConnection().ThrowOnConnect(), HealthyWithUser());
|
|
var svc = new LdapAuthService(Opts("dc2:3894"), factory);
|
|
|
|
var result = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
|
|
|
Assert.True(result.Succeeded);
|
|
Assert.Equal(2, factory.Created.Count);
|
|
Assert.Equal("dc1", factory.Created[0].ConnectArgs!.Value.Host);
|
|
Assert.Equal("dc2", factory.Created[1].ConnectArgs!.Value.Host);
|
|
Assert.Equal(3894, factory.Created[1].ConnectArgs!.Value.Port);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ServiceBindFailure_FailsOverToFallback()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
new FakeLdapConnection().ThrowOnServiceBind(), HealthyWithUser());
|
|
var svc = new LdapAuthService(Opts("dc2"), factory);
|
|
|
|
var result = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
|
|
|
Assert.True(result.Succeeded);
|
|
Assert.Equal(636, factory.Created[1].ConnectArgs!.Value.Port); // bare host adopts Port
|
|
}
|
|
|
|
// ---- user-side results NEVER fail over ----
|
|
|
|
[Fact]
|
|
public async Task BadUserCredentials_DoesNotFailOver()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
HealthyWithUser().ThrowOnUserBind());
|
|
var svc = new LdapAuthService(Opts("dc2"), factory);
|
|
|
|
var result = await svc.AuthenticateAsync("alice", "wrong", CancellationToken.None);
|
|
|
|
Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
|
|
Assert.Single(factory.Created);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UserNotFound_DoesNotFailOver()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection());
|
|
var svc = new LdapAuthService(Opts("dc2"), factory);
|
|
|
|
var result = await svc.AuthenticateAsync("ghost", "pw", CancellationToken.None);
|
|
|
|
Assert.Equal(LdapAuthFailure.UserNotFound, result.Failure);
|
|
Assert.Single(factory.Created);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GroupLookupFailed_DoesNotFailOver()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", Array.Empty<string>()));
|
|
var svc = new LdapAuthService(Opts("dc2"), factory);
|
|
|
|
var result = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
|
|
|
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
|
|
Assert.Single(factory.Created);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AmbiguousUser_DoesNotFailOver()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
new FakeLdapConnection().WithDuplicateMatch());
|
|
var svc = new LdapAuthService(Opts("dc2"), factory);
|
|
|
|
var result = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
|
|
|
Assert.Equal(LdapAuthFailure.AmbiguousUser, result.Failure);
|
|
Assert.Single(factory.Created);
|
|
}
|
|
|
|
// ---- exhaustion ----
|
|
|
|
[Fact]
|
|
public async Task AllEndpointsDown_ReturnsServiceAccountBindFailed()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
new FakeLdapConnection().ThrowOnConnect(),
|
|
new FakeLdapConnection().ThrowOnConnect());
|
|
var svc = new LdapAuthService(Opts("dc2"), factory);
|
|
|
|
var result = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
|
|
|
Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, result.Failure);
|
|
Assert.Equal(2, factory.Created.Count);
|
|
}
|
|
|
|
// ---- stickiness ----
|
|
|
|
[Fact]
|
|
public async Task StickyPreference_SecondLoginStartsAtLastGoodEndpoint()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
new FakeLdapConnection().ThrowOnConnect(), // login 1 @ dc1: dead
|
|
HealthyWithUser(), // login 1 @ dc2: answers
|
|
HealthyWithUser()); // login 2: should go straight to dc2
|
|
var svc = new LdapAuthService(Opts("dc2"), factory);
|
|
|
|
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
|
|
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
|
|
|
|
Assert.Equal(3, factory.Created.Count);
|
|
Assert.Equal("dc2", factory.Created[2].ConnectArgs!.Value.Host);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StickyPreference_DriftsBackToPrimary_WhenPreferredFails()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
new FakeLdapConnection().ThrowOnConnect(), // login 1 @ dc1: dead
|
|
HealthyWithUser(), // login 1 @ dc2: answers -> preferred
|
|
new FakeLdapConnection().ThrowOnConnect(), // login 2 @ dc2: now dead
|
|
HealthyWithUser(), // login 2 @ dc1 (declared order): recovered
|
|
HealthyWithUser()); // login 3: should start at dc1 again
|
|
var svc = new LdapAuthService(Opts("dc2"), factory);
|
|
|
|
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
|
|
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
|
|
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
|
|
|
|
Assert.Equal(5, factory.Created.Count);
|
|
Assert.Equal("dc1", factory.Created[3].ConnectArgs!.Value.Host);
|
|
Assert.Equal("dc1", factory.Created[4].ConnectArgs!.Value.Host);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StickyPreference_UpdatesOnUserSideAnswer_NotOnlySuccess()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
new FakeLdapConnection().ThrowOnConnect(), // login 1 @ dc1: dead
|
|
new FakeLdapConnection(), // login 1 @ dc2: answers UserNotFound
|
|
HealthyWithUser()); // login 2: should start at dc2
|
|
var svc = new LdapAuthService(Opts("dc2"), factory);
|
|
|
|
var first = await svc.AuthenticateAsync("ghost", "pw", CancellationToken.None);
|
|
Assert.Equal(LdapAuthFailure.UserNotFound, first.Failure);
|
|
|
|
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
|
|
Assert.Equal("dc2", factory.Created[2].ConnectArgs!.Value.Host);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AllEndpointsDown_LeavesPreferenceUnchanged()
|
|
{
|
|
// A total outage teaches nothing about which endpoint is healthy, so the walk
|
|
// must not move the preference — the next login still starts at the primary.
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
new FakeLdapConnection().ThrowOnConnect(), // login 1 @ dc1
|
|
new FakeLdapConnection().ThrowOnConnect(), // login 1 @ dc2
|
|
HealthyWithUser()); // login 2: must start at dc1 again
|
|
var svc = new LdapAuthService(Opts("dc2"), factory);
|
|
|
|
Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed,
|
|
(await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Failure);
|
|
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
|
|
|
|
Assert.Equal("dc1", factory.Created[2].ConnectArgs!.Value.Host);
|
|
}
|
|
|
|
// ---- back-compat ----
|
|
|
|
[Fact]
|
|
public async Task NoFallbacks_SingleAttempt_UnchangedBehavior()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
new FakeLdapConnection().ThrowOnConnect());
|
|
var svc = new LdapAuthService(Opts(/* no fallbacks */), factory);
|
|
|
|
var result = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
|
|
|
Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, result.Failure);
|
|
Assert.Single(factory.Created);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Disabled_NeverOpensAConnection_EvenWithFallbacks()
|
|
{
|
|
var factory = new SequenceLdapConnectionFactory(HealthyWithUser());
|
|
var svc = new LdapAuthService(Opts("dc2") with { Enabled = false }, factory);
|
|
|
|
var result = await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
|
|
|
Assert.Equal(LdapAuthFailure.Disabled, result.Failure);
|
|
Assert.Empty(factory.Created);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MalformedFallbackEntry_IsSkippedDefensively()
|
|
{
|
|
// The validator rejects this at boot; a service constructed without validation
|
|
// must degrade to the parseable subset, not throw.
|
|
var factory = new SequenceLdapConnectionFactory(HealthyWithUser());
|
|
var svc = new LdapAuthService(Opts(":::"), factory);
|
|
|
|
Assert.True((await svc.AuthenticateAsync("alice", "pw", CancellationToken.None)).Succeeded);
|
|
Assert.Single(factory.Created);
|
|
}
|
|
|
|
// ---- observability ----
|
|
|
|
/// <summary>Captures emitted log entries so failover visibility can be asserted.</summary>
|
|
private sealed class RecordingLogger : Microsoft.Extensions.Logging.ILogger<LdapAuthService>
|
|
{
|
|
public List<(Microsoft.Extensions.Logging.LogLevel Level, string Message)> Entries { get; } = new();
|
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
|
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true;
|
|
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId,
|
|
TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
|
=> Entries.Add((logLevel, formatter(state, exception)));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Failover_LogsWarningWithEndpointAndStage_AndInfoOnStickyChange()
|
|
{
|
|
var logger = new RecordingLogger();
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
new FakeLdapConnection().ThrowOnConnect(), HealthyWithUser());
|
|
var svc = new LdapAuthService(Opts("dc2:3894"), factory, logger);
|
|
|
|
await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
|
|
|
Assert.Contains(logger.Entries, e =>
|
|
e.Level == Microsoft.Extensions.Logging.LogLevel.Warning
|
|
&& e.Message.Contains("dc1:636") && e.Message.Contains("connect"));
|
|
Assert.Contains(logger.Entries, e =>
|
|
e.Level == Microsoft.Extensions.Logging.LogLevel.Information
|
|
&& e.Message.Contains("dc2:3894"));
|
|
// Never log secrets:
|
|
Assert.DoesNotContain(logger.Entries, e => e.Message.Contains("svcpw") || e.Message.Contains("pw\""));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task HappyPath_LogsNothing()
|
|
{
|
|
var logger = new RecordingLogger();
|
|
var factory = new SequenceLdapConnectionFactory(HealthyWithUser());
|
|
var svc = new LdapAuthService(Opts("dc2"), factory, logger);
|
|
|
|
await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
|
|
|
Assert.Empty(logger.Entries);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ServiceBindFailure_LogsTheServiceBindStage_NotConnect()
|
|
{
|
|
var logger = new RecordingLogger();
|
|
var factory = new SequenceLdapConnectionFactory(
|
|
new FakeLdapConnection().ThrowOnServiceBind(), HealthyWithUser());
|
|
var svc = new LdapAuthService(Opts("dc2"), factory, logger);
|
|
|
|
await svc.AuthenticateAsync("alice", "pw", CancellationToken.None);
|
|
|
|
Assert.Contains(logger.Entries, e =>
|
|
e.Level == Microsoft.Extensions.Logging.LogLevel.Warning
|
|
&& e.Message.Contains("dc1:636") && e.Message.Contains("service-bind"));
|
|
}
|
|
}
|