diff --git a/cdc-service/src/main/java/com/xtrmetl/cdc/config/ReplicaJdbcTemplateConfig.java b/cdc-service/src/main/java/com/xtrmetl/cdc/config/ReplicaJdbcTemplateConfig.java index c8b73816..3f6e8e01 100644 --- a/cdc-service/src/main/java/com/xtrmetl/cdc/config/ReplicaJdbcTemplateConfig.java +++ b/cdc-service/src/main/java/com/xtrmetl/cdc/config/ReplicaJdbcTemplateConfig.java @@ -12,10 +12,24 @@ import javax.sql.DataSource; +/** + * Creates the optional PostgreSQL replica JDBC infrastructure from deployment-owned configuration. + * + *

The configuration validates bounded connection coordinates before constructing the data source + * and reports invalid configuration by stable key rather than republishing rejected raw values or + * parser diagnostics into startup logs and support surfaces.

+ */ @Configuration @ConditionalOnProperty(prefix = "xtrmetl.replica", name = "enabled", havingValue = "true") public class ReplicaJdbcTemplateConfig { + /** + * Creates the Hikari data source used by CDC replica application. + * + * @param environment Spring environment containing the deployment-owned replica settings + * @return configured replica data source + * @throws IllegalStateException when a required replica setting is absent or invalid + */ @Bean(name = "replicaDataSource", destroyMethod = "close") public HikariDataSource replicaDataSource(Environment environment) { String host = ValidationUtils.requireValidHost( @@ -42,11 +56,8 @@ public HikariDataSource replicaDataSource(Environment environment) { long initializationFailTimeout; try { initializationFailTimeout = Long.parseLong(initializationFailTimeoutValue); - } catch (NumberFormatException e) { - throw new IllegalStateException( - "Invalid value for REPLICA_HIKARI_INITIALIZATION_FAIL_TIMEOUT_MS: " + initializationFailTimeoutValue, - e - ); + } catch (NumberFormatException exception) { + throw new IllegalStateException("Invalid value for REPLICA_HIKARI_INITIALIZATION_FAIL_TIMEOUT_MS"); } config.setInitializationFailTimeout(initializationFailTimeout); config.setDriverClassName("org.postgresql.Driver"); @@ -57,6 +68,12 @@ public HikariDataSource replicaDataSource(Environment environment) { return new HikariDataSource(config); } + /** + * Creates the JDBC template that applies validated CDC records to the configured replica. + * + * @param dataSource validated replica data source + * @return JDBC template bound to the replica data source + */ @Bean(name = "replicaJdbcTemplate") public JdbcTemplate replicaJdbcTemplate(@Qualifier("replicaDataSource") DataSource dataSource) { return new JdbcTemplate(dataSource); diff --git a/cdc-service/src/main/java/com/xtrmetl/cdc/util/ValidationUtils.java b/cdc-service/src/main/java/com/xtrmetl/cdc/util/ValidationUtils.java index 5fe24347..a869bbdf 100644 --- a/cdc-service/src/main/java/com/xtrmetl/cdc/util/ValidationUtils.java +++ b/cdc-service/src/main/java/com/xtrmetl/cdc/util/ValidationUtils.java @@ -2,47 +2,78 @@ import org.springframework.lang.Nullable; +/** + * Validates bounded replica database configuration without republishing rejected values. + * + *

Successful values are returned in their trimmed canonical form. Failure messages identify + * the configuration key and validation class only, because rejected deployment values can contain + * connection coordinates, control characters, or other secret-adjacent diagnostic material.

+ */ public final class ValidationUtils { private ValidationUtils() {} + /** + * Validates a decimal TCP port in the inclusive range 1 through 65535. + * + * @param value configured port text, possibly surrounded by whitespace + * @param key stable configuration key used in failure diagnostics + * @return the trimmed decimal port + * @throws IllegalStateException when the value is missing, non-decimal, or outside the port range + */ public static String requireValidPort(@Nullable String value, String key) { if (value == null || value.isBlank()) { throw new IllegalStateException("Missing required port for " + key); } String trimmed = value.trim(); if (!trimmed.matches("^[0-9]+$")) { - throw new IllegalStateException("Invalid port for " + key + ": " + value); + throw new IllegalStateException("Invalid port for " + key); } try { int port = Integer.parseInt(trimmed); if (port < 1 || port > 65535) { - throw new IllegalStateException("Invalid port for " + key + ": " + value); + throw new IllegalStateException("Invalid port for " + key); } return trimmed; - } catch (NumberFormatException e) { - throw new IllegalStateException("Invalid port for " + key + ": " + value, e); + } catch (NumberFormatException exception) { + throw new IllegalStateException("Invalid port for " + key); } } + /** + * Validates a hostname or numeric host token accepted by the replica JDBC configuration. + * + * @param value configured host text, possibly surrounded by whitespace + * @param key stable configuration key used in failure diagnostics + * @return the trimmed host token + * @throws IllegalStateException when the value is missing or contains unsupported characters + */ public static String requireValidHost(@Nullable String value, String key) { if (value == null || value.isBlank()) { throw new IllegalStateException("Missing required host for " + key); } String trimmed = value.trim(); if (!trimmed.matches("^[a-zA-Z0-9._-]+$")) { - throw new IllegalStateException("Invalid host for " + key + ": " + value); + throw new IllegalStateException("Invalid host for " + key); } return trimmed; } + /** + * Validates a simple configuration identifier such as a replica database name. + * + * @param value configured identifier text, possibly surrounded by whitespace + * @param key stable configuration key used in failure diagnostics + * @return the trimmed identifier + * @throws IllegalStateException when the value is missing or contains unsupported characters + */ public static String requireValidIdentifier(@Nullable String value, String key) { if (value == null || value.isBlank()) { throw new IllegalStateException("Missing required value for " + key); } String trimmed = value.trim(); if (!trimmed.matches("^[a-zA-Z0-9_-]+$")) { - throw new IllegalStateException("Invalid value for " + key + ": " + value); + throw new IllegalStateException("Invalid value for " + key); } return trimmed; } diff --git a/cdc-service/src/test/java/com/xtrmetl/cdc/config/ReplicaJdbcTemplateConfigTest.java b/cdc-service/src/test/java/com/xtrmetl/cdc/config/ReplicaJdbcTemplateConfigTest.java index 20eff496..97ac3463 100644 --- a/cdc-service/src/test/java/com/xtrmetl/cdc/config/ReplicaJdbcTemplateConfigTest.java +++ b/cdc-service/src/test/java/com/xtrmetl/cdc/config/ReplicaJdbcTemplateConfigTest.java @@ -9,6 +9,7 @@ import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -82,7 +83,9 @@ void failsFastWhenRequiredReplicaEnvIsMissing() { } @Test - void failsWithHelpfulMessageWhenInitializationFailTimeoutIsNotANumber() { + void invalidInitializationFailTimeoutDoesNotRepublishRejectedValueOrParserDiagnostics() { + String sensitiveFragment = "password=timeout-secret-9431"; + contextRunner .withPropertyValues( "xtrmetl.replica.enabled=true", @@ -90,12 +93,26 @@ void failsWithHelpfulMessageWhenInitializationFailTimeoutIsNotANumber() { "REPLICA_PGDATABASE=xtrmetl", "REPLICA_PGUSER=xtrmetl_user", "REPLICA_PGPASSWORD=xtrmetl_password", - "REPLICA_HIKARI_INITIALIZATION_FAIL_TIMEOUT_MS=not-a-number" + "REPLICA_HIKARI_INITIALIZATION_FAIL_TIMEOUT_MS=not-a-number?" + sensitiveFragment ) .run(context -> { Throwable failure = context.getStartupFailure(); assertNotNull(failure); - assertTrue(failure.getMessage().contains("REPLICA_HIKARI_INITIALIZATION_FAIL_TIMEOUT_MS")); + + String diagnosticChain = diagnosticChain(failure); + assertTrue(diagnosticChain.contains("REPLICA_HIKARI_INITIALIZATION_FAIL_TIMEOUT_MS")); + assertFalse(diagnosticChain.contains(sensitiveFragment)); + assertFalse(diagnosticChain.contains("NumberFormatException")); }); } + + private static String diagnosticChain(Throwable failure) { + StringBuilder diagnostics = new StringBuilder(); + Throwable current = failure; + while (current != null) { + diagnostics.append(current.getClass().getName()).append(':').append(current.getMessage()).append('\n'); + current = current.getCause(); + } + return diagnostics.toString(); + } } diff --git a/cdc-service/src/test/java/com/xtrmetl/cdc/util/ValidationUtilsTest.java b/cdc-service/src/test/java/com/xtrmetl/cdc/util/ValidationUtilsTest.java index 83681101..c7280a2a 100644 --- a/cdc-service/src/test/java/com/xtrmetl/cdc/util/ValidationUtilsTest.java +++ b/cdc-service/src/test/java/com/xtrmetl/cdc/util/ValidationUtilsTest.java @@ -3,7 +3,10 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; class ValidationUtilsTest { @@ -54,4 +57,50 @@ void requireValidIdentifierThrowsWhenMissingOrInvalid() { assertThrows(IllegalStateException.class, () -> ValidationUtils.requireValidIdentifier("xtrmetl?evil", "REPLICA_PGDATABASE")); assertThrows(IllegalStateException.class, () -> ValidationUtils.requireValidIdentifier("xtrmetl/db", "REPLICA_PGDATABASE")); } + + @Test + void invalidConfigurationDiagnosticsDoNotRepublishRejectedValues() { + String sensitiveFragment = "password=secret-8472"; + + IllegalStateException hostFailure = assertThrows( + IllegalStateException.class, + () -> ValidationUtils.requireValidHost( + "replica-host?" + sensitiveFragment + "\r\nforged-log-line", + "REPLICA_PGHOST" + ) + ); + IllegalStateException portFailure = assertThrows( + IllegalStateException.class, + () -> ValidationUtils.requireValidPort("5432?" + sensitiveFragment, "REPLICA_PGPORT") + ); + IllegalStateException oversizedPortFailure = assertThrows( + IllegalStateException.class, + () -> ValidationUtils.requireValidPort("999999999999999999999999999999999999", "REPLICA_PGPORT") + ); + IllegalStateException identifierFailure = assertThrows( + IllegalStateException.class, + () -> ValidationUtils.requireValidIdentifier( + "customer_db?" + sensitiveFragment, + "REPLICA_PGDATABASE" + ) + ); + + assertSafeDiagnostic(hostFailure, "REPLICA_PGHOST", sensitiveFragment); + assertSafeDiagnostic(portFailure, "REPLICA_PGPORT", sensitiveFragment); + assertSafeDiagnostic(oversizedPortFailure, "REPLICA_PGPORT", sensitiveFragment); + assertNull(oversizedPortFailure.getCause()); + assertSafeDiagnostic(identifierFailure, "REPLICA_PGDATABASE", sensitiveFragment); + } + + private static void assertSafeDiagnostic( + IllegalStateException failure, + String expectedKey, + String sensitiveFragment + ) { + assertTrue(failure.getMessage().contains(expectedKey)); + assertFalse(failure.getMessage().contains(sensitiveFragment)); + assertFalse(failure.getMessage().contains("forged-log-line")); + assertFalse(failure.getMessage().contains("\r")); + assertFalse(failure.getMessage().contains("\n")); + } }