This is an automated email from the ASF dual-hosted git repository.

davidzollo pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git


The following commit(s) were added to refs/heads/dev by this push:
     new 02aa837898 [Improve][Connector-V2] Migrate Elasticsearch validation to 
declarative OptionRule (#11122)
02aa837898 is described below

commit 02aa837898c821961abbef8ea567bfe2bcea2d2f
Author: zhiwei.niu <[email protected]>
AuthorDate: Sat Jun 27 21:10:53 2026 +0800

    [Improve][Connector-V2] Migrate Elasticsearch validation to declarative 
OptionRule (#11122)
---
 .../catalog/ElasticSearchCatalogFactory.java       |  49 ++-
 .../client/auth/ApiKeyAuthProvider.java            |  29 +-
 .../client/auth/ApiKeyEncodedAuthProvider.java     |  32 +-
 .../client/auth/BasicAuthProvider.java             |  38 +--
 .../config/ElasticsearchValidators.java            |  66 ++++
 .../sink/ElasticsearchSinkFactory.java             |  20 +-
 .../elasticsearch/source/ElasticsearchSource.java  |  20 +-
 .../source/ElasticsearchSourceFactory.java         |  50 ++-
 .../catalog/ElasticSearchCatalogFactoryTest.java   | 136 ++++++++
 .../sink/ElasticsearchSinkFactoryTest.java         | 217 +++++++++++++
 .../source/ElasticsearchSourceFactoryTest.java     | 342 +++++++++++++++++++++
 11 files changed, 896 insertions(+), 103 deletions(-)

diff --git 
a/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/catalog/ElasticSearchCatalogFactory.java
 
b/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/catalog/ElasticSearchCatalogFactory.java
index 76623f84fc..ecf463c011 100644
--- 
a/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/catalog/ElasticSearchCatalogFactory.java
+++ 
b/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/catalog/ElasticSearchCatalogFactory.java
@@ -18,13 +18,30 @@
 package org.apache.seatunnel.connectors.seatunnel.elasticsearch.catalog;
 
 import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.configuration.util.Conditions;
 import org.apache.seatunnel.api.configuration.util.OptionRule;
 import org.apache.seatunnel.api.table.catalog.Catalog;
 import org.apache.seatunnel.api.table.factory.CatalogFactory;
 import org.apache.seatunnel.api.table.factory.Factory;
+import 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.AuthTypeEnum;
+import 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchValidators.ApiKeyEncodedFormatValidator;
 
 import com.google.auto.service.AutoService;
 
+import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchBaseOptions.API_KEY;
+import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchBaseOptions.API_KEY_ENCODED;
+import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchBaseOptions.API_KEY_ID;
+import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchBaseOptions.AUTH_TYPE;
+import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchBaseOptions.HOSTS;
+import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchBaseOptions.PASSWORD;
+import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchBaseOptions.TLS_KEY_STORE_PASSWORD;
+import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchBaseOptions.TLS_KEY_STORE_PATH;
+import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchBaseOptions.TLS_TRUST_STORE_PASSWORD;
+import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchBaseOptions.TLS_TRUST_STORE_PATH;
+import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchBaseOptions.TLS_VERIFY_CERTIFICATE;
+import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchBaseOptions.TLS_VERIFY_HOSTNAME;
+import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchBaseOptions.USERNAME;
+
 @AutoService(Factory.class)
 public class ElasticSearchCatalogFactory implements CatalogFactory {
 
@@ -40,6 +57,36 @@ public class ElasticSearchCatalogFactory implements 
CatalogFactory {
 
     @Override
     public OptionRule optionRule() {
-        return OptionRule.builder().build();
+        return OptionRule.builder()
+                .required(HOSTS)
+                .optional(
+                        USERNAME,
+                        PASSWORD,
+                        TLS_VERIFY_CERTIFICATE,
+                        TLS_VERIFY_HOSTNAME,
+                        TLS_KEY_STORE_PATH,
+                        TLS_KEY_STORE_PASSWORD,
+                        TLS_TRUST_STORE_PATH,
+                        TLS_TRUST_STORE_PASSWORD)
+                .optional(AUTH_TYPE)
+                .conditionalRule(
+                        AUTH_TYPE,
+                        AuthTypeEnum.BASIC,
+                        OptionRule.builder().bundled(USERNAME, 
PASSWORD).build())
+                .conditional(AUTH_TYPE, AuthTypeEnum.BASIC, 
Conditions.notBlank(USERNAME))
+                .conditional(AUTH_TYPE, AuthTypeEnum.BASIC, 
Conditions.notBlank(PASSWORD))
+                .conditional(AUTH_TYPE, AuthTypeEnum.API_KEY, API_KEY_ID, 
API_KEY)
+                .conditional(AUTH_TYPE, AuthTypeEnum.API_KEY, 
Conditions.notBlank(API_KEY_ID))
+                .conditional(AUTH_TYPE, AuthTypeEnum.API_KEY, 
Conditions.notBlank(API_KEY))
+                .conditional(AUTH_TYPE, AuthTypeEnum.API_KEY_ENCODED, 
API_KEY_ENCODED)
+                .conditional(
+                        AUTH_TYPE,
+                        AuthTypeEnum.API_KEY_ENCODED,
+                        Conditions.notBlank(API_KEY_ENCODED))
+                .conditional(
+                        AUTH_TYPE,
+                        AuthTypeEnum.API_KEY_ENCODED,
+                        Conditions.extension(API_KEY_ENCODED, new 
ApiKeyEncodedFormatValidator()))
+                .build();
     }
 }
diff --git 
a/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/client/auth/ApiKeyAuthProvider.java
 
b/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/client/auth/ApiKeyAuthProvider.java
index bbd5def631..f89a1324d4 100644
--- 
a/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/client/auth/ApiKeyAuthProvider.java
+++ 
b/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/client/auth/ApiKeyAuthProvider.java
@@ -62,20 +62,14 @@ public class ApiKeyAuthProvider extends 
AbstractAuthenticationProvider {
         return AUTH_TYPE;
     }
 
+    /**
+     * No-op. Presence and non-blankness of {@code auth.api_key_id} / {@code 
auth.api_key} are now
+     * enforced declaratively via {@code OptionRule} (conditional on {@code 
auth_type=api_key}).
+     * This method is kept to honor the {@link AuthenticationProvider} 
contract.
+     */
     @Override
     public void validate(ReadonlyConfig config) {
-        Optional<String> apiKeyId = 
config.getOptional(ElasticsearchBaseOptions.API_KEY_ID);
-        Optional<String> apiKey = 
config.getOptional(ElasticsearchBaseOptions.API_KEY);
-        Optional<String> apiKeyEncoded =
-                config.getOptional(ElasticsearchBaseOptions.API_KEY_ENCODED);
-
-        if (!apiKeyId.isPresent() || !apiKey.isPresent()) {
-            throw new IllegalArgumentException(
-                    "API key authentication with auth_type='api_key' requires 
both api_key_id and api_key");
-        }
-        validateApiKeyIdAndSecret(apiKeyId.get(), apiKey.get());
-
-        log.debug("API key authentication configuration validated");
+        // intentionally empty - validation handled by OptionRule
     }
 
     /**
@@ -95,15 +89,4 @@ public class ApiKeyAuthProvider extends 
AbstractAuthenticationProvider {
 
         return null;
     }
-
-    /** Validate API key ID and secret. */
-    private void validateApiKeyIdAndSecret(String apiKeyId, String apiKey) {
-        if (apiKeyId == null || apiKeyId.trim().isEmpty()) {
-            throw new IllegalArgumentException("API key ID cannot be null or 
empty");
-        }
-
-        if (apiKey == null || apiKey.trim().isEmpty()) {
-            throw new IllegalArgumentException("API key cannot be null or 
empty");
-        }
-    }
 }
diff --git 
a/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/client/auth/ApiKeyEncodedAuthProvider.java
 
b/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/client/auth/ApiKeyEncodedAuthProvider.java
index 200bc13705..d3910303f6 100644
--- 
a/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/client/auth/ApiKeyEncodedAuthProvider.java
+++ 
b/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/client/auth/ApiKeyEncodedAuthProvider.java
@@ -24,8 +24,6 @@ import org.apache.http.impl.nio.client.HttpAsyncClientBuilder;
 
 import lombok.extern.slf4j.Slf4j;
 
-import java.nio.charset.StandardCharsets;
-import java.util.Base64;
 import java.util.Optional;
 
 @Slf4j
@@ -66,34 +64,6 @@ public class ApiKeyEncodedAuthProvider extends 
AbstractAuthenticationProvider {
 
     @Override
     public void validate(ReadonlyConfig config) {
-        Optional<String> apiKeyEncoded =
-                config.getOptional(ElasticsearchBaseOptions.API_KEY_ENCODED);
-        if (!apiKeyEncoded.isPresent()) {
-            throw new IllegalArgumentException(
-                    "API key authentication with auth_type='api_key_encoded' 
requires api_key_encoded");
-        }
-        validateEncodedApiKey(apiKeyEncoded.get());
-
-        log.debug("Encoded API key authentication configuration validated");
-    }
-
-    /** Validate encoded API key. */
-    private void validateEncodedApiKey(String apiKeyEncoded) {
-        if (apiKeyEncoded == null || apiKeyEncoded.trim().isEmpty()) {
-            throw new IllegalArgumentException("Encoded API key cannot be null 
or empty");
-        }
-
-        try {
-            byte[] decoded = Base64.getDecoder().decode(apiKeyEncoded);
-            String decodedStr = new String(decoded, StandardCharsets.UTF_8);
-
-            if (!decodedStr.contains(":")) {
-                throw new IllegalArgumentException(
-                        "Encoded API key must be Base64 encoded 'id:key' 
format");
-            }
-        } catch (IllegalArgumentException e) {
-            throw new IllegalArgumentException(
-                    "Invalid encoded API key format: " + e.getMessage(), e);
-        }
+        // intentionally empty - validation handled by OptionRule
     }
 }
diff --git 
a/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/client/auth/BasicAuthProvider.java
 
b/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/client/auth/BasicAuthProvider.java
index 7e4d0ce010..ee169ad1fc 100644
--- 
a/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/client/auth/BasicAuthProvider.java
+++ 
b/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/client/auth/BasicAuthProvider.java
@@ -60,38 +60,14 @@ public class BasicAuthProvider extends 
AbstractAuthenticationProvider {
         return AUTH_TYPE;
     }
 
+    /**
+     * No-op. Username/password presence and pairing are now enforced 
declaratively via {@code
+     * OptionRule} (see {@code ElasticsearchSourceFactory} / {@code 
ElasticsearchSinkFactory} /
+     * {@code ElasticSearchCatalogFactory}). This method is kept to honor the 
{@link
+     * AuthenticationProvider} contract.
+     */
     @Override
     public void validate(ReadonlyConfig config) {
-        Optional<String> username = 
config.getOptional(ElasticsearchBaseOptions.USERNAME);
-        Optional<String> password = 
config.getOptional(ElasticsearchBaseOptions.PASSWORD);
-
-        // For backward compatibility, we allow basic auth to be optional
-        // If username is provided, password must also be provided
-        if (username.isPresent() && !password.isPresent()) {
-            throw new IllegalArgumentException(
-                    "Password is required when username is provided for basic 
authentication");
-        }
-
-        if (!username.isPresent() && password.isPresent()) {
-            throw new IllegalArgumentException(
-                    "Username is required when password is provided for basic 
authentication");
-        }
-
-        if (username.isPresent()) {
-            String usernameValue = username.get();
-            if (usernameValue == null || usernameValue.trim().isEmpty()) {
-                throw new IllegalArgumentException("Username cannot be null or 
empty");
-            }
-
-            String passwordValue = password.get();
-            if (passwordValue == null || passwordValue.trim().isEmpty()) {
-                throw new IllegalArgumentException("Password cannot be null or 
empty");
-            }
-
-            log.debug("Basic authentication configuration validated for user: 
{}", usernameValue);
-        } else {
-            log.debug(
-                    "No basic authentication credentials provided - 
authentication will be skipped");
-        }
+        // intentionally empty - validation handled by OptionRule
     }
 }
diff --git 
a/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/config/ElasticsearchValidators.java
 
b/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/config/ElasticsearchValidators.java
new file mode 100644
index 0000000000..6d429527bc
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/config/ElasticsearchValidators.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.elasticsearch.config;
+
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.configuration.util.ConditionExtension;
+
+import lombok.experimental.UtilityClass;
+import lombok.extern.slf4j.Slf4j;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
+/**
+ * Reusable {@link ConditionExtension} validators shared by Elasticsearch 
Source / Sink / Catalog
+ * factories.
+ *
+ * <p>All validators return {@code false} on failure (instead of throwing) so 
that {@code
+ * ConfigValidator} can aggregate errors across the entire rule.
+ */
+@UtilityClass
+public final class ElasticsearchValidators {
+
+    /**
+     * Validates that {@code auth.api_key_encoded} is a Base64-encoded {@code 
id:key} string.
+     *
+     * <p>Skips when the value is null/blank — presence and non-blankness are 
enforced separately by
+     * the corresponding conditional rules.
+     */
+    @Slf4j
+    public static class ApiKeyEncodedFormatValidator implements 
ConditionExtension<String> {
+        @Override
+        public String description() {
+            return "'auth.api_key_encoded' must be a Base64-encoded 'id:key' 
string";
+        }
+
+        @Override
+        public boolean evaluate(ReadonlyConfig config, String value) {
+            if (value == null || value.trim().isEmpty()) {
+                return true;
+            }
+            try {
+                byte[] decoded = Base64.getDecoder().decode(value);
+                return new String(decoded, 
StandardCharsets.UTF_8).contains(":");
+            } catch (IllegalArgumentException e) {
+                log.warn("Failed to decode 'auth.api_key_encoded' as Base64", 
e);
+                return false;
+            }
+        }
+    }
+}
diff --git 
a/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/sink/ElasticsearchSinkFactory.java
 
b/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/sink/ElasticsearchSinkFactory.java
index 31b1438eec..0bbab15e35 100644
--- 
a/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/sink/ElasticsearchSinkFactory.java
+++ 
b/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/sink/ElasticsearchSinkFactory.java
@@ -18,6 +18,7 @@
 package org.apache.seatunnel.connectors.seatunnel.elasticsearch.sink;
 
 import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.configuration.util.Conditions;
 import org.apache.seatunnel.api.configuration.util.OptionRule;
 import org.apache.seatunnel.api.options.SinkConnectorCommonOptions;
 import org.apache.seatunnel.api.table.catalog.CatalogTable;
@@ -28,6 +29,7 @@ import 
org.apache.seatunnel.api.table.factory.TableSinkFactory;
 import org.apache.seatunnel.api.table.factory.TableSinkFactoryContext;
 import 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.AuthTypeEnum;
 import 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchSinkOptions;
+import 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchValidators.ApiKeyEncodedFormatValidator;
 
 import com.google.auto.service.AutoService;
 
@@ -63,8 +65,8 @@ public class ElasticsearchSinkFactory implements 
TableSinkFactory {
     @Override
     public OptionRule optionRule() {
         return OptionRule.builder()
+                .required(HOSTS)
                 .required(
-                        HOSTS,
                         INDEX,
                         ElasticsearchSinkOptions.SCHEMA_SAVE_MODE,
                         ElasticsearchSinkOptions.DATA_SAVE_MODE)
@@ -86,8 +88,24 @@ public class ElasticsearchSinkFactory implements 
TableSinkFactory {
                         VECTORIZATION_FIELDS,
                         VECTOR_DIMENSIONS)
                 .optional(AUTH_TYPE)
+                .conditionalRule(
+                        AUTH_TYPE,
+                        AuthTypeEnum.BASIC,
+                        OptionRule.builder().bundled(USERNAME, 
PASSWORD).build())
+                .conditional(AUTH_TYPE, AuthTypeEnum.BASIC, 
Conditions.notBlank(USERNAME))
+                .conditional(AUTH_TYPE, AuthTypeEnum.BASIC, 
Conditions.notBlank(PASSWORD))
                 .conditional(AUTH_TYPE, AuthTypeEnum.API_KEY, API_KEY_ID, 
API_KEY)
+                .conditional(AUTH_TYPE, AuthTypeEnum.API_KEY, 
Conditions.notBlank(API_KEY_ID))
+                .conditional(AUTH_TYPE, AuthTypeEnum.API_KEY, 
Conditions.notBlank(API_KEY))
                 .conditional(AUTH_TYPE, AuthTypeEnum.API_KEY_ENCODED, 
API_KEY_ENCODED)
+                .conditional(
+                        AUTH_TYPE,
+                        AuthTypeEnum.API_KEY_ENCODED,
+                        Conditions.notBlank(API_KEY_ENCODED))
+                .conditional(
+                        AUTH_TYPE,
+                        AuthTypeEnum.API_KEY_ENCODED,
+                        Conditions.extension(API_KEY_ENCODED, new 
ApiKeyEncodedFormatValidator()))
                 .build();
     }
 
diff --git 
a/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/source/ElasticsearchSource.java
 
b/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/source/ElasticsearchSource.java
index 46b01f2e89..6785b25cdb 100644
--- 
a/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/source/ElasticsearchSource.java
+++ 
b/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/source/ElasticsearchSource.java
@@ -43,8 +43,6 @@ import 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.Elasticsea
 import 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchSourceOptions;
 import 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.SearchApiTypeEnum;
 import 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.SearchTypeEnum;
-import 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.exception.ElasticsearchConnectorErrorCode;
-import 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.exception.ElasticsearchConnectorException;
 
 import org.apache.commons.collections4.CollectionUtils;
 
@@ -76,23 +74,10 @@ public class ElasticsearchSource
         boolean multiSource = 
config.getOptional(ElasticsearchSourceOptions.INDEX_LIST).isPresent();
         boolean singleSource = 
config.getOptional(ElasticsearchSourceOptions.INDEX).isPresent();
 
-        boolean sqlQuery = config.getOptional(SQL_QUERY).isPresent();
-
-        if (SearchTypeEnum.SQL.equals(config.get(SEARCH_TYPE)) && !sqlQuery) {
-            throw new ElasticsearchConnectorException(
-                    ElasticsearchConnectorErrorCode.SOURCE_CONFIG_ERROR_02,
-                    
ElasticsearchConnectorErrorCode.SOURCE_CONFIG_ERROR_02.getDescription());
-        }
-
         if (multiSource && singleSource) {
             log.warn(
                     "Elasticsearch Source config warn: when both 'index' and 
'index_list' are present in the configuration, only the 'index_list' 
configuration will take effect");
         }
-        if (!multiSource && !singleSource) {
-            throw new ElasticsearchConnectorException(
-                    ElasticsearchConnectorErrorCode.SOURCE_CONFIG_ERROR_01,
-                    
ElasticsearchConnectorErrorCode.SOURCE_CONFIG_ERROR_01.getDescription());
-        }
         if (multiSource) {
             this.elasticsearchConfigList = createMultiSource(config);
         } else {
@@ -107,6 +92,11 @@ public class ElasticsearchSource
                 
configMaps.stream().map(ReadonlyConfig::fromMap).collect(Collectors.toList());
         List<ElasticsearchConfig> elasticsearchConfigList = new 
ArrayList<>(configList.size());
         for (ReadonlyConfig readonlyConfig : configList) {
+            // NOTE: per-entry configs inside `index_list` are NOT validated 
by the factory's
+            // OptionRule (which only validates the top-level config). If an 
entry is missing
+            // `index`, or sets `search_type=SQL` without `sql_query`, 
parseOneIndexQueryConfig
+            // below will fail at runtime. This is a pre-existing limitation; 
revisit if/when
+            // per-entry declarative validation is supported.
             ElasticsearchConfig elasticsearchConfig = 
parseOneIndexQueryConfig(readonlyConfig);
             elasticsearchConfigList.add(elasticsearchConfig);
         }
diff --git 
a/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/source/ElasticsearchSourceFactory.java
 
b/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/source/ElasticsearchSourceFactory.java
index fc13b74d25..cbf9c4757a 100644
--- 
a/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/source/ElasticsearchSourceFactory.java
+++ 
b/seatunnel-connectors-v2/connector-elasticsearch/src/main/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/source/ElasticsearchSourceFactory.java
@@ -17,6 +17,9 @@
 
 package org.apache.seatunnel.connectors.seatunnel.elasticsearch.source;
 
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.configuration.util.ConditionExtension;
+import org.apache.seatunnel.api.configuration.util.Conditions;
 import org.apache.seatunnel.api.configuration.util.OptionRule;
 import org.apache.seatunnel.api.source.SeaTunnelSource;
 import org.apache.seatunnel.api.source.SourceSplit;
@@ -25,10 +28,13 @@ import org.apache.seatunnel.api.table.factory.Factory;
 import org.apache.seatunnel.api.table.factory.TableSourceFactory;
 import org.apache.seatunnel.api.table.factory.TableSourceFactoryContext;
 import 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.AuthTypeEnum;
+import 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchValidators.ApiKeyEncodedFormatValidator;
+import 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.SearchTypeEnum;
 
 import com.google.auto.service.AutoService;
 
 import java.io.Serializable;
+import java.util.List;
 
 import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchBaseOptions.API_KEY;
 import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchBaseOptions.API_KEY_ENCODED;
@@ -44,6 +50,7 @@ import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.Ela
 import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchBaseOptions.TLS_VERIFY_CERTIFICATE;
 import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchBaseOptions.TLS_VERIFY_HOSTNAME;
 import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchBaseOptions.USERNAME;
+import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchSourceOptions.ARRAY_COLUMN;
 import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchSourceOptions.INDEX_LIST;
 import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchSourceOptions.PIT_BATCH_SIZE;
 import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchSourceOptions.PIT_KEEP_ALIVE;
@@ -53,6 +60,9 @@ import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.Ela
 import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchSourceOptions.SCROLL_TIME;
 import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchSourceOptions.SEARCH_API_TYPE;
 import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchSourceOptions.SEARCH_TYPE;
+import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchSourceOptions.SLICE_MAX;
+import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchSourceOptions.SOURCE;
+import static 
org.apache.seatunnel.connectors.seatunnel.elasticsearch.config.ElasticsearchSourceOptions.SQL_QUERY;
 
 @AutoService(Factory.class)
 public class ElasticsearchSourceFactory implements TableSourceFactory {
@@ -64,7 +74,7 @@ public class ElasticsearchSourceFactory implements 
TableSourceFactory {
     @Override
     public OptionRule optionRule() {
         return OptionRule.builder()
-                .required(HOSTS)
+                .required(HOSTS, Conditions.extension(HOSTS, new 
RequireIndexValidator()))
                 .optional(
                         INDEX,
                         INDEX_LIST,
@@ -78,6 +88,9 @@ public class ElasticsearchSourceFactory implements 
TableSourceFactory {
                         PIT_BATCH_SIZE,
                         SEARCH_API_TYPE,
                         SEARCH_TYPE,
+                        SOURCE,
+                        ARRAY_COLUMN,
+                        SLICE_MAX,
                         TLS_VERIFY_CERTIFICATE,
                         TLS_VERIFY_HOSTNAME,
                         TLS_KEY_STORE_PATH,
@@ -85,8 +98,26 @@ public class ElasticsearchSourceFactory implements 
TableSourceFactory {
                         TLS_TRUST_STORE_PATH,
                         TLS_TRUST_STORE_PASSWORD)
                 .optional(AUTH_TYPE)
+                .conditionalRule(
+                        AUTH_TYPE,
+                        AuthTypeEnum.BASIC,
+                        OptionRule.builder().bundled(USERNAME, 
PASSWORD).build())
+                .conditional(AUTH_TYPE, AuthTypeEnum.BASIC, 
Conditions.notBlank(USERNAME))
+                .conditional(AUTH_TYPE, AuthTypeEnum.BASIC, 
Conditions.notBlank(PASSWORD))
                 .conditional(AUTH_TYPE, AuthTypeEnum.API_KEY, API_KEY_ID, 
API_KEY)
+                .conditional(AUTH_TYPE, AuthTypeEnum.API_KEY, 
Conditions.notBlank(API_KEY_ID))
+                .conditional(AUTH_TYPE, AuthTypeEnum.API_KEY, 
Conditions.notBlank(API_KEY))
                 .conditional(AUTH_TYPE, AuthTypeEnum.API_KEY_ENCODED, 
API_KEY_ENCODED)
+                .conditional(
+                        AUTH_TYPE,
+                        AuthTypeEnum.API_KEY_ENCODED,
+                        Conditions.notBlank(API_KEY_ENCODED))
+                .conditional(
+                        AUTH_TYPE,
+                        AuthTypeEnum.API_KEY_ENCODED,
+                        Conditions.extension(API_KEY_ENCODED, new 
ApiKeyEncodedFormatValidator()))
+                .conditional(SEARCH_TYPE, SearchTypeEnum.SQL, SQL_QUERY)
+                .conditional(SEARCH_TYPE, SearchTypeEnum.SQL, 
Conditions.notBlank(SQL_QUERY))
                 .build();
     }
 
@@ -101,4 +132,21 @@ public class ElasticsearchSourceFactory implements 
TableSourceFactory {
     public Class<? extends SeaTunnelSource> getSourceClass() {
         return ElasticsearchSource.class;
     }
+
+    /**
+     * Validates that at least one of {@code index} or {@code index_list} is 
provided. Attached to
+     * the always-present {@code HOSTS} so the rule runs even when both index 
options are missing.
+     */
+    static class RequireIndexValidator implements 
ConditionExtension<List<String>> {
+        @Override
+        public String description() {
+            return "at least one of 'index' or 'index_list' must be provided";
+        }
+
+        @Override
+        public boolean evaluate(ReadonlyConfig config, List<String> value) {
+            return config.getOptional(INDEX).isPresent()
+                    || config.getOptional(INDEX_LIST).isPresent();
+        }
+    }
 }
diff --git 
a/seatunnel-connectors-v2/connector-elasticsearch/src/test/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/catalog/ElasticSearchCatalogFactoryTest.java
 
b/seatunnel-connectors-v2/connector-elasticsearch/src/test/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/catalog/ElasticSearchCatalogFactoryTest.java
new file mode 100644
index 0000000000..10b0444928
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-elasticsearch/src/test/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/catalog/ElasticSearchCatalogFactoryTest.java
@@ -0,0 +1,136 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.elasticsearch.catalog;
+
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.configuration.util.ConfigValidator;
+import org.apache.seatunnel.api.configuration.util.OptionRule;
+import org.apache.seatunnel.api.configuration.util.OptionValidationException;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+
+class ElasticSearchCatalogFactoryTest {
+
+    private OptionRule rule;
+
+    @BeforeEach
+    void setUp() {
+        rule = new ElasticSearchCatalogFactory().optionRule();
+    }
+
+    @Test
+    void testValidConfig() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        Assertions.assertDoesNotThrow(
+                () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+    }
+
+    @Test
+    void testMissingHostsFails() {
+        Map<String, Object> config = new HashMap<>();
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(ex.getMessage().contains("hosts"));
+    }
+
+    @Test
+    void testUsernameWithoutPasswordFails() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        config.put("username", "admin");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(
+                ex.getMessage().contains("username") || 
ex.getMessage().contains("password"));
+    }
+
+    @Test
+    void testValidBasicAuth() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        config.put("username", "admin");
+        config.put("password", "secret");
+        Assertions.assertDoesNotThrow(
+                () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+    }
+
+    @Test
+    void testBasicAuthBlankUsernameFails() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        config.put("username", "   ");
+        config.put("password", "secret");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(
+                ex.getMessage().contains("username") || 
ex.getMessage().contains("blank"));
+    }
+
+    @Test
+    void testBasicAuthBlankPasswordFails() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        config.put("username", "admin");
+        config.put("password", "   ");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(
+                ex.getMessage().contains("password") || 
ex.getMessage().contains("blank"));
+    }
+
+    @Test
+    void testApiKeyAuthMissingKeysFails() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        config.put("auth_type", "API_KEY");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(
+                ex.getMessage().contains("api_key_id") || 
ex.getMessage().contains("api_key"));
+    }
+
+    @Test
+    void testApiKeyEncodedInvalidFormatFails() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        config.put("auth_type", "API_KEY_ENCODED");
+        config.put("auth.api_key_encoded", "not-base64-!@#");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(ex.getMessage().contains("Base64"));
+    }
+}
diff --git 
a/seatunnel-connectors-v2/connector-elasticsearch/src/test/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/sink/ElasticsearchSinkFactoryTest.java
 
b/seatunnel-connectors-v2/connector-elasticsearch/src/test/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/sink/ElasticsearchSinkFactoryTest.java
new file mode 100644
index 0000000000..38fd8e8d80
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-elasticsearch/src/test/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/sink/ElasticsearchSinkFactoryTest.java
@@ -0,0 +1,217 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.elasticsearch.sink;
+
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.configuration.util.ConfigValidator;
+import org.apache.seatunnel.api.configuration.util.OptionRule;
+import org.apache.seatunnel.api.configuration.util.OptionValidationException;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+
+class ElasticsearchSinkFactoryTest {
+
+    private OptionRule rule;
+
+    @BeforeEach
+    void setUp() {
+        rule = new ElasticsearchSinkFactory().optionRule();
+    }
+
+    private Map<String, Object> validSinkConfig() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        config.put("index", "test_index");
+        config.put("schema_save_mode", "CREATE_SCHEMA_WHEN_NOT_EXIST");
+        config.put("data_save_mode", "APPEND_DATA");
+        return config;
+    }
+
+    @Test
+    void testValidConfig() {
+        Assertions.assertDoesNotThrow(
+                () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(validSinkConfig())).validate(rule));
+    }
+
+    @Test
+    void testMissingHostsFails() {
+        Map<String, Object> config = validSinkConfig();
+        config.remove("hosts");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(ex.getMessage().contains("hosts"));
+    }
+
+    @Test
+    void testMissingIndexFails() {
+        Map<String, Object> config = validSinkConfig();
+        config.remove("index");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(ex.getMessage().contains("index"));
+    }
+
+    @Test
+    void testUsernameWithoutPasswordFails() {
+        Map<String, Object> config = validSinkConfig();
+        config.put("username", "admin");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(
+                ex.getMessage().contains("username") || 
ex.getMessage().contains("password"));
+    }
+
+    @Test
+    void testPasswordWithoutUsernameFails() {
+        Map<String, Object> config = validSinkConfig();
+        config.put("password", "secret");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(
+                ex.getMessage().contains("username") || 
ex.getMessage().contains("password"));
+    }
+
+    @Test
+    void testValidBasicAuth() {
+        Map<String, Object> config = validSinkConfig();
+        config.put("username", "admin");
+        config.put("password", "secret");
+        Assertions.assertDoesNotThrow(
+                () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+    }
+
+    @Test
+    void testBasicAuthBlankUsernameFails() {
+        Map<String, Object> config = validSinkConfig();
+        config.put("username", "   ");
+        config.put("password", "secret");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(
+                ex.getMessage().contains("username") || 
ex.getMessage().contains("blank"));
+    }
+
+    @Test
+    void testBasicAuthBlankPasswordFails() {
+        Map<String, Object> config = validSinkConfig();
+        config.put("username", "admin");
+        config.put("password", "   ");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(
+                ex.getMessage().contains("password") || 
ex.getMessage().contains("blank"));
+    }
+
+    @Test
+    void testApiKeyAuthMissingKeysFails() {
+        Map<String, Object> config = validSinkConfig();
+        config.put("auth_type", "API_KEY");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(
+                ex.getMessage().contains("api_key_id") || 
ex.getMessage().contains("api_key"));
+    }
+
+    @Test
+    void testApiKeyAuthBlankValueFails() {
+        Map<String, Object> config = validSinkConfig();
+        config.put("auth_type", "API_KEY");
+        config.put("auth.api_key_id", "valid_id");
+        config.put("auth.api_key", "  ");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(ex.getMessage().contains("api_key"));
+    }
+
+    @Test
+    void testApiKeyEncodedAuthBlankFails() {
+        Map<String, Object> config = validSinkConfig();
+        config.put("auth_type", "API_KEY_ENCODED");
+        config.put("auth.api_key_encoded", "  ");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(ex.getMessage().contains("api_key_encoded"));
+    }
+
+    @Test
+    void testApiKeyEncodedInvalidFormatFails() {
+        Map<String, Object> config = validSinkConfig();
+        config.put("auth_type", "API_KEY_ENCODED");
+        // valid Base64 but decoded value lacks the required ':' separator
+        config.put("auth.api_key_encoded", "bm9Db2xvbkhlcmU=");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(ex.getMessage().contains("id:key"));
+    }
+
+    @Test
+    void testApiKeyEncodedValidFormatPasses() {
+        Map<String, Object> config = validSinkConfig();
+        config.put("auth_type", "API_KEY_ENCODED");
+        config.put("auth.api_key_encoded", "aWQ6a2V5");
+        Assertions.assertDoesNotThrow(
+                () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+    }
+
+    @Test
+    void testValidApiKeyAuth() {
+        Map<String, Object> config = validSinkConfig();
+        config.put("auth_type", "API_KEY");
+        config.put("auth.api_key_id", "my_key_id");
+        config.put("auth.api_key", "my_secret");
+        Assertions.assertDoesNotThrow(
+                () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+    }
+
+    @Test
+    void testApiKeyAuthWithResidualUsernameDoesNotFail() {
+        Map<String, Object> config = validSinkConfig();
+        config.put("auth_type", "API_KEY");
+        config.put("auth.api_key_id", "my_key_id");
+        config.put("auth.api_key", "my_secret");
+        config.put("username", "residual_user");
+        Assertions.assertDoesNotThrow(
+                () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+    }
+}
diff --git 
a/seatunnel-connectors-v2/connector-elasticsearch/src/test/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/source/ElasticsearchSourceFactoryTest.java
 
b/seatunnel-connectors-v2/connector-elasticsearch/src/test/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/source/ElasticsearchSourceFactoryTest.java
new file mode 100644
index 0000000000..d1b6d2f4db
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-elasticsearch/src/test/java/org/apache/seatunnel/connectors/seatunnel/elasticsearch/source/ElasticsearchSourceFactoryTest.java
@@ -0,0 +1,342 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.seatunnel.connectors.seatunnel.elasticsearch.source;
+
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.configuration.util.ConfigValidator;
+import org.apache.seatunnel.api.configuration.util.OptionRule;
+import org.apache.seatunnel.api.configuration.util.OptionValidationException;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+
+class ElasticsearchSourceFactoryTest {
+
+    private OptionRule rule;
+
+    @BeforeEach
+    void setUp() {
+        rule = new ElasticsearchSourceFactory().optionRule();
+    }
+
+    @Test
+    void testValidConfigWithIndex() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        config.put("index", "test_index");
+        Assertions.assertDoesNotThrow(
+                () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+    }
+
+    @Test
+    void testValidConfigWithIndexList() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        config.put(
+                "index_list",
+                Arrays.asList(
+                        new HashMap<String, Object>() {
+                            {
+                                put("index", "idx1");
+                            }
+                        }));
+        Assertions.assertDoesNotThrow(
+                () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+    }
+
+    @Test
+    void testMissingHostsFails() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("index", "test_index");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(ex.getMessage().contains("hosts"));
+    }
+
+    @Test
+    void testMissingIndexAndIndexListFails() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(
+                ex.getMessage().contains("index") || 
ex.getMessage().contains("index_list"));
+    }
+
+    @Test
+    void testSqlSearchTypeMissingSqlQueryFails() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        config.put("index", "test_index");
+        config.put("search_type", "SQL");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(ex.getMessage().contains("sql_query"));
+    }
+
+    @Test
+    void testSqlSearchTypeBlankSqlQueryFails() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        config.put("index", "test_index");
+        config.put("search_type", "SQL");
+        config.put("sql_query", "   ");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(ex.getMessage().contains("sql_query"));
+    }
+
+    @Test
+    void testSqlSearchTypeWithValidSqlQuery() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        config.put("index", "test_index");
+        config.put("search_type", "SQL");
+        config.put("sql_query", "SELECT * FROM test_index");
+        Assertions.assertDoesNotThrow(
+                () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+    }
+
+    @Test
+    void testUsernameWithoutPasswordFails() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        config.put("index", "test_index");
+        config.put("username", "admin");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(
+                ex.getMessage().contains("username") || 
ex.getMessage().contains("password"));
+    }
+
+    @Test
+    void testPasswordWithoutUsernameFails() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        config.put("index", "test_index");
+        config.put("password", "secret");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(
+                ex.getMessage().contains("username") || 
ex.getMessage().contains("password"));
+    }
+
+    @Test
+    void testValidBasicAuth() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        config.put("index", "test_index");
+        config.put("username", "admin");
+        config.put("password", "secret");
+        Assertions.assertDoesNotThrow(
+                () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+    }
+
+    @Test
+    void testBasicAuthBlankUsernameFails() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        config.put("index", "test_index");
+        config.put("username", "   ");
+        config.put("password", "secret");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(
+                ex.getMessage().contains("username") || 
ex.getMessage().contains("blank"));
+    }
+
+    @Test
+    void testBasicAuthBlankPasswordFails() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        config.put("index", "test_index");
+        config.put("username", "admin");
+        config.put("password", "   ");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(
+                ex.getMessage().contains("password") || 
ex.getMessage().contains("blank"));
+    }
+
+    @Test
+    void testApiKeyAuthMissingKeysFails() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        config.put("index", "test_index");
+        config.put("auth_type", "API_KEY");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(
+                ex.getMessage().contains("api_key_id") || 
ex.getMessage().contains("api_key"));
+    }
+
+    @Test
+    void testApiKeyAuthBlankKeyIdFails() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        config.put("index", "test_index");
+        config.put("auth_type", "API_KEY");
+        config.put("auth.api_key_id", "   ");
+        config.put("auth.api_key", "my_secret");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(ex.getMessage().contains("api_key_id"));
+    }
+
+    @Test
+    void testApiKeyEncodedAuthMissingFails() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        config.put("index", "test_index");
+        config.put("auth_type", "API_KEY_ENCODED");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(ex.getMessage().contains("api_key_encoded"));
+    }
+
+    @Test
+    void testApiKeyEncodedAuthBlankFails() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        config.put("index", "test_index");
+        config.put("auth_type", "API_KEY_ENCODED");
+        config.put("auth.api_key_encoded", "  ");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(ex.getMessage().contains("api_key_encoded"));
+    }
+
+    @Test
+    void testApiKeyEncodedInvalidBase64Fails() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        config.put("index", "test_index");
+        config.put("auth_type", "API_KEY_ENCODED");
+        config.put("auth.api_key_encoded", "not-valid-base64!@#");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(ex.getMessage().contains("Base64"));
+    }
+
+    @Test
+    void testApiKeyEncodedBase64WithoutColonFails() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        config.put("index", "test_index");
+        config.put("auth_type", "API_KEY_ENCODED");
+        // "noColonHere" Base64-encoded
+        config.put("auth.api_key_encoded", "bm9Db2xvbkhlcmU=");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        Assertions.assertTrue(ex.getMessage().contains("id:key"));
+    }
+
+    @Test
+    void testApiKeyEncodedValidFormatPasses() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        config.put("index", "test_index");
+        config.put("auth_type", "API_KEY_ENCODED");
+        // "id:key" Base64-encoded
+        config.put("auth.api_key_encoded", "aWQ6a2V5");
+        Assertions.assertDoesNotThrow(
+                () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+    }
+
+    @Test
+    void testValidApiKeyAuth() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        config.put("index", "test_index");
+        config.put("auth_type", "API_KEY");
+        config.put("auth.api_key_id", "my_key_id");
+        config.put("auth.api_key", "my_secret");
+        Assertions.assertDoesNotThrow(
+                () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+    }
+
+    /**
+     * Verify the error-aggregation contract: when multiple independent rules 
fail in one config,
+     * all errors are reported together in a single exception message instead 
of fail-fast on the
+     * first one. Triggers (1) username-without-password (auth_type=BASIC), 
(2) search_type=SQL
+     * without sql_query.
+     */
+    @Test
+    void testMultipleValidationErrorsAreAggregated() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        config.put("index", "test_index");
+        config.put("username", "admin");
+        config.put("search_type", "SQL");
+        OptionValidationException ex =
+                Assertions.assertThrows(
+                        OptionValidationException.class,
+                        () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+        String msg = ex.getMessage();
+        Assertions.assertTrue(
+                msg.contains("password") || msg.contains("username"),
+                "expected basic-auth pair error in: " + msg);
+        Assertions.assertTrue(msg.contains("sql_query"), "expected sql_query 
error in: " + msg);
+    }
+
+    @Test
+    void testApiKeyAuthWithResidualUsernameDoesNotFail() {
+        Map<String, Object> config = new HashMap<>();
+        config.put("hosts", Arrays.asList("localhost:9200"));
+        config.put("index", "test_index");
+        config.put("auth_type", "API_KEY");
+        config.put("auth.api_key_id", "my_key_id");
+        config.put("auth.api_key", "my_secret");
+        config.put("username", "residual_user");
+        Assertions.assertDoesNotThrow(
+                () -> 
ConfigValidator.of(ReadonlyConfig.fromMap(config)).validate(rule));
+    }
+}

Reply via email to