1f8ab8e3b3
Active Directory answers any search based at a domain root with continuation references (CN=Configuration, DC=ForestDnsZones, ...). With referral following disabled -- the default, and what we want, since chasing one would re-bind somewhere we never authenticated -- Novell surfaces each by throwing LdapReferralException from Next(). That escaped NovellLdapConnection.Search, and LdapAuthService maps a failed search to ServiceAccountBindFailed. So every login against SearchBase = DC=nam,DC=zimmer,DC=com failed system-side on BOTH DCs in ~546 ms while the directory, the credentials and the service account were all healthy. Identical on each endpoint, so failover could not help either. A continuation reference is a pointer to another naming context, never a statement that this search failed, so the loop now skips them and continues -- returning exactly the entries in the naming context we bound to. Extracted as ReadEntries(ILdapSearchResults) so the handling is unit-testable through the Novell seam without a live directory. A 1000-referral cap guards a client that throws without advancing, which would otherwise spin forever inside a fail-closed auth path. Non-referral faults still propagate untouched: swallowing those would turn "the directory is broken" into "no such user", which is the wrong answer to give about someone's credentials. 7 tests, 6 verified red against the pre-fix code; the seventh is the non-referral control that must stay green either way. Suite 257/0, 2 skipped. Live-proven against the ZB NAM domain root before and after.
180 lines
6.6 KiB
C#
180 lines
6.6 KiB
C#
using Novell.Directory.Ldap;
|
|
using Xunit;
|
|
using ZB.MOM.WW.Auth.Ldap.Internal;
|
|
|
|
namespace ZB.MOM.WW.Auth.Ldap.Tests;
|
|
|
|
/// <summary>
|
|
/// Covers <see cref="NovellLdapConnection.ReadEntries"/>' handling of AD continuation
|
|
/// referrals. Active Directory returns one for any search based at a domain root, and
|
|
/// Novell surfaces it by throwing from <c>Next()</c>. Before this was handled, the
|
|
/// exception escaped the search stage and every login against
|
|
/// <c>DC=nam,DC=zimmer,DC=com</c> failed as <c>ServiceAccountBindFailed</c> while the
|
|
/// directory and credentials were healthy.
|
|
/// </summary>
|
|
public sealed class NovellSearchReferralTests
|
|
{
|
|
[Fact]
|
|
public void Referral_before_the_only_entry_does_not_lose_it()
|
|
{
|
|
var results = new ScriptedResults(
|
|
Step.Referral(),
|
|
Step.Entry("CN=dohertj2,OU=Office Users,OU=Warsaw,DC=nam,DC=zimmer,DC=com"));
|
|
|
|
var entries = NovellLdapConnection.ReadEntries(results);
|
|
|
|
var entry = Assert.Single(entries);
|
|
Assert.Equal("CN=dohertj2,OU=Office Users,OU=Warsaw,DC=nam,DC=zimmer,DC=com", entry.Dn);
|
|
}
|
|
|
|
[Fact]
|
|
public void Referral_after_the_entry_does_not_discard_what_was_already_read()
|
|
{
|
|
var results = new ScriptedResults(
|
|
Step.Entry("CN=a,DC=nam,DC=zimmer,DC=com"),
|
|
Step.Referral());
|
|
|
|
var entries = NovellLdapConnection.ReadEntries(results);
|
|
|
|
Assert.Equal("CN=a,DC=nam,DC=zimmer,DC=com", Assert.Single(entries).Dn);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The real shape observed against the NAM domain root: several references interleaved
|
|
/// with the one user entry. All three of AD's usual continuation points are present.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Multiple_interleaved_referrals_still_yield_every_entry()
|
|
{
|
|
var results = new ScriptedResults(
|
|
Step.Referral(),
|
|
Step.Entry("CN=a,DC=nam,DC=zimmer,DC=com"),
|
|
Step.Referral(),
|
|
Step.Referral(),
|
|
Step.Entry("CN=b,DC=nam,DC=zimmer,DC=com"));
|
|
|
|
var entries = NovellLdapConnection.ReadEntries(results);
|
|
|
|
Assert.Equal(["CN=a,DC=nam,DC=zimmer,DC=com", "CN=b,DC=nam,DC=zimmer,DC=com"],
|
|
entries.Select(e => e.Dn));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A referral-only result set is a legitimate "nothing here" answer, and must come back
|
|
/// empty rather than throwing — <c>LdapAuthService</c> reads empty as <c>UserNotFound</c>,
|
|
/// which is the honest verdict, where an exception would become a system-side failure.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Referrals_only_returns_empty_rather_than_throwing()
|
|
{
|
|
var results = new ScriptedResults(Step.Referral(), Step.Referral());
|
|
|
|
Assert.Empty(NovellLdapConnection.ReadEntries(results));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A base <see cref="LdapException"/> carrying the Referral result code is treated the
|
|
/// same as the derived type — the catch filter must not depend on the exact class.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Referral_result_code_on_a_base_exception_is_also_skipped()
|
|
{
|
|
var results = new ScriptedResults(
|
|
Step.Throwing(new LdapException("referral", LdapException.Referral, null)),
|
|
Step.Entry("CN=a,DC=nam,DC=zimmer,DC=com"));
|
|
|
|
Assert.Single(NovellLdapConnection.ReadEntries(results));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Only referrals are swallowed. A genuine directory fault must still propagate so the
|
|
/// caller maps it to a system-side failure — silently returning a short result set would
|
|
/// turn "the directory is broken" into "no such user", which is the wrong answer to give
|
|
/// about someone's credentials.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_non_referral_fault_still_propagates()
|
|
{
|
|
var results = new ScriptedResults(
|
|
Step.Throwing(new LdapException("busy", LdapException.Busy, null)),
|
|
Step.Entry("CN=a,DC=nam,DC=zimmer,DC=com"));
|
|
|
|
var ex = Assert.Throws<LdapException>(() => NovellLdapConnection.ReadEntries(results));
|
|
Assert.Equal(LdapException.Busy, ex.ResultCode);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A pathological client that throws a referral without advancing must not spin forever
|
|
/// inside a fail-closed authentication path.
|
|
/// </summary>
|
|
[Fact]
|
|
public void An_endless_referral_stream_terminates()
|
|
{
|
|
var results = new EndlessReferrals();
|
|
|
|
var entries = NovellLdapConnection.ReadEntries(results);
|
|
|
|
Assert.Empty(entries);
|
|
Assert.InRange(results.Calls, 1, 2_000);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
|
|
private sealed record Step(LdapEntry? Value, Exception? Throw)
|
|
{
|
|
public static Step Entry(string dn) => new(new LdapEntry(dn, new LdapAttributeSet()), null);
|
|
public static Step Referral() => new(null, new LdapReferralException("continuation reference"));
|
|
public static Step Throwing(Exception ex) => new(null, ex);
|
|
}
|
|
|
|
/// <summary>Replays a fixed script of entries and faults through the Novell result seam.</summary>
|
|
private sealed class ScriptedResults : ILdapSearchResults
|
|
{
|
|
private readonly Step[] _steps;
|
|
private int _index;
|
|
|
|
public ScriptedResults(params Step[] steps) => _steps = steps;
|
|
|
|
public int Count => _steps.Length;
|
|
|
|
public LdapControl[] ResponseControls => [];
|
|
|
|
public bool HasMore() => _index < _steps.Length;
|
|
|
|
public LdapEntry Next()
|
|
{
|
|
var step = _steps[_index++];
|
|
if (step.Throw is not null) throw step.Throw;
|
|
return step.Value!;
|
|
}
|
|
|
|
// The production reader drives HasMore()/Next() directly; enumeration is part of the
|
|
// interface but not part of the path under test.
|
|
public IEnumerator<LdapEntry> GetEnumerator() => throw new NotSupportedException();
|
|
|
|
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
|
|
}
|
|
|
|
/// <summary>Always claims more results and always throws a referral, never advancing.</summary>
|
|
private sealed class EndlessReferrals : ILdapSearchResults
|
|
{
|
|
public int Calls { get; private set; }
|
|
|
|
public int Count => int.MaxValue;
|
|
|
|
public LdapControl[] ResponseControls => [];
|
|
|
|
public bool HasMore() => true;
|
|
|
|
public LdapEntry Next()
|
|
{
|
|
Calls++;
|
|
throw new LdapReferralException("continuation reference");
|
|
}
|
|
|
|
public IEnumerator<LdapEntry> GetEnumerator() => throw new NotSupportedException();
|
|
|
|
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
|
|
}
|
|
}
|