using Novell.Directory.Ldap; using Xunit; using ZB.MOM.WW.Auth.Ldap.Internal; namespace ZB.MOM.WW.Auth.Ldap.Tests; /// /// Covers ' handling of AD continuation /// referrals. Active Directory returns one for any search based at a domain root, and /// Novell surfaces it by throwing from Next(). Before this was handled, the /// exception escaped the search stage and every login against /// DC=nam,DC=zimmer,DC=com failed as ServiceAccountBindFailed while the /// directory and credentials were healthy. /// 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); } /// /// 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. /// [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)); } /// /// A referral-only result set is a legitimate "nothing here" answer, and must come back /// empty rather than throwing — LdapAuthService reads empty as UserNotFound, /// which is the honest verdict, where an exception would become a system-side failure. /// [Fact] public void Referrals_only_returns_empty_rather_than_throwing() { var results = new ScriptedResults(Step.Referral(), Step.Referral()); Assert.Empty(NovellLdapConnection.ReadEntries(results)); } /// /// A base carrying the Referral result code is treated the /// same as the derived type — the catch filter must not depend on the exact class. /// [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)); } /// /// 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. /// [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(() => NovellLdapConnection.ReadEntries(results)); Assert.Equal(LdapException.Busy, ex.ResultCode); } /// /// A pathological client that throws a referral without advancing must not spin forever /// inside a fail-closed authentication path. /// [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); } /// Replays a fixed script of entries and faults through the Novell result seam. 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 GetEnumerator() => throw new NotSupportedException(); System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); } /// Always claims more results and always throws a referral, never advancing. 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 GetEnumerator() => throw new NotSupportedException(); System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); } }