Files
scadaproj/ZB.MOM.WW.Auth/tests/ZB.MOM.WW.Auth.Ldap.Tests/NovellSearchReferralTests.cs
T
Joseph Doherty 1f8ab8e3b3 fix(auth): skip AD continuation referrals in LDAP search (0.2.1)
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.
2026-08-12 21:27:05 -04:00

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