Repository: firebase/firebase-admin-java
Pull request #358 (2020-01-30 21:49:20)

Add ListTenantsPage class.
This pull request adds ListTenantsPage (and some supporting code) as part of adding multi-tenancy support (see issue #332).

This code was very largely based off of ListUsersPage and ListUsersPageTest.

hiranya911: @micahstairs thanks for putting this together. Please remember to set the assignee of the PRs when they are ready to be reviewed. That way it shows up in our radar sooner.
googlebot: We found a Contributor License Agreement for you (the sender of this pull request), but were unable to find agreements for all the commit author(s) or Co-authors.  If you authored these, maybe you used a different email address in the git commits than was used to sign the CLA ([login here](https://cla.developers.google.com/) to double check)?  If these were authored by someone else, then they will need to sign a CLA as well, and confirm that they're okay with these being contributed to Google.
In order to pass this check, please resolve this problem and then comment `@googlebot I fixed it.`. If the bot doesn't comment, it means it doesn't think anything has changed.

ℹ️ **Googlers: [Go here](https://goto.google.com/prinfo/https%3A%2F%2Fgithub.com%2Ffirebase%2Ffirebase-admin-java%2Fpull%2F358) for more info**.

<!-- need_author_cla -->
micahstairs: @googlebot I fixed it.
googlebot: CLAs look good, thanks!

ℹ️ **Googlers: [Go here](https://goto.google.com/prinfo/https%3A%2F%2Fgithub.com%2Ffirebase%2Ffirebase-admin-java%2Fpull%2F358) for more info**.

<!-- ok -->

Patch:
diff --git a/src/main/java/com/google/firebase/auth/FirebaseUserManager.java b/src/main/java/com/google/firebase/auth/FirebaseUserManager.java
index 8298499..21abdea 100644
--- a/src/main/java/com/google/firebase/auth/FirebaseUserManager.java
+++ b/src/main/java/com/google/firebase/auth/FirebaseUserManager.java
@@ -42,8 +42,8 @@ import com.google.firebase.auth.UserRecord.CreateRequest;
 import com.google.firebase.auth.UserRecord.UpdateRequest;
 import com.google.firebase.auth.internal.DownloadAccountResponse;
 import com.google.firebase.auth.internal.GetAccountInfoResponse;
-
 import com.google.firebase.auth.internal.HttpErrorResponse;
+import com.google.firebase.auth.internal.ListTenantsResponse;
 import com.google.firebase.auth.internal.UploadAccountResponse;
 import com.google.firebase.internal.FirebaseRequestInitializer;
 import com.google.firebase.internal.NonNull;
@@ -57,6 +57,9 @@ import java.util.Map;
 /**
  * FirebaseUserManager provides methods for interacting with the Google Identity Toolkit via its
  * REST API. This class does not hold any mutable state, and is thread safe.
+ * 
+ * <p>TODO(micahstairs): Consider renaming this to FirebaseAuthManager since this also supports
+ * tenants.
  *
  * @see <a href="https://developers.google.com/identity/toolkit/web/reference/relyingparty">
  *   Google Identity Toolkit</a>
@@ -87,6 +90,7 @@ class FirebaseUserManager {
       .put("INVALID_DYNAMIC_LINK_DOMAIN", "invalid-dynamic-link-domain")
       .build();
 
+  static final int MAX_LIST_TENANTS_RESULTS = 1000;
   static final int MAX_LIST_USERS_RESULTS = 1000;
   static final int MAX_IMPORT_USERS = 1000;
 
@@ -95,10 +99,11 @@ class FirebaseUserManager {
       "iss", "jti", "nbf", "nonce", "sub", "firebase");
 
   private static final String ID_TOOLKIT_URL =
-      "https://identitytoolkit.googleapis.com/v1/projects/%s";
+      "https://identitytoolkit.googleapis.com/%s/projects/%s";
   private static final String CLIENT_VERSION_HEADER = "X-Client-Version";
 
-  private final String baseUrl;
+  private final String userMgtBaseUrl;
+  private final String tenantMgtBaseUrl;
   private final JsonFactory jsonFactory;
   private final HttpRequestFactory requestFactory;
   private final String clientVersion = "Java/Admin/" + SdkUtils.getVersion();
@@ -117,7 +122,8 @@ class FirebaseUserManager {
         "Project ID is required to access the auth service. Use a service account credential or "
             + "set the project ID explicitly via FirebaseOptions. Alternatively you can also "
             + "set the project ID via the GOOGLE_CLOUD_PROJECT environment variable.");
-    this.baseUrl = String.format(ID_TOOLKIT_URL, projectId);
+    this.userMgtBaseUrl = String.format(ID_TOOLKIT_URL, "v1", projectId);
+    this.tenantMgtBaseUrl = String.format(ID_TOOLKIT_URL, "v2", projectId);
     this.jsonFactory = app.getOptions().getJsonFactory();
     HttpTransport transport = app.getOptions().getHttpTransport();
     this.requestFactory = transport.createRequestFactory(new FirebaseRequestInitializer(app));
@@ -201,7 +207,7 @@ class FirebaseUserManager {
       builder.put("nextPageToken", pageToken);
     }
 
-    GenericUrl url = new GenericUrl(baseUrl + "/accounts:batchGet");
+    GenericUrl url = new GenericUrl(userMgtBaseUrl + "/accounts:batchGet");
     url.putAll(builder.build());
     DownloadAccountResponse response = sendRequest(
             "GET", url, null, DownloadAccountResponse.class);
@@ -221,6 +227,24 @@ class FirebaseUserManager {
     return new UserImportResult(request.getUsersCount(), response);
   }
 
+  ListTenantsResponse listTenants(int maxResults, String pageToken) throws FirebaseAuthException {
+    ImmutableMap.Builder<String, Object> builder = ImmutableMap.<String, Object>builder()
+        .put("pageSize", maxResults);
+    if (pageToken != null) {
+      checkArgument(!pageToken.equals(
+          ListTenantsPage.END_OF_LIST), "invalid end of list page token");
+      builder.put("pageToken", pageToken);
+    }
+
+    GenericUrl url = new GenericUrl(tenantMgtBaseUrl + "/tenants:list");
+    url.putAll(builder.build());
+    ListTenantsResponse response = sendRequest("GET", url, null, ListTenantsResponse.class);
+    if (response == null) {
+      throw new FirebaseAuthException(INTERNAL_ERROR, "Failed to retrieve tenants.");
+    }
+    return response;
+  }
+
   String createSessionCookie(String idToken,
       SessionCookieOptions options) throws FirebaseAuthException {
     final Map<String, Object> payload = ImmutableMap.<String, Object>of(
@@ -257,7 +281,7 @@ class FirebaseUserManager {
   private <T> T post(String path, Object content, Class<T> clazz) throws FirebaseAuthException {
     checkArgument(!Strings.isNullOrEmpty(path), "path must not be null or empty");
     checkNotNull(content, "content must not be null for POST requests");
-    GenericUrl url = new GenericUrl(baseUrl + path);
+    GenericUrl url = new GenericUrl(userMgtBaseUrl + path);
     return sendRequest("POST", url, content, clazz);
   }
 
diff --git a/src/main/java/com/google/firebase/auth/ListTenantsPage.java b/src/main/java/com/google/firebase/auth/ListTenantsPage.java
new file mode 100644
index 0000000..5f5f213
--- /dev/null
+++ b/src/main/java/com/google/firebase/auth/ListTenantsPage.java
@@ -0,0 +1,248 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * 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.firebase.auth;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.api.client.json.JsonFactory;
+import com.google.api.gax.paging.Page;
+import com.google.common.collect.ImmutableList;
+import com.google.firebase.auth.internal.DownloadAccountResponse;
+import com.google.firebase.auth.internal.ListTenantsResponse;
+import com.google.firebase.internal.NonNull;
+import com.google.firebase.internal.Nullable;
+import java.util.Iterator;
+import java.util.List;
+import java.util.NoSuchElementException;
+
+/**
+ * Represents a page of {@link Tenant} instances.
+ * 
+ * <p>Provides methods for iterating over the tenants in the current page, and calling up
+ * subsequent pages of tenants.
+ * 
+ * <p>Instances of this class are thread-safe and immutable.
+ */
+public class ListTenantsPage implements Page<Tenant> {
+
+  static final String END_OF_LIST = "";
+
+  private final ListTenantsResponse currentBatch;
+  private final TenantSource source;
+  private final int maxResults;
+
+  private ListTenantsPage(
+      @NonNull ListTenantsResponse currentBatch, @NonNull TenantSource source, int maxResults) {
+    this.currentBatch = checkNotNull(currentBatch);
+    this.source = checkNotNull(source);
+    this.maxResults = maxResults;
+  }
+
+  /**
+   * Checks if there is another page of tenants available to retrieve.
+   *
+   * @return true if another page is available, or false otherwise.
+   */
+  @Override
+  public boolean hasNextPage() {
+    return !END_OF_LIST.equals(currentBatch.getPageToken());
+  }
+
+  /**
+   * Returns the string token that identifies the next page.
+   * 
+   * <p>Never returns null. Returns empty string if there are no more pages available to be
+   * retrieved.
+   *
+   * @return A non-null string token (possibly empty, representing no more pages)
+   */
+  @NonNull
+  @Override
+  public String getNextPageToken() {
+    return currentBatch.getPageToken();
+  }
+
+  /**
+   * Returns the next page of tenants.
+   *
+   * @return A new {@link ListTenantsPage} instance, or null if there are no more pages.
+   */
+  @Nullable
+  @Override
+  public ListTenantsPage getNextPage() {
+    if (hasNextPage()) {
+      PageFactory factory = new PageFactory(source, maxResults, currentBatch.getPageToken());
+      try {
+        return factory.create();
+      } catch (FirebaseAuthException e) {
+        throw new RuntimeException(e);
+      }
+    }
+    return null;
+  }
+
+  /**
+   * Returns an {@link Iterable} that facilitates transparently iterating over all the tenants in
+   * the current Firebase project, starting from this page.
+   * 
+   * <p>The {@link Iterator} instances produced by the returned {@link Iterable} never buffers more
+   * than one page of tenants at a time. It is safe to abandon the iterators (i.e. break the loops)
+   * at any time.
+   *
+   * @return a new {@link Iterable} instance.
+   */
+  @NonNull
+  @Override
+  public Iterable<Tenant> iterateAll() {
+    return new TenantIterable(this);
+  }
+
+  /**
+   * Returns an {@code Iterable} over the users in this page.
+   *
+   * @return a {@code Iterable<Tenant>} instance.
+   */
+  @NonNull
+  @Override
+  public Iterable<Tenant> getValues() {
+    return currentBatch.getTenants();
+  }
+
+  private static class TenantIterable implements Iterable<Tenant> {
+
+    private final ListTenantsPage startingPage;
+
+    TenantIterable(@NonNull ListTenantsPage startingPage) {
+      this.startingPage = checkNotNull(startingPage, "starting page must not be null");
+    }
+
+    @Override
+    @NonNull
+    public Iterator<Tenant> iterator() {
+      return new TenantIterator(startingPage);
+    }
+
+    /**
+     * An {@link Iterator} that cycles through tenants, one at a time.
+     * 
+     * <p>It buffers the last retrieved batch of tenants in memory. The {@code maxResults} parameter
+     * is an upper bound on the batch size.
+     */
+    private static class TenantIterator implements Iterator<Tenant> {
+
+      private ListTenantsPage currentPage;
+      private List<Tenant> batch;
+      private int index = 0;
+
+      private TenantIterator(ListTenantsPage startingPage) {
+        setCurrentPage(startingPage);
+      }
+
+      @Override
+      public boolean hasNext() {
+        if (index == batch.size()) {
+          if (currentPage.hasNextPage()) {
+            setCurrentPage(currentPage.getNextPage());
+          } else {
+            return false;
+          }
+        }
+
+        return index < batch.size();
+      }
+
+      @Override
+      public Tenant next() {
+        if (!hasNext()) {
+          throw new NoSuchElementException();
+        }
+        return batch.get(index++);
+      }
+
+      @Override
+      public void remove() {
+        throw new UnsupportedOperationException("remove operation not supported");
+      }
+
+      private void setCurrentPage(ListTenantsPage page) {
+        this.currentPage = checkNotNull(page);
+        this.batch = ImmutableList.copyOf(page.getValues());
+        this.index = 0;
+      }
+    }
+  }
+
+  /**
+   * Represents a source of tenant data that can be queried to load a batch of tenants.
+   */
+  interface TenantSource {
+    @NonNull
+    ListTenantsResponse fetch(int maxResults, String pageToken)
+      throws FirebaseAuthException;
+  }
+
+  static class DefaultTenantSource implements TenantSource {
+
+    private final FirebaseUserManager userManager;
+    private final JsonFactory jsonFactory;
+
+    DefaultTenantSource(FirebaseUserManager userManager, JsonFactory jsonFactory) {
+      this.userManager = checkNotNull(userManager, "user manager must not be null");
+      this.jsonFactory = checkNotNull(jsonFactory, "json factory must not be null");
+    }
+
+    @Override
+    public ListTenantsResponse fetch(int maxResults, String pageToken)
+        throws FirebaseAuthException {
+      return userManager.listTenants(maxResults, pageToken);
+    }
+  }
+
+  /**
+   * A simple factory class for {@link ListTenantsPage} instances.
+   * 
+   * <p>Performs argument validation before attempting to load any tenant data (which is expensive,
+   * and hence may be performed asynchronously on a separate thread).
+   */
+  static class PageFactory {
+
+    private final TenantSource source;
+    private final int maxResults;
+    private final String pageToken;
+
+    PageFactory(@NonNull TenantSource source) {
+      this(source, FirebaseUserManager.MAX_LIST_TENANTS_RESULTS, null);
+    }
+
+    PageFactory(@NonNull TenantSource source, int maxResults, @Nullable String pageToken) {
+      checkArgument(maxResults > 0 && maxResults <= FirebaseUserManager.MAX_LIST_TENANTS_RESULTS,
+          "maxResults must be a positive integer that does not exceed %s",
+          FirebaseUserManager.MAX_LIST_TENANTS_RESULTS);
+      checkArgument(!END_OF_LIST.equals(pageToken), "invalid end of list page token");
+      this.source = checkNotNull(source, "source must not be null");
+      this.maxResults = maxResults;
+      this.pageToken = pageToken;
+    }
+
+    ListTenantsPage create() throws FirebaseAuthException {
+      ListTenantsResponse batch = source.fetch(maxResults, pageToken);
+      return new ListTenantsPage(batch, source, maxResults);
+    }
+  }
+}
+
diff --git a/src/main/java/com/google/firebase/auth/Tenant.java b/src/main/java/com/google/firebase/auth/Tenant.java
index a3e1471..f64d93a 100644
--- a/src/main/java/com/google/firebase/auth/Tenant.java
+++ b/src/main/java/com/google/firebase/auth/Tenant.java
@@ -33,10 +33,26 @@ public final class Tenant {
   private String displayName;
 
   @Key("allowPasswordSignup")
-  private String passwordSignInAllowed;
+  private boolean passwordSignInAllowed;
 
   @Key("enableEmailLinkSignin")
-  private String emailLinkSignInEnabled;
+  private boolean emailLinkSignInEnabled;
+
+  public String getTenantId() {
+    return tenantId;
+  }
+
+  public String getDisplayName() {
+    return displayName;
+  }
+
+  public boolean isPasswordSignInAllowed() {
+    return passwordSignInAllowed;
+  }
+
+  public boolean isEmailLinkSignInEnabled() {
+    return emailLinkSignInEnabled;
+  }
 
   /**
    * Class used to hold the information needs to make a tenant create request.
@@ -73,8 +89,8 @@ public final class Tenant {
     }
 
     /**
-   * Builder class used to construct a create request.
-   */
+    * Builder class used to construct a create request.
+    */
     @AutoValue.Builder
     abstract static class Builder {
       public abstract Builder setDisplayName(String displayName);
@@ -122,8 +138,8 @@ public final class Tenant {
     }
 
     /**
-   * Builder class used to construct a update request.
-   */
+    * Builder class used to construct a update request.
+    */
     @AutoValue.Builder
     abstract static class Builder {
       public abstract Builder setDisplayName(String displayName);
diff --git a/src/main/java/com/google/firebase/auth/internal/ListTenantsResponse.java b/src/main/java/com/google/firebase/auth/internal/ListTenantsResponse.java
new file mode 100644
index 0000000..7abb798
--- /dev/null
+++ b/src/main/java/com/google/firebase/auth/internal/ListTenantsResponse.java
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * 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.firebase.auth.internal;
+
+import com.google.api.client.util.Key;
+import com.google.firebase.auth.Tenant;
+import java.util.List;
+
+/**
+ * JSON data binding for ListTenantsResponse messages sent by Google identity toolkit service.
+ */
+public class ListTenantsResponse {
+
+  @Key("tenants")
+  private List<Tenant> tenants;
+
+  @Key("pageToken")
+  private String pageToken;
+
+  public ListTenantsResponse(List<Tenant> tenants, String pageToken) {
+    this.tenants = tenants;
+    this.pageToken = pageToken;
+  }
+
+  public ListTenantsResponse() { }
+
+  public List<Tenant> getTenants() {
+    return tenants;
+  }
+
+  public boolean hasTenants() {
+    return tenants != null && !tenants.isEmpty();
+  }
+
+  public String getPageToken() {
+    return pageToken;
+  }
+}


Test patch:
diff --git a/src/test/java/com/google/firebase/auth/ListTenantsPageTest.java b/src/test/java/com/google/firebase/auth/ListTenantsPageTest.java
new file mode 100644
index 0000000..227e3dc
--- /dev/null
+++ b/src/test/java/com/google/firebase/auth/ListTenantsPageTest.java
@@ -0,0 +1,350 @@
+/*
+ * Copyright 2020 Google LLC
+ *
+ * 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.firebase.auth;
+
+import static junit.framework.TestCase.assertTrue;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.fail;
+
+import com.google.api.client.googleapis.util.Utils;
+import com.google.api.client.json.JsonFactory;
+import com.google.common.collect.ImmutableList;
+import com.google.common.io.BaseEncoding;
+import com.google.firebase.auth.internal.ListTenantsResponse;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.NoSuchElementException;
+
+import org.junit.Test;
+
+public class ListTenantsPageTest {
+
+  @Test
+  public void testSinglePage() throws FirebaseAuthException, IOException {
+    TestTenantSource source = new TestTenantSource(3);
+    ListTenantsPage page = new ListTenantsPage.PageFactory(source).create();
+    assertFalse(page.hasNextPage());
+    assertEquals(ListTenantsPage.END_OF_LIST, page.getNextPageToken());
+    assertNull(page.getNextPage());
+
+    ImmutableList<Tenant> tenants = ImmutableList.copyOf(page.getValues());
+    assertEquals(3, tenants.size());
+    for (int i = 0; i < 3; i++) {
+      assertEquals("tenant" + i, tenants.get(i).getTenantId());
+    }
+    assertEquals(1, source.calls.size());
+    assertNull(source.calls.get(0));
+  }
+
+  @Test
+  public void testMultiplePages() throws FirebaseAuthException, IOException {
+    ListTenantsResponse response = new ListTenantsResponse(
+        ImmutableList.of(newTenant("tenant0"), newTenant("tenant1"), newTenant("tenant2")),
+        "token");
+    TestTenantSource source = new TestTenantSource(response);
+    ListTenantsPage page1 = new ListTenantsPage.PageFactory(source).create();
+    assertTrue(page1.hasNextPage());
+    assertEquals("token", page1.getNextPageToken());
+    ImmutableList<Tenant> tenants = ImmutableList.copyOf(page1.getValues());
+    assertEquals(3, tenants.size());
+    for (int i = 0; i < 3; i++) {
+      assertEquals("tenant" + i, tenants.get(i).getTenantId());
+    }
+
+    response = new ListTenantsResponse(
+        ImmutableList.of(newTenant("tenant3"), newTenant("tenant4"), newTenant("tenant5")),
+        ListTenantsPage.END_OF_LIST);
+    source.response = response;
+    ListTenantsPage page2 = page1.getNextPage();
+    assertFalse(page2.hasNextPage());
+    assertEquals(ListTenantsPage.END_OF_LIST, page2.getNextPageToken());
+    tenants = ImmutableList.copyOf(page2.getValues());
+    assertEquals(3, tenants.size());
+    for (int i = 3; i < 6; i++) {
+      assertEquals("tenant" + i, tenants.get(i - 3).getTenantId());
+    }
+
+    assertEquals(2, source.calls.size());
+    assertNull(source.calls.get(0));
+    assertEquals("token", source.calls.get(1));
+
+    // Should iterate all tenants from both pages
+    int iterations = 0;
+    for (Tenant tenant : page1.iterateAll()) {
+      iterations++;
+    }
+    assertEquals(6, iterations);
+    assertEquals(3, source.calls.size());
+    assertEquals("token", source.calls.get(2));
+
+    // Should only iterate tenants in the last page
+    iterations = 0;
+    for (Tenant tenant : page2.iterateAll()) {
+      iterations++;
+    }
+    assertEquals(3, iterations);
+    assertEquals(3, source.calls.size());
+  }
+
+  @Test
+  public void testListTenantsIterable() throws FirebaseAuthException, IOException {
+    TestTenantSource source = new TestTenantSource(3);
+    ListTenantsPage page = new ListTenantsPage.PageFactory(source).create();
+    Iterable<Tenant> tenants = page.iterateAll();
+
+    int iterations = 0;
+    for (Tenant tenant : tenants) {
+      assertEquals("tenant" + iterations, tenant.getTenantId());
+      iterations++;
+    }
+    assertEquals(3, iterations);
+    assertEquals(1, source.calls.size());
+    assertNull(source.calls.get(0));
+
+    // Should result in a new iterator
+    iterations = 0;
+    for (Tenant tenant : tenants) {
+      assertEquals("tenant" + iterations, tenant.getTenantId());
+      iterations++;
+    }
+    assertEquals(3, iterations);
+    assertEquals(1, source.calls.size());
+    assertNull(source.calls.get(0));
+  }
+
+  @Test
+  public void testListTenantsIterator() throws FirebaseAuthException, IOException {
+    TestTenantSource source = new TestTenantSource(3);
+    ListTenantsPage page = new ListTenantsPage.PageFactory(source).create();
+    Iterable<Tenant> tenants = page.iterateAll();
+    Iterator<Tenant> iterator = tenants.iterator();
+    int iterations = 0;
+    while (iterator.hasNext()) {
+      assertEquals("tenant" + iterations, iterator.next().getTenantId());
+      iterations++;
+    }
+    assertEquals(3, iterations);
+    assertEquals(1, source.calls.size());
+    assertNull(source.calls.get(0));
+
+    while (iterator.hasNext()) {
+      fail("Should not be able to to iterate any more");
+    }
+    try {
+      iterator.next();
+      fail("Should not be able to iterate any more");
+    } catch (NoSuchElementException expected) {
+      // expected
+    }
+    assertEquals(1, source.calls.size());
+  }
+
+  @Test
+  public void testListTenantsPagedIterable() throws FirebaseAuthException, IOException {
+    ListTenantsResponse response = new ListTenantsResponse(
+        ImmutableList.of(newTenant("tenant0"), newTenant("tenant1"), newTenant("tenant2")),
+        "token");
+    TestTenantSource source = new TestTenantSource(response);
+    ListTenantsPage page = new ListTenantsPage.PageFactory(source).create();
+    int iterations = 0;
+    for (Tenant tenant : page.iterateAll()) {
+      assertEquals("tenant" + iterations, tenant.getTenantId());
+      iterations++;
+      if (iterations == 3) {
+        assertEquals(1, source.calls.size());
+        assertNull(source.calls.get(0));
+        response = new ListTenantsResponse(
+            ImmutableList.of(newTenant("tenant3"), newTenant("tenant4"), newTenant("tenant5")),
+            ListTenantsPage.END_OF_LIST);
+        source.response = response;
+      }
+    }
+
+    assertEquals(6, iterations);
+    assertEquals(2, source.calls.size());
+    assertEquals("token", source.calls.get(1));
+  }
+
+  @Test
+  public void testListTenantsPagedIterator() throws FirebaseAuthException, IOException {
+    ListTenantsResponse response = new ListTenantsResponse(
+        ImmutableList.of(newTenant("tenant0"), newTenant("tenant1"), newTenant("tenant2")),
+        "token");
+    TestTenantSource source = new TestTenantSource(response);
+    ListTenantsPage page = new ListTenantsPage.PageFactory(source).create();
+    Iterator<Tenant> tenants = page.iterateAll().iterator();
+    int iterations = 0;
+    while (tenants.hasNext()) {
+      assertEquals("tenant" + iterations, tenants.next().getTenantId());
+      iterations++;
+      if (iterations == 3) {
+        assertEquals(1, source.calls.size());
+        assertNull(source.calls.get(0));
+        response = new ListTenantsResponse(
+            ImmutableList.of(newTenant("tenant3"), newTenant("tenant4"), newTenant("tenant5")),
+            ListTenantsPage.END_OF_LIST);
+        source.response = response;
+      }
+    }
+
+    assertEquals(6, iterations);
+    assertEquals(2, source.calls.size());
+    assertEquals("token", source.calls.get(1));
+    assertFalse(tenants.hasNext());
+    try {
+      tenants.next();
+    } catch (NoSuchElementException e) {
+      // expected
+    }
+  }
+
+  @Test
+  public void testPageWithNoTenants() throws FirebaseAuthException {
+    ListTenantsResponse response = new ListTenantsResponse(
+        ImmutableList.<Tenant>of(),
+        ListTenantsPage.END_OF_LIST);
+    TestTenantSource source = new TestTenantSource(response);
+    ListTenantsPage page = new ListTenantsPage.PageFactory(source).create();
+    assertFalse(page.hasNextPage());
+    assertEquals(ListTenantsPage.END_OF_LIST, page.getNextPageToken());
+    assertNull(page.getNextPage());
+    assertEquals(0, ImmutableList.copyOf(page.getValues()).size());
+    assertEquals(1, source.calls.size());
+  }
+
+  @Test
+  public void testIterableWithNoTenants() throws FirebaseAuthException {
+    ListTenantsResponse response = new ListTenantsResponse(
+        ImmutableList.<Tenant>of(),
+        ListTenantsPage.END_OF_LIST);
+    TestTenantSource source = new TestTenantSource(response);
+    ListTenantsPage page = new ListTenantsPage.PageFactory(source).create();
+    for (Tenant tenant : page.iterateAll()) {
+      fail("Should not be able to iterate, but got: " + tenant);
+    }
+    assertEquals(1, source.calls.size());
+  }
+
+  @Test
+  public void testIteratorWithNoTenants() throws FirebaseAuthException {
+    ListTenantsResponse response = new ListTenantsResponse(
+        ImmutableList.<Tenant>of(),
+        ListTenantsPage.END_OF_LIST);
+    TestTenantSource source = new TestTenantSource(response);
+
+    ListTenantsPage page = new ListTenantsPage.PageFactory(source).create();
+    Iterator<Tenant> iterator = page.iterateAll().iterator();
+    while (iterator.hasNext()) {
+      fail("Should not be able to iterate");
+    }
+    assertEquals(1, source.calls.size());
+  }
+
+  @Test
+  public void testRemove() throws FirebaseAuthException, IOException {
+    ListTenantsResponse response = new ListTenantsResponse(
+        ImmutableList.of(newTenant("tenant1")),
+        ListTenantsPage.END_OF_LIST);
+    TestTenantSource source = new TestTenantSource(response);
+
+    ListTenantsPage page = new ListTenantsPage.PageFactory(source).create();
+    Iterator<Tenant> iterator = page.iterateAll().iterator();
+    while (iterator.hasNext()) {
+      assertNotNull(iterator.next());
+      try {
+        iterator.remove();
+      } catch (UnsupportedOperationException expected) {
+        // expected
+      }
+    }
+  }
+
+  @Test(expected = NullPointerException.class)
+  public void testNullSource() {
+    new ListTenantsPage.PageFactory(null);
+  }
+
+  @Test
+  public void testInvalidPageToken() throws IOException {
+    TestTenantSource source = new TestTenantSource(1);
+    try {
+      new ListTenantsPage.PageFactory(source, 1000, "");
+      fail("No error thrown for empty page token");
+    } catch (IllegalArgumentException expected) {
+      // expected
+    }
+  }
+
+  @Test
+  public void testInvalidMaxResults() throws IOException {
+    TestTenantSource source = new TestTenantSource(1);
+    try {
+      new ListTenantsPage.PageFactory(source, 1001, "");
+      fail("No error thrown for maxResult > 1000");
+    } catch (IllegalArgumentException expected) {
+      // expected
+    }
+
+    try {
+      new ListTenantsPage.PageFactory(source, 0, "next");
+      fail("No error thrown for maxResult = 0");
+    } catch (IllegalArgumentException expected) {
+      // expected
+    }
+
+    try {
+      new ListTenantsPage.PageFactory(source, -1, "next");
+      fail("No error thrown for maxResult < 0");
+    } catch (IllegalArgumentException expected) {
+      // expected
+    }
+  }
+
+  private static Tenant newTenant(String tenantId) throws IOException {
+    return Utils.getDefaultJsonFactory().fromString(
+        String.format("{\"tenantId\":\"%s\"}", tenantId), Tenant.class);
+  }
+
+  private static class TestTenantSource implements ListTenantsPage.TenantSource {
+
+    private ListTenantsResponse response;
+    private List<String> calls = new ArrayList<>();
+
+    TestTenantSource(int tenantCount) throws IOException {
+      ImmutableList.Builder<Tenant> tenants = ImmutableList.builder();
+      for (int i = 0; i < tenantCount; i++) {
+        tenants.add(newTenant("tenant" + i));
+      }
+      this.response = new ListTenantsResponse(tenants.build(), ListTenantsPage.END_OF_LIST);
+    }
+
+    TestTenantSource(ListTenantsResponse response) {
+      this.response = response;
+    }
+
+    @Override
+    public ListTenantsResponse fetch(int maxResults, String pageToken) {
+      calls.add(pageToken);
+      return response;
+    }
+  }
+}