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:
@@ -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`이
|
||||
|
||||
+22
-13
@@ -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)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+59
@@ -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;
|
||||
}
|
||||
}
|
||||
+94
@@ -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;
|
||||
}
|
||||
}
|
||||
+11
@@ -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
|
||||
|
||||
+9
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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();
|
||||
}
|
||||
|
||||
+1
-1
@@ -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();
|
||||
}
|
||||
|
||||
+5
-1
@@ -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. */
|
||||
|
||||
+49
-5
@@ -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
|
||||
|
||||
+91
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user