diff --git a/debezium-platform-conductor/pom.xml b/debezium-platform-conductor/pom.xml
index f402e87a..f463ec82 100644
--- a/debezium-platform-conductor/pom.xml
+++ b/debezium-platform-conductor/pom.xml
@@ -63,6 +63,8 @@
+ * This validator performs validation of Qdrant connection configurations
+ * including network connectivity and server accessibility.
+ *
+ * The validation process includes:
+ *
+ *
+ *
This test class validates the QdrantConnectionValidator functionality against + * a real Qdrant instance running in a Docker container WITH API key authentication + * enabled. It provides comprehensive testing of authenticated connections, security + * validation, and real-world production-like scenarios.
+ * + *Test scenarios covered:
+ *The tests use {@link QdrantTestResourceAuthenticated} which provides a containerized + * Qdrant instance with authentication enabled. The container is configured with a test + * API key that can be accessed via {@code QdrantTestResourceAuthenticated.getApiKey()}.
+ * + *Test Categories:
+ *Prerequisites:
+ *Security Note: This test uses a predefined test API key + * ("secure-test-api-key-123") which is only suitable for testing environments and should + * never be used in production.
+ * + * @author Pranav Tiwari + * @since 1.0 + */ +@QuarkusTest +@QuarkusTestResource(value = QdrantTestResourceAuthenticated.class, restrictToAnnotatedClass = true) +class QdrantConnectionValidatorAuthIT { + + @Inject + QdrantConnectionValidator connectionValidator; + + @Test + @DisplayName("Should authenticate successfully with correct API key") + void shouldAuthenticateWithCorrectApiKey() { + QdrantContainer container = QdrantTestResourceAuthenticated.getContainer(); + + Awaitility.await() + .atMost(300, TimeUnit.SECONDS) + .until(container::isRunning); + + // Use the correct API key + Connection connectionConfig = new TestConnectionView(ConnectionEntity.Type.QDRANT, Map.of( + "hostname", container.getHost(), + "port", container.getMappedPort(6334), + "useTls", false, + "apiKey", QdrantTestResourceAuthenticated.getApiKey())); + + ConnectionValidationResult result = connectionValidator.validate(connectionConfig); + + assertTrue(result.valid(), "Connection should succeed with correct API key"); + assertThat(result.message()).isNotNull(); + } + + @Test + @DisplayName("Should fail authentication with incorrect API key") + void shouldFailWithIncorrectApiKey() { + QdrantContainer container = QdrantTestResourceAuthenticated.getContainer(); + + Awaitility.await() + .atMost(300, TimeUnit.SECONDS) + .until(container::isRunning); + + // Use wrong API key + Connection connectionConfig = new TestConnectionView(ConnectionEntity.Type.QDRANT, Map.of( + "hostname", container.getHost(), + "port", container.getMappedPort(6334), + "useTls", false, + "apiKey", "wrong-api-key")); + + ConnectionValidationResult result = connectionValidator.validate(connectionConfig); + + assertFalse(result.valid(), "Connection should fail with incorrect API key"); + assertThat(result.message()).containsAnyOf( + "Authentication failed", "UNAUTHENTICATED", "API key", "Permission denied"); + } + + @Test + @DisplayName("Should fail authentication without API key") + void shouldFailWithoutApiKeyWhenRequired() { + QdrantContainer container = QdrantTestResourceAuthenticated.getContainer(); + + Awaitility.await() + .atMost(300, TimeUnit.SECONDS) + .until(container::isRunning); + + // Don't provide API key + Connection connectionConfig = new TestConnectionView(ConnectionEntity.Type.QDRANT, Map.of( + "hostname", container.getHost(), + "port", container.getMappedPort(6334), + "useTls", false)); + + ConnectionValidationResult result = connectionValidator.validate(connectionConfig); + + assertFalse(result.valid(), "Connection should fail without API key when auth is required"); + assertThat(result.message()).containsAnyOf( + "Authentication failed", "UNAUTHENTICATED", "API key", "Permission denied"); + } + + @Test + @DisplayName("Should fail authentication with empty API key") + void shouldFailWithEmptyApiKey() { + QdrantContainer container = QdrantTestResourceAuthenticated.getContainer(); + + Awaitility.await() + .atMost(300, TimeUnit.SECONDS) + .until(container::isRunning); + + // Provide empty API key + Connection connectionConfig = new TestConnectionView(ConnectionEntity.Type.QDRANT, Map.of( + "hostname", container.getHost(), + "port", container.getMappedPort(6334), + "useTls", false, + "apiKey", "")); + + ConnectionValidationResult result = connectionValidator.validate(connectionConfig); + + assertFalse(result.valid(), "Connection should fail with empty API key"); + assertThat(result.message()).containsAnyOf( + "Authentication failed", "UNAUTHENTICATED", "API key", "Permission denied"); + } + + @Test + @DisplayName("Should handle network errors with authentication") + void shouldHandleNetworkErrorsWithAuth() { + QdrantContainer container = QdrantTestResourceAuthenticated.getContainer(); + + Awaitility.await() + .atMost(300, TimeUnit.SECONDS) + .until(container::isRunning); + + // Test with wrong port but correct API key + Connection connectionConfig = new TestConnectionView(ConnectionEntity.Type.QDRANT, Map.of( + "hostname", container.getHost(), + "port", 9999, // Wrong port + "useTls", false, + "apiKey", QdrantTestResourceAuthenticated.getApiKey())); + + ConnectionValidationResult result = connectionValidator.validate(connectionConfig); + + assertFalse(result.valid(), "Connection should fail with wrong port"); + // Should be a connection error, not an auth error + assertThat(result.message()).containsAnyOf("timeout", "unavailable", "Failed to connect"); + } + + @Test + @DisplayName("Should handle TLS with authentication") + void shouldHandleTlsWithAuth() { + QdrantContainer container = QdrantTestResourceAuthenticated.getContainer(); + + Awaitility.await() + .atMost(300, TimeUnit.SECONDS) + .until(container::isRunning); + + // Test with TLS enabled and correct API key + Connection connectionConfig = new TestConnectionView(ConnectionEntity.Type.QDRANT, Map.of( + "hostname", container.getHost(), + "port", container.getMappedPort(6334), + "useTls", true, + "apiKey", QdrantTestResourceAuthenticated.getApiKey())); + + ConnectionValidationResult result = connectionValidator.validate(connectionConfig); + + // This might fail due to TLS configuration, but should handle API key correctly + assertThat(result).isNotNull(); + assertThat(result.message()).isNotNull(); + // If it fails, it should be due to TLS, not authentication + if (!result.valid()) { + assertThat(result.message()).doesNotContainIgnoringCase("authentication"); + } + } + + // ========== PARAMETER VALIDATION TESTS WITH AUTH ========== + // These tests verify that parameter validation works even with auth enabled + + @Test + @DisplayName("Should fail validation when hostname is missing (with auth)") + void shouldFailValidationWithoutHostnameWithAuth() { + Connection connectionConfig = new TestConnectionView(ConnectionEntity.Type.QDRANT, Map.of( + "port", 6334, + "useTls", false, + "apiKey", QdrantTestResourceAuthenticated.getApiKey())); + + ConnectionValidationResult result = connectionValidator.validate(connectionConfig); + + assertFalse(result.valid(), "Connection validation should fail"); + assertEquals("Hostname must be specified", result.message()); + } + + @Test + @DisplayName("Should fail validation when port is missing (with auth)") + void shouldFailValidationWithoutPortWithAuth() { + Connection connectionConfig = new TestConnectionView(ConnectionEntity.Type.QDRANT, Map.of( + "hostname", "localhost", + "useTls", false, + "apiKey", QdrantTestResourceAuthenticated.getApiKey())); + + ConnectionValidationResult result = connectionValidator.validate(connectionConfig); + + assertFalse(result.valid(), "Connection validation should fail"); + assertEquals("Port must be specified", result.message()); + } + + @Test + @DisplayName("Should fail validation when connection config is null (with auth)") + void shouldFailValidationWithNullConnectionWithAuth() { + ConnectionValidationResult result = connectionValidator.validate(null); + + assertFalse(result.valid(), "Connection validation should fail"); + assertEquals("Connection configuration cannot be null", result.message()); + } +} diff --git a/debezium-platform-conductor/src/test/java/io/debezium/platform/environment/connection/QdrantConnectionValidatorIT.java b/debezium-platform-conductor/src/test/java/io/debezium/platform/environment/connection/QdrantConnectionValidatorIT.java new file mode 100644 index 00000000..a6f3a10b --- /dev/null +++ b/debezium-platform-conductor/src/test/java/io/debezium/platform/environment/connection/QdrantConnectionValidatorIT.java @@ -0,0 +1,268 @@ +/* + * Copyright Debezium Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ +package io.debezium.platform.environment.connection; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.qdrant.QdrantContainer; +import org.testcontainers.shaded.org.awaitility.Awaitility; + +import io.debezium.platform.data.dto.ConnectionValidationResult; +import io.debezium.platform.data.model.ConnectionEntity; +import io.debezium.platform.domain.views.Connection; +import io.debezium.platform.environment.connection.destination.QdrantConnectionValidator; +import io.debezium.platform.environment.database.db.QdrantTestResource; +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.junit.QuarkusTest; + +/** + * Integration tests for QdrantConnectionValidator using Testcontainers WITHOUT authentication. + * + *This test class validates the QdrantConnectionValidator functionality against + * a real Qdrant instance running in a Docker container WITHOUT authentication. + * It provides comprehensive testing of basic connection validation, network connectivity, + * and parameter handling in a non-authenticated environment.
+ * + *Test scenarios covered:
+ *The tests use {@link QdrantTestResource} which provides a containerized Qdrant + * instance without authentication enabled. This makes it ideal for testing basic + * connection logic, parameter validation, and network error scenarios without the + * complexity of authentication setup.
+ * + *Test Categories:
+ *Prerequisites:
+ *These tests are faster and more reliable than authenticated tests since they don't + * require complex container configuration, making them ideal for continuous integration + * and rapid feedback during development.
+ * + * @author Pranav Tiwari + * @since 1.0 + */ +@QuarkusTest +@QuarkusTestResource(value = QdrantTestResource.class, restrictToAnnotatedClass = true) +class QdrantConnectionValidatorIT { + + @Inject + QdrantConnectionValidator connectionValidator; + + @Test + @DisplayName("Should successfully validate connection with valid Qdrant configuration") + void shouldValidateSuccessfulConnection() { + QdrantContainer container = QdrantTestResource.getContainer(); + + Awaitility.await() + .atMost(300, TimeUnit.SECONDS) + .until(container::isRunning); + + Connection connectionConfig = new TestConnectionView(ConnectionEntity.Type.QDRANT, Map.of( + "hostname", container.getHost(), + "port", container.getMappedPort(6334), // gRPC port + "useTls", false)); + + ConnectionValidationResult result = connectionValidator.validate(connectionConfig); + + assertTrue(result.valid(), "Connection validation should succeed"); + assertThat(result.message()).isNotNull(); + } + + @Test + @DisplayName("Should fail validation with wrong hostname") + void shouldFailValidationWithWrongHostname() { + QdrantContainer container = QdrantTestResource.getContainer(); + + Awaitility.await() + .atMost(300, TimeUnit.SECONDS) + .until(container::isRunning); + + Connection connectionConfig = new TestConnectionView(ConnectionEntity.Type.QDRANT, Map.of( + "hostname", "non-existent-host", + "port", container.getMappedPort(6334), + "useTls", false)); + + ConnectionValidationResult result = connectionValidator.validate(connectionConfig); + + assertFalse(result.valid(), "Connection validation should fail"); + assertThat(result.message()).containsAnyOf("timeout", "unavailable", "Failed to connect"); + } + + @Test + @DisplayName("Should fail validation with wrong port") + void shouldFailValidationWithWrongPort() { + QdrantContainer container = QdrantTestResource.getContainer(); + + Awaitility.await() + .atMost(300, TimeUnit.SECONDS) + .until(container::isRunning); + + Connection connectionConfig = new TestConnectionView(ConnectionEntity.Type.QDRANT, Map.of( + "hostname", container.getHost(), + "port", 9999, // Wrong port + "useTls", false)); + + ConnectionValidationResult result = connectionValidator.validate(connectionConfig); + + assertFalse(result.valid(), "Connection validation should fail"); + assertThat(result.message()).containsAnyOf("timeout", "unavailable", "Failed to connect"); + } + + @Test + @DisplayName("Should handle API key gracefully when server doesn't require authentication") + void shouldHandleApiKeyWithoutServerAuth() { + QdrantContainer container = QdrantTestResource.getContainer(); + + Awaitility.await() + .atMost(300, TimeUnit.SECONDS) + .until(container::isRunning); + + // Test with API key (container doesn't require authentication, so should succeed) + Connection connectionConfig = new TestConnectionView(ConnectionEntity.Type.QDRANT, Map.of( + "hostname", container.getHost(), + "port", container.getMappedPort(6334), + "useTls", false, + "apiKey", "unused-api-key")); + + ConnectionValidationResult result = connectionValidator.validate(connectionConfig); + + // Should succeed since the container doesn't enforce authentication + assertTrue(result.valid(), "Connection validation should succeed with API key when server doesn't require auth"); + } + + @Test + @DisplayName("Should handle TLS configuration") + void shouldHandleTlsConfiguration() { + QdrantContainer container = QdrantTestResource.getContainer(); + + Awaitility.await() + .atMost(300, TimeUnit.SECONDS) + .until(container::isRunning); + + // Test with TLS enabled (should fail since container doesn't use TLS) + Connection connectionConfig = new TestConnectionView(ConnectionEntity.Type.QDRANT, Map.of( + "hostname", container.getHost(), + "port", container.getMappedPort(6334), + "useTls", true)); + + ConnectionValidationResult result = connectionValidator.validate(connectionConfig); + + // This might fail because the container is not configured with TLS + // The important thing is that the validator handles the TLS parameter + assertThat(result).isNotNull(); + assertThat(result.message()).isNotNull(); + } + + // ========== PARAMETER VALIDATION TESTS ========== + + @Test + @DisplayName("Should fail validation when hostname is missing") + void shouldFailValidationWithoutHostname() { + Connection connectionConfig = new TestConnectionView(ConnectionEntity.Type.QDRANT, Map.of( + "port", 6334, + "useTls", false)); + + ConnectionValidationResult result = connectionValidator.validate(connectionConfig); + + assertFalse(result.valid(), "Connection validation should fail"); + assertEquals("Hostname must be specified", result.message()); + } + + @Test + @DisplayName("Should fail validation when port is missing") + void shouldFailValidationWithoutPort() { + Connection connectionConfig = new TestConnectionView(ConnectionEntity.Type.QDRANT, Map.of( + "hostname", "localhost", + "useTls", false)); + + ConnectionValidationResult result = connectionValidator.validate(connectionConfig); + + assertFalse(result.valid(), "Connection validation should fail"); + assertEquals("Port must be specified", result.message()); + } + + @Test + @DisplayName("Should fail validation when hostname is empty") + void shouldFailValidationWithEmptyHostname() { + Connection connectionConfig = new TestConnectionView(ConnectionEntity.Type.QDRANT, Map.of( + "hostname", "", + "port", 6334, + "useTls", false)); + + ConnectionValidationResult result = connectionValidator.validate(connectionConfig); + + assertFalse(result.valid(), "Connection validation should fail"); + assertEquals("Hostname must be specified", result.message()); + } + + @Test + @DisplayName("Should fail validation when port is invalid") + void shouldFailValidationWithInvalidPort() { + Connection connectionConfig = new TestConnectionView(ConnectionEntity.Type.QDRANT, Map.of( + "hostname", "localhost", + "port", -1, + "useTls", false)); + + ConnectionValidationResult result = connectionValidator.validate(connectionConfig); + + assertFalse(result.valid(), "Connection validation should fail"); + assertEquals("Port must be between 1 and 65535", result.message()); + } + + @Test + @DisplayName("Should fail validation when connection config is null") + void shouldFailValidationWithNullConnection() { + ConnectionValidationResult result = connectionValidator.validate(null); + + assertFalse(result.valid(), "Connection validation should fail"); + assertEquals("Connection configuration cannot be null", result.message()); + } + + @Test + @DisplayName("Should handle timeout scenarios gracefully") + void shouldHandleTimeoutScenarios() { + // Use a non-routable IP address to simulate timeout + Connection connectionConfig = new TestConnectionView(ConnectionEntity.Type.QDRANT, Map.of( + "hostname", "10.255.255.1", // Non-routable IP + "port", 6334, + "useTls", false)); + + ConnectionValidationResult result = connectionValidator.validate(connectionConfig); + + assertFalse(result.valid(), "Connection validation should fail"); + assertThat(result.message()).containsAnyOf( + "timeout", "Connection timeout", "Failed to connect", "unavailable"); + } +} \ No newline at end of file diff --git a/debezium-platform-conductor/src/test/java/io/debezium/platform/environment/connection/QdrantConnectionValidatorTest.java b/debezium-platform-conductor/src/test/java/io/debezium/platform/environment/connection/QdrantConnectionValidatorTest.java new file mode 100644 index 00000000..985b6122 --- /dev/null +++ b/debezium-platform-conductor/src/test/java/io/debezium/platform/environment/connection/QdrantConnectionValidatorTest.java @@ -0,0 +1,318 @@ +/* + * Copyright Debezium Authors. + * + * Licensed under the Apache Software License version 2.0, available at http://www.apache.org/licenses/LICENSE-2.0 + */ +package io.debezium.platform.environment.connection; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashMap; +import java.util.Map; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import io.debezium.platform.data.dto.ConnectionValidationResult; +import io.debezium.platform.data.model.ConnectionEntity; +import io.debezium.platform.domain.views.Connection; +import io.debezium.platform.environment.connection.destination.QdrantConnectionValidator; + +/** + * Unit tests for QdrantConnectionValidator. + * + *This test class focuses on parameter validation, error handling scenarios, + * and business logic testing without requiring an actual Qdrant instance. It uses + * mock connections and invalid configurations to test various edge cases and + * validation rules.
+ * + *Test coverage includes:
+ *Test Categories:
+ *These tests are fast-running and don't require Docker or external dependencies, + * making them ideal for continuous integration and rapid feedback during development. + * They use the {@link TestConnectionView} helper class to create mock connection + * configurations for testing various scenarios.
+ * + *Key Testing Techniques:
+ *This class provides a containerized Qdrant instance WITHOUT authentication + * for integration testing. It manages the lifecycle of a Docker container running + * Qdrant server in non-authenticated mode, making it suitable for testing basic + * connection validation scenarios.
+ * + *Key features:
+ *This resource is ideal for testing connection validation logic, parameter + * handling, and network connectivity without the complexity of authentication + * setup. It uses Qdrant v1.7.4 and exposes the standard HTTP port (6333) for + * client connections.
+ * + * @author Pranav Tiwari + * @since 1.0 + */ +public class QdrantTestResource implements QuarkusTestResourceLifecycleManager { + + private static final QdrantContainer QDRANT = new QdrantContainer( + DockerImageName.parse("qdrant/qdrant:v1.7.4")); + + public static QdrantContainer getContainer() { + return QDRANT; + } + + @Override + public MapThis class provides a containerized Qdrant instance WITH API key authentication + * enabled for integration testing. It manages the lifecycle of a Docker container running + * Qdrant server in authenticated mode, making it suitable for testing secure connection + * validation scenarios that mirror production environments.
+ * + *Key features:
+ *The container is configured with environment variables to enable authentication:
+ *The default API key used is "secure-test-api-key-123" which can be accessed + * via {@link #getApiKey()} method. This key is only suitable for testing environments + * and should never be used in production.
+ * + * @author Pranav Tiwari + * @since 1.0 + */ +public class QdrantTestResourceAuthenticated implements QuarkusTestResourceLifecycleManager { + + public static final String API_KEY = "secure-test-api-key-123"; + + private static final QdrantContainer QDRANT = new QdrantContainer( + DockerImageName.parse("qdrant/qdrant:v1.7.4")) + .withEnv("QDRANT__SERVICE__API_KEY", API_KEY) + .withEnv("QDRANT__SERVICE__ENABLE_CORS", "true"); + + public static QdrantContainer getContainer() { + return QDRANT; + } + + public static String getApiKey() { + return API_KEY; + } + + @Override + public Map