diff --git a/integ-test/build.gradle b/integ-test/build.gradle index 21eeb767d57..5f622a11968 100644 --- a/integ-test/build.gradle +++ b/integ-test/build.gradle @@ -691,6 +691,13 @@ task integTestWithSecurity(type: RestIntegTestTask) { useCluster testClusters.integTestWithSecurity useCluster testClusters.remoteIntegTestWithSecurity + // This task provisions the security plugin and a remote cluster, so their absence is a real + // breakage: mark both required so the security and cross-cluster ITs fail loudly instead of + // skipping. These properties are scoped to this task's test JVM and are intentionally NOT set + // on integTestRemote. + systemProperty 'tests.required.plugins', 'opensearch-security' + systemProperty 'tests.required.remote.cluster', 'true' + systemProperty "cluster.names", getClusters().stream().map(cluster -> cluster.getName()).collect(Collectors.joining(",")) @@ -756,6 +763,12 @@ yamlRestTest { integTest { useCluster testClusters.remoteCluster + // This task's cluster bundles the geospatial plugin (see testClusters.integTest above), so a + // missing geospatial plugin here is a real breakage, not an environment gap: mark it required + // so GeoIpFunctionsIT fails loudly instead of silently skipping. NOT set on integTestRemote, + // whose external cluster legitimately may not bundle geospatial. + systemProperty 'tests.required.plugins', 'opensearch-geospatial' + // Set properties for connection to clusters and between clusters doFirst { getClusters().forEach { cluster -> @@ -904,6 +917,11 @@ task tracingIntegTest(type: RestIntegTestTask) { dependsOn ':opensearch-sql-plugin:bundlePlugin' dependsOn downloadTelemetryOtel + // This task's cluster bundles telemetry-otel (see testClusters.tracingIntegTest above), so a + // missing plugin is a real breakage: mark it required so PPLTracingIT fails loudly instead of + // skipping. NOT set on integTestRemote. + systemProperty 'tests.required.plugins', 'telemetry-otel' + systemProperty 'tests.security.manager', 'false' systemProperty 'project.root', project.projectDir.absolutePath systemProperty 'tests.tracing.otlp.port', '4318' diff --git a/integ-test/src/test/java/org/opensearch/sql/calcite/tracing/PPLTracingIT.java b/integ-test/src/test/java/org/opensearch/sql/calcite/tracing/PPLTracingIT.java index 8e20b3fd1a5..e7d983cbc47 100644 --- a/integ-test/src/test/java/org/opensearch/sql/calcite/tracing/PPLTracingIT.java +++ b/integ-test/src/test/java/org/opensearch/sql/calcite/tracing/PPLTracingIT.java @@ -28,6 +28,7 @@ import org.opensearch.client.ResponseException; import org.opensearch.sql.calcite.tracing.OtlpHttpTraceReceiver.Span; import org.opensearch.sql.ppl.PPLIntegTestCase; +import org.opensearch.sql.util.ClusterPlugins; public class PPLTracingIT extends PPLIntegTestCase { @@ -55,6 +56,10 @@ public static void stopReceiver() { @Override public void init() throws Exception { super.init(); + ClusterPlugins.requirePluginOrAssume( + client(), + ClusterPlugins.TELEMETRY_OTEL_PLUGIN, + "telemetry-otel plugin not installed on test cluster; skipping PPL tracing tests"); enableCalcite(); loadIndex(Index.BANK); receiver.clear(); diff --git a/integ-test/src/test/java/org/opensearch/sql/ppl/GeoIpFunctionsIT.java b/integ-test/src/test/java/org/opensearch/sql/ppl/GeoIpFunctionsIT.java index 14c6e2fb8f8..7ba4636e4c6 100644 --- a/integ-test/src/test/java/org/opensearch/sql/ppl/GeoIpFunctionsIT.java +++ b/integ-test/src/test/java/org/opensearch/sql/ppl/GeoIpFunctionsIT.java @@ -29,6 +29,7 @@ import org.opensearch.common.xcontent.XContentFactory; import org.opensearch.common.xcontent.XContentType; import org.opensearch.core.xcontent.XContentBuilder; +import org.opensearch.sql.util.ClusterPlugins; /** IP enrichment PPL request with OpenSearch Geo-sptial plugin */ public class GeoIpFunctionsIT extends PPLIntegTestCase { @@ -52,6 +53,11 @@ public class GeoIpFunctionsIT extends PPLIntegTestCase { @Override public void init() throws Exception { super.init(); + ClusterPlugins.requirePluginOrAssume( + client(), + ClusterPlugins.GEOSPATIAL_PLUGIN, + "opensearch-geospatial plugin not installed on test cluster; skipping geoip enrichment" + + " tests"); loadIndex(Index.GEOIP); if (!initialized) { // Create a new dataSource diff --git a/integ-test/src/test/java/org/opensearch/sql/security/CrossClusterTestBase.java b/integ-test/src/test/java/org/opensearch/sql/security/CrossClusterTestBase.java index dc4d7d0dafd..c0686d9e365 100644 --- a/integ-test/src/test/java/org/opensearch/sql/security/CrossClusterTestBase.java +++ b/integ-test/src/test/java/org/opensearch/sql/security/CrossClusterTestBase.java @@ -11,24 +11,42 @@ import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_MVEXPAND_EDGE_CASES; import static org.opensearch.sql.legacy.TestsConstants.TEST_INDEX_TIME_DATA; +import org.junit.BeforeClass; import org.opensearch.sql.ppl.PPLIntegTestCase; +import org.opensearch.sql.util.ClusterPlugins; public class CrossClusterTestBase extends PPLIntegTestCase { static { - // find a remote cluster - String[] clusterNames = System.getProperty("cluster.names").split(","); - var remote = "remoteCluster"; - for (var cluster : clusterNames) { - if (cluster.startsWith("remote")) { - remote = cluster; - break; - } - } - REMOTE_CLUSTER = remote; + // find a remote cluster; the "cluster.names" property is only set by tasks that stand up a + // second (remote) cluster, so it may be absent on a plain single-cluster integTestRemote run. + // Selection (including whitespace trimming of comma-separated tokens) is delegated to the pure, + // unit-tested ClusterPlugins.selectRemoteCluster helper. + String remote = ClusterPlugins.selectRemoteCluster(System.getProperty("cluster.names")); + HAS_REMOTE_CLUSTER = remote != null; + REMOTE_CLUSTER = remote != null ? remote : ClusterPlugins.DEFAULT_REMOTE_CLUSTER; } + /** True only when a remote cluster is configured; cross-cluster tests are skipped otherwise. */ + public static final boolean HAS_REMOTE_CLUSTER; + public static final String REMOTE_CLUSTER; + @BeforeClass + public static void requireRemoteCluster() { + // On plain external-cluster runs a missing remote cluster is a skipped assumption. On the + // dedicated task that provisions the remote cluster (integTestWithSecurity sets + // -Dtests.required.remote.cluster=true) its absence is instead a hard failure, so a broken + // cross-cluster setup cannot masquerade as an all-green run. + ClusterPlugins.requireOrAssume( + HAS_REMOTE_CLUSTER, + Boolean.getBoolean(ClusterPlugins.REQUIRE_REMOTE_CLUSTER_PROPERTY), + "Cross-cluster search requires a configured remote cluster (-Dcluster.names must include a" + + " 'remote*' cluster); skipping", + "Cross-cluster search requires a configured remote cluster (-Dcluster.names must include a" + + " 'remote*' cluster) but none was found, and this task marks it required via -D" + + ClusterPlugins.REQUIRE_REMOTE_CLUSTER_PROPERTY); + } + protected static final String TEST_INDEX_BANK_REMOTE = REMOTE_CLUSTER + ":" + TEST_INDEX_BANK; protected static final String TEST_INDEX_DOG_REMOTE = REMOTE_CLUSTER + ":" + TEST_INDEX_DOG; protected static final String TEST_INDEX_DOG_MATCH_ALL_REMOTE = diff --git a/integ-test/src/test/java/org/opensearch/sql/security/PPLPermissionsIT.java b/integ-test/src/test/java/org/opensearch/sql/security/PPLPermissionsIT.java index dbe07b9c6a5..b61c4f0a4a1 100644 --- a/integ-test/src/test/java/org/opensearch/sql/security/PPLPermissionsIT.java +++ b/integ-test/src/test/java/org/opensearch/sql/security/PPLPermissionsIT.java @@ -16,6 +16,7 @@ import org.json.JSONObject; import org.junit.Test; import org.opensearch.client.ResponseException; +import org.opensearch.sql.util.ClusterPlugins; /** * Integration tests for PPL permissions issue fix. Tests that PPL queries work correctly when users @@ -55,6 +56,10 @@ public class PPLPermissionsIT extends SecurityTestBase { @Override protected void init() throws Exception { + ClusterPlugins.requirePluginOrAssume( + client(), + ClusterPlugins.SECURITY_PLUGIN, + "opensearch-security plugin not installed on test cluster; skipping FGAC tests"); super.init(); createSecurityRolesAndUsers(); loadIndex(Index.BANK); diff --git a/integ-test/src/test/java/org/opensearch/sql/security/RestCommandSecurityIT.java b/integ-test/src/test/java/org/opensearch/sql/security/RestCommandSecurityIT.java index c4b5e726a54..fe126d43ba7 100644 --- a/integ-test/src/test/java/org/opensearch/sql/security/RestCommandSecurityIT.java +++ b/integ-test/src/test/java/org/opensearch/sql/security/RestCommandSecurityIT.java @@ -13,6 +13,7 @@ import org.junit.Test; import org.opensearch.client.ResponseException; import org.opensearch.sql.legacy.TestUtils; +import org.opensearch.sql.util.ClusterPlugins; /** * Integration tests that the rest command is subject to the security plugin fine grained access @@ -32,6 +33,10 @@ public class RestCommandSecurityIT extends SecurityTestBase { @Override protected void init() throws Exception { + ClusterPlugins.requirePluginOrAssume( + client(), + ClusterPlugins.SECURITY_PLUGIN, + "opensearch-security plugin not installed on test cluster; skipping FGAC tests"); super.init(); setupRolesAndUsers(); enableCalcite(); diff --git a/integ-test/src/test/java/org/opensearch/sql/security/SQLCursorPermissionsIT.java b/integ-test/src/test/java/org/opensearch/sql/security/SQLCursorPermissionsIT.java index cacec3b2623..3e7ff640220 100644 --- a/integ-test/src/test/java/org/opensearch/sql/security/SQLCursorPermissionsIT.java +++ b/integ-test/src/test/java/org/opensearch/sql/security/SQLCursorPermissionsIT.java @@ -17,6 +17,7 @@ import org.opensearch.client.Response; import org.opensearch.sql.legacy.SQLIntegTestCase; import org.opensearch.sql.legacy.TestUtils; +import org.opensearch.sql.util.ClusterPlugins; /** * Regression test for SQL cursor pagination under Fine-Grained Access Control (FGAC). @@ -35,6 +36,10 @@ public class SQLCursorPermissionsIT extends SQLIntegTestCase { @Override protected void init() throws Exception { + ClusterPlugins.requirePluginOrAssume( + client(), + ClusterPlugins.SECURITY_PLUGIN, + "opensearch-security plugin not installed on test cluster; skipping FGAC tests"); loadIndex(Index.ACCOUNT); createSecurityRolesAndUsers(); } diff --git a/integ-test/src/test/java/org/opensearch/sql/util/ClusterPlugins.java b/integ-test/src/test/java/org/opensearch/sql/util/ClusterPlugins.java new file mode 100644 index 00000000000..bd5a2057ea3 --- /dev/null +++ b/integ-test/src/test/java/org/opensearch/sql/util/ClusterPlugins.java @@ -0,0 +1,184 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.util; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import org.junit.Assert; +import org.junit.Assume; +import org.opensearch.client.Request; +import org.opensearch.client.Response; +import org.opensearch.client.RestClient; + +/** + * Runtime detection of optional cluster plugins, used to environment-gate integration tests that + * depend on plugins which are not always installed on the target cluster (for example the plain + * external {@code integTestRemote} cluster does not bundle security, and a multi-shard external + * cluster may lack geospatial or telemetry). + * + *

The probe reads {@code /_cat/plugins?h=component} and matches an installed component by exact + * line equality (never substring), so {@code opensearch-security} is not falsely reported present + * on a cluster that only has {@code opensearch-security-analytics}. + * + *

Gating uses two modes decided by the running Gradle task: + * + *

+ * + * The required set is declared by the task via the {@link #REQUIRED_PLUGINS_PROPERTY} system + * property; tasks leave it unset to keep the optional (skip-on-absence) behaviour. + */ +public final class ClusterPlugins { + + /** Component name of the OpenSearch security plugin as reported by {@code _cat/plugins}. */ + public static final String SECURITY_PLUGIN = "opensearch-security"; + + /** Component name of the OpenSearch geospatial plugin (provides ip2geo / geoip enrichment). */ + public static final String GEOSPATIAL_PLUGIN = "opensearch-geospatial"; + + /** Component name of the OpenTelemetry exporter plugin that backs PPL query tracing. */ + public static final String TELEMETRY_OTEL_PLUGIN = "telemetry-otel"; + + /** + * Comma-separated list of plugin component names the current Gradle test task declares mandatory. + * When a plugin appears here, {@link #requirePluginOrAssume} turns a missing plugin into a hard + * assertion failure instead of a skipped assumption. Tasks that run against arbitrary external + * clusters (for example {@code integTestRemote}) leave this unset so absence remains a skip. + */ + public static final String REQUIRED_PLUGINS_PROPERTY = "tests.required.plugins"; + + /** + * Boolean system property set by dedicated tasks that provision a remote (cross-cluster) cluster. + * When {@code true}, an absent remote cluster is a hard failure rather than a skipped assumption. + */ + public static final String REQUIRE_REMOTE_CLUSTER_PROPERTY = "tests.required.remote.cluster"; + + /** + * Name used for {@code REMOTE_CLUSTER} when no remote cluster is configured. It keeps the derived + * {@code REMOTE_CLUSTER} constant (and the {@code :} names built from it) + * non-null so cross-cluster test classes still load; the {@code @BeforeClass} gate skips or fails + * the tests before any body that would actually query this non-existent cluster runs. + */ + public static final String DEFAULT_REMOTE_CLUSTER = "remoteCluster"; + + /** + * Selects the remote cluster name from a comma-separated {@code cluster.names} property value. + * Returns the first token (leading/trailing whitespace trimmed) that starts with {@code + * "remote"}, or {@code null} when the value is {@code null}, blank, or contains no such token. + * + *

Extracted as a pure, side-effect-free function so the selection logic can be unit-tested + * directly, without mutating the {@code cluster.names} system property, reloading a static + * initializer, or loading the integration base class (whose {@code OpenSearchTestCase} ancestry + * runs randomized-runner bootstrap in its static initializer). Trimming ensures a property such + * as {@code "clusterA, remoteCluster"} still matches the {@code remote*} token despite the space + * that {@code split(",")} leaves attached. + * + * @param clusterNamesProperty raw value of the {@code cluster.names} system property (may be + * null) + * @return the selected remote cluster name, or {@code null} if none is present + */ + public static String selectRemoteCluster(String clusterNamesProperty) { + if (clusterNamesProperty == null || clusterNamesProperty.isBlank()) { + return null; + } + for (String cluster : clusterNamesProperty.split(",")) { + String trimmed = cluster.trim(); + if (trimmed.startsWith("remote")) { + return trimmed; + } + } + return null; + } + + /** + * Returns {@code true} when the given plugin component is installed on the cluster the client is + * pointed at, matching the {@code _cat/plugins?h=component} output line by line with exact + * equality. Any I/O or HTTP error (including a non-2xx {@link + * org.opensearch.client.ResponseException}, which extends {@link IOException}) propagates to the + * caller so a broken probe fails loudly rather than being misreported as "not installed". + * + * @param client REST client connected to the cluster under test + * @param pluginComponentName component name as it appears in {@code _cat/plugins?h=component} + * @throws IOException if the probe request fails or returns a non-2xx response + */ + public static boolean isPluginInstalled(RestClient client, String pluginComponentName) + throws IOException { + Response response = client.performRequest(new Request("GET", "/_cat/plugins?h=component")); + String body; + try (InputStream content = response.getEntity().getContent()) { + body = new String(content.readAllBytes(), StandardCharsets.UTF_8); + } + for (String line : body.split("\n")) { + if (line.trim().equals(pluginComponentName)) { + return true; + } + } + return false; + } + + /** + * Returns {@code true} when {@code pluginComponentName} is listed in {@link + * #REQUIRED_PLUGINS_PROPERTY}. Matching is exact per comma-separated token (whitespace trimmed). + */ + public static boolean isPluginRequired(String pluginComponentName) { + String required = System.getProperty(REQUIRED_PLUGINS_PROPERTY, ""); + for (String token : required.split(",")) { + if (token.trim().equals(pluginComponentName)) { + return true; + } + } + return false; + } + + /** + * Gates a test on the presence of {@code pluginComponentName}. If the current task marks the + * plugin required (see {@link #REQUIRED_PLUGINS_PROPERTY}) an absent plugin fails the test; + * otherwise it is skipped with {@code skipMessage}. Probe I/O errors propagate. + * + * @throws IOException if the underlying probe request fails + */ + public static void requirePluginOrAssume( + RestClient client, String pluginComponentName, String skipMessage) throws IOException { + requireOrAssume( + isPluginInstalled(client, pluginComponentName), + isPluginRequired(pluginComponentName), + skipMessage, + "Plugin '" + + pluginComponentName + + "' is marked required for this task via -D" + + REQUIRED_PLUGINS_PROPERTY + + " but is not installed on the target cluster"); + } + + /** + * Core gate primitive. When {@code required} is {@code true} an absent capability ({@code present + * == false}) fails via {@link Assert#assertTrue}; otherwise it is skipped via {@link + * Assume#assumeTrue}. When the capability is present the caller proceeds unchanged in both modes. + * + * @param present whether the capability (plugin, remote cluster, ...) is available + * @param required whether the current task declares the capability mandatory + * @param skipMessage assumption message used when optional and absent + * @param failMessage assertion message used when required and absent + */ + public static void requireOrAssume( + boolean present, boolean required, String skipMessage, String failMessage) { + if (required) { + Assert.assertTrue(failMessage, present); + } else { + Assume.assumeTrue(skipMessage, present); + } + } + + private ClusterPlugins() {} +} diff --git a/integ-test/src/test/java/org/opensearch/sql/util/ClusterPluginsTests.java b/integ-test/src/test/java/org/opensearch/sql/util/ClusterPluginsTests.java new file mode 100644 index 00000000000..e6ccd04feba --- /dev/null +++ b/integ-test/src/test/java/org/opensearch/sql/util/ClusterPluginsTests.java @@ -0,0 +1,293 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.sql.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import org.apache.hc.core5.http.HttpHost; +import org.junit.After; +import org.junit.AssumptionViolatedException; +import org.junit.Test; +import org.opensearch.client.RestClient; + +/** + * Unit tests for {@link ClusterPlugins}. These exercise the real helper against an in-process + * {@link HttpServer} that emulates {@code _cat/plugins?h=component}, so nothing is re-implemented + * in the test. The server follows the same pattern as {@code OtlpHttpTraceReceiver}. + * + *

Coverage: + * + *

+ */ +public class ClusterPluginsTests { + + private HttpServer server; + private RestClient client; + + @After + public void tearDown() { + if (client != null) { + try { + client.close(); + } catch (IOException ignored) { + // best effort + } + client = null; + } + if (server != null) { + server.stop(0); + server = null; + } + System.clearProperty(ClusterPlugins.REQUIRED_PLUGINS_PROPERTY); + } + + /** + * Starts a localhost-only server that answers every request with {@code status} and {@code body}. + */ + private void startServer(int status, String body) throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + byte[] payload = body.getBytes(StandardCharsets.UTF_8); + server.createContext( + "/", + exchange -> { + exchange.sendResponseHeaders(status, payload.length == 0 ? -1 : payload.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(payload); + } + }); + server.setExecutor(null); + server.start(); + client = + RestClient.builder(new HttpHost("http", "127.0.0.1", server.getAddress().getPort())) + .build(); + } + + // ---- exact component detection ------------------------------------------------------------- + + @Test + public void exactComponentIsDetected() throws IOException { + startServer(200, "opensearch-security\n"); + assertTrue(ClusterPlugins.isPluginInstalled(client, ClusterPlugins.SECURITY_PLUGIN)); + } + + @Test + public void securityAnalyticsDoesNotCollideWithSecurity() throws IOException { + // A cluster that only has security-analytics must NOT be reported as having security. + startServer(200, "opensearch-security-analytics\n"); + assertFalse(ClusterPlugins.isPluginInstalled(client, ClusterPlugins.SECURITY_PLUGIN)); + } + + @Test + public void unrelatedComponentsReturnFalse() throws IOException { + startServer(200, "opensearch-job-scheduler\nopensearch-geospatial\n"); + assertFalse(ClusterPlugins.isPluginInstalled(client, ClusterPlugins.SECURITY_PLUGIN)); + } + + @Test + public void emptyOutputReturnsFalse() throws IOException { + startServer(200, ""); + assertFalse(ClusterPlugins.isPluginInstalled(client, ClusterPlugins.SECURITY_PLUGIN)); + } + + @Test + public void multilineExactMatchIsDetected() throws IOException { + startServer( + 200, + "opensearch-job-scheduler\nopensearch-geospatial\nopensearch-security\ntelemetry-otel\n"); + assertTrue(ClusterPlugins.isPluginInstalled(client, ClusterPlugins.SECURITY_PLUGIN)); + assertTrue(ClusterPlugins.isPluginInstalled(client, ClusterPlugins.GEOSPATIAL_PLUGIN)); + assertTrue(ClusterPlugins.isPluginInstalled(client, ClusterPlugins.TELEMETRY_OTEL_PLUGIN)); + } + + @Test + public void multilineCollisionOnlyReturnsFalse() throws IOException { + startServer( + 200, "opensearch-security-analytics\nopensearch-job-scheduler\nopensearch-geospatial\n"); + assertFalse(ClusterPlugins.isPluginInstalled(client, ClusterPlugins.SECURITY_PLUGIN)); + } + + // ---- probe failures propagate -------------------------------------------------------------- + + @Test + public void nonSuccessResponsePropagatesIoException() throws IOException { + // A 5xx from the cluster must surface as an IOException (ResponseException), never a silent + // "plugin not installed". + startServer(500, "cluster error"); + assertThrows( + IOException.class, + () -> ClusterPlugins.isPluginInstalled(client, ClusterPlugins.SECURITY_PLUGIN)); + } + + @Test + public void connectionFailurePropagatesIoException() throws IOException { + // Bind then immediately stop the server so the port is dead: a connection error must propagate. + startServer(200, "opensearch-security\n"); + server.stop(0); + server = null; + assertThrows( + IOException.class, + () -> ClusterPlugins.isPluginInstalled(client, ClusterPlugins.SECURITY_PLUGIN)); + } + + // ---- required-plugins property (exact token match) ----------------------------------------- + + @Test + public void isPluginRequiredMatchesExactToken() { + System.setProperty( + ClusterPlugins.REQUIRED_PLUGINS_PROPERTY, "telemetry-otel, opensearch-security"); + assertTrue(ClusterPlugins.isPluginRequired(ClusterPlugins.SECURITY_PLUGIN)); + assertTrue(ClusterPlugins.isPluginRequired(ClusterPlugins.TELEMETRY_OTEL_PLUGIN)); + assertFalse(ClusterPlugins.isPluginRequired(ClusterPlugins.GEOSPATIAL_PLUGIN)); + } + + @Test + public void isPluginRequiredDoesNotCollide() { + System.setProperty(ClusterPlugins.REQUIRED_PLUGINS_PROPERTY, "opensearch-security-analytics"); + assertFalse(ClusterPlugins.isPluginRequired(ClusterPlugins.SECURITY_PLUGIN)); + } + + // ---- require-or-assume gate ---------------------------------------------------------------- + + @Test + public void optionalAbsentIsSkipped() { + assertThrows( + AssumptionViolatedException.class, + () -> ClusterPlugins.requireOrAssume(false, false, "skip", "fail")); + } + + @Test + public void requiredAbsentFails() { + assertThrows( + AssertionError.class, () -> ClusterPlugins.requireOrAssume(false, true, "skip", "fail")); + } + + @Test + public void requiredPresentRuns() { + // No exception means the test body would proceed. + ClusterPlugins.requireOrAssume(true, true, "skip", "fail"); + } + + @Test + public void optionalPresentRuns() { + ClusterPlugins.requireOrAssume(true, false, "skip", "fail"); + } + + @Test + public void requirePluginOrAssumeOptionalAbsentSkips() throws IOException { + startServer(200, "opensearch-job-scheduler\n"); + assertThrows( + AssumptionViolatedException.class, + () -> + ClusterPlugins.requirePluginOrAssume( + client, ClusterPlugins.SECURITY_PLUGIN, "skip security")); + } + + @Test + public void requirePluginOrAssumeRequiredAbsentFails() throws IOException { + System.setProperty(ClusterPlugins.REQUIRED_PLUGINS_PROPERTY, ClusterPlugins.SECURITY_PLUGIN); + startServer(200, "opensearch-job-scheduler\n"); + assertThrows( + AssertionError.class, + () -> + ClusterPlugins.requirePluginOrAssume( + client, ClusterPlugins.SECURITY_PLUGIN, "skip security")); + } + + @Test + public void requirePluginOrAssumeRequiredPresentRuns() throws IOException { + System.setProperty(ClusterPlugins.REQUIRED_PLUGINS_PROPERTY, ClusterPlugins.SECURITY_PLUGIN); + startServer(200, "opensearch-security\nopensearch-job-scheduler\n"); + // Present + required => no exception, test proceeds. + ClusterPlugins.requirePluginOrAssume(client, ClusterPlugins.SECURITY_PLUGIN, "skip security"); + } + + // ---- remote-cluster selection from cluster.names (pure parser) ----------------------------- + + @Test + public void selectRemoteClusterReturnsNullWhenPropertyMissing() { + assertNull(ClusterPlugins.selectRemoteCluster(null)); + } + + @Test + public void selectRemoteClusterReturnsNullWhenBlank() { + assertNull(ClusterPlugins.selectRemoteCluster("")); + assertNull(ClusterPlugins.selectRemoteCluster(" ")); + } + + @Test + public void selectRemoteClusterReturnsNullWhenNoRemoteToken() { + assertNull(ClusterPlugins.selectRemoteCluster("clusterA,clusterB")); + } + + @Test + public void selectRemoteClusterPicksRemoteToken() { + assertEquals("remoteCluster", ClusterPlugins.selectRemoteCluster("clusterA,remoteCluster")); + } + + @Test + public void selectRemoteClusterTrimsSpacesAroundCommaTokens() { + // split(",") leaves a leading space on the second token; without trimming, startsWith("remote") + // would miss it and cross-cluster tests would silently skip on a correctly-configured cluster. + assertEquals("remoteCluster", ClusterPlugins.selectRemoteCluster("clusterA, remoteCluster")); + assertEquals("remoteCluster", ClusterPlugins.selectRemoteCluster(" clusterA , remoteCluster ")); + assertEquals("remote1", ClusterPlugins.selectRemoteCluster(" remote1 , clusterB ")); + } + + @Test + public void selectRemoteClusterReturnsFirstRemoteToken() { + assertEquals("remoteA", ClusterPlugins.selectRemoteCluster("clusterX, remoteA, remoteB")); + } + + @Test + public void selectRemoteClusterRequiresRemotePrefixNotSubstring() { + // Must START WITH "remote"; a token that merely contains it (or is a different word) is + // ignored. + assertNull(ClusterPlugins.selectRemoteCluster("myremote,premote,notremote")); + } + + /** + * Documents and locks the exact mapping {@code CrossClusterTestBase} applies on top of the + * parser: {@code HAS_REMOTE_CLUSTER = selected != null} and {@code REMOTE_CLUSTER = selected != + * null ? selected : DEFAULT_REMOTE_CLUSTER}. Kept here (rather than referencing + * CrossClusterTestBase) so the assertion runs without loading the integration base class's + * OpenSearchTestCase bootstrap. + */ + @Test + public void selectRemoteClusterDrivesHasAndRemoteConstants() { + // Present: HAS_REMOTE_CLUSTER true, REMOTE_CLUSTER is the selected (trimmed) name. + String selectedPresent = ClusterPlugins.selectRemoteCluster("clusterA, remoteCluster"); + assertTrue(selectedPresent != null); + assertEquals( + "remoteCluster", + selectedPresent != null ? selectedPresent : ClusterPlugins.DEFAULT_REMOTE_CLUSTER); + + // Absent: HAS_REMOTE_CLUSTER false, REMOTE_CLUSTER falls back to the non-null default so the + // static final field and the derived : constants stay well-defined. + String selectedAbsent = ClusterPlugins.selectRemoteCluster("clusterA,clusterB"); + assertFalse(selectedAbsent != null); + assertEquals( + ClusterPlugins.DEFAULT_REMOTE_CLUSTER, + selectedAbsent != null ? selectedAbsent : ClusterPlugins.DEFAULT_REMOTE_CLUSTER); + assertEquals("remoteCluster", ClusterPlugins.DEFAULT_REMOTE_CLUSTER); + } +}