From e615c2415251f10c4e785839da6a73692b9b6761 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Wed, 19 Aug 2026 18:26:34 +0900 Subject: [PATCH] fix: serve Studio at the contract path instead of /api/api/v1/... MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PresentationWebConfig prefixes every controller mapping with ca-skeleton.presentation.api-base-path ("/api"), which is why every other controller in this repository declares its path without it — healthcheck is "/healthcheck", uploads are "/v1/uploads", files are "/v1/files". The two Studio controllers declared "/api/v1/studio/..." instead, so the prefix landed on top of a path that already had it and both operations were served at /api/api/v1/studio/... — nowhere near the address studio-v1.yaml declares (servers: "/", paths: /api/v1/studio/...). The frontend calls the contract path, so nothing connected. Verified against a running backend on PostgreSQL behind a real Keycloak: /api/v1/studio/catalog?type=TOPIC 200, 3 items (was 404) /api/api/v1/studio/catalog?type=TOPIC 404 (was 200) Why the tests were green while production was broken: the three slice tests and both nested apps in StudioContractDriftTest build contexts that never include PresentationWebConfig, so no prefix was applied and the controllers' literal "/api/v1/..." matched. They now import it and supply the same "/api" the real app uses, which makes the paths they exercise the effective ones. Re-introducing the bug fails five of them. StudioContractDriftTest needed two more repairs to stay meaningful: springdoc's own endpoint is prefixed too, so the published document is read from /api/v3/api-docs; and publishedStudioOperationsMatchTheContract skips any path not starting with /api/v1/studio/, so a missing prefix would have made it compare nothing and pass. It now asserts it compared at least one path — a gate that cannot see drift is not the same as no drift. Co-Authored-By: Claude Opus 5 (1M context) --- .../controller/StudioCatalogController.java | 8 +- .../controller/StudioSessionController.java | 8 +- ...StudioCatalogBindingErrorEnvelopeTest.java | 13 ++ .../StudioSessionCsrfDisabledTest.java | 13 ++ .../controller/StudioSessionEnvelopeTest.java | 13 ++ .../config/PersistenceVendorSettings.java | 5 +- .../h2/H2IdempotencyClaimRepository.java | 4 +- .../h2/H2LocalTimeoutConfigurer.java | 12 +- .../persistence/h2/H2PersistenceConfig.java | 16 +- .../persistence/h2/H2ClaimSqlTest.java | 9 +- .../contract/StudioContractDriftTest.java | 186 +++++++++++------- .../architecture/StudioErrorRegistryTest.java | 38 ++-- .../architecture/TechLogBoundaryArchTest.java | 4 +- .../ProfileSeparationContractTest.java | 17 +- ...oSessionCsrfHeaderProfileContractTest.java | 9 +- 15 files changed, 227 insertions(+), 128 deletions(-) diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogController.java index 6490190..6ccdf4c 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogController.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogController.java @@ -29,7 +29,13 @@ public class StudioCatalogController { this.listCatalog = listCatalog; } - @GetMapping("/api/v1/studio/catalog") + /** + * 경로에 {@code /api} 를 쓰지 않는다 — {@code PresentationWebConfig} 가 {@code + * ca-skeleton.presentation.api-base-path}("/api") 를 모든 컨트롤러 매핑에 붙인다. 이 저장소의 다른 컨트롤러들(healthcheck, + * /v1/uploads, /v1/files)과 같은 규칙이며, 여기에 {@code /api} 를 다시 쓰면 실제 경로가 {@code /api/api/v1/...} 로 밀려 + * 계약(studio-v1.yaml, {@code servers: "/"})이 선언한 주소에서 사라진다. + */ + @GetMapping("/v1/studio/catalog") public CatalogPage listStudioCatalog( @RequestParam("type") CatalogEntryType type, @RequestParam(value = "q", required = false) String q, diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionController.java index 2068679..3f0e585 100644 --- a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionController.java +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionController.java @@ -55,7 +55,13 @@ public class StudioSessionController { this.csrfHeaderName = configured; } - @GetMapping("/api/v1/studio/session") + /** + * 경로에 {@code /api} 를 쓰지 않는다 — {@code PresentationWebConfig} 가 {@code + * ca-skeleton.presentation.api-base-path}("/api") 를 모든 컨트롤러 매핑에 붙인다. 이 저장소의 다른 컨트롤러들(healthcheck, + * /v1/uploads, /v1/files)과 같은 규칙이며, 여기에 {@code /api} 를 다시 쓰면 실제 경로가 {@code /api/api/v1/...} 로 밀려 + * 계약(studio-v1.yaml, {@code servers: "/"})이 선언한 주소에서 사라진다. + */ + @GetMapping("/v1/studio/session") public StudioSession getStudioSession( @AuthenticationPrincipal AuthenticatedPrincipal principal, CsrfToken csrfToken) { if (csrfToken == null) { diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogBindingErrorEnvelopeTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogBindingErrorEnvelopeTest.java index ecc264e..cee2c3e 100644 --- a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogBindingErrorEnvelopeTest.java +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioCatalogBindingErrorEnvelopeTest.java @@ -5,8 +5,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import dev.caskeleton.adapter.inbound.web.config.PresentationWebConfig; import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice; import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler; +import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings; import dev.caskeleton.adapter.inbound.web.techlog.StudioExceptionHandler; import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort; import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase; @@ -58,6 +60,7 @@ import org.springframework.test.web.servlet.MockMvc; excludeAutoConfiguration = SecurityAutoConfiguration.class) @AutoConfigureMockMvc(addFilters = false) @Import({ + PresentationWebConfig.class, StudioCatalogController.class, StudioExceptionHandler.class, GlobalExceptionHandler.class, @@ -98,6 +101,16 @@ class StudioCatalogBindingErrorEnvelopeTest { static class TestBeans { + /** + * PresentationWebConfig 가 이 값으로 모든 컨트롤러 매핑에 "/api" 를 붙인다. 실제 앱의 PRESENTATION_API_BASE_PATH 와 같은 + * 값이라, 아래 테스트들이 호출하는 /api/v1/... 은 컨트롤러가 선언한 /v1/... 에 prefix 가 적용된 결과다 — 컨트롤러가 "/api" 를 다시 + * 선언하면 /api/api/... 로 밀려 이 테스트들이 404 로 깨진다. + */ + @Bean + PresentationSettings presentationSettings() { + return new PresentationSettings("/api"); + } + @Bean ListCatalogUseCase listCatalogUseCase() { CatalogQueryPort neverInvoked = diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionCsrfDisabledTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionCsrfDisabledTest.java index aff5c33..41e6995 100644 --- a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionCsrfDisabledTest.java +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionCsrfDisabledTest.java @@ -6,9 +6,11 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.adapter.inbound.web.config.PresentationWebConfig; import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice; import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler; import dev.caskeleton.adapter.inbound.web.observability.MdcKeys; +import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings; import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings; import dev.caskeleton.adapter.inbound.web.techlog.StudioExceptionHandler; import java.util.List; @@ -46,6 +48,7 @@ import org.springframework.test.web.servlet.MockMvc; */ @WebMvcTest(controllers = StudioSessionController.class) @Import({ + PresentationWebConfig.class, StudioSessionController.class, EnvelopeBodyAdvice.class, StudioExceptionHandler.class, @@ -89,6 +92,16 @@ class StudioSessionCsrfDisabledTest { @EnableWebSecurity static class SecurityTestConfig { + /** + * PresentationWebConfig 가 이 값으로 모든 컨트롤러 매핑에 "/api" 를 붙인다. 실제 앱의 PRESENTATION_API_BASE_PATH 와 같은 + * 값이라, 아래 테스트들이 호출하는 /api/v1/... 은 컨트롤러가 선언한 /v1/... 에 prefix 가 적용된 결과다 — 컨트롤러가 "/api" 를 다시 + * 선언하면 /api/api/... 로 밀려 이 테스트들이 404 로 깨진다. + */ + @Bean + PresentationSettings presentationSettings() { + return new PresentationSettings("/api"); + } + @Bean SecurityFilterChain csrfDisabledFilterChain(HttpSecurity http) throws Exception { http.csrf(csrf -> csrf.disable()) diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionEnvelopeTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionEnvelopeTest.java index f42b732..242afe8 100644 --- a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionEnvelopeTest.java +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/techlog/studio/controller/StudioSessionEnvelopeTest.java @@ -7,8 +7,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; +import dev.caskeleton.adapter.inbound.web.config.PresentationWebConfig; import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice; import dev.caskeleton.adapter.inbound.web.observability.MdcKeys; +import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings; import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings; import java.util.List; import java.util.Set; @@ -68,6 +70,7 @@ import org.springframework.test.web.servlet.MockMvc; */ @WebMvcTest(controllers = StudioSessionController.class) @Import({ + PresentationWebConfig.class, StudioSessionController.class, EnvelopeBodyAdvice.class, StudioSessionEnvelopeTest.SecurityTestConfig.class @@ -111,6 +114,16 @@ class StudioSessionEnvelopeTest { @EnableWebSecurity static class SecurityTestConfig { + /** + * PresentationWebConfig 가 이 값으로 모든 컨트롤러 매핑에 "/api" 를 붙인다. 실제 앱의 PRESENTATION_API_BASE_PATH 와 같은 + * 값이라, 아래 테스트들이 호출하는 /api/v1/... 은 컨트롤러가 선언한 /v1/... 에 prefix 가 적용된 결과다 — 컨트롤러가 "/api" 를 다시 + * 선언하면 /api/api/... 로 밀려 이 테스트들이 404 로 깨진다. + */ + @Bean + PresentationSettings presentationSettings() { + return new PresentationSettings("/api"); + } + @Bean SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated()); diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceVendorSettings.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceVendorSettings.java index eba19cb..e08dbc9 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceVendorSettings.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceVendorSettings.java @@ -12,9 +12,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties; * *

Binding to an enum is what makes an unknown vendor a startup failure. With a raw string the * two {@code @ConditionalOnProperty} vendor configurations would both stay off, and the first - * missing SPI bean would surface as a {@code NoSuchBeanDefinitionException} naming - * {@code OutboxClaimRepository} — a symptom several layers away from the misspelled value that - * caused it. + * missing SPI bean would surface as a {@code NoSuchBeanDefinitionException} naming {@code + * OutboxClaimRepository} — a symptom several layers away from the misspelled value that caused it. */ @ConfigurationProperties(prefix = PersistenceVendorSettings.PREFIX) public record PersistenceVendorSettings(Vendor vendor) { diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2IdempotencyClaimRepository.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2IdempotencyClaimRepository.java index bcfa66c..7c7cac5 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2IdempotencyClaimRepository.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2IdempotencyClaimRepository.java @@ -12,8 +12,8 @@ import org.jspecify.annotations.Nullable; * H2 atomic scope claim. * *

H2 has no {@code INSERT ... ON CONFLICT ... DO UPDATE ... RETURNING}, so the PostgreSQL - * statement does not port. The standard {@code MERGE ... USING} does, and carries the same - * meaning in one statement: + * statement does not port. The standard {@code MERGE ... USING} does, and carries the same meaning + * in one statement: * *

* *

The millisecond values are inlined because H2's {@code SET} takes no bind parameter. They diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2PersistenceConfig.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2PersistenceConfig.java index 2c1c70a..e59f4cd 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2PersistenceConfig.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/h2/H2PersistenceConfig.java @@ -15,13 +15,13 @@ import org.springframework.jdbc.core.JdbcOperations; /** * H2 vendor persistence configuration — the same four SPI beans the PostgreSQL vendor registers, - * implemented against H2. Selected by {@code ca-skeleton.persistence.vendor=h2}, which the - * {@code local} profile sets. + * implemented against H2. Selected by {@code ca-skeleton.persistence.vendor=h2}, which the {@code + * local} profile sets. * *

No Flyway location customizer, deliberately. The PostgreSQL vendor points Flyway at * {@code classpath:db/migration/postgresql}; there is no H2 equivalent tree, because the local - * profile turns Flyway off and lets Hibernate derive the schema from the entities. Two - * consequences worth stating out loud: + * profile turns Flyway off and lets Hibernate derive the schema from the entities. Two consequences + * worth stating out loud: * *

* - *

Local therefore verifies wiring and behaviour, not migrations. Migration and vendor-concurrency - * fidelity stay with the real-PostgreSQL integration suites. + *

Local therefore verifies wiring and behaviour, not migrations. Migration and + * vendor-concurrency fidelity stay with the real-PostgreSQL integration suites. */ @Configuration(proxyBeanMethods = false) @ConditionalOnProperty( diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/h2/H2ClaimSqlTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/h2/H2ClaimSqlTest.java index a18dd28..8ada120 100644 --- a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/h2/H2ClaimSqlTest.java +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/h2/H2ClaimSqlTest.java @@ -31,8 +31,8 @@ import org.springframework.transaction.support.TransactionTemplate; * USING}, and only an execution proves that the substitution kept the three outcomes intact. * *

In-memory and process-local, so this stays an ordinary unit test: no container, no network, - * nothing to skip when Docker is absent. Real-PostgreSQL fidelity remains the job of the - * {@code postgresqlIntegrationTest} source set. + * nothing to skip when Docker is absent. Real-PostgreSQL fidelity remains the job of the {@code + * postgresqlIntegrationTest} source set. */ class H2ClaimSqlTest { @@ -142,8 +142,9 @@ class H2ClaimSqlTest { List claimed = claimEligible(now, 10); - assertThat(claimed).extracting(OutboxEventEntity::getEventId).containsExactly("evt-old", - "evt-other"); + assertThat(claimed) + .extracting(OutboxEventEntity::getEventId) + .containsExactly("evt-old", "evt-other"); } @Test diff --git a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java b/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java index 8d4a876..74b5f1e 100644 --- a/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java +++ b/src/app-bootstrap/src/functionalTest/java/dev/caskeleton/bootstrap/contract/StudioContractDriftTest.java @@ -6,7 +6,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import dev.caskeleton.adapter.inbound.web.config.PresentationWebConfig; import dev.caskeleton.adapter.inbound.web.envelope.EnvelopeBodyAdvice; +import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings; import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings; import dev.caskeleton.adapter.inbound.web.techlog.studio.controller.StudioCatalogController; import dev.caskeleton.adapter.inbound.web.techlog.studio.controller.StudioSessionController; @@ -40,72 +42,73 @@ import org.yaml.snakeyaml.Yaml; /** * feature-techlog-studio-backend Task 10 — the drift gate spec §5.5 calls for: springdoc's * published {@code /v3/api-docs} is diffed against the vendored {@code - * config/openapi/studio-v1.yaml} for every {@code /api/v1/studio/**} path it actually exposes. - * Only implemented operations are checked (direction is "published ⊆ contract", never the - * reverse), so this stays green as slices 2-5 add the other 17 operations — on one condition: - * the new controllers must live somewhere under {@code dev.caskeleton.adapter.inbound.web.techlog}, - * the package {@link ContractSurface.ContractSurfaceApp} and {@link EnvelopeWrapping.EnvelopeApp} - * {@code @ComponentScan}. A controller placed there is picked up automatically, with no edit to this - * file. A controller placed outside that package tree is invisible to both minimal contexts - * — springdoc never sees it, so this gate stays green even if its path/method/operationId contradicts - * the contract — and the {@code @ComponentScan} base package below must be widened (or the new - * controller moved) before this gate can be trusted again. (An earlier draft of this class named the - * two controllers directly via {@code @Import} instead of scanning; that hardcoded list had exactly - * this blind spot — confirmed by temporarily reintroducing it and observing a controller with an - * out-of-contract mapping pass silently, see task-10-report.md.) This test also fails the moment an - * in-scan controller's method name drifts from its {@code operationId} or ships an endpoint outside - * the contract. + * config/openapi/studio-v1.yaml} for every {@code /api/v1/studio/**} path it actually exposes. Only + * implemented operations are checked (direction is "published ⊆ contract", never the + * reverse), so this stays green as slices 2-5 add the other 17 operations — on one + * condition: the new controllers must live somewhere under {@code + * dev.caskeleton.adapter.inbound.web.techlog}, the package {@link + * ContractSurface.ContractSurfaceApp} and {@link EnvelopeWrapping.EnvelopeApp} + * {@code @ComponentScan}. A controller placed there is picked up automatically, with no edit to + * this file. A controller placed outside that package tree is invisible to both minimal + * contexts — springdoc never sees it, so this gate stays green even if its path/method/operationId + * contradicts the contract — and the {@code @ComponentScan} base package below must be widened (or + * the new controller moved) before this gate can be trusted again. (An earlier draft of this class + * named the two controllers directly via {@code @Import} instead of scanning; that hardcoded list + * had exactly this blind spot — confirmed by temporarily reintroducing it and observing a + * controller with an out-of-contract mapping pass silently, see task-10-report.md.) This test also + * fails the moment an in-scan controller's method name drifts from its {@code operationId} or ships + * an endpoint outside the contract. * *

Why a hand-built minimal context rather than {@code CaSkeletonApplication}

* *

This repository's own tests never boot the full app under test: {@code * FileserverRoundTripContractTest} and {@code ActuatorSecurityHttpTest} (both in this module's - * {@code src/test}) spell out why — {@code application.yml} resolves ~50 {@code ${...}} 자리표시자 - * from {@code src/.env} (datasource, OIDC issuer, Redis, messaging, ...), so a full boot drags in + * {@code src/test}) spell out why — {@code application.yml} resolves ~50 {@code ${...}} 자리표시자 from + * {@code src/.env} (datasource, OIDC issuer, Redis, messaging, ...), so a full boot drags in * infrastructure a contract-shape test has nothing to say about. This test follows the same - * playbook: a throwaway {@code @SpringBootConfiguration} that {@code @ComponentScan}s the studio web - * package (so real production controllers like {@link StudioSessionController} and {@link + * playbook: a throwaway {@code @SpringBootConfiguration} that {@code @ComponentScan}s the studio + * web package (so real production controllers like {@link StudioSessionController} and {@link * StudioCatalogController} are picked up the same way the real app's component scan finds them — * see the class-level "why scan, not @Import" note above), with {@link SecurityAutoConfiguration} - * excluded and MockMvc filters off ({@code addFilters = false}) — the exclude/addFilters combination - * {@code EnvelopeBodyAdviceTest} and {@code NoResourceFoundErrorHandlingTest} (adapter-inbound-web's - * own test sourceSet) already use for this class of test. + * excluded and MockMvc filters off ({@code addFilters = false}) — the exclude/addFilters + * combination {@code EnvelopeBodyAdviceTest} and {@code NoResourceFoundErrorHandlingTest} + * (adapter-inbound-web's own test sourceSet) already use for this class of test. * *

Because this functionalTest module depends only on {@code :adapter:inbound:web} and {@code * :application-core} (not {@code :adapter:outbound:persistence-jpa}, cache, or messaging), none of - * the DataSource/Flyway/Redis auto-configuration classes are even on the classpath for {@code - * @EnableAutoConfiguration} to attempt — there is nothing to exclude for them, unlike {@code + * the DataSource/Flyway/Redis auto-configuration classes are even on the classpath for + * {@code @EnableAutoConfiguration} to attempt — there is nothing to exclude for them, unlike {@code * ActuatorSecurityHttpTest}'s explicit JPA/Flyway exclude list. * *

{@code @SpringBootTest} (full, unsliced {@code @EnableAutoConfiguration}) is used instead of * {@code @WebMvcTest}: springdoc's own auto-configuration is a third-party {@code * AutoConfiguration.imports} entry, not part of Boot's curated {@code @WebMvcTest} slice allowlist, * so {@code /v3/api-docs} would not be exposed under a sliced test. A full, unsliced context that - * only sees two modules' worth of dependencies keeps the cost bounded to "start web MVC + springdoc" - * without paying for DB/security infrastructure. + * only sees two modules' worth of dependencies keeps the cost bounded to "start web MVC + + * springdoc" without paying for DB/security infrastructure. * *

Why two nested contexts instead of one

* *

The obvious design is one shared {@code @SpringBootTest} context for both tests. That does not - * work here, and the reason is worth recording: springdoc's {@code /v3/api-docs} handler - * ({@code OpenApiWebMvcResource.openapiJson}) returns raw {@code byte[]} — it serializes the OpenAPI - * model itself and hands Spring MVC already-encoded bytes. {@link EnvelopeBodyAdvice#supports} - * returns {@code true} unconditionally (by design — it wraps every controller response in the real - * app, not just Studio's), so if it is on the classpath of *that* request it rewrites the body from - * {@code byte[]} to {@code Envelope} — but Spring MVC picks the {@code HttpMessageConverter} - * from the *original* return type before the advice runs, so {@code ByteArrayHttpMessageConverter} - * (already selected for {@code byte[]}) is then asked to write an {@code Envelope}, and - * {@code writeInternal} throws {@code ClassCastException: Envelope cannot be cast to [B}. This - * reproduced with a full stack trace during this task (see task-10-report.md) — it is a real, - * pre-existing defect in shared skeleton code ({@code EnvelopeBodyAdvice} is not Studio-owned and - * not part of this task's brief), not an artifact of this test's plumbing: any app that boots both - * springdoc and {@code EnvelopeBodyAdvice} together and serves {@code /v3/api-docs} unauthenticated - * would hit the same crash. Fixing that advice is out of scope for a contract-regression test, so - * {@link ContractSurface} boots a context *without* {@link EnvelopeBodyAdvice} (springdoc doesn't - * invoke it for anything test 1 checks anyway — introspection is pure reflection over the mapping), - * and {@link EnvelopeWrapping} boots a separate context *with* it, hitting - * {@link StudioCatalogController} instead, whose {@code CatalogPage} return type is an ordinary POJO - * that the same JSON converter handles before and after wrapping. + * work here, and the reason is worth recording: springdoc's {@code /v3/api-docs} handler ({@code + * OpenApiWebMvcResource.openapiJson}) returns raw {@code byte[]} — it serializes the OpenAPI model + * itself and hands Spring MVC already-encoded bytes. {@link EnvelopeBodyAdvice#supports} returns + * {@code true} unconditionally (by design — it wraps every controller response in the real app, not + * just Studio's), so if it is on the classpath of *that* request it rewrites the body from {@code + * byte[]} to {@code Envelope} — but Spring MVC picks the {@code HttpMessageConverter} from + * the *original* return type before the advice runs, so {@code ByteArrayHttpMessageConverter} + * (already selected for {@code byte[]}) is then asked to write an {@code Envelope}, and {@code + * writeInternal} throws {@code ClassCastException: Envelope cannot be cast to [B}. This reproduced + * with a full stack trace during this task (see task-10-report.md) — it is a real, pre-existing + * defect in shared skeleton code ({@code EnvelopeBodyAdvice} is not Studio-owned and not part of + * this task's brief), not an artifact of this test's plumbing: any app that boots both springdoc + * and {@code EnvelopeBodyAdvice} together and serves {@code /v3/api-docs} unauthenticated would hit + * the same crash. Fixing that advice is out of scope for a contract-regression test, so {@link + * ContractSurface} boots a context *without* {@link EnvelopeBodyAdvice} (springdoc doesn't invoke + * it for anything test 1 checks anyway — introspection is pure reflection over the mapping), and + * {@link EnvelopeWrapping} boots a separate context *with* it, hitting {@link + * StudioCatalogController} instead, whose {@code CatalogPage} return type is an ordinary POJO that + * the same JSON converter handles before and after wrapping. * *

Why {@link ListCatalogUseCase} is real, not mocked

* @@ -125,12 +128,12 @@ import org.yaml.snakeyaml.Yaml; * than skip the envelope assertion or force real persistence/security infrastructure into a * contract-shape test, {@link EnvelopeWrapping} proves the same regression the brief wants — {@link * EnvelopeBodyAdvice} still wraps {@link StudioCatalogController}'s response — against a minimal - * slice. Envelope wrapping is a {@code ResponseBodyAdvice} concern that is orthogonal to persistence - * and authentication, so stubbing those out does not weaken what the assertion proves, and no - * production security surface changes: {@code SECURITY_PUBLIC_PATHS} is untouched, and this slice - * simply never wires a {@code SecurityFilterChain} at all (same as the two adapter-inbound-web - * precedents cited above), rather than widening what unauthenticated callers may reach in the real - * app. + * slice. Envelope wrapping is a {@code ResponseBodyAdvice} concern that is orthogonal to + * persistence and authentication, so stubbing those out does not weaken what the assertion proves, + * and no production security surface changes: {@code SECURITY_PUBLIC_PATHS} is untouched, and this + * slice simply never wires a {@code SecurityFilterChain} at all (same as the two + * adapter-inbound-web precedents cited above), rather than widening what unauthenticated callers + * may reach in the real app. */ class StudioContractDriftTest { @@ -142,9 +145,8 @@ class StudioContractDriftTest { @Autowired private MockMvc mvc; /** - * "구현된 것만 검사" 방향 고정: 계약(19개 operation)이 아니라 published(springdoc이 실제로 내놓는 것, 지금은 2개)를 - * 순회한다. 슬라이스 2~5가 나머지 17개를 추가해도 이 순회 방향 덕분에 이 테스트는 그대로 통과한다 — 반대로 순회했다면 미구현 operation마다 - * 매번 실패했을 것이다. + * "구현된 것만 검사" 방향 고정: 계약(19개 operation)이 아니라 published(springdoc이 실제로 내놓는 것, 지금은 2개)를 순회한다. 슬라이스 + * 2~5가 나머지 17개를 추가해도 이 순회 방향 덕분에 이 테스트는 그대로 통과한다 — 반대로 순회했다면 미구현 operation마다 매번 실패했을 것이다. */ @Test void publishedStudioOperationsMatchTheContract() throws Exception { @@ -152,11 +154,13 @@ class StudioContractDriftTest { JsonNode published = readPublishedApiDocs(); List problems = new ArrayList<>(); + List compared = new ArrayList<>(); JsonNode publishedPaths = published.path("paths"); for (Map.Entry path : publishedPaths.properties()) { if (!path.getKey().startsWith("/api/v1/studio/")) { continue; } + compared.add(path.getKey()); JsonNode contractPath = contract.path("paths").path(path.getKey()); if (contractPath.isMissingNode()) { problems.add("계약에 없는 path: " + path.getKey()); @@ -184,6 +188,16 @@ class StudioContractDriftTest { } } assertThat(problems).isEmpty(); + // 이 순회는 "/api/v1/studio/" 로 시작하는 published path 만 본다. prefix 배선이 빠지면 + // 모든 경로가 필터에 걸러져 아무것도 비교하지 않은 채 green 이 된다 — 게이트가 드리프트를 + // "못 보는" 상태와 "없는" 상태가 구별되지 않는다. 그래서 최소 하나는 실제로 대조했음을 함께 고정한다. + assertThat(compared) + .as( + "published 표면에서 /api/v1/studio/ 경로를 하나도 대조하지 못했다 —" + + " PresentationWebConfig 의 api-base-path 배선이 빠졌는지 확인하라." + + " published paths=" + + publishedPaths.properties().stream().map(Map.Entry::getKey).toList()) + .isNotEmpty(); } /** @@ -204,9 +218,14 @@ class StudioContractDriftTest { return new ObjectMapper().valueToTree(contractYaml); } + /** + * {@code /api/v3/api-docs} — springdoc 의 엔드포인트에도 {@link PresentationWebConfig} 의 {@code + * api-base-path} 가 붙는다. 실제 앱에서 published 문서가 실제로 서비스되는 주소이며, 여기서 {@code /v3/api-docs} 를 두드리면 + * 404 가 난다. + */ private JsonNode readPublishedApiDocs() throws Exception { String body = - mvc.perform(get("/v3/api-docs")) + mvc.perform(get("/api/v3/api-docs")) .andExpect(status().isOk()) .andReturn() .getResponse() @@ -215,20 +234,30 @@ class StudioContractDriftTest { } /** - * {@code @Import}로 컨트롤러를 하나씩 나열하지 않고 {@code @ComponentScan}으로 studio web 패키지 전체를 스캔한다. - * 나열 방식은 슬라이스 2~5가 새 컨트롤러를 추가해도 이 파일을 고치지 않는 한 published 표면에 안 잡히는 채로 green을 유지하는 - * 함정이 있었다 — 드리프트가 "없는" 게 아니라 게이트가 "못 보는" 상태였다. 리뷰에서 이 패키지 아래에 계약 밖 매핑을 가진 임시 - * 컨트롤러를 하나 추가해(어떤 {@code @Import}/{@code @ComponentScan} 목록에도 안 넣고) 실측으로 확인했다 — 옛 {@code - * @Import} 목록으로는 이 테스트가 통과, 이 {@code @ComponentScan}으로는 실패. 재현 절차와 두 결과 모두 - * task-10-report.md의 "자동 포함 성질 RED 검증" 절에 남아 있다. {@code EnvelopeBodyAdvice}는 이 패키지 밖({@code - * ...web.envelope})이라 스캔에 안 걸린다 — 일부러 두지 않는다(클래스 javadoc "Why two nested contexts" 참조). - * springdoc은 리플렉션만 하므로 이 test1엔 애초에 관여하지 않는다. + * {@code @Import}로 컨트롤러를 하나씩 나열하지 않고 {@code @ComponentScan}으로 studio web 패키지 전체를 스캔한다. 나열 방식은 + * 슬라이스 2~5가 새 컨트롤러를 추가해도 이 파일을 고치지 않는 한 published 표면에 안 잡히는 채로 green을 유지하는 함정이 있었다 — 드리프트가 "없는" + * 게 아니라 게이트가 "못 보는" 상태였다. 리뷰에서 이 패키지 아래에 계약 밖 매핑을 가진 임시 컨트롤러를 하나 추가해(어떤 + * {@code @Import}/{@code @ComponentScan} 목록에도 안 넣고) 실측으로 확인했다 — 옛 {@code @Import} 목록으로는 이 테스트가 + * 통과, 이 {@code @ComponentScan}으로는 실패. 재현 절차와 두 결과 모두 task-10-report.md의 "자동 포함 성질 RED 검증" 절에 남아 + * 있다. {@code EnvelopeBodyAdvice}는 이 패키지 밖({@code ...web.envelope})이라 스캔에 안 걸린다 — 일부러 두지 않는다(클래스 + * javadoc "Why two nested contexts" 참조). springdoc은 리플렉션만 하므로 이 test1엔 애초에 관여하지 않는다. */ @SpringBootConfiguration @EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class) @ComponentScan("dev.caskeleton.adapter.inbound.web.techlog") + @Import(PresentationWebConfig.class) static class ContractSurfaceApp { + /** + * 실제 앱의 {@code PRESENTATION_API_BASE_PATH}. {@link PresentationWebConfig} 가 이 값을 모든 컨트롤러 매핑에 + * 붙이므로, published 표면의 경로는 컨트롤러가 선언한 {@code /v1/studio/...} 이 아니라 계약이 선언한 {@code + * /api/v1/studio/...} 여야 한다. + */ + @Bean + PresentationSettings presentationSettings() { + return new PresentationSettings("/api"); + } + @Bean SecuritySettings securitySettings() { return StudioContractDriftTest.securitySettingsForTest(); @@ -262,8 +291,8 @@ class StudioContractDriftTest { } /** - * {@code adapter-inbound-web}에 실제 애플리케이션이 없어({@code CaSkeletonApplication}은 app-bootstrap - * 소유) {@code @SpringBootTest}가 부트스트랩할 {@code @SpringBootConfiguration}이 필요하다 — {@code + * {@code adapter-inbound-web}에 실제 애플리케이션이 없어({@code CaSkeletonApplication}은 app-bootstrap 소유) + * {@code @SpringBootTest}가 부트스트랩할 {@code @SpringBootConfiguration}이 필요하다 — {@code * StudioSessionEnvelopeTest}(adapter-inbound-web 자체 테스트)가 쓰는 것과 같은 이유의 같은 패턴. {@link * ContractSurface.ContractSurfaceApp}과 마찬가지로 {@code @ComponentScan}으로 studio web 패키지를 스캔하고, * {@link EnvelopeBodyAdvice}만 별도로 {@code @Import}한다(스캔 범위 밖 패키지라서) — 이 컨텍스트는 {@code @@ -272,9 +301,19 @@ class StudioContractDriftTest { @SpringBootConfiguration @EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class) @ComponentScan("dev.caskeleton.adapter.inbound.web.techlog") - @Import(EnvelopeBodyAdvice.class) + @Import({EnvelopeBodyAdvice.class, PresentationWebConfig.class}) static class EnvelopeApp { + /** + * 실제 앱의 {@code PRESENTATION_API_BASE_PATH}. {@link PresentationWebConfig} 가 이 값을 모든 컨트롤러 매핑에 + * 붙이므로, published 표면의 경로는 컨트롤러가 선언한 {@code /v1/studio/...} 이 아니라 계약이 선언한 {@code + * /api/v1/studio/...} 여야 한다. + */ + @Bean + PresentationSettings presentationSettings() { + return new PresentationSettings("/api"); + } + @Bean SecuritySettings securitySettings() { return StudioContractDriftTest.securitySettingsForTest(); @@ -287,18 +326,25 @@ class StudioContractDriftTest { } } - /** {@code csrfHeaderName}이 계약 const {@code X-CSRF-TOKEN}과 다르면 {@link StudioSessionController}의 생성자가 즉시 실패한다. */ + /** + * {@code csrfHeaderName}이 계약 const {@code X-CSRF-TOKEN}과 다르면 {@link StudioSessionController}의 + * 생성자가 즉시 실패한다. + */ private static SecuritySettings securitySettingsForTest() { SecuritySettings.SessionCookieSettings session = new SecuritySettings.SessionCookieSettings( null, null, null, null, null, null, "X-CSRF-TOKEN"); return new SecuritySettings( - SecuritySettings.AuthenticationMode.JWT, "https://issuer.example", null, List.of(), session); + SecuritySettings.AuthenticationMode.JWT, + "https://issuer.example", + null, + List.of(), + session); } /** - * 실제 영속성 어댑터 대신 최소 stub 포트로 구성한 진짜 {@link ListCatalogUseCase}. springdoc은 컨트롤러 메서드를 호출하지 않고 - * 리플렉션만 하므로 첫 번째 테스트에는 아예 관여하지 않고, 두 번째 테스트(봉투 확인)는 결과 내용이 아니라 감싸는 모양만 보므로 빈 목록으로 충분하다. + * 실제 영속성 어댑터 대신 최소 stub 포트로 구성한 진짜 {@link ListCatalogUseCase}. springdoc은 컨트롤러 메서드를 호출하지 않고 리플렉션만 + * 하므로 첫 번째 테스트에는 아예 관여하지 않고, 두 번째 테스트(봉투 확인)는 결과 내용이 아니라 감싸는 모양만 보므로 빈 목록으로 충분하다. */ private static ListCatalogUseCase listCatalogUseCaseForTest() { return new ListCatalogUseCase(new StubCatalogQueryPort(), new PassThroughTransactionPort()); diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/StudioErrorRegistryTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/StudioErrorRegistryTest.java index 08b8e6a..9721c05 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/StudioErrorRegistryTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/StudioErrorRegistryTest.java @@ -27,11 +27,11 @@ import org.yaml.snakeyaml.Yaml; * *
    *
  1. every {@link StudioError} constant has a matching registry row (code presence); - *
  2. that row's {@code category}/{@code http_status}/{@code retryable} match the enum's - * declared values exactly — a standing drift gate. Nothing else in the suite checks this for - * {@link StudioError}: {@code ErrorCodeRegistryMappingTest} (a different branch's template) - * only walks {@code OperationalError}. Its absence let the {@code VALIDATION_FAILED} ↔ - * {@code OperationalError.VALIDATION_FAILED} code-name collision ship once already (see + *
  3. that row's {@code category}/{@code http_status}/{@code retryable} match the enum's declared + * values exactly — a standing drift gate. Nothing else in the suite checks this for {@link + * StudioError}: {@code ErrorCodeRegistryMappingTest} (a different branch's template) only + * walks {@code OperationalError}. Its absence let the {@code VALIDATION_FAILED} ↔ {@code + * OperationalError.VALIDATION_FAILED} code-name collision ship once already (see * task-5-report.md Fix Round 1) — this closes that gap for good; *
  4. {@link StudioClientSafeMessages#forError(StudioError)}'s text matches the row's {@code * client_safe_message} exactly — the single source of truth for {@code error.message} is the @@ -45,8 +45,8 @@ import org.yaml.snakeyaml.Yaml; * not guaranteed to be the module directory the brief's naive relative path assumed. Parses the * registry with SnakeYaml — the same library/pattern {@code ErrorCodeRegistryMappingTest} and * {@code RunbookCoverageContractTest} already use in this suite — rather than line-scanning, since - * this test needs structured field access (category/http_status/retryable/client_safe_message), - * not just the {@code code:} key. + * this test needs structured field access (category/http_status/retryable/client_safe_message), not + * just the {@code code:} key. */ class StudioErrorRegistryTest { @@ -102,8 +102,8 @@ class StudioErrorRegistryTest { /** * {@code StudioClientSafeMessages} must never drift from the registry's {@code - * client_safe_message} — that column is the single source of truth for what {@code - * error.message} clients see (task-5-report.md Important 1). + * client_safe_message} — that column is the single source of truth for what {@code error.message} + * clients see (task-5-report.md Important 1). */ @Test void everyStudioErrorClientSafeMessageMatchesRegistry() { @@ -118,14 +118,14 @@ class StudioErrorRegistryTest { } /** - * final whole-branch review B3: {@code StudioErrorTest.declaresExactlyTheTwentyThreeContractCodes} - * only counts ({@code hasSize(23)}) — it never reads the contract, so a rename or a 1:1 code - * substitution on either side (enum or {@code studio-v1.yaml}) leaves the count at 23 and passes. - * This is the gate that reads {@code src/config/openapi/studio-v1.yaml}'s {@code - * components.schemas.ApiError.properties.code.enum} and requires the two sets to be identical in - * both directions — a code present only in the contract, or only in the enum, fails here. This - * drift already happened once for real (Task 5's vendor copy carrying stale names) and a human - * caught it, not a gate; this closes that gap. + * final whole-branch review B3: {@code + * StudioErrorTest.declaresExactlyTheTwentyThreeContractCodes} only counts ({@code hasSize(23)}) — + * it never reads the contract, so a rename or a 1:1 code substitution on either side (enum or + * {@code studio-v1.yaml}) leaves the count at 23 and passes. This is the gate that reads {@code + * src/config/openapi/studio-v1.yaml}'s {@code components.schemas.ApiError.properties.code.enum} + * and requires the two sets to be identical in both directions — a code present only in the + * contract, or only in the enum, fails here. This drift already happened once for real (Task 5's + * vendor copy carrying stale names) and a human caught it, not a gate; this closes that gap. */ @Test void enumMatchesContractCodeSetExactly() throws Exception { @@ -180,8 +180,8 @@ class StudioErrorRegistryTest { } /** - * Parses lines shaped {@code }, skipping {@code #}-prefixed comment - * lines such as MANIFEST.sha256's {@code # source: ...} provenance line. + * Parses lines shaped {@code }, skipping {@code #}-prefixed comment lines + * such as MANIFEST.sha256's {@code # source: ...} provenance line. */ private static String recordedSha256(Path manifest, String filename) throws IOException { return Files.readAllLines(manifest).stream() diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TechLogBoundaryArchTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TechLogBoundaryArchTest.java index 4821656..7d8b223 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TechLogBoundaryArchTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/TechLogBoundaryArchTest.java @@ -7,8 +7,8 @@ import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; /** - * 설계 08장의 bounded context 경계를 빌드로 강제한다. Gradle leaf를 늘리지 않고 - * 패키지로 나눴으므로(spec D1/D2) 경계는 이 규칙이 유일한 방어선이다. + * 설계 08장의 bounded context 경계를 빌드로 강제한다. Gradle leaf를 늘리지 않고 패키지로 나눴으므로(spec D1/D2) 경계는 이 규칙이 유일한 + * 방어선이다. */ @AnalyzeClasses(packages = "dev.caskeleton", importOptions = ProductionClassImportOption.class) class TechLogBoundaryArchTest { diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ProfileSeparationContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ProfileSeparationContractTest.java index 026e7f7..396cffe 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ProfileSeparationContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ProfileSeparationContractTest.java @@ -30,11 +30,11 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; * composition root inside this source set is not currently possible — the component scan that makes * {@code CaSkeletonApplication} the composition root also finds the nested {@code @Configuration} * classes that dozens of tests here declare, and they collide. The behaviour behind these files is - * covered where it can be: {@code H2ClaimSqlTest} runs the H2 statements against a real H2, - * {@code PersistenceVendorSelectionTest} covers the selector, and - * {@code PersistenceVendorProdSafetyValidatorTest} covers the prod refusal. What is left, and what - * this test guards, is the wiring between them drifting — a profile quietly changing vendor, or - * local regaining a migration expectation it cannot satisfy. + * covered where it can be: {@code H2ClaimSqlTest} runs the H2 statements against a real H2, {@code + * PersistenceVendorSelectionTest} covers the selector, and {@code + * PersistenceVendorProdSafetyValidatorTest} covers the prod refusal. What is left, and what this + * test guards, is the wiring between them drifting — a profile quietly changing vendor, or local + * regaining a migration expectation it cannot satisfy. */ class ProfileSeparationContractTest { @@ -136,7 +136,8 @@ class ProfileSeparationContractTest { return path; } } - throw new IllegalStateException("repository root not found from " + Paths.get("").toAbsolutePath()); + throw new IllegalStateException( + "repository root not found from " + Paths.get("").toAbsolutePath()); } /** @@ -197,7 +198,9 @@ class ProfileSeparationContractTest { private record Placeholder(String variable, String inlineDefault) {} - /** Property path → the {@code ${VAR}} or {@code ${VAR:default}} application.yml resolves it from. */ + /** + * Property path → the {@code ${VAR}} or {@code ${VAR:default}} application.yml resolves it from. + */ private static Map placeholders() throws IOException { Pattern syntax = Pattern.compile("^\\$\\{([A-Z0-9_]+)(?::(.*))?}$"); Map found = new LinkedHashMap<>(); diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/StudioSessionCsrfHeaderProfileContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/StudioSessionCsrfHeaderProfileContractTest.java index e2d241c..2cbb28f 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/StudioSessionCsrfHeaderProfileContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/StudioSessionCsrfHeaderProfileContractTest.java @@ -23,9 +23,9 @@ import org.yaml.snakeyaml.constructor.SafeConstructor; * configured header name disagrees with the contract const. That controller is an unconditional * {@code @RestController} bean, so the constructor failure becomes a {@code BeanCreationException} * during context refresh — the process never starts, taking healthcheck/actuator/fileserver down - * with it. Only {@code application-dev.yml} declared the override before this fix; - * {@code application-local.yml} (the profile {@code src/.env:8} actually activates) and - * {@code application-prod.yml} both resolved to the template default {@code X-XSRF-TOKEN} + * with it. Only {@code application-dev.yml} declared the override before this fix; {@code + * application-local.yml} (the profile {@code src/.env:8} actually activates) and {@code + * application-prod.yml} both resolved to the template default {@code X-XSRF-TOKEN} * (application.yml:498's inline default, restated verbatim by {@code src/.env:125}), so both * profiles could not boot. * @@ -54,8 +54,7 @@ class StudioSessionCsrfHeaderProfileContractTest { } private static String csrfHeaderNameOf(Map configuration) { - Map session = - child(child(child(configuration, "ca-skeleton"), "security"), "session"); + Map session = child(child(child(configuration, "ca-skeleton"), "security"), "session"); Object value = session == null ? null : session.get("csrf-header-name"); return value == null ? null : value.toString(); }