Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,24 @@

import javax.sql.DataSource;

/**
* Creates the optional PostgreSQL replica JDBC infrastructure from deployment-owned configuration.
*
* <p>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.</p>
*/
@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(
Expand All @@ -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");
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,47 +2,78 @@

import org.springframework.lang.Nullable;

/**
* Validates bounded replica database configuration without republishing rejected values.
*
* <p>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.</p>
*/
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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -82,20 +83,36 @@ void failsFastWhenRequiredReplicaEnvIsMissing() {
}

@Test
void failsWithHelpfulMessageWhenInitializationFailTimeoutIsNotANumber() {
void invalidInitializationFailTimeoutDoesNotRepublishRejectedValueOrParserDiagnostics() {
String sensitiveFragment = "password=timeout-secret-9431";

contextRunner
.withPropertyValues(
"xtrmetl.replica.enabled=true",
"REPLICA_PGHOST=replica-host",
"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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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"));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Loading