fix: serve Studio at the contract path instead of /api/api/v1/...
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ab0447a0f9
commit
e615c24152
+7
-1
@@ -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,
|
||||
|
||||
+7
-1
@@ -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) {
|
||||
|
||||
+13
@@ -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 =
|
||||
|
||||
+13
@@ -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())
|
||||
|
||||
+13
@@ -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());
|
||||
|
||||
+2
-3
@@ -12,9 +12,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
*
|
||||
* <p>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) {
|
||||
|
||||
+2
-2
@@ -12,8 +12,8 @@ import org.jspecify.annotations.Nullable;
|
||||
* H2 atomic scope claim.
|
||||
*
|
||||
* <p>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:
|
||||
*
|
||||
* <ul>
|
||||
* <li>no row for the scope → {@code WHEN NOT MATCHED} inserts the claim (1 row);
|
||||
|
||||
+6
-6
@@ -15,12 +15,12 @@ import org.springframework.jdbc.core.JdbcOperations;
|
||||
* <li><b>Session scope, not transaction scope.</b> PostgreSQL takes {@code set_config(..., true)}
|
||||
* — a value that reverts at transaction end. H2's {@code SET} is session-wide and outlives
|
||||
* the transaction on a pooled connection. It is not left stale in practice because the
|
||||
* transaction port applies these before every transaction, so each one overwrites the last;
|
||||
* a connection borrowed outside that path keeps the previous transaction's guard.
|
||||
* <li><b>No idle-in-transaction guard.</b> H2 has no counterpart to
|
||||
* {@code idle_in_transaction_session_timeout}, so that budget cannot be pushed into the
|
||||
* database here. It is left to the caller-side deadline the transaction port already
|
||||
* enforces, rather than silently reported as applied.
|
||||
* transaction port applies these before every transaction, so each one overwrites the last; a
|
||||
* connection borrowed outside that path keeps the previous transaction's guard.
|
||||
* <li><b>No idle-in-transaction guard.</b> H2 has no counterpart to {@code
|
||||
* idle_in_transaction_session_timeout}, so that budget cannot be pushed into the database
|
||||
* here. It is left to the caller-side deadline the transaction port already enforces, rather
|
||||
* than silently reported as applied.
|
||||
* </ul>
|
||||
*
|
||||
* <p>The millisecond values are inlined because H2's {@code SET} takes no bind parameter. They
|
||||
|
||||
+8
-8
@@ -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.
|
||||
*
|
||||
* <p><b>No Flyway location customizer, deliberately.</b> 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:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Tables that exist only in migrations — the capability schema registry, the polling-delivery
|
||||
@@ -30,12 +30,12 @@ import org.springframework.jdbc.core.JdbcOperations;
|
||||
* there will fail on a missing table rather than silently misbehave.
|
||||
* <li>A fork that enables Flyway while this vendor is selected gets no location override, so
|
||||
* Flyway falls back to {@code classpath:db/migration} and walks the whole tree — including
|
||||
* PostgreSQL DDL H2 cannot parse. Such a fork should register its own
|
||||
* {@code FlywayConfigurationCustomizer} naming an H2 location.
|
||||
* PostgreSQL DDL H2 cannot parse. Such a fork should register its own {@code
|
||||
* FlywayConfigurationCustomizer} naming an H2 location.
|
||||
* </ul>
|
||||
*
|
||||
* <p>Local therefore verifies wiring and behaviour, not migrations. Migration and vendor-concurrency
|
||||
* fidelity stay with the real-PostgreSQL integration suites.
|
||||
* <p>Local therefore verifies wiring and behaviour, not migrations. Migration and
|
||||
* vendor-concurrency fidelity stay with the real-PostgreSQL integration suites.
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnProperty(
|
||||
|
||||
+5
-4
@@ -31,8 +31,8 @@ import org.springframework.transaction.support.TransactionTemplate;
|
||||
* USING}, and only an execution proves that the substitution kept the three outcomes intact.
|
||||
*
|
||||
* <p>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<OutboxEventEntity> 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
|
||||
|
||||
Reference in New Issue
Block a user