Merge branch 'stable-3.5' into stable-3.6

* stable-3.5:
  test: Simplify SSL setup
  Add 8.9.* to supported versions
  test: Always enable SSL for ES containers
  Bump testcontainers to 1.19.7
  Remove unused build var
  test: Add assert for closing indexes
  test: Use the 'withTag' helper to get DockerImageName
  Include an 'Accept' header for Content-Type in requests
  Add a debug log while insert/replace change index operation

Release-Notes: skip
Change-Id: I1426e2fd38b21d64c81804560b96b34a07ccd466
diff --git a/BUILD b/BUILD
index 94ce436..858c03a 100644
--- a/BUILD
+++ b/BUILD
@@ -53,8 +53,6 @@
 
 QUERY_TESTS_DEP = "//javatests/com/google/gerrit/server/query/%s:abstract_query_tests"
 
-ACCOUNT_QUERY_TESTS_DEP = "//javatests/com/google/gerrit/server/query/account:abstract_query_tests"
-
 TYPES = [
     "account",
     "change",
@@ -64,6 +62,20 @@
 
 SUFFIX = "sTest.java"
 
+ABSTRACT_ELASTICSEARCH_TESTS = {i: "Elastic*Query" + i.capitalize() + SUFFIX for i in TYPES}
+
+[java_library(
+    name = "abstract_elasticsearch_query_%ss_test" % name,
+    testonly = True,
+    srcs = glob(["src/test/java/com/google/gerrit/elasticsearch/" + src]),
+    visibility = ["//visibility:public"],
+    deps = ELASTICSEARCH_DEPS + PLUGIN_TEST_DEPS + [
+        QUERY_TESTS_DEP % name,
+        ":elasticsearch_test_utils",
+        ":index-elasticsearch__plugin",
+    ],
+) for name, src in ABSTRACT_ELASTICSEARCH_TESTS.items()]
+
 ELASTICSEARCH_TESTS_V7 = {i: "ElasticV7Query" + i.capitalize() + SUFFIX for i in TYPES}
 
 [junit_tests(
@@ -79,9 +91,29 @@
         QUERY_TESTS_DEP % name,
         ":elasticsearch_test_utils",
         ":index-elasticsearch__plugin",
+        ":abstract_elasticsearch_query_%ss_test" % name,
     ],
 ) for name, src in ELASTICSEARCH_TESTS_V7.items()]
 
+ELASTICSEARCH_TESTS_V8 = {i: "ElasticV8Query" + i.capitalize() + SUFFIX for i in TYPES}
+
+[junit_tests(
+    name = "elasticsearch_query_%ss_test_V8" % name,
+    size = "enormous",
+    srcs = ["src/test/java/com/google/gerrit/elasticsearch/" + src],
+    tags = [
+        "docker",
+        "elastic",
+        "exclusive",
+    ],
+    deps = ELASTICSEARCH_DEPS + PLUGIN_TEST_DEPS + [
+        QUERY_TESTS_DEP % name,
+        ":elasticsearch_test_utils",
+        ":index-elasticsearch__plugin",
+        ":abstract_elasticsearch_query_%ss_test" % name,
+    ],
+) for name, src in ELASTICSEARCH_TESTS_V8.items()]
+
 junit_tests(
     name = "index-elasticsearch_tests",
     size = "small",
diff --git a/external_plugin_deps.bzl b/external_plugin_deps.bzl
index 7ab2c29..72549a8 100644
--- a/external_plugin_deps.bzl
+++ b/external_plugin_deps.bzl
@@ -8,19 +8,19 @@
     )
 
     # Ensure artifacts compatibility by selecting them from the Bill Of Materials
-    # https://search.maven.org/artifact/org.testcontainers/testcontainers/1.17.5/pom
-    TESTCONTAINERS_VERSION = "1.17.5"
+    # https://search.maven.org/artifact/org.testcontainers/testcontainers/1.19.7/pom
+    TESTCONTAINERS_VERSION = "1.19.7"
 
     maven_jar(
         name = "testcontainers",
         artifact = "org.testcontainers:testcontainers:" + TESTCONTAINERS_VERSION,
-        sha1 = "7c5ad975fb789ecd09b1ee5f72907f48a300bc61",
+        sha1 = "2dd7b1497fc444755582b0efc88636c4d299601f",
     )
 
     maven_jar(
         name = "testcontainers-elasticsearch",
         artifact = "org.testcontainers:elasticsearch:" + TESTCONTAINERS_VERSION,
-        sha1 = "060895f2fc6640ab4a6c383bc98c5c39ef644fbb",
+        sha1 = "8cd9f4ae67c9299143eb718541ff544b66273283",
     )
 
     maven_jar(
@@ -29,36 +29,36 @@
         sha1 = "92edc22a9ab2f3e17c9bf700aaee377d50e8b530",
     )
 
-    DOCKER_JAVA_VERS = "3.2.13"
+    DOCKER_JAVA_VERS = "3.3.6"
 
     maven_jar(
         name = "docker-java-api",
         artifact = "com.github.docker-java:docker-java-api:" + DOCKER_JAVA_VERS,
-        sha1 = "5817ef8f770cb7e740d590090bf352df9491f3c1",
+        sha1 = "8e152880bfe595c81a25501e21a6d7b1d4df97be",
     )
 
     maven_jar(
         name = "docker-java-transport",
         artifact = "com.github.docker-java:docker-java-transport:" + DOCKER_JAVA_VERS,
-        sha1 = "e9d308d1822181a9d48c99739f5eca014ec89199",
+        sha1 = "0d536d16a297f9139b833955390a3d581e336e67",
     )
 
     maven_jar(
         name = "docker-java-transport-zerodep",
         artifact = "com.github.docker-java:docker-java-transport-zerodep:" + DOCKER_JAVA_VERS,
-        sha1 = "4cbc2c09d6c264767a39624066987ed4a152bc68",
+        sha1 = "c9cde0239ce03376f6dfd0465bd461853af22196",
     )
 
     # Match version used in docker-java-transport
-    # https://search.maven.org/artifact/com.github.docker-java/docker-java-transport/3.2.12/pom
+    # https://search.maven.org/artifact/com.github.docker-java/docker-java-transport/3.3.6/pom
     maven_jar(
         name = "jna",
-        artifact = "net.java.dev.jna:jna:5.8.0",
-        sha1 = "3551d8d827e54858214107541d3aff9c615cb615",
+        artifact = "net.java.dev.jna:jna:5.13.0",
+        sha1 = "1200e7ebeedbe0d10062093f32925a912020e747",
     )
 
     # Match jackson.version from docker-java
-    # https://search.maven.org/artifact/com.github.docker-java/docker-java-parent/3.2.12/pom
+    # https://search.maven.org/artifact/com.github.docker-java/docker-java-parent/3.3.6/pom
     maven_jar(
         name = "jackson-annotations",
         artifact = "com.fasterxml.jackson.core:jackson-annotations:2.10.3",
diff --git a/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
index 482868c..58a53b1 100644
--- a/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
+++ b/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
@@ -278,9 +278,9 @@
 
   protected boolean hasErrors(Response response) {
     try {
-      String contentType = response.getEntity().getContentType().getValue();
+      ContentType contentType = ContentType.get(response.getEntity());
       Preconditions.checkState(
-          contentType.equals(ContentType.APPLICATION_JSON.toString()),
+          contentType.toString().equalsIgnoreCase(ContentType.APPLICATION_JSON.toString()),
           String.format("Expected %s, but was: %s", ContentType.APPLICATION_JSON, contentType));
       String responseStr = EntityUtils.toString(response.getEntity());
       JsonObject responseJson = (JsonObject) new JsonParser().parse(responseStr);
diff --git a/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
index 8504e16..b6121e8 100644
--- a/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ b/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -17,6 +17,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.elasticsearch.ElasticMapping.Mapping;
 import com.google.gerrit.elasticsearch.bulk.BulkRequest;
 import com.google.gerrit.elasticsearch.bulk.IndexRequest;
@@ -52,6 +53,8 @@
 /** Secondary index implementation using Elasticsearch. */
 class ElasticChangeIndex extends AbstractElasticIndex<Change.Id, ChangeData>
     implements ChangeIndex {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   static class ChangeMapping {
     final Mapping changes;
     final Mapping openChanges;
@@ -99,6 +102,17 @@
     BulkRequest bulk =
         new IndexRequest(getId(cd), indexName).add(new UpdateRequest<>(schema, cd, skipFields));
 
+    if (logger.atFine().isEnabled()) {
+      String metaRevision = null;
+      try {
+        metaRevision = cd.metaRevisionOrThrow().name();
+      } catch (Exception ignored) {
+      }
+      logger.atFine().log(
+          "Indexing: change: %s, status: %s, meta revision: %s",
+          cd.change().currentPatchSetId(), cd.change().getStatus(), metaRevision);
+    }
+
     String uri = getURI(BULK);
     Response response = postRequestWithRefreshParam(uri, bulk);
     int statusCode = response.getStatusLine().getStatusCode();
diff --git a/src/main/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java b/src/main/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
index b41f365..9caa14d 100644
--- a/src/main/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
+++ b/src/main/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
@@ -22,14 +22,17 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import org.apache.http.Header;
 import org.apache.http.HttpStatus;
 import org.apache.http.StatusLine;
 import org.apache.http.auth.AuthScope;
 import org.apache.http.auth.UsernamePasswordCredentials;
 import org.apache.http.client.CredentialsProvider;
 import org.apache.http.client.config.RequestConfig;
+import org.apache.http.entity.ContentType;
 import org.apache.http.impl.client.BasicCredentialsProvider;
 import org.apache.http.impl.nio.client.HttpAsyncClientBuilder;
+import org.apache.http.message.BasicHeader;
 import org.elasticsearch.client.Request;
 import org.elasticsearch.client.Response;
 import org.elasticsearch.client.RestClient;
@@ -129,6 +132,8 @@
 
   private RestClient build() {
     RestClientBuilder builder = RestClient.builder(cfg.getHosts());
+    builder.setDefaultHeaders(
+        new Header[] {new BasicHeader("Accept", ContentType.APPLICATION_JSON.toString())});
     setConfiguredTimeouts(builder);
     setConfiguredCredentialsIfAny(builder);
     return builder.build();
@@ -150,8 +155,13 @@
       credentialsProvider.setCredentials(
           AuthScope.ANY, new UsernamePasswordCredentials(username, password));
       builder.setHttpClientConfigCallback(
-          (HttpAsyncClientBuilder httpClientBuilder) ->
-              httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider));
+          (HttpAsyncClientBuilder httpClientBuilder) -> {
+            httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider);
+            configureHttpClientBuilder(httpClientBuilder);
+            return httpClientBuilder;
+          });
     }
   }
+
+  protected void configureHttpClientBuilder(HttpAsyncClientBuilder httpClientBuilder) {}
 }
diff --git a/src/main/java/com/google/gerrit/elasticsearch/ElasticVersion.java b/src/main/java/com/google/gerrit/elasticsearch/ElasticVersion.java
index dffdf3e..2fad786 100644
--- a/src/main/java/com/google/gerrit/elasticsearch/ElasticVersion.java
+++ b/src/main/java/com/google/gerrit/elasticsearch/ElasticVersion.java
@@ -18,7 +18,8 @@
 import java.util.regex.Pattern;
 
 public enum ElasticVersion {
-  V7_16("7.16.*");
+  V7_16("7.16.*"),
+  V8_9("8.9.*");
 
   private final String version;
   private final Pattern pattern;
diff --git a/src/test/java/com/google/gerrit/elasticsearch/ElasticAbstractQueryAccountsTest.java b/src/test/java/com/google/gerrit/elasticsearch/ElasticAbstractQueryAccountsTest.java
new file mode 100644
index 0000000..ce16b2e
--- /dev/null
+++ b/src/test/java/com/google/gerrit/elasticsearch/ElasticAbstractQueryAccountsTest.java
@@ -0,0 +1,78 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// 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.gerrit.elasticsearch;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.query.account.AbstractQueryAccountsTest;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Injector;
+import org.apache.http.impl.nio.client.CloseableHttpAsyncClient;
+import org.eclipse.jgit.lib.Config;
+import org.junit.AfterClass;
+import org.junit.Test;
+
+public abstract class ElasticAbstractQueryAccountsTest extends AbstractQueryAccountsTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return ElasticTestUtils.createConfig();
+  }
+
+  @ConfigSuite.Config
+  public static Config searchAfterPaginationType() {
+    Config config = defaultConfig();
+    config.setString("index", null, "paginationType", "SEARCH_AFTER");
+    return config;
+  }
+
+  private static ElasticContainer container;
+  private static CloseableHttpAsyncClient client;
+
+  protected static void startIndexService(ElasticVersion elasticVersion) {
+    container = ElasticContainer.createAndStart(elasticVersion);
+    client = ElasticTestUtils.createHttpAsyncClient(container);
+    client.start();
+  }
+
+  @AfterClass
+  public static void stopElasticsearchServer() {
+    if (container != null) {
+      container.stop();
+    }
+  }
+
+  @Override
+  protected void initAfterLifecycleStart() throws Exception {
+    super.initAfterLifecycleStart();
+    ElasticTestUtils.createAllIndexes(injector);
+  }
+
+  @Override
+  protected Injector createInjector() {
+    return ElasticTestUtils.createInjector(config, testName, container);
+  }
+
+  @Test
+  public void testErrorResponseFromAccountIndex() throws Exception {
+    gApi.accounts().self().index();
+
+    ElasticTestUtils.closeIndex(client, container, testName);
+    StorageException thrown =
+        assertThrows(StorageException.class, () -> gApi.accounts().self().index());
+    assertThat(thrown).hasMessageThat().contains("Failed to replace account");
+  }
+}
diff --git a/src/test/java/com/google/gerrit/elasticsearch/ElasticAbstractQueryChangesTest.java b/src/test/java/com/google/gerrit/elasticsearch/ElasticAbstractQueryChangesTest.java
new file mode 100644
index 0000000..d37e626
--- /dev/null
+++ b/src/test/java/com/google/gerrit/elasticsearch/ElasticAbstractQueryChangesTest.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// 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.gerrit.elasticsearch;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.GerritTestName;
+import com.google.gerrit.testing.InMemoryRepositoryManager;
+import com.google.inject.Injector;
+import org.apache.http.impl.nio.client.CloseableHttpAsyncClient;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Rule;
+import org.junit.Test;
+
+public abstract class ElasticAbstractQueryChangesTest extends AbstractQueryChangesTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return ElasticTestUtils.createConfig();
+  }
+
+  @ConfigSuite.Config
+  public static Config searchAfterPaginationType() {
+    Config config = defaultConfig();
+    config.setString("index", null, "paginationType", "SEARCH_AFTER");
+    return config;
+  }
+
+  private static ElasticContainer container;
+  private static CloseableHttpAsyncClient client;
+
+  protected static void startIndexService(ElasticVersion elasticVersion) {
+    container = ElasticContainer.createAndStart(elasticVersion);
+    client = ElasticTestUtils.createHttpAsyncClient(container);
+    client.start();
+  }
+
+  @AfterClass
+  public static void stopElasticsearchServer() {
+    if (container != null) {
+      container.stop();
+    }
+  }
+
+  @Rule public final GerritTestName testName = new GerritTestName();
+
+  @After
+  public void closeIndex() throws Exception {
+    // Close the index after each test to prevent exceeding Elasticsearch's
+    // shard limit (see Issue 10120).
+    ElasticTestUtils.closeIndex(client, container, testName);
+  }
+
+  @Override
+  protected void initAfterLifecycleStart() throws Exception {
+    super.initAfterLifecycleStart();
+    ElasticTestUtils.createAllIndexes(injector);
+  }
+
+  @Override
+  protected Injector createInjector() {
+    return ElasticTestUtils.createInjector(config, testName, container);
+  }
+
+  @Test
+  public void testErrorResponseFromChangeIndex() throws Exception {
+    TestRepository<InMemoryRepositoryManager.Repo> repo = createProject("repo");
+    Change c = insert(repo, newChangeWithStatus(repo, Change.Status.NEW));
+    gApi.changes().id(c.getChangeId()).index();
+
+    ElasticTestUtils.closeIndex(client, container, testName);
+    StorageException thrown =
+        assertThrows(StorageException.class, () -> gApi.changes().id(c.getChangeId()).index());
+    assertThat(thrown).hasMessageThat().contains("Failed to reindex change");
+  }
+}
diff --git a/src/test/java/com/google/gerrit/elasticsearch/ElasticAbstractQueryGroupsTest.java b/src/test/java/com/google/gerrit/elasticsearch/ElasticAbstractQueryGroupsTest.java
new file mode 100644
index 0000000..f449bca
--- /dev/null
+++ b/src/test/java/com/google/gerrit/elasticsearch/ElasticAbstractQueryGroupsTest.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// 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.gerrit.elasticsearch;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.api.groups.GroupApi;
+import com.google.gerrit.server.query.group.AbstractQueryGroupsTest;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Injector;
+import org.apache.http.impl.nio.client.CloseableHttpAsyncClient;
+import org.eclipse.jgit.lib.Config;
+import org.junit.AfterClass;
+import org.junit.Test;
+
+public abstract class ElasticAbstractQueryGroupsTest extends AbstractQueryGroupsTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return ElasticTestUtils.createConfig();
+  }
+
+  @ConfigSuite.Config
+  public static Config searchAfterPaginationType() {
+    Config config = defaultConfig();
+    config.setString("index", null, "paginationType", "SEARCH_AFTER");
+    return config;
+  }
+
+  private static ElasticContainer container;
+  private static CloseableHttpAsyncClient client;
+
+  protected static void startIndexService(ElasticVersion elasticVersion) {
+    container = ElasticContainer.createAndStart(elasticVersion);
+    client = ElasticTestUtils.createHttpAsyncClient(container);
+    client.start();
+  }
+
+  @AfterClass
+  public static void stopElasticsearchServer() {
+    if (container != null) {
+      container.stop();
+    }
+  }
+
+  @Override
+  protected void initAfterLifecycleStart() throws Exception {
+    super.initAfterLifecycleStart();
+    ElasticTestUtils.createAllIndexes(injector);
+  }
+
+  @Override
+  protected Injector createInjector() {
+    return ElasticTestUtils.createInjector(config, testName, container);
+  }
+
+  @Test
+  public void testErrorResponseFromGroupIndex() throws Exception {
+    GroupApi group = gApi.groups().create("test");
+    group.index();
+
+    ElasticTestUtils.closeIndex(client, container, testName);
+    StorageException thrown = assertThrows(StorageException.class, () -> group.index());
+    assertThat(thrown).hasMessageThat().contains("Failed to replace group");
+  }
+}
diff --git a/src/test/java/com/google/gerrit/elasticsearch/ElasticAbstractQueryProjectsTest.java b/src/test/java/com/google/gerrit/elasticsearch/ElasticAbstractQueryProjectsTest.java
new file mode 100644
index 0000000..5f4847c
--- /dev/null
+++ b/src/test/java/com/google/gerrit/elasticsearch/ElasticAbstractQueryProjectsTest.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// 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.gerrit.elasticsearch;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.api.projects.ProjectApi;
+import com.google.gerrit.server.query.project.AbstractQueryProjectsTest;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Injector;
+import org.apache.http.impl.nio.client.CloseableHttpAsyncClient;
+import org.eclipse.jgit.lib.Config;
+import org.junit.AfterClass;
+import org.junit.Test;
+
+public abstract class ElasticAbstractQueryProjectsTest extends AbstractQueryProjectsTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return ElasticTestUtils.createConfig();
+  }
+
+  @ConfigSuite.Config
+  public static Config searchAfterPaginationType() {
+    Config config = defaultConfig();
+    config.setString("index", null, "paginationType", "SEARCH_AFTER");
+    return config;
+  }
+
+  private static ElasticContainer container;
+  private static CloseableHttpAsyncClient client;
+
+  protected static void startIndexService(ElasticVersion elasticVersion) {
+    container = ElasticContainer.createAndStart(elasticVersion);
+    client = ElasticTestUtils.createHttpAsyncClient(container);
+    client.start();
+  }
+
+  @AfterClass
+  public static void stopElasticsearchServer() {
+    if (container != null) {
+      container.stop();
+    }
+  }
+
+  @Override
+  protected void initAfterLifecycleStart() throws Exception {
+    super.initAfterLifecycleStart();
+    ElasticTestUtils.createAllIndexes(injector);
+  }
+
+  @Override
+  protected Injector createInjector() {
+    return ElasticTestUtils.createInjector(config, testName, container);
+  }
+
+  @Test
+  public void testErrorResponseFromProjectIndex() throws Exception {
+    ProjectApi project = gApi.projects().create("test");
+    project.index(false);
+
+    ElasticTestUtils.closeIndex(client, container, testName);
+    StorageException thrown = assertThrows(StorageException.class, () -> project.index(false));
+    assertThat(thrown).hasMessageThat().contains("Failed to replace project");
+  }
+}
diff --git a/src/test/java/com/google/gerrit/elasticsearch/ElasticContainer.java b/src/test/java/com/google/gerrit/elasticsearch/ElasticContainer.java
index 86b1bac..a65e57c 100644
--- a/src/test/java/com/google/gerrit/elasticsearch/ElasticContainer.java
+++ b/src/test/java/com/google/gerrit/elasticsearch/ElasticContainer.java
@@ -15,11 +15,13 @@
 package com.google.gerrit.elasticsearch;
 
 import com.google.common.flogger.FluentLogger;
+import java.nio.file.Path;
 import org.apache.http.HttpHost;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.testcontainers.containers.ContainerLaunchException;
 import org.testcontainers.elasticsearch.ElasticsearchContainer;
+import org.testcontainers.images.builder.Transferable;
 import org.testcontainers.utility.DockerImageName;
 
 /* Helper class for running ES integration tests in docker container */
@@ -30,6 +32,51 @@
   public static ElasticContainer createAndStart(ElasticVersion version) {
     ElasticContainer container = new ElasticContainer(version);
     try {
+      Path certs = Path.of("/usr/share/elasticsearch/config/certs");
+      String customizedCertPath = certs.resolve("http_ca_customized.crt").toString();
+      String sslKeyPath = certs.resolve("elasticsearch.key").toString();
+      String sslCrtPath = certs.resolve("elasticsearch.crt").toString();
+      container =
+          (ElasticContainer)
+              container
+                  .withPassword(ElasticsearchContainer.ELASTICSEARCH_DEFAULT_PASSWORD)
+                  .withEnv("xpack.security.enabled", "true")
+                  .withEnv("xpack.security.http.ssl.enabled", "true")
+                  .withEnv("xpack.security.http.ssl.key", sslKeyPath)
+                  .withEnv("xpack.security.http.ssl.certificate", sslCrtPath)
+                  .withEnv("xpack.security.http.ssl.certificate_authorities", customizedCertPath)
+                  // Create our own cert so that the gerrit-ci docker hostname
+                  // matches the certificate subject. Otherwise we get an error like:
+                  // Host name '10.0.1.1' does not match the certificate subject provided by the
+                  // peer (CN=4932da9bab1d)
+                  .withCopyToContainer(
+                      Transferable.of(
+                          "#!/bin/bash\n"
+                              + "mkdir -p "
+                              + certs.toString()
+                              + ";"
+                              + "openssl req -x509 -newkey rsa:4096 -keyout "
+                              + sslKeyPath
+                              + " -out "
+                              + sslCrtPath
+                              + " -days 365 -nodes -subj \"/CN="
+                              + container.getHost()
+                              + "\";"
+                              + "openssl x509 -outform der -in "
+                              + sslCrtPath
+                              + " -out "
+                              + customizedCertPath
+                              + "; chown -R elasticsearch "
+                              + certs.toString(),
+                          555),
+                      "/usr/share/elasticsearch/generate-certs.sh")
+                  // because we need to generate the certificates before Elasticsearch starts, the
+                  // entry command has to be adjusted accordingly
+                  .withCommand(
+                      "sh",
+                      "-c",
+                      "/usr/share/elasticsearch/generate-certs.sh && /usr/local/bin/docker-entrypoint.sh")
+                  .withCertPath(customizedCertPath);
       container.start();
     } catch (ContainerLaunchException e) {
       logger.atSevere().log(
@@ -39,16 +86,20 @@
     return container;
   }
 
-  private static String getImageName(ElasticVersion version) {
+  private static DockerImageName getImageName(ElasticVersion version) {
+    DockerImageName image = DockerImageName.parse("docker.elastic.co/elasticsearch/elasticsearch");
     switch (version) {
       case V7_16:
-        return "docker.elastic.co/elasticsearch/elasticsearch:7.16.2";
+        return image.withTag("7.16.2");
+      case V8_9:
+        return image.withTag("8.9.2");
     }
     throw new IllegalStateException("No tests for version: " + version.name());
   }
 
   private ElasticContainer(ElasticVersion version) {
-    super(DockerImageName.parse(getImageName(version)));
+    super(getImageName(version));
+    withEnv("action.destructive_requires_name", "false");
   }
 
   @Override
@@ -57,6 +108,7 @@
   }
 
   public HttpHost getHttpHost() {
-    return new HttpHost(getContainerIpAddress(), getMappedPort(ELASTICSEARCH_DEFAULT_PORT));
+    String protocol = caCertAsBytes().isPresent() ? "https://" : "http://";
+    return HttpHost.create(protocol + getHttpHostAddress());
   }
 }
diff --git a/src/test/java/com/google/gerrit/elasticsearch/ElasticContainerRestClientProvider.java b/src/test/java/com/google/gerrit/elasticsearch/ElasticContainerRestClientProvider.java
new file mode 100644
index 0000000..5b8ad40
--- /dev/null
+++ b/src/test/java/com/google/gerrit/elasticsearch/ElasticContainerRestClientProvider.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// 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.gerrit.elasticsearch;
+
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import org.apache.http.impl.nio.client.HttpAsyncClientBuilder;
+
+@Singleton
+class ElasticContainerRestClientProvider extends ElasticRestClientProvider {
+  private final ElasticContainer container;
+
+  @Inject
+  ElasticContainerRestClientProvider(ElasticConfiguration cfg, ElasticContainer container) {
+    super(cfg);
+    this.container = container;
+  }
+
+  @Override
+  protected void configureHttpClientBuilder(HttpAsyncClientBuilder httpClientBuilder) {
+    if (container.caCertAsBytes().isPresent()) {
+      httpClientBuilder.setSSLContext(container.createSslContextFromCa());
+    }
+  }
+}
diff --git a/src/test/java/com/google/gerrit/elasticsearch/ElasticTestUtils.java b/src/test/java/com/google/gerrit/elasticsearch/ElasticTestUtils.java
index f5ba9db..a280ba5 100644
--- a/src/test/java/com/google/gerrit/elasticsearch/ElasticTestUtils.java
+++ b/src/test/java/com/google/gerrit/elasticsearch/ElasticTestUtils.java
@@ -14,32 +14,49 @@
 
 package com.google.gerrit.elasticsearch;
 
+import static com.google.common.truth.Truth.assertWithMessage;
 import static java.util.concurrent.TimeUnit.MINUTES;
+import static org.testcontainers.elasticsearch.ElasticsearchContainer.ELASTICSEARCH_DEFAULT_PASSWORD;
 
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.server.LibModuleType;
 import com.google.gerrit.testing.GerritTestName;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.gerrit.testing.IndexConfig;
+import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
 import com.google.inject.Key;
 import com.google.inject.TypeLiteral;
 import java.util.Collection;
 import java.util.UUID;
+import org.apache.http.HttpResponse;
+import org.apache.http.HttpStatus;
+import org.apache.http.auth.AuthScope;
+import org.apache.http.auth.UsernamePasswordCredentials;
+import org.apache.http.client.CredentialsProvider;
 import org.apache.http.client.methods.HttpPost;
 import org.apache.http.client.protocol.HttpClientContext;
+import org.apache.http.impl.client.BasicCredentialsProvider;
 import org.apache.http.impl.nio.client.CloseableHttpAsyncClient;
+import org.apache.http.impl.nio.client.HttpAsyncClientBuilder;
+import org.apache.http.impl.nio.client.HttpAsyncClients;
+import org.apache.http.util.EntityUtils;
 import org.eclipse.jgit.lib.Config;
 
 public final class ElasticTestUtils {
+  private static final String ELASTIC_USERNAME = "elastic";
+  private static final String ELASTIC_PASSWORD = ELASTICSEARCH_DEFAULT_PASSWORD;
+
   public static void configure(Config config, ElasticContainer container, String prefix) {
-    String hostname = container.getHttpHost().getHostName();
-    int port = container.getHttpHost().getPort();
     config.setString("index", null, "type", "elasticsearch");
-    config.setString("elasticsearch", null, "server", "http://" + hostname + ":" + port);
+    config.setString("elasticsearch", null, "server", container.getHttpHost().toURI());
     config.setString("elasticsearch", null, "prefix", prefix);
     config.setInt("index", null, "maxLimit", 10000);
+    if (container.caCertAsBytes().isPresent()) {
+      config.setString("elasticsearch", null, "username", ELASTIC_USERNAME);
+      config.setString("elasticsearch", null, "password", ELASTIC_PASSWORD);
+    }
   }
 
   public static void createAllIndexes(Injector injector) {
@@ -76,6 +93,20 @@
         "com.google.gerrit.elasticsearch.ElasticIndexModule");
   }
 
+  public static class ElasticContainerTestModule extends AbstractModule {
+    private final ElasticContainer container;
+
+    ElasticContainerTestModule(ElasticContainer container) {
+      this.container = container;
+    }
+
+    @Override
+    protected void configure() {
+      bind(ElasticRestClientProvider.class).to(ElasticContainerRestClientProvider.class);
+      bind(ElasticContainer.class).toInstance(container);
+    }
+  }
+
   public static Injector createInjector(
       Config config, GerritTestName testName, ElasticContainer container) {
     Config elasticsearchConfig = new Config(config);
@@ -83,23 +114,42 @@
     InMemoryModule.setDefaults(elasticsearchConfig);
     String indicesPrefix = testName.getSanitizedMethodName();
     ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
+    return Guice.createInjector(
+        new ElasticContainerTestModule(container), new InMemoryModule(elasticsearchConfig));
+  }
+
+  public static CloseableHttpAsyncClient createHttpAsyncClient(ElasticContainer container) {
+    HttpAsyncClientBuilder builder = HttpAsyncClients.custom();
+    if (container.caCertAsBytes().isPresent()) {
+      CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
+      credentialsProvider.setCredentials(
+          AuthScope.ANY, new UsernamePasswordCredentials(ELASTIC_USERNAME, ELASTIC_PASSWORD));
+      builder
+          .setSSLContext(container.createSslContextFromCa())
+          .setDefaultCredentialsProvider(credentialsProvider);
+    }
+    return builder.build();
   }
 
   public static void closeIndex(
       CloseableHttpAsyncClient client, ElasticContainer container, GerritTestName testName)
       throws Exception {
-    client
-        .execute(
-            new HttpPost(
-                String.format(
-                    "http://%s:%d/%s*/_close",
-                    container.getHttpHost().getHostName(),
-                    container.getHttpHost().getPort(),
-                    testName.getSanitizedMethodName())),
-            HttpClientContext.create(),
-            null)
-        .get(5, MINUTES);
+    HttpResponse response =
+        client
+            .execute(
+                new HttpPost(
+                    String.format(
+                        "%s/%s*/_close",
+                        container.getHttpHost().toURI(), testName.getSanitizedMethodName())),
+                HttpClientContext.create(),
+                null)
+            .get(5, MINUTES);
+    int statusCode = response.getStatusLine().getStatusCode();
+    assertWithMessage(
+            "response status code should be %s, but was %s. Full response was %s",
+            HttpStatus.SC_OK, statusCode, EntityUtils.toString(response.getEntity()))
+        .that(statusCode)
+        .isEqualTo(HttpStatus.SC_OK);
   }
 
   private ElasticTestUtils() {
diff --git a/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java b/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
index 7f6a585..a9c8361 100644
--- a/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
+++ b/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
@@ -14,68 +14,11 @@
 
 package com.google.gerrit.elasticsearch;
 
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.server.query.account.AbstractQueryAccountsTest;
-import com.google.gerrit.testing.ConfigSuite;
-import com.google.inject.Injector;
-import org.apache.http.impl.nio.client.CloseableHttpAsyncClient;
-import org.apache.http.impl.nio.client.HttpAsyncClients;
-import org.eclipse.jgit.lib.Config;
-import org.junit.AfterClass;
 import org.junit.BeforeClass;
-import org.junit.Test;
 
-public class ElasticV7QueryAccountsTest extends AbstractQueryAccountsTest {
-  @ConfigSuite.Default
-  public static Config defaultConfig() {
-    return ElasticTestUtils.createConfig();
-  }
-
-  @ConfigSuite.Config
-  public static Config searchAfterPaginationType() {
-    Config config = defaultConfig();
-    config.setString("index", null, "paginationType", "SEARCH_AFTER");
-    return config;
-  }
-
-  private static ElasticContainer container;
-  private static CloseableHttpAsyncClient client;
-
+public class ElasticV7QueryAccountsTest extends ElasticAbstractQueryAccountsTest {
   @BeforeClass
   public static void startIndexService() {
-    container = ElasticContainer.createAndStart(ElasticVersion.V7_16);
-    client = HttpAsyncClients.createDefault();
-    client.start();
-  }
-
-  @AfterClass
-  public static void stopElasticsearchServer() {
-    if (container != null) {
-      container.stop();
-    }
-  }
-
-  @Override
-  protected void initAfterLifecycleStart() throws Exception {
-    super.initAfterLifecycleStart();
-    ElasticTestUtils.createAllIndexes(injector);
-  }
-
-  @Override
-  protected Injector createInjector() {
-    return ElasticTestUtils.createInjector(config, testName, container);
-  }
-
-  @Test
-  public void testErrorResponseFromAccountIndex() throws Exception {
-    gApi.accounts().self().index();
-
-    ElasticTestUtils.closeIndex(client, container, testName);
-    StorageException thrown =
-        assertThrows(StorageException.class, () -> gApi.accounts().self().index());
-    assertThat(thrown).hasMessageThat().contains("Failed to replace account");
+    startIndexService(ElasticVersion.V7_16);
   }
 }
diff --git a/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java b/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
index 9160b9e..9961987 100644
--- a/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
+++ b/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
@@ -14,85 +14,11 @@
 
 package com.google.gerrit.elasticsearch;
 
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
-import com.google.gerrit.testing.ConfigSuite;
-import com.google.gerrit.testing.GerritTestName;
-import com.google.gerrit.testing.InMemoryRepositoryManager;
-import com.google.inject.Injector;
-import org.apache.http.impl.nio.client.CloseableHttpAsyncClient;
-import org.apache.http.impl.nio.client.HttpAsyncClients;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.junit.After;
-import org.junit.AfterClass;
 import org.junit.BeforeClass;
-import org.junit.Rule;
-import org.junit.Test;
 
-public class ElasticV7QueryChangesTest extends AbstractQueryChangesTest {
-  @ConfigSuite.Default
-  public static Config defaultConfig() {
-    return ElasticTestUtils.createConfig();
-  }
-
-  @ConfigSuite.Config
-  public static Config searchAfterPaginationType() {
-    Config config = defaultConfig();
-    config.setString("index", null, "paginationType", "SEARCH_AFTER");
-    return config;
-  }
-
-  private static ElasticContainer container;
-  private static CloseableHttpAsyncClient client;
-
+public class ElasticV7QueryChangesTest extends ElasticAbstractQueryChangesTest {
   @BeforeClass
   public static void startIndexService() {
-    container = ElasticContainer.createAndStart(ElasticVersion.V7_16);
-    client = HttpAsyncClients.createDefault();
-    client.start();
-  }
-
-  @AfterClass
-  public static void stopElasticsearchServer() {
-    if (container != null) {
-      container.stop();
-    }
-  }
-
-  @Rule public final GerritTestName testName = new GerritTestName();
-
-  @After
-  public void closeIndex() throws Exception {
-    // Close the index after each test to prevent exceeding Elasticsearch's
-    // shard limit (see Issue 10120).
-    ElasticTestUtils.closeIndex(client, container, testName);
-  }
-
-  @Override
-  protected void initAfterLifecycleStart() throws Exception {
-    super.initAfterLifecycleStart();
-    ElasticTestUtils.createAllIndexes(injector);
-  }
-
-  @Override
-  protected Injector createInjector() {
-    return ElasticTestUtils.createInjector(config, testName, container);
-  }
-
-  @Test
-  public void testErrorResponseFromChangeIndex() throws Exception {
-    TestRepository<InMemoryRepositoryManager.Repo> repo = createProject("repo");
-    Change c = insert(repo, newChangeWithStatus(repo, Change.Status.NEW));
-    gApi.changes().id(c.getChangeId()).index();
-
-    ElasticTestUtils.closeIndex(client, container, testName);
-    StorageException thrown =
-        assertThrows(StorageException.class, () -> gApi.changes().id(c.getChangeId()).index());
-    assertThat(thrown).hasMessageThat().contains("Failed to reindex change");
+    startIndexService(ElasticVersion.V7_16);
   }
 }
diff --git a/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java b/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
index 271ee19..9a48181 100644
--- a/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
+++ b/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
@@ -14,69 +14,11 @@
 
 package com.google.gerrit.elasticsearch;
 
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.api.groups.GroupApi;
-import com.google.gerrit.server.query.group.AbstractQueryGroupsTest;
-import com.google.gerrit.testing.ConfigSuite;
-import com.google.inject.Injector;
-import org.apache.http.impl.nio.client.CloseableHttpAsyncClient;
-import org.apache.http.impl.nio.client.HttpAsyncClients;
-import org.eclipse.jgit.lib.Config;
-import org.junit.AfterClass;
 import org.junit.BeforeClass;
-import org.junit.Test;
 
-public class ElasticV7QueryGroupsTest extends AbstractQueryGroupsTest {
-  @ConfigSuite.Default
-  public static Config defaultConfig() {
-    return ElasticTestUtils.createConfig();
-  }
-
-  @ConfigSuite.Config
-  public static Config searchAfterPaginationType() {
-    Config config = defaultConfig();
-    config.setString("index", null, "paginationType", "SEARCH_AFTER");
-    return config;
-  }
-
-  private static ElasticContainer container;
-  private static CloseableHttpAsyncClient client;
-
+public class ElasticV7QueryGroupsTest extends ElasticAbstractQueryGroupsTest {
   @BeforeClass
   public static void startIndexService() {
-    container = ElasticContainer.createAndStart(ElasticVersion.V7_16);
-    client = HttpAsyncClients.createDefault();
-    client.start();
-  }
-
-  @AfterClass
-  public static void stopElasticsearchServer() {
-    if (container != null) {
-      container.stop();
-    }
-  }
-
-  @Override
-  protected void initAfterLifecycleStart() throws Exception {
-    super.initAfterLifecycleStart();
-    ElasticTestUtils.createAllIndexes(injector);
-  }
-
-  @Override
-  protected Injector createInjector() {
-    return ElasticTestUtils.createInjector(config, testName, container);
-  }
-
-  @Test
-  public void testErrorResponseFromGroupIndex() throws Exception {
-    GroupApi group = gApi.groups().create("test");
-    group.index();
-
-    ElasticTestUtils.closeIndex(client, container, testName);
-    StorageException thrown = assertThrows(StorageException.class, () -> group.index());
-    assertThat(thrown).hasMessageThat().contains("Failed to replace group");
+    startIndexService(ElasticVersion.V7_16);
   }
 }
diff --git a/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java b/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
index 38eef22..f37bbff 100644
--- a/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
+++ b/src/test/java/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
@@ -14,69 +14,11 @@
 
 package com.google.gerrit.elasticsearch;
 
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.api.projects.ProjectApi;
-import com.google.gerrit.server.query.project.AbstractQueryProjectsTest;
-import com.google.gerrit.testing.ConfigSuite;
-import com.google.inject.Injector;
-import org.apache.http.impl.nio.client.CloseableHttpAsyncClient;
-import org.apache.http.impl.nio.client.HttpAsyncClients;
-import org.eclipse.jgit.lib.Config;
-import org.junit.AfterClass;
 import org.junit.BeforeClass;
-import org.junit.Test;
 
-public class ElasticV7QueryProjectsTest extends AbstractQueryProjectsTest {
-  @ConfigSuite.Default
-  public static Config defaultConfig() {
-    return ElasticTestUtils.createConfig();
-  }
-
-  @ConfigSuite.Config
-  public static Config searchAfterPaginationType() {
-    Config config = defaultConfig();
-    config.setString("index", null, "paginationType", "SEARCH_AFTER");
-    return config;
-  }
-
-  private static ElasticContainer container;
-  private static CloseableHttpAsyncClient client;
-
+public class ElasticV7QueryProjectsTest extends ElasticAbstractQueryProjectsTest {
   @BeforeClass
   public static void startIndexService() {
-    container = ElasticContainer.createAndStart(ElasticVersion.V7_16);
-    client = HttpAsyncClients.createDefault();
-    client.start();
-  }
-
-  @AfterClass
-  public static void stopElasticsearchServer() {
-    if (container != null) {
-      container.stop();
-    }
-  }
-
-  @Override
-  protected void initAfterLifecycleStart() throws Exception {
-    super.initAfterLifecycleStart();
-    ElasticTestUtils.createAllIndexes(injector);
-  }
-
-  @Override
-  protected Injector createInjector() {
-    return ElasticTestUtils.createInjector(config, testName, container);
-  }
-
-  @Test
-  public void testErrorResponseFromProjectIndex() throws Exception {
-    ProjectApi project = gApi.projects().create("test");
-    project.index(false);
-
-    ElasticTestUtils.closeIndex(client, container, testName);
-    StorageException thrown = assertThrows(StorageException.class, () -> project.index(false));
-    assertThat(thrown).hasMessageThat().contains("Failed to replace project");
+    startIndexService(ElasticVersion.V7_16);
   }
 }
diff --git a/src/test/java/com/google/gerrit/elasticsearch/ElasticV8QueryAccountsTest.java b/src/test/java/com/google/gerrit/elasticsearch/ElasticV8QueryAccountsTest.java
new file mode 100644
index 0000000..b1ef2e5
--- /dev/null
+++ b/src/test/java/com/google/gerrit/elasticsearch/ElasticV8QueryAccountsTest.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// 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.gerrit.elasticsearch;
+
+import org.junit.BeforeClass;
+
+public class ElasticV8QueryAccountsTest extends ElasticAbstractQueryAccountsTest {
+  @BeforeClass
+  public static void startIndexService() {
+    startIndexService(ElasticVersion.V8_9);
+  }
+}
diff --git a/src/test/java/com/google/gerrit/elasticsearch/ElasticV8QueryChangesTest.java b/src/test/java/com/google/gerrit/elasticsearch/ElasticV8QueryChangesTest.java
new file mode 100644
index 0000000..a9a220d
--- /dev/null
+++ b/src/test/java/com/google/gerrit/elasticsearch/ElasticV8QueryChangesTest.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// 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.gerrit.elasticsearch;
+
+import org.junit.BeforeClass;
+
+public class ElasticV8QueryChangesTest extends ElasticAbstractQueryChangesTest {
+  @BeforeClass
+  public static void startIndexService() {
+    startIndexService(ElasticVersion.V8_9);
+  }
+}
diff --git a/src/test/java/com/google/gerrit/elasticsearch/ElasticV8QueryGroupsTest.java b/src/test/java/com/google/gerrit/elasticsearch/ElasticV8QueryGroupsTest.java
new file mode 100644
index 0000000..37a204d
--- /dev/null
+++ b/src/test/java/com/google/gerrit/elasticsearch/ElasticV8QueryGroupsTest.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// 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.gerrit.elasticsearch;
+
+import org.junit.BeforeClass;
+
+public class ElasticV8QueryGroupsTest extends ElasticAbstractQueryGroupsTest {
+  @BeforeClass
+  public static void startIndexService() {
+    startIndexService(ElasticVersion.V8_9);
+  }
+}
diff --git a/src/test/java/com/google/gerrit/elasticsearch/ElasticV8QueryProjectsTest.java b/src/test/java/com/google/gerrit/elasticsearch/ElasticV8QueryProjectsTest.java
new file mode 100644
index 0000000..ace5767
--- /dev/null
+++ b/src/test/java/com/google/gerrit/elasticsearch/ElasticV8QueryProjectsTest.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2024 The Android Open Source Project
+//
+// 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.gerrit.elasticsearch;
+
+import org.junit.BeforeClass;
+
+public class ElasticV8QueryProjectsTest extends ElasticAbstractQueryProjectsTest {
+  @BeforeClass
+  public static void startIndexService() {
+    startIndexService(ElasticVersion.V8_9);
+  }
+}
diff --git a/src/test/java/com/google/gerrit/elasticsearch/ElasticVersionTest.java b/src/test/java/com/google/gerrit/elasticsearch/ElasticVersionTest.java
index ea7782b..b750466 100644
--- a/src/test/java/com/google/gerrit/elasticsearch/ElasticVersionTest.java
+++ b/src/test/java/com/google/gerrit/elasticsearch/ElasticVersionTest.java
@@ -24,6 +24,10 @@
   public void supportedVersion() throws Exception {
     assertThat(ElasticVersion.forVersion("7.16.0")).isEqualTo(ElasticVersion.V7_16);
     assertThat(ElasticVersion.forVersion("7.16.1")).isEqualTo(ElasticVersion.V7_16);
+
+    assertThat(ElasticVersion.forVersion("8.9.0")).isEqualTo(ElasticVersion.V8_9);
+    assertThat(ElasticVersion.forVersion("8.9.1")).isEqualTo(ElasticVersion.V8_9);
+    assertThat(ElasticVersion.forVersion("8.9.2")).isEqualTo(ElasticVersion.V8_9);
   }
 
   @Test