Merge branch 'master' of https://code.google.com/p/plexi.ad
diff --git a/src/com/google/enterprise/adaptor/ad/AdServer.java b/src/com/google/enterprise/adaptor/ad/AdServer.java
index 33046b0..b125701 100644
--- a/src/com/google/enterprise/adaptor/ad/AdServer.java
+++ b/src/com/google/enterprise/adaptor/ad/AdServer.java
@@ -14,7 +14,7 @@
 
 package com.google.enterprise.adaptor.ad;
 
-import com.google.common.base.Strings;
+import com.google.common.annotations.VisibleForTesting;
 
 import java.io.IOException;
 import java.sql.Timestamp;
@@ -48,18 +48,14 @@
   private static final Logger LOGGER
       = Logger.getLogger(AdServer.class.getName());
 
-  protected LdapContext ldapContext = null;
+  private LdapContext ldapContext = null;
   private SearchControls searchCtls;
 
   // properties necessary for connection
-  private String hostName;
-  private int port;
-  private String principal;
-  private String password;
+  private final String hostName;
 
   // retrieved properties of the Active Directory controller
   private String nETBIOSName;
-  private Method connectMethod;
   private String dn;
   private String configurationNamingContext;
   private String dsServiceName;
@@ -67,10 +63,27 @@
   private long highestCommittedUSN;
   private String invocationID;
   private String dnsRoot;
-  private Timestamp lastFullSync;
 
   public AdServer(Method connectMethod, String hostName,
       int port, String principal, String password) {
+    this(hostName, createLdapContext(connectMethod, hostName, port,
+        principal, password));
+  }
+
+  @VisibleForTesting
+  AdServer(String hostName, LdapContext ldapContext) {
+    this.hostName = hostName;
+    this.ldapContext = ldapContext;
+    searchCtls = new SearchControls();
+    searchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);
+  }
+
+  /**
+   * Called (only) by public constructor
+   */
+  private static LdapContext createLdapContext(Method connectMethod,
+      String hostName, int port, String principal, String password) {
+    Hashtable<String, String> env = new Hashtable<String, String>();
     if (null == connectMethod || null == hostName
         || null == principal || null == password) {
       throw new NullPointerException();
@@ -84,86 +97,64 @@
     if ("".equals(password)) {
       throw new IllegalArgumentException("password needs to be non-empty");
     }
-    this.hostName = hostName;
-    this.port = port;
-    this.principal = principal;
-    this.password = password;
-    searchCtls = new SearchControls();
-    searchCtls.setSearchScope(SearchControls.SUBTREE_SCOPE);
-    this.connectMethod = connectMethod;
+
+    // Use the built-in LDAP support.
+    env.put(Context.INITIAL_CONTEXT_FACTORY,
+        "com.sun.jndi.ldap.LdapCtxFactory");
+    // Connecting to configuration naming context is very slow for crawl users
+    // in large multidomain environment, which belong to thousands of groups
+    // TODO: make this configurable
+    env.put("com.sun.jndi.ldap.read.timeout", "90000");
+    env.put(Context.SECURITY_AUTHENTICATION, "simple");
+    env.put(Context.SECURITY_PRINCIPAL, principal);
+    env.put(Context.SECURITY_CREDENTIALS, password);
+
+    String ldapUrl =
+        connectMethod.protocol() + hostName + ":" + port;
+    LOGGER.info("LDAP provider url: " + ldapUrl);
+    env.put(Context.PROVIDER_URL, ldapUrl);
+    try {
+      return new InitialLdapContext(env, null);
+    } catch (NamingException ne) {
+      throw new AssertionError(ne);
+    }
   }
 
   /**
    * Connects to the Active Directory server and retrieves AD configuration
    * information.
-   * 
-   * This method is used for crawling as well as authorization of credentials 
+   *
+   * This method is used for crawling as well as authorization of credentials
    * against Active Directory.
    */
   public void connect() throws CommunicationException, NamingException {
-    Hashtable<String, String> env = new Hashtable<String, String>();
-
-    // Use the built-in LDAP support.
-    env.put(Context.INITIAL_CONTEXT_FACTORY,
-        AdConstants.COM_SUN_JNDI_LDAP_LDAP_CTX_FACTORY);
-    // Connecting to configuration naming context is very slow for crawl users
-    // in large multidomain environment, which belong to thousands of groups
-    // TODO: make this configurable
-    env.put("com.sun.jndi.ldap.read.timeout", "90000");
-    if (Strings.isNullOrEmpty(principal)) {
-      env.put(Context.SECURITY_AUTHENTICATION, 
-          AdConstants.AUTHN_TYPE_ANONYMOUS);
-    } else {
-      env.put(Context.SECURITY_AUTHENTICATION,
-          AdConstants.AUTHN_TYPE_SIMPLE);
-      env.put(Context.SECURITY_PRINCIPAL, principal);
-      env.put(Context.SECURITY_CREDENTIALS, password);
-    }
-
-    String ldapUrl =
-        connectMethod.protocol() + hostName + AdConstants.COLON + port;
-    LOGGER.info("LDAP provider url: " + ldapUrl);
-    env.put(Context.PROVIDER_URL, ldapUrl);
-    ldapContext = new InitialLdapContext(env, null);
-
-    Attributes attributes = ldapContext.getAttributes(AdConstants.EMPTY);
-    dn = attributes.get(
-        AdConstants.ATTR_DEFAULTNAMINGCONTEXT).get(0).toString();
-    dsServiceName = attributes.get(
-        AdConstants.ATTR_DSSERVICENAME).get(0).toString();
+    Attributes attributes = ldapContext.getAttributes("");
+    dn = attributes.get("defaultNamingContext").get(0).toString();
+    dsServiceName = attributes.get("dsServiceName").get(0).toString();
     highestCommittedUSN = Long.parseLong(attributes.get(
-        AdConstants.ATTR_HIGHESTCOMMITTEDUSN).get(0).toString());
+        "highestCommittedUSN").get(0).toString());
     configurationNamingContext = attributes.get(
-        AdConstants.ATTR_CONFIGURATIONNAMINGCONTEXT).get(0).toString();
+        "configurationNamingContext").get(0).toString();
   }
 
   public void initialize() {
     try {
       connect();
       sid = AdEntity.getTextSid((byte[])get(
-          AdConstants.ATTR_DISTINGUISHEDNAME + AdConstants.EQUALS + dn,
-          AdConstants.ATTR_OBJECTSID, dn));
+          "distinguishedName=" + dn, "objectSid;binary", dn));
       invocationID = AdEntity.getTextGuid((byte[]) get(
-          AdConstants.ATTR_DISTINGUISHEDNAME
-          + AdConstants.EQUALS + dsServiceName,
-          AdConstants.ATTR_INVOCATIONID,
-          dsServiceName));
-    } catch (CommunicationException e) {
-      throw new IllegalStateException(e);
-    } catch (AuthenticationNotSupportedException e) {
-      throw new IllegalStateException(e);
-    } catch (AuthenticationException e) {
-      throw new IllegalStateException(e);
+          "distinguishedName=" + dsServiceName,
+          "invocationID;binary", dsServiceName));
     } catch (NamingException e) {
-      throw new IllegalStateException(e);
+      throw new RuntimeException(e);
     }
 
-    LOGGER.info("Sucessfully created an Initial LDAP context");
+    LOGGER.info("Successfully created an Initial LDAP context");
 
     nETBIOSName = (String) get("(ncName=" + dn + ")",
-        AdConstants.ATTR_NETBIOSNAME, configurationNamingContext);
-    dnsRoot = (String) get("(ncName=" + dn + ")",
-        AdConstants.ATTR_DNSROOT, configurationNamingContext);
+        "nETBIOSName", configurationNamingContext);
+    dnsRoot = (String) get("(ncName=" + dn + ")", "dnsRoot",
+        configurationNamingContext);
     LOGGER.log(Level.INFO, "Connected to domain (dn = " + dn + ", netbios = "
         + nETBIOSName + ", hostname = " + hostName + ", dsServiceName = "
         + dsServiceName + ", highestCommittedUSN = " + highestCommittedUSN
@@ -200,7 +191,7 @@
   }
 
   /**
-   * Set request controls on the LDAP query 
+   * Set request controls on the LDAP query
    * @param deleted include deleted control
    */
   private void setControls(boolean deleted) {
@@ -245,7 +236,7 @@
           SearchResult sr = ldapResults.next();
           try {
             results.add(new AdEntity(sr));
-          } catch (Exception ex) {           
+          } catch (Exception ex) {
             // It is possible that Search Result returned is missing
             // few attributes required to construct AD Entity object.
             // Such results will be ignored.
@@ -279,8 +270,8 @@
         int batch = g.getMembers().size();
         int start = g.getMembers().size();
         do {
-          String memberRange = String.format(AdConstants.ATTR_MEMBER_RANGE, 
-              start, start + batch - 1);
+          String memberRange = String.format("member;Range=%d-%d", start,
+              start + batch - 1);
           LOGGER.finest(
               "Retrieving additional groups for [" + g + "] " + memberRange);
           searchCtls.setReturningAttributes(new String[] {memberRange});
@@ -305,26 +296,6 @@
   }
 
   /**
-   * Generate properties to be used for parameter binding in JDBC
-   * @return map of names and properties of current object
-   */
-  public Map<String, Object> getSqlParams() {
-    HashMap<String, Object> map = new HashMap<String, Object>();
-    map.put(AdConstants.DB_DN, dn);
-    map.put(AdConstants.DB_DSSERVICENAME, dsServiceName);
-    map.put(AdConstants.DB_INVOCATIONID, invocationID);
-    map.put(AdConstants.DB_HIGHESTCOMMITTEDUSN, highestCommittedUSN);
-    map.put(AdConstants.DB_NETBIOSNAME, nETBIOSName);
-    map.put(AdConstants.DB_SID, sid);
-    map.put(AdConstants.DB_DNSROOT, dnsRoot);
-    if (lastFullSync != null) {
-      map.put(AdConstants.DB_LASTFULLSYNC,
-          new java.sql.Timestamp(lastFullSync.getTime()));
-    }
-    return map;
-  }
-
-  /**
    * @return the distinguished Name
    */
   public final String getDn() {
@@ -374,20 +345,6 @@
     }
   }
 
-  /**
-   * @return the lastFullSync
-   */
-  public Timestamp getLastFullSync() {
-    return lastFullSync;
-  }
-
-  /**
-   * @param lastFullSync the lastFullSync to set
-   */
-  public void setLastFullSync(Timestamp lastFullSync) {
-    this.lastFullSync = lastFullSync;
-  }
-  
   @Override
   public String toString() {
     return "[" + nETBIOSName + "] ";
diff --git a/test/com/google/enterprise/adaptor/ad/AdServerTest.java b/test/com/google/enterprise/adaptor/ad/AdServerTest.java
new file mode 100644
index 0000000..da5d5eb
--- /dev/null
+++ b/test/com/google/enterprise/adaptor/ad/AdServerTest.java
@@ -0,0 +1,393 @@
+// Copyright 2013 Google Inc. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.enterprise.adaptor.ad;
+
+import static org.junit.Assert.*;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+import javax.naming.*;
+import javax.naming.directory.*;
+import javax.naming.ldap.*;
+
+import java.util.*;
+
+/** Test cases for {@link AdServer}. */
+public class AdServerTest {
+  @Rule
+  public ExpectedException thrown = ExpectedException.none();
+
+  @Test
+  public void testStandardServer() throws Exception {
+    AdServer adServer = new AdServer("hostname", new MockLdapContext());
+    assertEquals("hostname", adServer.getHostName());
+  }
+
+  @Test
+  public void testNPEOnNullConnectMethod() {
+    thrown.expect(NullPointerException.class);
+    AdServer adServer = new AdServer(null, "hostname", 1234, "principal", "pw");
+  }
+
+  @Test
+  public void testNPEOnNullHostname() {
+    thrown.expect(NullPointerException.class);
+    AdServer adServer = new AdServer(Method.SSL, null, 1234, "principal", "pw");
+  }
+
+  @Test
+  public void testIAEOnEmptyHostname() {
+    thrown.expect(IllegalArgumentException.class);
+    AdServer adServer = new AdServer(Method.SSL, "", 1234, "principal", "pw");
+  }
+
+  @Test
+  public void testNPEOnNullPrincipal() {
+    thrown.expect(NullPointerException.class);
+    AdServer adServer = new AdServer(Method.SSL, "hostname", 1234, null, "pw");
+  }
+
+  @Test
+  public void testIAEOnEmptyPrincipal() throws Exception {
+    thrown.expect(IllegalArgumentException.class);
+    MockLdapContext ldapContext = new MockLdapContext();
+    AdServer adServer = new AdServer(Method.SSL, "hostname", 1234, "", "pw");
+  }
+
+  @Test
+  public void testNPEOnNullPassword() {
+    thrown.expect(NullPointerException.class);
+    AdServer adServer = new AdServer(Method.SSL, "host", 1234, "princ", null);
+  }
+
+  @Test
+  public void testIAEOnEmptyPassword() {
+    thrown.expect(IllegalArgumentException.class);
+    AdServer adServer = new AdServer(Method.SSL, "hostname", 1234, "princ", "");
+  }
+
+  @Test
+  public void testPublicConstructor() {
+    thrown.expect(AssertionError.class);
+    AdServer adServer = new AdServer(Method.SSL, "localhost", 389, " ", " ");
+  }
+
+  @Test
+  public void testStandardServerInitialize() throws Exception {
+    MockLdapContext ldapContext = new MockLdapContext();
+    addStandardKeysAndResults(ldapContext);
+    // populate additional attributes with values we can test
+    ldapContext.addSearchResult("dn=empty", "empty", "empty", "")
+               .addSearchResult("dn=empty", "attr1", "basedn", "val1");
+    AdServer adServer = new AdServer("localhost", ldapContext);
+    assertEquals("localhost", adServer.getHostName());
+    adServer.initialize();
+    assertEquals("DN_for_default_naming_context", adServer.getDn());
+    assertEquals("ds_service_name", adServer.getDsServiceName());
+    assertEquals(12345678L, adServer.getHighestCommittedUSN());
+    assertEquals("S-1-0-0", adServer.getSid());
+    assertEquals("0x0123456789abc", adServer.getInvocationID());
+    assertEquals("GSA-CONNECTORS", adServer.getnETBIOSName());
+    assertEquals("[GSA-CONNECTORS] ", adServer.toString());
+    assertEquals("", adServer.get("dn=empty", "empty", "empty"));
+    assertNull(adServer.get("dn=ds_service_name", "null", "null"));
+    assertEquals("val1", adServer.get("dn=empty", "attr1", "basedn"));
+    assertNull(adServer.get("dn=empty", "attr2", "basedn"));
+  }
+
+  @Test
+  /*
+   * This tests a code path that the author doesn't think can actually happen
+   *
+   * <p> where <code>ldapResults</code> is not <code>null</code>, but where
+   * <code>attrs.get(attribute)</code> does return <code>null</code>.
+   */
+  public void testGetNotReturningAttribute() throws Exception {
+    MockLdapContext ldapContext = new MockLdapContext() {
+      @Override
+      public NamingEnumeration<SearchResult> search(String base, String filter,
+        SearchControls searchControls) throws NamingException {
+        if (!("dn=empty".equals(filter))) {
+          return super.search(base, filter, searchControls);
+        };
+        // prepare "broken" SearchResult
+        Vector<SearchResult> brokenSRs = new Vector<SearchResult>();
+        brokenSRs.add(new SearchResult("search result name", brokenSRs,
+          new BasicAttributes()));
+        return new MockLdapContext.SearchResultsNamingEnumeration(brokenSRs);
+      };
+    };
+    addStandardKeysAndResults(ldapContext);
+    ldapContext.addSearchResult("dn=empty", "attr1", "basedn", "val1");
+    AdServer adServer = new AdServer("localhost", ldapContext);
+    adServer.initialize();
+    assertNull(adServer.get("dn=empty", "attr1", "basedn"));
+  }
+
+  @Test
+  public void testGetThrowsNamingException() throws Exception {
+    MockLdapContext ldapContext = new MockLdapContext() {
+      @Override
+      public NamingEnumeration<SearchResult> search(String base, String filter,
+        SearchControls searchControls) throws NamingException {
+        if (!("dn=empty".equals(filter))) {
+          return super.search(base, filter, searchControls);
+        };
+        throw new NamingException("Gotcha");
+      };
+    };
+    addStandardKeysAndResults(ldapContext);
+    ldapContext.addSearchResult("dn=empty", "attr1", "basedn", "val1");
+    AdServer adServer = new AdServer("localhost", ldapContext);
+    adServer.initialize();
+    assertNull(adServer.get("dn=empty", "attr1", "basedn"));
+  }
+
+  @Test
+  public void testConnectThrowsNamingException() throws Exception {
+    thrown.expect(RuntimeException.class);
+    MockLdapContext ldapContext = new MockLdapContext() {
+      @Override
+        public Attributes getAttributes(String name) throws NamingException {
+        throw new NamingException("Can't connect");
+      };
+    };
+    addStandardKeysAndResults(ldapContext);
+    ldapContext.addSearchResult("dn=empty", "attr1", "basedn", "val1");
+    AdServer adServer = new AdServer("localhost", ldapContext);
+    adServer.initialize();
+  }
+
+  @Test
+  public void testSearchReturnsOneUser() throws Exception {
+    MockLdapContext ldapContext = new MockLdapContext();
+    addStandardKeysAndResults(ldapContext);
+    // populate additional attributes with values we can test
+    final String filter = "ou=Users";
+    final String userDn = "DN_for_default_naming_context";
+    ldapContext.addSearchResult(filter, "cn", userDn, "user1")
+               .addSearchResult(filter, "primaryGroupId", userDn, "users")
+               .addSearchResult(filter, "objectGUID;binary", userDn,
+                   hexStringToByteArray("000102030405060708090a0b0c"));
+    AdServer adServer = new AdServer("localhost", ldapContext);
+    adServer.initialize();
+    Set<AdEntity> resultSet = adServer.search(filter, false,
+        new String[] { "cn", "primaryGroupId", "objectGUID;binary" });
+    assertEquals(1, resultSet.size());
+    for (AdEntity ae : resultSet) {
+      assertEquals("name under", ae.getCommonName());
+    }
+  }
+
+  @Test
+  public void testSearchReturnsNoUsersWhenMissingGUID() throws Exception {
+    MockLdapContext ldapContext = new MockLdapContext();
+    addStandardKeysAndResults(ldapContext);
+    // populate additional attributes with values we can test
+    final String filter = "ou=Users";
+    final String userDn = "DN_for_default_naming_context";
+    ldapContext.addSearchResult(filter, "cn", userDn, "user1")
+               .addSearchResult(filter, "primaryGroupId", userDn, "users");
+    AdServer adServer = new AdServer("localhost", ldapContext);
+    adServer.initialize();
+    Set<AdEntity> resultSet = adServer.search(filter, false,
+        new String[] { "cn", "primaryGroupId", "objectGUID;binary" });
+    assertEquals(0, resultSet.size());
+  }
+
+  @Test
+  public void testSearchReturnsOneDeletedUser() throws Exception {
+    MockLdapContext ldapContext = new MockLdapContext();
+    addStandardKeysAndResults(ldapContext);
+    // populate additional attributes with values we can test
+    final String filter = "ou=Users";
+    final String userDn = "DN_for_default_naming_context";
+    ldapContext.addSearchResult(filter, "cn", userDn, "user1")
+               .addSearchResult(filter, "primaryGroupId", userDn, "users")
+               .addSearchResult(filter, "objectGUID;binary", userDn,
+                   hexStringToByteArray("000102030405060708090a0b0c"));
+    AdServer adServer = new AdServer("localhost", ldapContext);
+    adServer.initialize();
+    Set<AdEntity> resultSet = adServer.search(filter, true,
+        new String[] { "cn", "primaryGroupId", "objectGUID;binary" });
+    assertEquals(1, resultSet.size());
+    for (AdEntity ae : resultSet) {
+      assertEquals("name under", ae.getCommonName());
+    }
+    // now verify the DeletedControl is exactly as we expect
+    assertEquals(2, ldapContext.getResponseControls().length);
+    Control deletedControl = ldapContext.getResponseControls()[1];
+    assertArrayEquals(new byte[0], deletedControl.getEncodedValue());
+    assertEquals("1.2.840.113556.1.4.417", deletedControl.getID());
+    assertTrue(deletedControl.isCritical());
+  }
+
+  @Test
+  public void testSearchReturnsOneEmptyGroup() throws Exception {
+    MockLdapContext ldapContext = new MockLdapContext();
+    addStandardKeysAndResults(ldapContext);
+    // populate additional attributes with values we can test
+    final String filter = "ou=Users";
+    final String userDn = "DN_for_default_naming_context";
+    ldapContext.addSearchResult(filter, "cn", userDn, "users")
+               .addSearchResult(filter, "objectGUID;binary", userDn,
+                   hexStringToByteArray("000102030405060708090a0b0c"));
+    AdServer adServer = new AdServer("localhost", ldapContext);
+    adServer.initialize();
+    Set<AdEntity> resultSet = adServer.search(filter, true,
+        new String[] { "cn", "members", "objectGUID;binary" });
+    assertEquals(1, resultSet.size());
+    for (AdEntity ae : resultSet) {
+      assertEquals("name under", ae.getCommonName());
+    }
+  }
+
+  @Test
+  public void testSearchReturnsOneNonemptyGroup() throws Exception {
+    MockLdapContext ldapContext = new MockLdapContext();
+    addStandardKeysAndResults(ldapContext);
+    // populate additional attributes with values we can test
+    final String filter = "ou=Users";
+    final String userDn = "DN_for_default_naming_context";
+    List<String> members = Arrays.asList("dn_for_user_1", "dn_for_user_2");
+    ldapContext.addSearchResult(filter, "cn", userDn, "users")
+               .addSearchResult(filter, "objectGUID;binary", userDn,
+                   hexStringToByteArray("000102030405060708090a0b0c"))
+               .addSearchResult(filter, "member", userDn, members);
+    AdServer adServer = new AdServer("localhost", ldapContext);
+    adServer.initialize();
+    Set<AdEntity> resultSet = adServer.search(filter, false,
+        new String[] { "cn", "member", "objectGUID;binary" });
+    assertEquals(1, resultSet.size());
+    for (AdEntity ae : resultSet) {
+      assertEquals(new HashSet<String>(members), ae.getMembers());
+    }
+  }
+
+  @Test
+  public void testSearchReturnsMembersinTwoRanges() throws Exception {
+    MockLdapContext ldapContext = new MockLdapContext();
+    addStandardKeysAndResults(ldapContext);
+    // populate additional attributes with values we can test
+    final String filter = "ou=Users";
+    final String filter2 = "(sAMAccountName=sam)";
+    final String userDn = "DN_for_default_naming_context";
+    List<String> members = Arrays.asList("dn_for_user_0", "dn_for_user_1");
+    List<String> moreMembers = Arrays.asList("dn_for_user_2", "dn_for_user_3");
+    ldapContext.addSearchResult(filter, "cn", userDn, "users")
+               .addSearchResult(filter, "objectGUID;binary", userDn,
+                   hexStringToByteArray("000102030405060708090a0b0c"))
+               .addSearchResult(filter, "sAMAccountName", userDn, "sam")
+               .addSearchResult(filter, "member;Range=0-1", userDn, members);
+    ldapContext.addSearchResult(filter2, "cn", userDn, "users")
+               .addSearchResult(filter2, "objectGUID;binary", userDn,
+                   hexStringToByteArray("000102030405060708090a0b0c"))
+               .addSearchResult(filter2, "sAMAccountName", userDn, "sam2")
+               .addSearchResult(filter2, "member;Range=2-3*", userDn,
+                    moreMembers);
+    AdServer adServer = new AdServer("localhost", ldapContext);
+    adServer.initialize();
+    Set<AdEntity> resultSet = adServer.search(filter, false,
+        // need the ranged members for MockLdapContext, not for "real" AD.
+        new String[] { "cn", "member", "member;Range=0-1", "member;Range=2-3",
+                       "objectGUID;binary", "sAMAccountName" });
+    assertEquals(1, resultSet.size());
+    HashSet<String> expectedMembers = new HashSet<String>(members);
+    expectedMembers.addAll(moreMembers);
+    for (AdEntity ae : resultSet) {
+      assertEquals(expectedMembers, ae.getMembers());
+    }
+  }
+
+  @Test
+  public void testSearchReturnsMembersinThreeRanges() throws Exception {
+    MockLdapContext ldapContext = new MockLdapContext();
+    addStandardKeysAndResults(ldapContext);
+    // populate additional attributes with values we can test
+    final String filter = "ou=Users";
+    final String filter2 = "(sAMAccountName=sam)";
+    final String userDn = "DN_for_default_naming_context";
+    List<String> members = Arrays.asList("dn_for_user_0", "dn_for_user_1");
+    List<String> moreMembers = Arrays.asList("dn_for_user_2", "dn_for_user_3");
+    List<String> evenMore = Arrays.asList("dn_for_user_4");
+    ldapContext.addSearchResult(filter, "cn", userDn, "users")
+               .addSearchResult(filter, "objectGUID;binary", userDn,
+                   hexStringToByteArray("000102030405060708090a0b0c"))
+               .addSearchResult(filter, "sAMAccountName", userDn, "sam")
+               .addSearchResult(filter, "member;Range=0-1", userDn, members);
+    ldapContext.addSearchResult(filter2, "cn", userDn, "users")
+               .addSearchResult(filter2, "objectGUID;binary", userDn,
+                   hexStringToByteArray("000102030405060708090a0b0c"))
+               .addSearchResult(filter2, "sAMAccountName", userDn, "sam2")
+               .addSearchResult(filter2, "member;Range=2-3", userDn,
+                    moreMembers)
+               .addSearchResult(filter2, "member;Range=4-5*", userDn,
+                    evenMore);
+    AdServer adServer = new AdServer("localhost", ldapContext);
+    adServer.initialize();
+    Set<AdEntity> resultSet = adServer.search(filter, false,
+        // need the ranged members for MockLdapContext, not for "real" AD.
+        new String[] { "cn", "member", "member;Range=0-1", "member;Range=2-3",
+                       "member;Range=4-5*", "objectGUID;binary",
+                       "sAMAccountName" });
+    assertEquals(1, resultSet.size());
+    HashSet<String> expectedMembers = new HashSet<String>(members);
+    expectedMembers.addAll(moreMembers);
+    expectedMembers.addAll(evenMore);
+    for (AdEntity ae : resultSet) {
+      assertEquals(expectedMembers, ae.getMembers());
+    }
+  }
+
+  /**
+    * Generate a common LdapContext used for various tests above
+    */
+  private void addStandardKeysAndResults(MockLdapContext ldapContext) {
+    // populate the attributes with values we can test
+    ldapContext.addKey("defaultNamingContext", "DN_for_default_naming_context")
+               .addKey("dsServiceName", "ds_service_name")
+               .addKey("highestCommittedUSN", "12345678")
+               .addKey("configurationNamingContext", "naming_context")
+               .addSearchResult(
+                 "distinguishedName=DN_for_default_naming_context",
+                 "objectSid;binary",
+                 "DN_for_default_naming_context",
+                 hexStringToByteArray("010100000000000000000000")) // S-1-0-0
+               .addSearchResult("distinguishedName=ds_service_name",
+                 "invocationID;binary",
+                 "ds_service_name",
+                 hexStringToByteArray("000102030405060708090a0b0c"))
+               .addSearchResult("(ncName=DN_for_default_naming_context)",
+                 "nETBIOSName",
+                 "naming_context",
+                 "GSA-CONNECTORS")
+               .addSearchResult("(ncName=DN_for_default_naming_context)",
+                 "dnsRoot",
+                 "naming_context",
+                 "gsa-connectors.com");
+  }
+
+  public static byte[] hexStringToByteArray(String s) {
+    int len = s.length();
+    byte[] data = new byte[len / 2];
+    for (int i = 0; i < len; i += 2) {
+        data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4)
+                             + Character.digit(s.charAt(i+1), 16));
+    }
+    return data;
+  }
+}
diff --git a/test/com/google/enterprise/adaptor/ad/MockLdapContext.java b/test/com/google/enterprise/adaptor/ad/MockLdapContext.java
new file mode 100644
index 0000000..1cfcf68
--- /dev/null
+++ b/test/com/google/enterprise/adaptor/ad/MockLdapContext.java
@@ -0,0 +1,183 @@
+// Copyright 2013 Google Inc. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.enterprise.adaptor.ad;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import java.io.*;
+import java.util.*;
+
+import javax.naming.*;
+import javax.naming.directory.*;
+import javax.naming.ldap.Control;
+import javax.naming.ldap.InitialLdapContext;
+
+/**
+ * Mock of {@link Config}.
+ */
+public class MockLdapContext extends InitialLdapContext {
+  private BasicAttributes attributes = new BasicAttributes();
+  private Hashtable<String, Object> searchResults
+      = new Hashtable<String, Object>();
+  private Control[] controls = new Control[0];
+
+  // A hack to throw exactly 2 exceptions on calls to setRequestControls()
+  static int numberOfTimesSetRequestControlsWasCalled = 0;
+
+  public MockLdapContext() throws NamingException {
+  }
+
+  @Override
+  public Attributes getAttributes(String name) throws NamingException {
+    return attributes;
+  }
+
+  /** Normal way to add things to this Mock */
+  public MockLdapContext addKey(String key, Object value) {
+    attributes.put(key, value);
+    return this;
+  }
+
+  public MockLdapContext removeKey(String key) {
+    attributes.remove(key);
+    return this;
+  }
+
+  public static String makeKey(String filter, String attribute, String baseDn) {
+    return filter + "|" + attribute + "|" + baseDn;
+  }
+
+  public MockLdapContext addSearchResult(String filter,
+      String attribute, String baseDn, Object value) {
+    searchResults.put(makeKey(filter, attribute, baseDn), value);
+    return this;
+  }
+
+  /** Returns a <code>NamingEnumeration</code> of <code>SearchResult</code>s */
+  public NamingEnumeration<SearchResult> search(String base, String filter,
+      SearchControls searchControls) throws NamingException {
+    Vector<SearchResult> results = new Vector<SearchResult>();
+    SearchResult currentResult = null;
+    for (String attribute : searchControls.getReturningAttributes()) {
+      Object o = searchResults.get(makeKey(filter, attribute, base));
+      if (o == null) {
+        // hack to make search for member;Range=X-Y succeed when that's the
+        // final range present
+        if (attribute.startsWith("member;Range=")) {
+          attribute += "*";
+          o = searchResults.get(makeKey(filter, attribute, base));
+        }
+      }
+      if (o != null) {
+        if (currentResult == null) {
+          currentResult = makeSearchResult(attribute, o, base);
+          results.add(currentResult);
+        } else {
+          Attributes attrs = currentResult.getAttributes();
+          this.addAttribute(attrs, attribute, o);
+          currentResult.setAttributes(attrs);
+        }
+      }
+    }
+    return new SearchResultsNamingEnumeration(results);
+  }
+
+  /** properly handle collections as we add a new attribute */
+  private static void addAttribute(Attributes attrs, String newAttr,
+      Object value) {
+    attrs.put(newAttr, value);
+    if (value instanceof Collection<?>) {
+      Attribute attr = attrs.get(newAttr);
+      attr.clear();
+      for (Object member: (Collection<?>) value) {
+        attr.add(member);
+      }
+    }
+  }
+
+  /** Creates a <code>SearchResults</code> wrapper with a single attribute */
+  private static SearchResult makeSearchResult(String attribute, Object o,
+      String baseDn) {
+    SearchResult sr = new SearchResult("search result name", o,
+        new BasicAttributes());
+    addAttribute(sr.getAttributes(), attribute, o);
+    sr.setNameInNamespace("cn=name\\ under," + baseDn);
+    return sr;
+  }
+
+  @Override
+  public Control[] getResponseControls() throws NamingException {
+    return controls;
+  };
+
+  /** Sets the controls (which this class does nothing with)
+   *
+   * <p>throws Exceptions exactly once, to test the Exception processing code.
+   */
+  @Override
+  public void setRequestControls(Control[] requestControls)
+      throws NamingException {
+    controls = requestControls;
+    if (++numberOfTimesSetRequestControlsWasCalled == 2) {
+      throw new NamingException("calling setRequestControls for second time");
+    }
+  };
+
+  @VisibleForTesting
+  static class SearchResultsNamingEnumeration
+      implements NamingEnumeration<SearchResult> {
+    private final Enumeration<SearchResult> results;
+    private SearchResult nextElement = null;
+
+    SearchResultsNamingEnumeration(Vector<SearchResult> results) {
+      this.results = results.elements();
+    }
+
+    private SearchResult getNextElement() {
+      if (results.hasMoreElements()) {
+        return results.nextElement();
+      }
+      return null;
+    }
+
+    public boolean hasMore() {
+      if (nextElement != null) {
+        return true;
+      }
+      nextElement = getNextElement();
+      return (nextElement != null);
+    }
+
+    public boolean hasMoreElements() {
+      return hasMore();
+    }
+
+    public SearchResult next() {
+      if (!hasMore()) {
+        throw new NoSuchElementException();
+      }
+      SearchResult res = nextElement;
+      nextElement = null;
+      return res;
+    }
+
+    public SearchResult nextElement() {
+      return next();
+    }
+
+    public void close() {
+    }
+  }
+}