diff --git a/README.md b/README.md
index fd90f65..0a1a8d6 100644
--- a/README.md
+++ b/README.md
@@ -56,6 +56,23 @@ docker compose -f docker-compose.yml -f docker-compose.local.yml down
`src/.env`는 커밋된 안전 기본값이라 별도 `.env.example`을 만들지 않습니다. 전체 환경 변수 목록과 조정 시점은 [src/README.md](src/README.md)와 [docs/registries/env-keys.yaml](docs/registries/env-keys.yaml)에 있습니다.
+### 프로파일별 데이터스토어
+
+`bootstrap`은 컨테이너 경로(PostgreSQL)를 검증하는 첫 실행 진입점입니다. 일상 개발은 Docker 없이 돌리는 `local` 프로파일이며, 이때 데이터스토어는 H2 in-memory입니다.
+
+```bash
+cd src
+./gradlew :app-bootstrap:bootRun
+```
+
+| 프로파일 | 데이터스토어 | 스키마 소유자 |
+| --- | --- | --- |
+| `local` (bootRun 기본) | H2 in-memory | Hibernate `create-drop` |
+| `dev` | PostgreSQL | Flyway |
+| `prod` | PostgreSQL | Flyway |
+
+`local`은 wiring과 애플리케이션 동작을 검증하고, migration과 vendor 동작은 검증하지 않습니다. 프로파일별 설정은 [src/app-bootstrap/src/main/resources/](src/app-bootstrap/src/main/resources/)의 `application-{local,dev,prod}.yml`이, 상세 설명은 [src/README.md](src/README.md)가 소유합니다.
+
## 새 프로젝트로 시작하기
이 저장소를 새 서비스의 출발점으로 쓸 때 핵심 단계는 다음과 같습니다. 전체 체크리스트는 [AGENTS.md](AGENTS.md)의 "템플릿 재사용 체크리스트"에 있습니다.
diff --git a/docker-compose.local.yml b/docker-compose.local.yml
index e69b6f9..082c2a0 100644
--- a/docker-compose.local.yml
+++ b/docker-compose.local.yml
@@ -8,8 +8,9 @@
# - Starts a local PostgreSQL database for integration testing without Testcontainers.
# - Wires the app environment to point at the local DB.
# - Keeps read-only filesystem and memory limits from the base compose.
-# - Does NOT expose the DB port publicly; app and db communicate on the
-# internal `caskeleton-local` network only.
+# - Publishes the DB on the loopback interface only, so a host-side run
+# (`./gradlew :app-bootstrap:bootRun`, IDE) reaches the same database the
+# containerised app reaches over the internal `caskeleton-local` network.
# =============================================================================
services:
@@ -59,7 +60,17 @@ services:
- type: volume
source: caskeleton-db-data
target: /var/lib/postgresql/data
- # No host port: startup Flyway runs in the app container over the internal network.
+ # The containerised app reaches this over the internal network and needs no host port. A
+ # host-side run does: src/.env is the dotenv source bootRun reads, and its committed
+ # APP_DATASOURCE_URL is jdbc:postgresql://localhost:5433/ca_skeleton. With the port unpublished
+ # that default named an address nothing in the repository provisioned, so every bootRun died in
+ # the startup migration phase with a connection refusal.
+ #
+ # Bound to 127.0.0.1, never 0.0.0.0: the database is reachable from this machine and from
+ # nowhere else on the network. Host 5433 (not 5432) so a PostgreSQL already installed on the
+ # host keeps its conventional port.
+ ports:
+ - "127.0.0.1:5433:5432"
networks:
- caskeleton-local
healthcheck:
diff --git a/docs/httpclient/repository-adaptation.md b/docs/httpclient/repository-adaptation.md
index 4cd9064..baa15fe 100644
--- a/docs/httpclient/repository-adaptation.md
+++ b/docs/httpclient/repository-adaptation.md
@@ -27,7 +27,7 @@ Therefore the design's 19 library modules become **package boundaries inside the
| Design module | Repository home | Reason |
|---|---|---|
| `httpclient-spring-boot-starter` | `:app-bootstrap` (`dev.caskeleton.bootstrap.autoconfigure.httpclient`) | This repository's composition root owns wiring and canonical activation; an adapter leaf must not auto-configure itself. |
-| `httpclient-testkit` | `:adapter:outbound:httpclient` `src/test/java/**/testkit` | The design forbids production modules depending on the testkit; a test source set gives the same guarantee without a new Gradle project. |
+| `httpclient-testkit` | `:adapter:outbound:httpclient` `src/testkit/java/**/testkit` | The design forbids production modules depending on the testkit; a source set whose dependencies are declared only on the test configurations gives the same guarantee without a new Gradle project. It is its own source set rather than part of `test` because three lanes consume it — `test`, `httpClientPerformanceTest` and `jmh` — and reaching into `sourceSets.test.output` from `jmh` compiled under Gradle but could not be modelled by an IDE, which classifies a source set as test source only when a `Test` task runs its output and forbids main source from reading test source. `PlatformClasses` excludes the source set's output so the boundary rules keep meaning production classes. |
The package boundary is enforced by ArchUnit rules (`PublicApiArchitectureTest`,
`HttpClientModuleBoundaryTest`) that reproduce the design's module dependency table.
@@ -56,7 +56,7 @@ Root package: `io.backend.skeleton.httpclient` → `dev.caskeleton.adapter.outbo
| `httpclient-spring7-service-groups` | `…httpclient.spring7` | `…outbound.httpclient.spring7` |
| `httpclient-jetty-http3-experimental` | `…httpclient.http3` | `…outbound.httpclient.http3` |
| `httpclient-spring-boot-starter` | `…httpclient.autoconfigure` | `dev.caskeleton.bootstrap.autoconfigure.httpclient` |
-| `httpclient-testkit` | `…httpclient.testkit` | `…outbound.httpclient.testkit` (test source set) |
+| `httpclient-testkit` | `…httpclient.testkit` | `…outbound.httpclient.testkit` (`testkit` source set) |
## 3. Other deliberate substitutions
diff --git a/docs/registries/env-keys.yaml b/docs/registries/env-keys.yaml
index 17978fd..11fe5ec 100644
--- a/docs/registries/env-keys.yaml
+++ b/docs/registries/env-keys.yaml
@@ -2698,7 +2698,11 @@ env_keys:
allowed_values: null
classification: sensitive-config
required: false
- required_when: app.redis.mode=sentinel
+ # Applied only when present: RedisTopologyClientFactory sets the sentinel credentials provider
+ # through ifPresent, so a Sentinel deployment whose sentinels accept unauthenticated discovery
+ # starts without it. The unconditional "app.redis.mode=sentinel" this used to declare was a
+ # requirement the runtime never enforced.
+ required_when: app.redis.mode=sentinel and the sentinels require authentication
reload_policy: restart-only
owner_branch: redis-optionality-and-composition
validation: nonblank_when_required
@@ -2724,11 +2728,13 @@ env_keys:
property: app.redis.sentinel.nodes
owner_module: adapter-outbound-cache-redis
type: csv
- default: null
+ default: app.redis.nodes
allowed_values: null
classification: public-config
required: false
- required_when: app.redis.mode=sentinel
+ # Falls back to app.redis.nodes by design, so a deployment that points nodes at its sentinels
+ # and says nothing else is the common case rather than a misconfiguration.
+ required_when: app.redis.mode=sentinel and app.redis.nodes does not list the sentinels
reload_policy: restart-only
owner_branch: redis-optionality-and-composition
validation: csv_nonempty
@@ -2743,7 +2749,9 @@ env_keys:
allowed_values: null
classification: public-config
required: false
- required_when: app.redis.tls.enabled=true
+ # A key manager is configured only when this is present. Ordinary one-way TLS needs no client
+ # certificate, so requiring one whenever TLS is on was a claim the runtime never made.
+ required_when: app.redis.tls.enabled=true and the server requires mutual TLS
reload_policy: restart-only
owner_branch: redis-optionality-and-composition
validation: nonblank_when_required
@@ -2758,7 +2766,10 @@ env_keys:
allowed_values: null
classification: sensitive-config
required: false
- required_when: app.redis.tls.enabled=true
+ # A relationship between two settings rather than a switch: a client certificate without its
+ # key cannot build a key manager. Enforced in RedisSdkSettings.validate and covered by
+ # RedisSdkSettingsTest, which is where the registry's prose conditions are proven.
+ required_when: app.redis.tls.client-certificate-resource is configured
reload_policy: restart-only
owner_branch: redis-optionality-and-composition
validation: nonblank_when_required
@@ -2804,7 +2815,9 @@ env_keys:
allowed_values: null
classification: public-config
required: false
- required_when: app.redis.tls.enabled=true
+ # A trust manager is installed only when this is present; otherwise the JDK default trust
+ # anchors apply, which is enough for a server certificate from a public CA.
+ required_when: app.redis.tls.enabled=true and the server certificate is not publicly trusted
reload_policy: restart-only
owner_branch: redis-optionality-and-composition
validation: nonblank_when_required
diff --git a/infra/redis-sdk/acl/sentinel-accounts.acl b/infra/redis-sdk/acl/sentinel-accounts.acl
new file mode 100644
index 0000000..2e0ed04
--- /dev/null
+++ b/infra/redis-sdk/acl/sentinel-accounts.acl
@@ -0,0 +1,4 @@
+user default off
+user ca-skeleton-sentinel-client on >fixture-sentinel-client ~* &* -@all +auth +hello +ping +client|setname +client|id +subscribe +psubscribe +unsubscribe +punsubscribe +info +sentinel|get-master-addr-by-name +sentinel|master +sentinel|masters +sentinel|replicas +sentinel|slaves +sentinel|sentinels +sentinel|is-master-down-by-addr
+user ca-skeleton-sentinel-peer on >fixture-sentinel-peer ~* &* +@all
+user ca-skeleton-sentinel-operator on >fixture-sentinel-operator ~* &* -@all +auth +hello +ping +client|setname +info +subscribe +psubscribe +sentinel|get-master-addr-by-name +sentinel|master +sentinel|masters +sentinel|replicas +sentinel|slaves +sentinel|sentinels +sentinel|failover +sentinel|reset
diff --git a/src/README.md b/src/README.md
index 482dfda..d229319 100644
--- a/src/README.md
+++ b/src/README.md
@@ -431,13 +431,51 @@ Boot 기본값이 바뀌어도 wire 계약이 조용히 깨지지 않게 합니
- **`APP_SECURITY_CORS_ALLOW_CREDENTIALS`** — `true` | `false`.
- **`APP_SECURITY_CORS_MAX_AGE`** — preflight 캐시 TTL(초).
+### 프로파일과 데이터베이스
+
+프로파일마다 데이터스토어가 다르고, 그 차이는 `app-bootstrap/src/main/resources/application-*.yml`
+가 소유합니다.
+
+| 프로파일 | 데이터스토어 | 스키마 소유자 | 외부 인프라 |
+| --- | --- | --- | --- |
+| `local` (기본) | H2 in-memory | Hibernate `ddl-auto: create-drop` | 없음 |
+| `dev` | PostgreSQL | Flyway `db/migration/postgresql` | 필요 |
+| `prod` | PostgreSQL | Flyway `db/migration/postgresql` | 필요 |
+
+`local` 은 `./gradlew :app-bootstrap:bootRun` 의 기본값(`src/.env` 의 `SPRING_PROFILES_ACTIVE=local`)
+이라 Docker 없이 바로 뜹니다. 아래 `APP_DATASOURCE_*` 값은 `local` 에서는 쓰이지 않고
+`application-local.yml` 이 덮어씁니다.
+
+`local` 이 검증하는 것은 wiring·요청/응답·애플리케이션 로직이고, **검증하지 않는 것은 migration 과
+vendor 동작**입니다. H2 에는 migration tree 가 없어 migration 에만 존재하는 테이블(capability schema
+registry, polling-delivery·inbox stream, Spring Integration lock)이 만들어지지 않습니다. 해당
+capability 는 `local` 기본값에서 꺼져 있고, 켜면 테이블 없음으로 실패합니다.
+
+`dev` 를 호스트에서 띄우려면(`SPRING_PROFILES_ACTIVE=dev`) PostgreSQL 이 필요합니다.
+`docker-compose.local.yml` 의 `db` 서비스가 루프백(`127.0.0.1:5433`)에만 게시하며, 이 주소가
+아래 `APP_DATASOURCE_URL` 의 커밋된 기본값입니다. 저장소 루트에서 실행합니다.
+
+```bash
+docker compose -f docker-compose.yml -f docker-compose.local.yml up -d --wait db
+```
+
+컨테이너로 띄우는 `./gradlew bootstrap` 경로는 이 호스트 포트를 쓰지 않습니다. compose 가 app
+컨테이너의 `APP_DATASOURCE_URL` 을 내부 네트워크 주소 `jdbc:postgresql://db:5432/...` 로 덮어씁니다.
+
+`ca-skeleton.persistence.vendor`(`postgresql` | `h2`)가 어느 vendor 구성을 조립할지 고르는 단일
+스위치입니다. 값이 둘 중 하나가 아니면 기동이 실패하고, prod 에서 `h2` 이거나 datasource URL 이
+`jdbc:h2:` 이면 `PersistenceVendorProdSafetyValidator` 가 기동을 거부합니다(env 로 덮어써도 동일).
+
### Database (Postgres)
-- **`APP_DATASOURCE_URL`** — JDBC URL(예: `jdbc:postgresql://host:5432/dbname`).
+- **`APP_DATASOURCE_URL`** — JDBC URL(예: `jdbc:postgresql://host:5432/dbname`). `dev`·`prod` 에서
+ 쓰이며, 커밋된 기본값 `jdbc:postgresql://localhost:5433/ca_skeleton` 은 위 compose `db` 서비스의
+ 호스트 주소입니다.
- **`APP_DATASOURCE_USERNAME`** / **`APP_DATASOURCE_PASSWORD`** — DB 접속 계정.
- **`APP_DATASOURCE_DRIVER`** — Hibernate dialect 에 맞는 드라이버(예: `org.postgresql.Driver`).
- **`APP_DATASOURCE_DDL_AUTO`** — `none` | `validate` | `update` | `create` | `create-drop`. **prod
- 는 `validate` 또는 `none`**, local 은 `update` 가 편리합니다.
+ 는 `validate` 또는 `none`**(`JpaSchemaSafetyValidator` 가 기동 시 강제). `local` 은 이 값을 쓰지
+ 않습니다 — `application-local.yml` 이 `create-drop` 으로 고정합니다.
- **`APP_DATASOURCE_SHOW_SQL`** — `true` 면 SQL 을 로그로 echo.
- **`APP_DATASOURCE_FORMAT_SQL`** — SQL pretty-print(`SHOW_SQL=true` 일 때만 의미 있음).
- **`APP_DATASOURCE_OPEN_IN_VIEW`** — Hibernate OSIV. **prod 에서는 피하세요.**
diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkSettings.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkSettings.java
index fcf8058..2f577f0 100644
--- a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkSettings.java
+++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkSettings.java
@@ -82,8 +82,20 @@ public class RedisSdkSettings {
if (mode == RedisDeploymentMode.SENTINEL
&& (sentinel.getMasterName() == null || sentinel.getMasterName().isBlank())) {
throw new IllegalStateException(
- "a Sentinel deployment must name the monitored primary; without it the client cannot"
- + " resolve a primary at all, let alone follow a promotion");
+ "app.redis.sentinel.master-name is required when app.redis.mode=sentinel: a Sentinel"
+ + " deployment must name the monitored primary; without it the client cannot resolve"
+ + " a primary at all, let alone follow a promotion");
+ }
+ // A certificate without its key cannot build a key manager. Refused here rather than at the
+ // first connection, where it surfaced as a NullPointerException from inside the SSL options.
+ if (tls.isEnabled()
+ && tls.getClientCertificateResource() != null
+ && !tls.getClientCertificateResource().isBlank()
+ && (tls.getClientKeyReference() == null || tls.getClientKeyReference().isBlank())) {
+ throw new IllegalStateException(
+ "app.redis.tls.client-key-reference is required when"
+ + " app.redis.tls.client-certificate-resource is configured: mutual TLS presents a"
+ + " certificate, and a certificate without its private key cannot be presented");
}
if (tls.isEnabled() && !tls.isHostnameVerification()) {
warnings.add(
@@ -99,11 +111,15 @@ public class RedisSdkSettings {
}
if (raw.isEnabled()
&& (raw.getCredentialReference() == null || raw.getCredentialReference().isBlank())) {
- throw new IllegalStateException("the raw gateway requires its own credential reference");
+ throw new IllegalStateException(
+ "app.redis.raw.credential-reference is required when app.redis.raw.enabled=true: the raw"
+ + " gateway authenticates as its own account");
}
if (admin.isEnabled()
&& (admin.getCredentialReference() == null || admin.getCredentialReference().isBlank())) {
- throw new IllegalStateException("the admin plane requires its own credential reference");
+ throw new IllegalStateException(
+ "app.redis.admin.credential-reference is required when app.redis.admin.enabled=true: the"
+ + " admin plane authenticates as its own account");
}
if (!advanced.isEnabled() && !advanced.getPolicies().isEmpty()) {
throw new IllegalStateException(
diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkAutoConfigurationTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkAutoConfigurationTest.java
index 304bce4..6e97086 100644
--- a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkAutoConfigurationTest.java
+++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkAutoConfigurationTest.java
@@ -441,7 +441,7 @@ class RedisSdkAutoConfigurationTest {
context -> {
assertThat(context).hasFailed();
assertThat(context.getStartupFailure())
- .hasStackTraceContaining("the admin plane requires its own credential reference");
+ .hasStackTraceContaining("app.redis.admin.credential-reference");
});
}
}
diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkSettingsTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkSettingsTest.java
index 55c7978..6b576bb 100644
--- a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkSettingsTest.java
+++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RedisSdkSettingsTest.java
@@ -71,7 +71,10 @@ class RedisSdkSettingsTest {
RedisSdkSettings missingCredential = validProperties();
missingCredential.getRaw().setEnabled(true);
- assertThatThrownBy(missingCredential::validate).hasMessageContaining("credential reference");
+ // The setting by name. An operator reading "requires its own credential reference" has to work
+ // out which of the five credential references the registry declares is the missing one.
+ assertThatThrownBy(missingCredential::validate)
+ .hasMessageContaining("app.redis.raw.credential-reference");
}
@Test
@@ -79,7 +82,37 @@ class RedisSdkSettingsTest {
RedisSdkSettings properties = validProperties();
properties.getAdmin().setEnabled(true);
- assertThatThrownBy(properties::validate).hasMessageContaining("credential reference");
+ assertThatThrownBy(properties::validate)
+ .hasMessageContaining("app.redis.admin.credential-reference");
+ }
+
+ @Test
+ void aClientCertificateWithoutItsKeyIsRefusedAtStartup() {
+ // The registry declares this one as a relationship between two settings rather than a switch,
+ // so RequiredWhenIsEnforcedTest skips it and this is where the claim is kept. Before it was
+ // checked here, the missing key surfaced as a NullPointerException while the SSL options were
+ // being built — at the first connection, not at startup.
+ RedisSdkSettings properties = validProperties();
+ properties.getTls().setEnabled(true);
+ properties.getTls().setClientCertificateResource("classpath:redis/client.crt");
+
+ assertThatThrownBy(properties::validate)
+ .hasMessageContaining("app.redis.tls.client-key-reference");
+
+ properties.getTls().setClientKeyReference("secret://environment/APP_REDIS_TLS_CLIENT_KEY");
+
+ assertThatCode(properties::validate).doesNotThrowAnyException();
+ }
+
+ @Test
+ void oneWayTlsNeedsNoClientCertificateAndNoTrustMaterial() {
+ // What the registry used to claim was required whenever TLS was on. A server certificate from
+ // a public CA verifies against the JDK trust anchors, and a server that does not ask for a
+ // client certificate is not given one, so neither setting is a startup requirement.
+ RedisSdkSettings properties = validProperties();
+ properties.getTls().setEnabled(true);
+
+ assertThatCode(properties::validate).doesNotThrowAnyException();
}
@Test
diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RequiredWhenIsEnforcedTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RequiredWhenIsEnforcedTest.java
new file mode 100644
index 0000000..ebb0ed7
--- /dev/null
+++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/sdk/config/RequiredWhenIsEnforcedTest.java
@@ -0,0 +1,152 @@
+package dev.caskeleton.adapter.outbound.cache.redis.sdk.config;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.DynamicTest;
+import org.junit.jupiter.api.TestFactory;
+import org.springframework.boot.autoconfigure.AutoConfigurations;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.yaml.snakeyaml.Yaml;
+
+/**
+ * Makes the registry's {@code required_when} keep its promise.
+ *
+ *
{@code verifyEnvKeys} checks that every bindable property has a row and every row names a
+ * property, which leaves the most load-bearing field on the row unchecked: {@code required_when}
+ * declares that a setting must be present once some condition holds. Nothing verified
+ * that, so several rows claimed a requirement the runtime did not enforce — a deployment could
+ * satisfy the documentation and still start with the setting missing, which is worse than an
+ * undocumented setting because it reads as covered.
+ *
+ *
Each machine-readable condition — {@code =} — becomes a case: enable the
+ * condition, omit the property, and require the context to fail. Prose conditions ("… is
+ * configured") are deliberately excluded and are exactly the rows whose rule is a relationship
+ * between two settings rather than a switch; {@code RedisSdkSettingsTest} covers those.
+ *
+ * A row that cannot pass this test has two honest fixes and one dishonest one. Enforce the
+ * requirement, or weaken the declaration to what is true. Deleting the case is the third.
+ */
+class RequiredWhenIsEnforcedTest {
+
+ private static final Path REGISTRY =
+ Path.of("..", "..", "..", "..", "docs", "registries", "env-keys.yaml");
+
+ /** Conditions this test can drive: a property, an equals sign, and a literal. */
+ private record Condition(String property, String value) {
+
+ static Optional parse(String declared) {
+ if (declared == null || !declared.contains("=") || declared.contains(" ")) {
+ return Optional.empty();
+ }
+ int equals = declared.indexOf('=');
+ return Optional.of(
+ new Condition(
+ declared.substring(0, equals).strip(), declared.substring(equals + 1).strip()));
+ }
+ }
+
+ @TestFactory
+ @DisplayName("every declared required_when condition is refused at startup when unmet")
+ List everyRequiredWhenIsEnforced() throws IOException {
+ List cases = new ArrayList<>();
+ for (Map row : rows()) {
+ Object property = row.get("property");
+ Object declared = row.get("required_when");
+ if (!(property instanceof String bound) || !bound.startsWith("app.redis.")) {
+ continue;
+ }
+ Optional condition =
+ Condition.parse(declared instanceof String text ? text : null);
+ if (condition.isEmpty() || "app.redis.enabled".equals(condition.get().property())) {
+ // `app.redis.enabled=true` scopes a setting to Redis being on; it does not claim the
+ // setting must be present. Those rows are the SDK's defaults and have them.
+ continue;
+ }
+ cases.add(
+ DynamicTest.dynamicTest(
+ row.get("name") + " is required when " + declared,
+ () -> assertRefused(bound, condition.get(), String.valueOf(row.get("name")))));
+ }
+ assertThat(cases)
+ .as("the registry declares conditional requirements; a run with none is a parse failure")
+ .isNotEmpty();
+ return cases;
+ }
+
+ private void assertRefused(String property, Condition condition, String envName) {
+ List properties = new ArrayList<>();
+ properties.add("app.redis.enabled=true");
+ properties.add("app.redis.nodes=redis-a:6379");
+ properties.add(
+ "app.redis.authentication.credential-reference=secret://u@environment/APP_REDIS_PASSWORD");
+ properties.add(condition.property() + "=" + condition.value());
+ // Everything the condition itself needs in order to be reachable, minus the property under
+ // test — otherwise an unrelated earlier rule would fail the context and this case would pass
+ // for the wrong reason.
+ prerequisites(condition).forEach((key, value) -> properties.add(key + "=" + value));
+ properties.removeIf(entry -> entry.startsWith(property + "="));
+
+ new ApplicationContextRunner()
+ .withConfiguration(AutoConfigurations.of(RedisSdkAutoConfiguration.class))
+ .withBean(
+ RedisSdkAutoConfiguration.RedisSecretSource.class,
+ () -> name -> Optional.of("resolved-" + name))
+ .withPropertyValues(properties.toArray(String[]::new))
+ .run(
+ context -> {
+ assertThat(context)
+ .as(
+ "%s declares it is required when %s=%s, so a context without it must not"
+ + " start",
+ envName, condition.property(), condition.value())
+ .hasFailed();
+ assertThat(context.getStartupFailure())
+ .as("the failure must name the setting, not something downstream of it")
+ .hasStackTraceContaining(shortName(property));
+ });
+ }
+
+ /** The other settings a condition needs before the property under test can be the cause. */
+ private static Map prerequisites(Condition condition) {
+ Map extra = new LinkedHashMap<>();
+ if ("app.redis.mode".equals(condition.property())
+ && "sentinel".equals(condition.value().toLowerCase(Locale.ROOT))) {
+ extra.put("app.redis.sentinel.master-name", "skeleton");
+ extra.put(
+ "app.redis.sentinel.credential-reference", "secret://s@environment/APP_REDIS_SENTINEL");
+ }
+ if ("app.redis.raw.enabled".equals(condition.property())) {
+ extra.put("app.redis.raw.credential-reference", "secret://r@environment/APP_REDIS_RAW");
+ extra.put("app.redis.raw.policy-resource", "classpath:redis-sdk/redis-command-policy.yml");
+ }
+ if ("app.redis.admin.enabled".equals(condition.property())) {
+ extra.put("app.redis.admin.credential-reference", "secret://a@environment/APP_REDIS_ADMIN");
+ }
+ return extra;
+ }
+
+ /** The last segment of the property, which is what a failure message can be expected to name. */
+ private static String shortName(String property) {
+ int dot = property.lastIndexOf('.');
+ return dot < 0 ? property : property.substring(dot + 1);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static List