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

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