merge: integrate JPA production capability

# Conflicts:
#	.github/ci-gate-matrix.yml
#	.github/scripts/verify-gate-matrix.sh
#	.github/workflows/ci-quality-gates.yml
#	src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringTransactionPort.java
This commit is contained in:
donghyeon-ka
2026-07-31 23:57:29 +09:00
142 changed files with 13206 additions and 325 deletions
+14 -5
View File
@@ -226,11 +226,11 @@ refresh 완료 전에 실패시킨다.
흔한 connection-timeout 5s/5000ms 와 같아지는 충돌을 해소). `keepalive-time < max-lifetime`(둘 다
있을 때; keepalive 가 lifetime 보다 길면 의미 없음). `leak-detection-threshold`는 0(비활성)이
아니라면 `>= 2000ms`(너무 작으면 정상 사용을 누수로 오탐).
- **모든 노브를 `String`으로 읽어 직접 파싱하는 방어적 처리.** `env-keys.yaml``connection-timeout`
기본값은 Duration 문자열 `5s`인데 `src/.env``30000`(ms)을 준다. `Environment#getProperty(...,
Long.class)``"5s"`에 호출하면 `ConversionFailedException`이 난다. 그래서 각 값을 `String`으로
읽어 `parseMillis`로 넘기고, `null`/blank 또는 plain-integer 가 아닌 값은 `null`(= 부재로 간주,
조용히 skip)로 처리한다. 덕분에 검증기가 형식 drift 값에 절대 죽지 않는다.
- **Spring Boot와 같은 Duration 문법을 검증한다.** `env-keys.yaml``connection-timeout`
기본값은 `5s`인데 `src/.env``30000`(ms)을 준다. resolved 값을 `String`으로 읽은 뒤
`DurationStyle`로 plain milliseconds, simple duration(`5s`)과 ISO-8601(`PT5S`)을 같은
milliseconds 계약으로 변환한다. present-but-invalid 값은 부재로 조용히 취급하지 않고 property
이름을 포함한 startup validation failure로 거절한다.
- **env 키가 아직 없는 노브는 "env key pending" 문구를 쓴다.** `connection-timeout` /
`max-lifetime``env-keys.yaml`에 등록돼 있고, greenfield 노브(validation-timeout, keepalive-time,
leak-detection-threshold)는 env 키가 없다. 없는 키 이름을 지어내는 대신 pending 문구를 메시지에 넣는다.
@@ -246,6 +246,15 @@ refresh 완료 전에 실패시킨다.
으로 한 번만 검사하고, 값이 *없으면* Spring Boot 기본(이 스켈레톤은 `application.yml`에서 OSIV off
가 기본)에 맡기며 *있는 `true`*만 거부한다.
### JpaSchemaSafetyValidator
- **Flyway를 production physical schema의 유일한 writer로 유지한다.** `prod` profile에서는
`spring.jpa.hibernate.ddl-auto``none` 또는 `validate`일 때만 허용한다. `update`, `create`,
`create-drop` 또는 그 밖의 값이면 `APP_DATASOURCE_DDL_AUTO`를 이름으로 포함한
`PROFILE_MISMATCH`로 부팅을 중단한다.
- **local 개발 편의와 production 권위를 분리한다.** non-prod profile의 `update`/`create`는 이
validator가 막지 않는다. prod profile 비교와 mode 비교는 대소문자를 무시해 `PROD`/`UPDATE`
같은 변형도 guard를 우회하지 못한다.
### RuntimeNumericBoundsValidator
- **고위험 숫자 노브(pool/thread 사이징)만 일부러 좁게 검증한다.** pool/connector 사이징 키는
Spring-native property(`spring.datasource.hikari.*`, `server.tomcat.*`)로 직결되고 `env-keys.yaml`
@@ -1,9 +1,11 @@
package dev.caskeleton.bootstrap.runtime;
import dev.caskeleton.bootstrap.runtime.startup.StartupFailures;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.boot.convert.DurationStyle;
import org.springframework.core.env.Environment;
/**
@@ -35,11 +37,15 @@ public class HikariPoolConstraintValidator implements SmartInitializingSingleton
public void afterSingletonsInstantiated() {
List<String> violations = new ArrayList<>();
Long connectionTimeout = parseMillis(environment.getProperty(CONNECTION_TIMEOUT_KEY));
Long validationTimeout = parseMillis(environment.getProperty(VALIDATION_TIMEOUT_KEY));
Long keepaliveTime = parseMillis(environment.getProperty(KEEPALIVE_TIME_KEY));
Long maxLifetime = parseMillis(environment.getProperty(MAX_LIFETIME_KEY));
Long leakDetection = parseMillis(environment.getProperty(LEAK_DETECTION_KEY));
Long connectionTimeout =
parseMillis(CONNECTION_TIMEOUT_KEY, environment.getProperty(CONNECTION_TIMEOUT_KEY));
Long validationTimeout =
parseMillis(VALIDATION_TIMEOUT_KEY, environment.getProperty(VALIDATION_TIMEOUT_KEY));
Long keepaliveTime =
parseMillis(KEEPALIVE_TIME_KEY, environment.getProperty(KEEPALIVE_TIME_KEY));
Long maxLifetime = parseMillis(MAX_LIFETIME_KEY, environment.getProperty(MAX_LIFETIME_KEY));
Long leakDetection =
parseMillis(LEAK_DETECTION_KEY, environment.getProperty(LEAK_DETECTION_KEY));
if (connectionTimeout != null && connectionTimeout < 250L) {
violations.add(
@@ -105,20 +111,23 @@ public class HikariPoolConstraintValidator implements SmartInitializingSingleton
}
/**
* Parses a raw property string as a plain long (milliseconds). A non-plain-integer value (e.g. a
* Duration string such as {@code "5s"}) yields {@code null}, which the caller treats as absent.
* See README for the design rationale.
* Parses the same plain-millisecond, simple Duration ({@code 5s}) and ISO-8601 ({@code PT5S})
* syntax that Spring Boot accepts for Duration-bound properties. A present invalid value is a
* startup error, never an absent-property fallback.
*
* @return the parsed milliseconds, or {@code null} when absent/non-numeric
* @return the parsed milliseconds, or {@code null} when absent
*/
private static Long parseMillis(String raw) {
private static Long parseMillis(String propertyKey, String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
try {
return Long.parseLong(raw.trim());
} catch (NumberFormatException e) {
return null; // non-numeric (e.g. Duration string) — treat as absent
return DurationStyle.detectAndParse(raw.trim(), ChronoUnit.MILLIS).toMillis();
} catch (IllegalArgumentException | ArithmeticException e) {
throw StartupFailures.envValidation(
propertyKey
+ " must be a valid duration (plain milliseconds, simple duration such as 5s, "
+ "or ISO-8601 such as PT5S)");
}
}
}
@@ -0,0 +1,59 @@
package dev.caskeleton.bootstrap.runtime;
import dev.caskeleton.bootstrap.runtime.startup.StartupFailures;
import java.util.Locale;
import java.util.Set;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.core.env.Environment;
/**
* Prevents Hibernate from becoming a production schema writer. Flyway owns the physical schema;
* production may only disable Hibernate DDL or validate the schema.
*/
public class JpaSchemaSafetyValidator implements SmartInitializingSingleton {
static final String DDL_AUTO_KEY = "spring.jpa.hibernate.ddl-auto";
static final String DDL_AUTO_ENV_KEY = "APP_DATASOURCE_DDL_AUTO";
private static final String PROD_PROFILE = "prod";
private static final Set<String> PROD_ALLOWED_MODES = Set.of("none", "validate");
private final Environment environment;
public JpaSchemaSafetyValidator(Environment environment) {
this.environment = environment;
}
@Override
public void afterSingletonsInstantiated() {
if (!isProdActive()) {
return;
}
String rawMode = environment.getProperty(DDL_AUTO_KEY);
if (rawMode == null) {
return;
}
String mode = rawMode.trim().toLowerCase(Locale.ROOT);
if (!PROD_ALLOWED_MODES.contains(mode)) {
throw StartupFailures.profileMismatch(
"prod profile requires "
+ DDL_AUTO_ENV_KEY
+ " ("
+ DDL_AUTO_KEY
+ ") to be none or validate"
+ ", but was "
+ (mode.isEmpty() ? "<blank>" : mode)
+ "; Flyway is the production schema writer");
}
}
private boolean isProdActive() {
for (String profile : environment.getActiveProfiles()) {
if (PROD_PROFILE.equalsIgnoreCase(profile)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,94 @@
package dev.caskeleton.bootstrap.runtime;
import dev.caskeleton.bootstrap.runtime.startup.StartupFailures;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.core.env.Environment;
/**
* Fails production startup unless pgJDBC performs trust-chain and hostname verification.
*
* <p>The validator never includes a JDBC URL in its failure because URLs can carry credentials,
* endpoints, and database names.
*/
public final class PostgreSqlTransportSecurityValidator implements SmartInitializingSingleton {
static final String JDBC_URL_KEY = "spring.datasource.url";
static final String JDBC_URL_ENV_KEY = "APP_DATASOURCE_URL";
static final String HIKARI_SSLMODE_KEY =
"spring.datasource.hikari.data-source-properties.sslmode";
private static final String PROD_PROFILE = "prod";
private static final String POSTGRESQL_PREFIX = "jdbc:postgresql:";
private static final String VERIFY_FULL = "verify-full";
private static final Pattern URL_SSLMODE = Pattern.compile("(?i)(?:[?&])sslmode=([^&]*)");
private final Environment environment;
public PostgreSqlTransportSecurityValidator(Environment environment) {
this.environment = environment;
}
@Override
public void afterSingletonsInstantiated() {
if (!isProdActive()) {
return;
}
String jdbcUrl = environment.getProperty(JDBC_URL_KEY);
if (jdbcUrl == null || !jdbcUrl.trim().toLowerCase(Locale.ROOT).startsWith(POSTGRESQL_PREFIX)) {
return;
}
List<String> urlModes = urlSslModes(jdbcUrl);
String propertyMode = normalized(environment.getProperty(HIKARI_SSLMODE_KEY));
if (urlModes.size() > 1) {
reject("prod PostgreSQL transport has ambiguous duplicate sslmode declarations");
}
String urlMode = urlModes.isEmpty() ? null : urlModes.getFirst();
if (urlMode != null && propertyMode != null && !urlMode.equals(propertyMode)) {
reject("prod PostgreSQL transport has conflicting sslmode declarations");
}
String effectiveMode = propertyMode != null ? propertyMode : urlMode;
if (!VERIFY_FULL.equals(effectiveMode)) {
reject(
"prod PostgreSQL transport requires pgJDBC sslmode=verify-full through "
+ JDBC_URL_ENV_KEY
+ " ("
+ JDBC_URL_KEY
+ ") or "
+ HIKARI_SSLMODE_KEY);
}
}
private static List<String> urlSslModes(String jdbcUrl) {
Matcher matcher = URL_SSLMODE.matcher(jdbcUrl);
List<String> modes = new ArrayList<>();
while (matcher.find()) {
modes.add(normalized(matcher.group(1)));
}
return modes;
}
private static String normalized(String value) {
return value == null ? null : value.trim().toLowerCase(Locale.ROOT);
}
private static void reject(String message) {
throw StartupFailures.profileMismatch(message);
}
private boolean isProdActive() {
for (String profile : environment.getActiveProfiles()) {
if (PROD_PROFILE.equalsIgnoreCase(profile)) {
return true;
}
}
return false;
}
}
@@ -29,6 +29,17 @@ public class RuntimeSafetyConfig {
return new OpenInViewSafetyValidator(environment);
}
@Bean
JpaSchemaSafetyValidator jpaSchemaSafetyValidator(Environment environment) {
return new JpaSchemaSafetyValidator(environment);
}
@Bean
PostgreSqlTransportSecurityValidator postgreSqlTransportSecurityValidator(
Environment environment) {
return new PostgreSqlTransportSecurityValidator(environment);
}
@Bean
HikariPoolConstraintValidator hikariPoolConstraintValidator(Environment environment) {
return new HikariPoolConstraintValidator(environment);
@@ -45,11 +45,8 @@ spring:
# D2 (HIKARI-CFG-C1): fail-fast pin — reject pool-starved threads quickly rather than
# holding them for 30 s (HikariCP default). Must be >= 250 ms (enforced at startup by
# HikariPoolConstraintValidator). Typical synchronous HTTP path value: a few seconds.
# CONNECTION_TIMEOUT_FORMAT_DRIFT: env-keys.yaml default is "5s" (Duration string) while
# src/.env carries 30000 (ms). HikariPoolConstraintValidator reads this defensively as a
# String to avoid ConversionFailedException on the drift value. Alignment is delegated to
# feature-env-driven-runtime-configuration (APP_DATASOURCE_CONNECTION_TIMEOUT).
# milliseconds (or Spring Duration string when env-keys default overrides)
# env-keys.yaml default "5s", plain milliseconds and ISO-8601 values are parsed by
# HikariPoolConstraintValidator with Spring Boot DurationStyle; invalid values fail startup.
connection-timeout: ${APP_DATASOURCE_CONNECTION_TIMEOUT}
# milliseconds
idle-timeout: ${APP_DATASOURCE_POOL_IDLE_TIMEOUT}
@@ -145,6 +142,7 @@ spring:
jpa:
hibernate:
# none | validate | update | create | create-drop
# prod accepts only none|validate; JpaSchemaSafetyValidator rejects schema-writing modes.
ddl-auto: ${APP_DATASOURCE_DDL_AUTO}
# true | false
show-sql: ${APP_DATASOURCE_SHOW_SQL}
@@ -444,6 +442,23 @@ ca-skeleton:
lock:
wait-time: 3s
lease-ttl: 30s
# JPA named-policy deadline envelope. JpaTransactionSettings validates the hierarchy;
# SpringPolicyTransactionPort intersects these limits with the caller's absolute CallBudget
# and the actual Hikari connection timeout before acquiring a transaction.
jpa:
transaction:
transaction-timeout: 30s
begin-budget: 250ms
minimum-action-window: 1s
completion-margin: 500ms
statement-timeout: 10s
lock-timeout: 2s
idle-guard-timeout: 15s
transaction-margin: 250ms
lock-margin: 100ms
retry-base-delay: 10ms
retry-maximum-delay: 50ms
retry-maximum-attempts: 2
presentation:
# feature-api-contract-baseline D2: API version prefix. Default is the URI
# prefix "/v1" (major-version path, AIP-185); override via env, or set "" for
@@ -14,7 +14,12 @@ import org.junit.jupiter.api.Test;
class RedisCiAggregatorContractTest {
private static final Set<String> BLOCKING_JOBS =
Set.of("quality-gates", "sample-off", "gate-matrix-lint", "redis-standalone");
Set.of(
"quality-gates",
"sample-off",
"gate-matrix-lint",
"redis-standalone",
"jpa-candidate-evidence");
@Test
void releaseAggregatorNeedsAndChecksEveryBlockingJob() throws IOException {
@@ -28,7 +33,9 @@ class RedisCiAggregatorContractTest {
.contains("SAMPLE_OFF_RESULT: ${{ needs.sample-off.result }}")
.contains("MATRIX_RESULT: ${{ needs.gate-matrix-lint.result }}")
.contains("REDIS_RESULT: ${{ needs.redis-standalone.result }}")
.contains("\"${REDIS_RESULT}\"");
.contains("JPA_CANDIDATE_RESULT: ${{ needs.jpa-candidate-evidence.result }}")
.contains("\"${REDIS_RESULT}\"")
.contains("\"${JPA_CANDIDATE_RESULT}\"");
}
@Test
@@ -92,7 +92,7 @@ class DistributedLockProviderContractTest {
// app-bootstrap test classpath), V3 (outbox), V4 (INT_LOCK).
Flyway.configure()
.dataSource(sharedDataSource)
.locations("classpath:db/migration")
.locations("classpath:db/migration/postgresql")
.load()
.migrate();
}
@@ -90,7 +90,7 @@ class IdempotencyUniqueScopeContractTest {
// classpath via implementation project(':adapter:outbound:persistence-jpa')).
Flyway.configure()
.dataSource(sharedDataSource)
.locations("classpath:db/migration")
.locations("classpath:db/migration/postgresql")
.load()
.migrate();
}
@@ -59,7 +59,11 @@ final class OutboxContainerTestSupport {
* work_log from sample-portfolio on app-bootstrap test classpath, V3 outbox_event) are applied.
*/
static void migrate(DataSource dataSource) {
Flyway.configure().dataSource(dataSource).locations("classpath:db/migration").load().migrate();
Flyway.configure()
.dataSource(dataSource)
.locations("classpath:db/migration/postgresql")
.load()
.migrate();
}
/** Creates a HikariDataSource pointing to the given PostgreSQL container. */
@@ -143,17 +143,61 @@ class HikariPoolConstraintValidatorTest {
runner.run(context -> assertThat(context).hasNotFailed());
}
// --- CONNECTION_TIMEOUT_FORMAT_DRIFT: non-numeric duration string must not crash ---
// --- CONNECTION_TIMEOUT_FORMAT_DRIFT: Boot Duration syntax must be validated, never skipped ---
@Test
void connectionTimeoutAsDurationStringIsDefensivelySkipped() {
// env-keys.yaml default for connection-timeout is "5s" (Duration string).
// The validator must not throw ConversionFailedException — it silently skips.
void connectionTimeoutAsSimpleDurationParticipatesInMinimumValidation() {
runner
.withPropertyValues("spring.datasource.hikari.connection-timeout=5s")
.withPropertyValues("spring.datasource.hikari.connection-timeout=100ms")
.run(
context -> {
assertThat(context).hasFailed();
assertThat(context.getStartupFailure())
.isInstanceOf(StartupValidationException.class)
.hasStackTraceContaining("connection-timeout")
.hasStackTraceContaining(">= 250");
});
}
@Test
void simpleAndIsoDurationStringsStartWhenValid() {
runner
.withPropertyValues(
"spring.datasource.hikari.connection-timeout=5s",
"spring.datasource.hikari.validation-timeout=PT3S")
.run(context -> assertThat(context).hasNotFailed());
}
@Test
void durationStringsParticipateInCrossPropertyValidation() {
runner
.withPropertyValues(
"spring.datasource.hikari.connection-timeout=5s",
"spring.datasource.hikari.validation-timeout=PT5S")
.run(
context -> {
assertThat(context).hasFailed();
assertThat(context.getStartupFailure())
.isInstanceOf(StartupValidationException.class)
.hasStackTraceContaining("validation-timeout")
.hasStackTraceContaining("connection-timeout");
});
}
@Test
void invalidDurationFailsStartupInsteadOfBeingTreatedAsAbsent() {
runner
.withPropertyValues("spring.datasource.hikari.connection-timeout=five-seconds")
.run(
context -> {
assertThat(context).hasFailed();
assertThat(context.getStartupFailure())
.isInstanceOf(StartupValidationException.class)
.hasStackTraceContaining("connection-timeout")
.hasStackTraceContaining("valid duration");
});
}
@Configuration
static class ValidatorConfig {
@Bean
@@ -0,0 +1,91 @@
package dev.caskeleton.bootstrap.runtime;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.bootstrap.runtime.startup.ProfileMismatchException;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
class JpaSchemaSafetyValidatorTest {
private final ApplicationContextRunner runner =
new ApplicationContextRunner().withUserConfiguration(ValidatorConfig.class);
@ParameterizedTest
@ValueSource(strings = {"update", "create", "create-drop"})
void prodRejectsHibernateSchemaMutationModes(String ddlAuto) {
runner
.withInitializer(context -> context.getEnvironment().setActiveProfiles("prod"))
.withPropertyValues("spring.jpa.hibernate.ddl-auto=" + ddlAuto)
.run(
context -> {
assertThat(context).hasFailed();
assertThat(context.getStartupFailure())
.isInstanceOf(ProfileMismatchException.class)
.hasStackTraceContaining("APP_DATASOURCE_DDL_AUTO")
.hasStackTraceContaining("none")
.hasStackTraceContaining("validate");
});
}
@ParameterizedTest
@ValueSource(strings = {"none", "validate"})
void prodAllowsNonMutatingSchemaModes(String ddlAuto) {
runner
.withInitializer(context -> context.getEnvironment().setActiveProfiles("prod"))
.withPropertyValues("spring.jpa.hibernate.ddl-auto=" + ddlAuto)
.run(context -> assertThat(context).hasNotFailed());
}
@ParameterizedTest
@ValueSource(strings = {" ", "\t"})
void prodRejectsPresentButBlankDdlMode(String ddlAuto) {
runner
.withInitializer(context -> context.getEnvironment().setActiveProfiles("prod"))
.withPropertyValues("spring.jpa.hibernate.ddl-auto=" + ddlAuto)
.run(
context -> {
assertThat(context).hasFailed();
assertThat(context.getStartupFailure())
.isInstanceOf(ProfileMismatchException.class)
.hasStackTraceContaining("APP_DATASOURCE_DDL_AUTO");
});
}
@ParameterizedTest
@ValueSource(strings = {"update", "create"})
void nonProdMayUseLocalSchemaConvenienceModes(String ddlAuto) {
runner
.withInitializer(context -> context.getEnvironment().setActiveProfiles("local"))
.withPropertyValues("spring.jpa.hibernate.ddl-auto=" + ddlAuto)
.run(context -> assertThat(context).hasNotFailed());
}
@ParameterizedTest
@ValueSource(strings = {"PROD", "Prod"})
void profileAndDdlModeComparisonIsCaseInsensitive(String profile) {
runner
.withInitializer(context -> context.getEnvironment().setActiveProfiles(profile))
.withPropertyValues("spring.jpa.hibernate.ddl-auto=UPDATE")
.run(
context -> {
assertThat(context).hasFailed();
assertThat(context.getStartupFailure())
.isInstanceOf(ProfileMismatchException.class)
.hasStackTraceContaining("APP_DATASOURCE_DDL_AUTO");
});
}
@Configuration
static class ValidatorConfig {
@Bean
JpaSchemaSafetyValidator jpaSchemaSafetyValidator(Environment environment) {
return new JpaSchemaSafetyValidator(environment);
}
}
}
@@ -0,0 +1,103 @@
package dev.caskeleton.bootstrap.runtime;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.bootstrap.runtime.startup.ProfileMismatchException;
import java.io.PrintWriter;
import java.io.StringWriter;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
class PostgreSqlTransportSecurityValidatorTest {
private final ApplicationContextRunner runner =
new ApplicationContextRunner().withUserConfiguration(ValidatorConfig.class);
@ParameterizedTest
@ValueSource(
strings = {
"jdbc:postgresql://db.internal:5432/app",
"jdbc:postgresql://db.internal:5432/app?sslmode=disable",
"jdbc:postgresql://db.internal:5432/app?sslmode=require",
"jdbc:postgresql://db.internal:5432/app?sslmode=verify-ca"
})
void prodRejectsPostgreSqlUrlsWithoutVerifyFull(String jdbcUrl) {
prod(jdbcUrl)
.run(
context -> {
assertThat(context).hasFailed();
assertThat(context.getStartupFailure())
.isInstanceOf(ProfileMismatchException.class)
.hasStackTraceContaining("APP_DATASOURCE_URL")
.hasStackTraceContaining("verify-full");
assertThat(stackTrace(context.getStartupFailure())).doesNotContain(jdbcUrl);
});
}
@Test
void prodAllowsVerifyFullInTheJdbcUrl() {
prod("jdbc:postgresql://db.internal:5432/app?sslmode=verify-full")
.run(context -> assertThat(context).hasNotFailed());
}
@Test
void prodAllowsVerifyFullAsAnExplicitHikariDataSourceProperty() {
prod("jdbc:postgresql://db.internal:5432/app")
.withPropertyValues("spring.datasource.hikari.data-source-properties.sslmode=verify-full")
.run(context -> assertThat(context).hasNotFailed());
}
@Test
void prodRejectsConflictingUrlAndDataSourcePropertyWithoutEchoingTheUrl() {
String jdbcUrl =
"jdbc:postgresql://db.internal:5432/app?sslmode=verify-full&password=do-not-log";
prod(jdbcUrl)
.withPropertyValues("spring.datasource.hikari.data-source-properties.sslmode=disable")
.run(
context -> {
assertThat(context).hasFailed();
assertThat(context.getStartupFailure())
.isInstanceOf(ProfileMismatchException.class)
.hasStackTraceContaining("conflicting");
assertThat(stackTrace(context.getStartupFailure()))
.doesNotContain("do-not-log")
.doesNotContain("db.internal");
});
}
@Test
void nonProdMayUseAPlainLocalPostgreSqlUrl() {
runner
.withInitializer(context -> context.getEnvironment().setActiveProfiles("local"))
.withPropertyValues("spring.datasource.url=jdbc:postgresql://localhost:5432/app")
.run(context -> assertThat(context).hasNotFailed());
}
private ApplicationContextRunner prod(String jdbcUrl) {
return runner
.withInitializer(context -> context.getEnvironment().setActiveProfiles("prod"))
.withPropertyValues("spring.datasource.url=" + jdbcUrl);
}
private static String stackTrace(Throwable failure) {
StringWriter output = new StringWriter();
failure.printStackTrace(new PrintWriter(output));
return output.toString();
}
@Configuration
static class ValidatorConfig {
@Bean
PostgreSqlTransportSecurityValidator postgreSqlTransportSecurityValidator(
Environment environment) {
return new PostgreSqlTransportSecurityValidator(environment);
}
}
}