From 9e4b1ede11021607ef0aeda9f38de5d8b828e4e9 Mon Sep 17 00:00:00 2001 From: David Pilato Date: Wed, 9 Sep 2026 20:45:17 +0200 Subject: [PATCH] Generate an Elasticsearch API key after container start Elasticsearch 8+ with security now exposes getApiKey() so tests can authenticate without the elastic password. Co-authored-by: Cursor --- docs/modules/elasticsearch.md | 9 ++ .../elasticsearch/ElasticsearchContainer.java | 91 +++++++++++++++++++ .../ElasticsearchContainerTest.java | 63 +++++++++++++ 3 files changed, 163 insertions(+) diff --git a/docs/modules/elasticsearch.md b/docs/modules/elasticsearch.md index d7b8b551f86..302ecdf8c6d 100644 --- a/docs/modules/elasticsearch.md +++ b/docs/modules/elasticsearch.md @@ -30,6 +30,15 @@ HTTPS can be turned off if you do not need it: [HttpClient with TLS disabled](../../modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/ElasticsearchContainerTest.java) inside_block:httpClientTlsDisabled +### API key + +From Elasticsearch 8 onwards, with security enabled, the container generates an API key at startup. +`getApiKey()` returns the Base64-encoded `id:api_key` credential for the `Authorization: ApiKey` header: + + +[HttpClient with API key](../../modules/elasticsearch/src/test/java/org/testcontainers/elasticsearch/ElasticsearchContainerTest.java) inside_block:httpClientApiKey + + ### Elasticsearch 7 (deprecated) Elasticsearch 7 listens on HTTP and does not enable security unless you opt in with `withPassword()`. diff --git a/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java b/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java index e08db06581e..68f3875790c 100644 --- a/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java +++ b/modules/elasticsearch/src/main/java/org/testcontainers/elasticsearch/ElasticsearchContainer.java @@ -1,5 +1,8 @@ package org.testcontainers.elasticsearch; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.dockerjava.api.command.InspectContainerResponse; import com.github.dockerjava.api.exception.NotFoundException; import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.IOUtils; @@ -9,6 +12,7 @@ import org.testcontainers.containers.wait.strategy.AbstractWaitStrategy; import org.testcontainers.containers.wait.strategy.HttpWaitStrategy; import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.Base58; import org.testcontainers.utility.ComparableVersion; import org.testcontainers.utility.DockerImageName; @@ -72,6 +76,8 @@ public class ElasticsearchContainer extends GenericContainer= 8 private static final String DEFAULT_CERT_PATH = "/usr/share/elasticsearch/config/certs/http_ca.crt"; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + @Deprecated private boolean isOss = false; @@ -81,6 +87,11 @@ public class ElasticsearchContainer extends GenericContainer getAnonymousClient(container).performRequest(new Request("GET", "/")))) .as("anonymous requests are rejected") .isInstanceOf(ResponseException.class); + assertThat(container.getApiKey()).as("API key is generated for Elasticsearch 8+").isNotBlank(); // httpClientLatest {{ } // } @@ -190,11 +193,67 @@ void latestCanDisableTls() throws IOException { assertThat(response.getStatusLine().getStatusCode()).as("cluster health is available").isEqualTo(200); assertThat(EntityUtils.toString(response.getEntity())).contains("cluster_name"); assertThat(container.getHttpScheme()).as("HTTP API uses HTTP when TLS is disabled").isEqualTo("http"); + assertThat(container.getApiKey()) + .as("API key is generated when security stays enabled without TLS") + .isNotBlank(); // httpClientTlsDisabled {{ } // } } + @Test + void latestCanAuthenticateWithGeneratedApiKey() throws IOException { + // httpClientApiKey { + try (ElasticsearchContainer container = new ElasticsearchContainer(ELASTICSEARCH_IMAGE_LATEST)) { + container.start(); + + client = + RestClient + .builder(HttpHost.create("https://" + container.getHttpHostAddress())) + .setDefaultHeaders( + new Header[] { new BasicHeader("Authorization", "ApiKey " + container.getApiKey()) } + ) + .setHttpClientConfigCallback(httpClientBuilder -> { + httpClientBuilder.setSSLContext(container.createSslContextFromCa()); + return httpClientBuilder; + }) + .build(); + + Response response = client.performRequest(new Request("GET", "/_cluster/health")); + // }} + assertThat(container.getApiKey()).as("encoded API key is generated on start").isNotBlank(); + assertThat(response.getStatusLine().getStatusCode()).as("cluster health is available").isEqualTo(200); + assertThat(EntityUtils.toString(response.getEntity())).contains("cluster_name"); + // httpClientApiKey {{ + } + // } + } + + @Test + void getApiKeyBeforeStartThrows() { + try (ElasticsearchContainer container = new ElasticsearchContainer(ELASTICSEARCH_IMAGE_LATEST)) { + assertThat(catchThrowable(container::getApiKey)) + .as("API key is not available before the container has started") + .isInstanceOf(IllegalStateException.class); + } + } + + @Test + void getApiKeyThrowsWhenSecurityIsDisabled() { + try ( + ElasticsearchContainer container = new ElasticsearchContainer(ELASTICSEARCH_IMAGE_LATEST) + .withEnv("xpack.security.enabled", "false") + .withEnv("xpack.security.http.ssl.enabled", "false") + .withEnv("xpack.security.transport.ssl.enabled", "false") + ) { + container.start(); + + assertThat(catchThrowable(container::getApiKey)) + .as("API key is not generated when security is disabled") + .isInstanceOf(IllegalStateException.class); + } + } + @Test void latestRejectsMismatchedCa() throws Exception { final MountableFile mountableFile = MountableFile.forClasspathResource("http_ca.crt"); @@ -312,6 +371,7 @@ void v8StartsWithDefaults() throws IOException { .as("reported version matches the 8.x image") .contains(ELASTICSEARCH_VERSION_8); assertThat(container.getHttpScheme()).as("HTTP API uses HTTPS by default").isEqualTo("https"); + assertThat(container.getApiKey()).as("API key is generated for Elasticsearch 8").isNotBlank(); } } @@ -332,6 +392,9 @@ void v7UsesHttpWithoutSecurityByDefault() throws IOException { assertThat(response.getStatusLine().getStatusCode()).as("cluster health is available").isEqualTo(200); assertThat(EntityUtils.toString(response.getEntity())).contains("cluster_name"); assertThat(container.getHttpScheme()).as("HTTP API uses HTTP by default on 7.x").isEqualTo("http"); + assertThat(catchThrowable(container::getApiKey)) + .as("API key is not generated for Elasticsearch 7") + .isInstanceOf(IllegalStateException.class); // httpClientV7 {{ } // }