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:
DongHyeonka
2026-08-19 18:26:34 +09:00
co-authored by Claude Opus 5
parent ab0447a0f9
commit e615c24152
15 changed files with 227 additions and 128 deletions
@@ -29,7 +29,13 @@ public class StudioCatalogController {
this.listCatalog = listCatalog; 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( public CatalogPage listStudioCatalog(
@RequestParam("type") CatalogEntryType type, @RequestParam("type") CatalogEntryType type,
@RequestParam(value = "q", required = false) String q, @RequestParam(value = "q", required = false) String q,
@@ -55,7 +55,13 @@ public class StudioSessionController {
this.csrfHeaderName = configured; 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( public StudioSession getStudioSession(
@AuthenticationPrincipal AuthenticatedPrincipal principal, CsrfToken csrfToken) { @AuthenticationPrincipal AuthenticatedPrincipal principal, CsrfToken csrfToken) {
if (csrfToken == null) { if (csrfToken == null) {
@@ -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.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 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.envelope.EnvelopeBodyAdvice;
import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler; 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.adapter.inbound.web.techlog.StudioExceptionHandler;
import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort; import dev.caskeleton.application.techlog.studio.port.out.CatalogQueryPort;
import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase; import dev.caskeleton.application.techlog.studio.service.ListCatalogUseCase;
@@ -58,6 +60,7 @@ import org.springframework.test.web.servlet.MockMvc;
excludeAutoConfiguration = SecurityAutoConfiguration.class) excludeAutoConfiguration = SecurityAutoConfiguration.class)
@AutoConfigureMockMvc(addFilters = false) @AutoConfigureMockMvc(addFilters = false)
@Import({ @Import({
PresentationWebConfig.class,
StudioCatalogController.class, StudioCatalogController.class,
StudioExceptionHandler.class, StudioExceptionHandler.class,
GlobalExceptionHandler.class, GlobalExceptionHandler.class,
@@ -98,6 +101,16 @@ class StudioCatalogBindingErrorEnvelopeTest {
static class TestBeans { 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 @Bean
ListCatalogUseCase listCatalogUseCase() { ListCatalogUseCase listCatalogUseCase() {
CatalogQueryPort neverInvoked = CatalogQueryPort neverInvoked =
@@ -6,9 +6,11 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; 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.envelope.EnvelopeBodyAdvice;
import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler; import dev.caskeleton.adapter.inbound.web.error.GlobalExceptionHandler;
import dev.caskeleton.adapter.inbound.web.observability.MdcKeys; 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.settings.SecuritySettings;
import dev.caskeleton.adapter.inbound.web.techlog.StudioExceptionHandler; import dev.caskeleton.adapter.inbound.web.techlog.StudioExceptionHandler;
import java.util.List; import java.util.List;
@@ -46,6 +48,7 @@ import org.springframework.test.web.servlet.MockMvc;
*/ */
@WebMvcTest(controllers = StudioSessionController.class) @WebMvcTest(controllers = StudioSessionController.class)
@Import({ @Import({
PresentationWebConfig.class,
StudioSessionController.class, StudioSessionController.class,
EnvelopeBodyAdvice.class, EnvelopeBodyAdvice.class,
StudioExceptionHandler.class, StudioExceptionHandler.class,
@@ -89,6 +92,16 @@ class StudioSessionCsrfDisabledTest {
@EnableWebSecurity @EnableWebSecurity
static class SecurityTestConfig { 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 @Bean
SecurityFilterChain csrfDisabledFilterChain(HttpSecurity http) throws Exception { SecurityFilterChain csrfDisabledFilterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable()) http.csrf(csrf -> csrf.disable())
@@ -7,8 +7,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import dev.caskeleton.adapter.inbound.web.auth.AuthenticatedPrincipal; 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.envelope.EnvelopeBodyAdvice;
import dev.caskeleton.adapter.inbound.web.observability.MdcKeys; 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.settings.SecuritySettings;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
@@ -68,6 +70,7 @@ import org.springframework.test.web.servlet.MockMvc;
*/ */
@WebMvcTest(controllers = StudioSessionController.class) @WebMvcTest(controllers = StudioSessionController.class)
@Import({ @Import({
PresentationWebConfig.class,
StudioSessionController.class, StudioSessionController.class,
EnvelopeBodyAdvice.class, EnvelopeBodyAdvice.class,
StudioSessionEnvelopeTest.SecurityTestConfig.class StudioSessionEnvelopeTest.SecurityTestConfig.class
@@ -111,6 +114,16 @@ class StudioSessionEnvelopeTest {
@EnableWebSecurity @EnableWebSecurity
static class SecurityTestConfig { 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 @Bean
SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception { SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated()); http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
@@ -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 * <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 * two {@code @ConditionalOnProperty} vendor configurations would both stay off, and the first
* missing SPI bean would surface as a {@code NoSuchBeanDefinitionException} naming * missing SPI bean would surface as a {@code NoSuchBeanDefinitionException} naming {@code
* {@code OutboxClaimRepository} — a symptom several layers away from the misspelled value that * OutboxClaimRepository} — a symptom several layers away from the misspelled value that caused it.
* caused it.
*/ */
@ConfigurationProperties(prefix = PersistenceVendorSettings.PREFIX) @ConfigurationProperties(prefix = PersistenceVendorSettings.PREFIX)
public record PersistenceVendorSettings(Vendor vendor) { public record PersistenceVendorSettings(Vendor vendor) {
@@ -12,8 +12,8 @@ import org.jspecify.annotations.Nullable;
* H2 atomic scope claim. * H2 atomic scope claim.
* *
* <p>H2 has no {@code INSERT ... ON CONFLICT ... DO UPDATE ... RETURNING}, so the PostgreSQL * <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 * statement does not port. The standard {@code MERGE ... USING} does, and carries the same meaning
* meaning in one statement: * in one statement:
* *
* <ul> * <ul>
* <li>no row for the scope → {@code WHEN NOT MATCHED} inserts the claim (1 row); * <li>no row for the scope → {@code WHEN NOT MATCHED} inserts the claim (1 row);
@@ -15,12 +15,12 @@ import org.springframework.jdbc.core.JdbcOperations;
* <li><b>Session scope, not transaction scope.</b> PostgreSQL takes {@code set_config(..., true)} * <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 * — 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 * 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; * transaction port applies these before every transaction, so each one overwrites the last; a
* a connection borrowed outside that path keeps the previous transaction's guard. * connection borrowed outside that path keeps the previous transaction's guard.
* <li><b>No idle-in-transaction guard.</b> H2 has no counterpart to * <li><b>No idle-in-transaction guard.</b> H2 has no counterpart to {@code
* {@code idle_in_transaction_session_timeout}, so that budget cannot be pushed into the * idle_in_transaction_session_timeout}, so that budget cannot be pushed into the database
* database here. It is left to the caller-side deadline the transaction port already * here. It is left to the caller-side deadline the transaction port already enforces, rather
* enforces, rather than silently reported as applied. * than silently reported as applied.
* </ul> * </ul>
* *
* <p>The millisecond values are inlined because H2's {@code SET} takes no bind parameter. They * <p>The millisecond values are inlined because H2's {@code SET} takes no bind parameter. They
@@ -15,13 +15,13 @@ import org.springframework.jdbc.core.JdbcOperations;
/** /**
* H2 vendor persistence configuration — the same four SPI beans the PostgreSQL vendor registers, * 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 * implemented against H2. Selected by {@code ca-skeleton.persistence.vendor=h2}, which the {@code
* {@code local} profile sets. * local} profile sets.
* *
* <p><b>No Flyway location customizer, deliberately.</b> The PostgreSQL vendor points Flyway at * <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 * {@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 * profile turns Flyway off and lets Hibernate derive the schema from the entities. Two consequences
* consequences worth stating out loud: * worth stating out loud:
* *
* <ul> * <ul>
* <li>Tables that exist only in migrations — the capability schema registry, the polling-delivery * <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. * 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 * <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 * 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 * PostgreSQL DDL H2 cannot parse. Such a fork should register its own {@code
* {@code FlywayConfigurationCustomizer} naming an H2 location. * FlywayConfigurationCustomizer} naming an H2 location.
* </ul> * </ul>
* *
* <p>Local therefore verifies wiring and behaviour, not migrations. Migration and vendor-concurrency * <p>Local therefore verifies wiring and behaviour, not migrations. Migration and
* fidelity stay with the real-PostgreSQL integration suites. * vendor-concurrency fidelity stay with the real-PostgreSQL integration suites.
*/ */
@Configuration(proxyBeanMethods = false) @Configuration(proxyBeanMethods = false)
@ConditionalOnProperty( @ConditionalOnProperty(
@@ -31,8 +31,8 @@ import org.springframework.transaction.support.TransactionTemplate;
* USING}, and only an execution proves that the substitution kept the three outcomes intact. * 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, * <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 * nothing to skip when Docker is absent. Real-PostgreSQL fidelity remains the job of the {@code
* {@code postgresqlIntegrationTest} source set. * postgresqlIntegrationTest} source set.
*/ */
class H2ClaimSqlTest { class H2ClaimSqlTest {
@@ -142,8 +142,9 @@ class H2ClaimSqlTest {
List<OutboxEventEntity> claimed = claimEligible(now, 10); List<OutboxEventEntity> claimed = claimEligible(now, 10);
assertThat(claimed).extracting(OutboxEventEntity::getEventId).containsExactly("evt-old", assertThat(claimed)
"evt-other"); .extracting(OutboxEventEntity::getEventId)
.containsExactly("evt-old", "evt-other");
} }
@Test @Test
@@ -6,7 +6,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; 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.envelope.EnvelopeBodyAdvice;
import dev.caskeleton.adapter.inbound.web.settings.PresentationSettings;
import dev.caskeleton.adapter.inbound.web.settings.SecuritySettings; 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.StudioCatalogController;
import dev.caskeleton.adapter.inbound.web.techlog.studio.controller.StudioSessionController; 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 * 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 * 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. * config/openapi/studio-v1.yaml} for every {@code /api/v1/studio/**} path it actually exposes. Only
* Only <em>implemented</em> operations are checked (direction is "published ⊆ contract", never the * <em>implemented</em> operations are checked (direction is "published ⊆ contract", never the
* reverse), so this stays green as slices 2-5 add the other 17 operations — <b>on one condition</b>: * reverse), so this stays green as slices 2-5 add the other 17 operations — <b>on one
* the new controllers must live somewhere under {@code dev.caskeleton.adapter.inbound.web.techlog}, * condition</b>: the new controllers must live somewhere under {@code
* the package {@link ContractSurface.ContractSurfaceApp} and {@link EnvelopeWrapping.EnvelopeApp} * dev.caskeleton.adapter.inbound.web.techlog}, the package {@link
* {@code @ComponentScan}. A controller placed there is picked up automatically, with no edit to this * ContractSurface.ContractSurfaceApp} and {@link EnvelopeWrapping.EnvelopeApp}
* file. A controller placed <em>outside</em> that package tree is invisible to both minimal contexts * {@code @ComponentScan}. A controller placed there is picked up automatically, with no edit to
* — springdoc never sees it, so this gate stays green even if its path/method/operationId contradicts * this file. A controller placed <em>outside</em> that package tree is invisible to both minimal
* the contract — and the {@code @ComponentScan} base package below must be widened (or the new * contexts — springdoc never sees it, so this gate stays green even if its path/method/operationId
* controller moved) before this gate can be trusted again. (An earlier draft of this class named the * contradicts the contract — and the {@code @ComponentScan} base package below must be widened (or
* two controllers directly via {@code @Import} instead of scanning; that hardcoded list had exactly * the new controller moved) before this gate can be trusted again. (An earlier draft of this class
* this blind spot — confirmed by temporarily reintroducing it and observing a controller with an * named the two controllers directly via {@code @Import} instead of scanning; that hardcoded list
* out-of-contract mapping pass silently, see task-10-report.md.) This test also fails the moment an * had exactly this blind spot — confirmed by temporarily reintroducing it and observing a
* in-scan controller's method name drifts from its {@code operationId} or ships an endpoint outside * controller with an out-of-contract mapping pass silently, see task-10-report.md.) This test also
* the contract. * fails the moment an in-scan controller's method name drifts from its {@code operationId} or ships
* an endpoint outside the contract.
* *
* <h2>Why a hand-built minimal context rather than {@code CaSkeletonApplication}</h2> * <h2>Why a hand-built minimal context rather than {@code CaSkeletonApplication}</h2>
* *
* <p>This repository's own tests never boot the full app under test: {@code * <p>This repository's own tests never boot the full app under test: {@code
* FileserverRoundTripContractTest} and {@code ActuatorSecurityHttpTest} (both in this module's * FileserverRoundTripContractTest} and {@code ActuatorSecurityHttpTest} (both in this module's
* {@code src/test}) spell out why — {@code application.yml} resolves ~50 {@code ${...}} 자리표시자 * {@code src/test}) spell out why — {@code application.yml} resolves ~50 {@code ${...}} 자리표시자 from
* from {@code src/.env} (datasource, OIDC issuer, Redis, messaging, ...), so a full boot drags in * {@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 * 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 * playbook: a throwaway {@code @SpringBootConfiguration} that {@code @ComponentScan}s the studio
* package (so real production controllers like {@link StudioSessionController} and {@link * 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 — * 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} * 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 * excluded and MockMvc filters off ({@code addFilters = false}) — the exclude/addFilters
* {@code EnvelopeBodyAdviceTest} and {@code NoResourceFoundErrorHandlingTest} (adapter-inbound-web's * combination {@code EnvelopeBodyAdviceTest} and {@code NoResourceFoundErrorHandlingTest}
* own test sourceSet) already use for this class of test. * (adapter-inbound-web's own test sourceSet) already use for this class of test.
* *
* <p>Because this functionalTest module depends only on {@code :adapter:inbound:web} and {@code * <p>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 * :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 * the DataSource/Flyway/Redis auto-configuration classes are even on the classpath for
* @EnableAutoConfiguration} to attempt — there is nothing to exclude for them, unlike {@code * {@code @EnableAutoConfiguration} to attempt — there is nothing to exclude for them, unlike {@code
* ActuatorSecurityHttpTest}'s explicit JPA/Flyway exclude list. * ActuatorSecurityHttpTest}'s explicit JPA/Flyway exclude list.
* *
* <p>{@code @SpringBootTest} (full, unsliced {@code @EnableAutoConfiguration}) is used instead of * <p>{@code @SpringBootTest} (full, unsliced {@code @EnableAutoConfiguration}) is used instead of
* {@code @WebMvcTest}: springdoc's own auto-configuration is a third-party {@code * {@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, * 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 * 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" * only sees two modules' worth of dependencies keeps the cost bounded to "start web MVC +
* without paying for DB/security infrastructure. * springdoc" without paying for DB/security infrastructure.
* *
* <h2>Why two nested contexts instead of one</h2> * <h2>Why two nested contexts instead of one</h2>
* *
* <p>The obvious design is one shared {@code @SpringBootTest} context for both tests. That does not * <p>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 * work here, and the reason is worth recording: springdoc's {@code /v3/api-docs} handler ({@code
* ({@code OpenApiWebMvcResource.openapiJson}) returns raw {@code byte[]} — it serializes the OpenAPI * OpenApiWebMvcResource.openapiJson}) returns raw {@code byte[]} — it serializes the OpenAPI model
* model itself and hands Spring MVC already-encoded bytes. {@link EnvelopeBodyAdvice#supports} * itself and hands Spring MVC already-encoded bytes. {@link EnvelopeBodyAdvice#supports} returns
* returns {@code true} unconditionally (by design — it wraps every controller response in the real * {@code true} unconditionally (by design — it wraps every controller response in the real app, not
* app, not just Studio's), so if it is on the classpath of *that* request it rewrites the body from * just Studio's), so if it is on the classpath of *that* request it rewrites the body from {@code
* {@code byte[]} to {@code Envelope<byte[]>} — but Spring MVC picks the {@code HttpMessageConverter} * byte[]} to {@code Envelope<byte[]>} — but Spring MVC picks the {@code HttpMessageConverter} from
* from the *original* return type before the advice runs, so {@code ByteArrayHttpMessageConverter} * 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 * (already selected for {@code byte[]}) is then asked to write an {@code Envelope}, and {@code
* {@code writeInternal} throws {@code ClassCastException: Envelope cannot be cast to [B}. This * writeInternal} throws {@code ClassCastException: Envelope cannot be cast to [B}. This reproduced
* reproduced with a full stack trace during this task (see task-10-report.md) — it is a real, * with a full stack trace during this task (see task-10-report.md) — it is a real, pre-existing
* pre-existing defect in shared skeleton code ({@code EnvelopeBodyAdvice} is not Studio-owned and * defect in shared skeleton code ({@code EnvelopeBodyAdvice} is not Studio-owned and not part of
* not part of this task's brief), not an artifact of this test's plumbing: any app that boots both * this task's brief), not an artifact of this test's plumbing: any app that boots both springdoc
* springdoc and {@code EnvelopeBodyAdvice} together and serves {@code /v3/api-docs} unauthenticated * and {@code EnvelopeBodyAdvice} together and serves {@code /v3/api-docs} unauthenticated would hit
* would hit the same crash. Fixing that advice is out of scope for a contract-regression test, so * the same crash. Fixing that advice is out of scope for a contract-regression test, so {@link
* {@link ContractSurface} boots a context *without* {@link EnvelopeBodyAdvice} (springdoc doesn't * ContractSurface} boots a context *without* {@link EnvelopeBodyAdvice} (springdoc doesn't invoke
* invoke it for anything test 1 checks anyway — introspection is pure reflection over the mapping), * it for anything test 1 checks anyway — introspection is pure reflection over the mapping), and
* and {@link EnvelopeWrapping} boots a separate context *with* it, hitting * {@link EnvelopeWrapping} boots a separate context *with* it, hitting {@link
* {@link StudioCatalogController} instead, whose {@code CatalogPage} return type is an ordinary POJO * StudioCatalogController} instead, whose {@code CatalogPage} return type is an ordinary POJO that
* that the same JSON converter handles before and after wrapping. * the same JSON converter handles before and after wrapping.
* *
* <h2>Why {@link ListCatalogUseCase} is real, not mocked</h2> * <h2>Why {@link ListCatalogUseCase} is real, not mocked</h2>
* *
@@ -125,12 +128,12 @@ import org.yaml.snakeyaml.Yaml;
* than skip the envelope assertion or force real persistence/security infrastructure into a * 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 * contract-shape test, {@link EnvelopeWrapping} proves the same regression the brief wants — {@link
* EnvelopeBodyAdvice} still wraps {@link StudioCatalogController}'s response — against a minimal * EnvelopeBodyAdvice} still wraps {@link StudioCatalogController}'s response — against a minimal
* slice. Envelope wrapping is a {@code ResponseBodyAdvice} concern that is orthogonal to persistence * slice. Envelope wrapping is a {@code ResponseBodyAdvice} concern that is orthogonal to
* and authentication, so stubbing those out does not weaken what the assertion proves, and no * persistence and authentication, so stubbing those out does not weaken what the assertion proves,
* production security surface changes: {@code SECURITY_PUBLIC_PATHS} is untouched, and this slice * and no production security surface changes: {@code SECURITY_PUBLIC_PATHS} is untouched, and this
* simply never wires a {@code SecurityFilterChain} at all (same as the two adapter-inbound-web * slice simply never wires a {@code SecurityFilterChain} at all (same as the two
* precedents cited above), rather than widening what unauthenticated callers may reach in the real * adapter-inbound-web precedents cited above), rather than widening what unauthenticated callers
* app. * may reach in the real app.
*/ */
class StudioContractDriftTest { class StudioContractDriftTest {
@@ -142,9 +145,8 @@ class StudioContractDriftTest {
@Autowired private MockMvc mvc; @Autowired private MockMvc mvc;
/** /**
* "구현된 것만 검사" 방향 고정: 계약(19개 operation)이 아니라 published(springdoc이 실제로 내놓는 것, 지금은 2개)를 * "구현된 것만 검사" 방향 고정: 계약(19개 operation)이 아니라 published(springdoc이 실제로 내놓는 것, 지금은 2개)를 순회한다. 슬라이스
* 순회한다. 슬라이스 2~5가 나머지 17개를 추가해도 이 순회 방향 덕분에 이 테스트는 그대로 통과한다 — 반대로 순회했다면 미구현 operation마다 * 2~5가 나머지 17개를 추가해도 이 순회 방향 덕분에 이 테스트는 그대로 통과한다 — 반대로 순회했다면 미구현 operation마다 매번 실패했을 것이다.
* 매번 실패했을 것이다.
*/ */
@Test @Test
void publishedStudioOperationsMatchTheContract() throws Exception { void publishedStudioOperationsMatchTheContract() throws Exception {
@@ -152,11 +154,13 @@ class StudioContractDriftTest {
JsonNode published = readPublishedApiDocs(); JsonNode published = readPublishedApiDocs();
List<String> problems = new ArrayList<>(); List<String> problems = new ArrayList<>();
List<String> compared = new ArrayList<>();
JsonNode publishedPaths = published.path("paths"); JsonNode publishedPaths = published.path("paths");
for (Map.Entry<String, JsonNode> path : publishedPaths.properties()) { for (Map.Entry<String, JsonNode> path : publishedPaths.properties()) {
if (!path.getKey().startsWith("/api/v1/studio/")) { if (!path.getKey().startsWith("/api/v1/studio/")) {
continue; continue;
} }
compared.add(path.getKey());
JsonNode contractPath = contract.path("paths").path(path.getKey()); JsonNode contractPath = contract.path("paths").path(path.getKey());
if (contractPath.isMissingNode()) { if (contractPath.isMissingNode()) {
problems.add("계약에 없는 path: " + path.getKey()); problems.add("계약에 없는 path: " + path.getKey());
@@ -184,6 +188,16 @@ class StudioContractDriftTest {
} }
} }
assertThat(problems).isEmpty(); 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); 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 { private JsonNode readPublishedApiDocs() throws Exception {
String body = String body =
mvc.perform(get("/v3/api-docs")) mvc.perform(get("/api/v3/api-docs"))
.andExpect(status().isOk()) .andExpect(status().isOk())
.andReturn() .andReturn()
.getResponse() .getResponse()
@@ -215,20 +234,30 @@ class StudioContractDriftTest {
} }
/** /**
* {@code @Import}로 컨트롤러를 하나씩 나열하지 않고 {@code @ComponentScan}으로 studio web 패키지 전체를 스캔한다. * {@code @Import}로 컨트롤러를 하나씩 나열하지 않고 {@code @ComponentScan}으로 studio web 패키지 전체를 스캔한다. 나열 방식은
* 나열 방식은 슬라이스 2~5가 새 컨트롤러를 추가해도 이 파일을 고치지 않는 한 published 표면에 안 잡히는 채로 green을 유지하는 * 슬라이스 2~5가 새 컨트롤러를 추가해도 이 파일을 고치지 않는 한 published 표면에 안 잡히는 채로 green을 유지하는 함정이 있었다 — 드리프트가 "없는"
* 함정이 있었다 — 드리프트가 "없는" 게 아니라 게이트가 "못 보는" 상태였다. 리뷰에서 이 패키지 아래에 계약 밖 매핑을 가진 임시 * 게 아니라 게이트가 "못 보는" 상태였다. 리뷰에서 이 패키지 아래에 계약 밖 매핑을 가진 임시 컨트롤러를 하나 추가해(어떤
* 컨트롤러를 하나 추가해(어떤 {@code @Import}/{@code @ComponentScan} 목록에도 안 넣고) 실측으로 확인했다 — 옛 {@code * {@code @Import}/{@code @ComponentScan} 목록에도 안 넣고) 실측으로 확인했다 — 옛 {@code @Import} 목록으로는 이 테스트가
* @Import} 목록으로는 이 테스트가 통과, 이 {@code @ComponentScan}으로는 실패. 재현 절차와 두 결과 모두 * 통과, 이 {@code @ComponentScan}으로는 실패. 재현 절차와 두 결과 모두 task-10-report.md의 "자동 포함 성질 RED 검증" 절에 남아
* task-10-report.md의 "자동 포함 성질 RED 검증" 절에 남아 있다. {@code EnvelopeBodyAdvice}는 이 패키지 밖({@code * 있다. {@code EnvelopeBodyAdvice}는 이 패키지 밖({@code ...web.envelope})이라 스캔에 안 걸린다 — 일부러 두지 않는다(클래스
* ...web.envelope})이라 스캔에 안 걸린다 — 일부러 두지 않는다(클래스 javadoc "Why two nested contexts" 참조). * javadoc "Why two nested contexts" 참조). springdoc은 리플렉션만 하므로 이 test1엔 애초에 관여하지 않는다.
* springdoc은 리플렉션만 하므로 이 test1엔 애초에 관여하지 않는다.
*/ */
@SpringBootConfiguration @SpringBootConfiguration
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class) @EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
@ComponentScan("dev.caskeleton.adapter.inbound.web.techlog") @ComponentScan("dev.caskeleton.adapter.inbound.web.techlog")
@Import(PresentationWebConfig.class)
static class ContractSurfaceApp { 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 @Bean
SecuritySettings securitySettings() { SecuritySettings securitySettings() {
return StudioContractDriftTest.securitySettingsForTest(); return StudioContractDriftTest.securitySettingsForTest();
@@ -262,8 +291,8 @@ class StudioContractDriftTest {
} }
/** /**
* {@code adapter-inbound-web}에 실제 애플리케이션이 없어({@code CaSkeletonApplication}은 app-bootstrap * {@code adapter-inbound-web}에 실제 애플리케이션이 없어({@code CaSkeletonApplication}은 app-bootstrap 소유)
* 소유) {@code @SpringBootTest}가 부트스트랩할 {@code @SpringBootConfiguration}이 필요하다 — {@code * {@code @SpringBootTest}가 부트스트랩할 {@code @SpringBootConfiguration}이 필요하다 — {@code
* StudioSessionEnvelopeTest}(adapter-inbound-web 자체 테스트)가 쓰는 것과 같은 이유의 같은 패턴. {@link * StudioSessionEnvelopeTest}(adapter-inbound-web 자체 테스트)가 쓰는 것과 같은 이유의 같은 패턴. {@link
* ContractSurface.ContractSurfaceApp}과 마찬가지로 {@code @ComponentScan}으로 studio web 패키지를 스캔하고, * ContractSurface.ContractSurfaceApp}과 마찬가지로 {@code @ComponentScan}으로 studio web 패키지를 스캔하고,
* {@link EnvelopeBodyAdvice}만 별도로 {@code @Import}한다(스캔 범위 밖 패키지라서) — 이 컨텍스트는 {@code * {@link EnvelopeBodyAdvice}만 별도로 {@code @Import}한다(스캔 범위 밖 패키지라서) — 이 컨텍스트는 {@code
@@ -272,9 +301,19 @@ class StudioContractDriftTest {
@SpringBootConfiguration @SpringBootConfiguration
@EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class) @EnableAutoConfiguration(exclude = SecurityAutoConfiguration.class)
@ComponentScan("dev.caskeleton.adapter.inbound.web.techlog") @ComponentScan("dev.caskeleton.adapter.inbound.web.techlog")
@Import(EnvelopeBodyAdvice.class) @Import({EnvelopeBodyAdvice.class, PresentationWebConfig.class})
static class EnvelopeApp { 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 @Bean
SecuritySettings securitySettings() { SecuritySettings securitySettings() {
return StudioContractDriftTest.securitySettingsForTest(); 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() { private static SecuritySettings securitySettingsForTest() {
SecuritySettings.SessionCookieSettings session = SecuritySettings.SessionCookieSettings session =
new SecuritySettings.SessionCookieSettings( new SecuritySettings.SessionCookieSettings(
null, null, null, null, null, null, "X-CSRF-TOKEN"); null, null, null, null, null, null, "X-CSRF-TOKEN");
return new SecuritySettings( 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() { private static ListCatalogUseCase listCatalogUseCaseForTest() {
return new ListCatalogUseCase(new StubCatalogQueryPort(), new PassThroughTransactionPort()); return new ListCatalogUseCase(new StubCatalogQueryPort(), new PassThroughTransactionPort());
@@ -27,11 +27,11 @@ import org.yaml.snakeyaml.Yaml;
* *
* <ol> * <ol>
* <li>every {@link StudioError} constant has a matching registry row (code presence); * <li>every {@link StudioError} constant has a matching registry row (code presence);
* <li>that row's {@code category}/{@code http_status}/{@code retryable} match the enum's * <li>that row's {@code category}/{@code http_status}/{@code retryable} match the enum's declared
* declared values exactly — a standing drift gate. Nothing else in the suite checks this for * values exactly — a standing drift gate. Nothing else in the suite checks this for {@link
* {@link StudioError}: {@code ErrorCodeRegistryMappingTest} (a different branch's template) * StudioError}: {@code ErrorCodeRegistryMappingTest} (a different branch's template) only
* only walks {@code OperationalError}. Its absence let the {@code VALIDATION_FAILED} ↔ * walks {@code OperationalError}. Its absence let the {@code VALIDATION_FAILED} ↔ {@code
* {@code OperationalError.VALIDATION_FAILED} code-name collision ship once already (see * OperationalError.VALIDATION_FAILED} code-name collision ship once already (see
* task-5-report.md Fix Round 1) — this closes that gap for good; * task-5-report.md Fix Round 1) — this closes that gap for good;
* <li>{@link StudioClientSafeMessages#forError(StudioError)}'s text matches the row's {@code * <li>{@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 * 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 * 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 * registry with SnakeYaml — the same library/pattern {@code ErrorCodeRegistryMappingTest} and
* {@code RunbookCoverageContractTest} already use in this suite — rather than line-scanning, since * {@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), * this test needs structured field access (category/http_status/retryable/client_safe_message), not
* not just the {@code code:} key. * just the {@code code:} key.
*/ */
class StudioErrorRegistryTest { class StudioErrorRegistryTest {
@@ -102,8 +102,8 @@ class StudioErrorRegistryTest {
/** /**
* {@code StudioClientSafeMessages} must never drift from the registry's {@code * {@code StudioClientSafeMessages} must never drift from the registry's {@code
* client_safe_message} — that column is the single source of truth for what {@code * client_safe_message} — that column is the single source of truth for what {@code error.message}
* error.message} clients see (task-5-report.md Important 1). * clients see (task-5-report.md Important 1).
*/ */
@Test @Test
void everyStudioErrorClientSafeMessageMatchesRegistry() { void everyStudioErrorClientSafeMessageMatchesRegistry() {
@@ -118,14 +118,14 @@ class StudioErrorRegistryTest {
} }
/** /**
* final whole-branch review B3: {@code StudioErrorTest.declaresExactlyTheTwentyThreeContractCodes} * final whole-branch review B3: {@code
* only counts ({@code hasSize(23)}) — it never reads the contract, so a rename or a 1:1 code * StudioErrorTest.declaresExactlyTheTwentyThreeContractCodes} only counts ({@code hasSize(23)}) —
* substitution on either side (enum or {@code studio-v1.yaml}) leaves the count at 23 and passes. * it never reads the contract, so a rename or a 1:1 code substitution on either side (enum or
* This is the gate that reads {@code src/config/openapi/studio-v1.yaml}'s {@code * {@code studio-v1.yaml}) leaves the count at 23 and passes. This is the gate that reads {@code
* components.schemas.ApiError.properties.code.enum} and requires the two sets to be identical in * src/config/openapi/studio-v1.yaml}'s {@code components.schemas.ApiError.properties.code.enum}
* both directions — a code present only in the contract, or only in the enum, fails here. This * and requires the two sets to be identical in both directions — a code present only in the
* drift already happened once for real (Task 5's vendor copy carrying stale names) and a human * contract, or only in the enum, fails here. This drift already happened once for real (Task 5's
* caught it, not a gate; this closes that gap. * vendor copy carrying stale names) and a human caught it, not a gate; this closes that gap.
*/ */
@Test @Test
void enumMatchesContractCodeSetExactly() throws Exception { void enumMatchesContractCodeSetExactly() throws Exception {
@@ -180,8 +180,8 @@ class StudioErrorRegistryTest {
} }
/** /**
* Parses lines shaped {@code <hex sha256> <filename>}, skipping {@code #}-prefixed comment * Parses lines shaped {@code <hex sha256> <filename>}, skipping {@code #}-prefixed comment lines
* lines such as MANIFEST.sha256's {@code # source: ...} provenance line. * such as MANIFEST.sha256's {@code # source: ...} provenance line.
*/ */
private static String recordedSha256(Path manifest, String filename) throws IOException { private static String recordedSha256(Path manifest, String filename) throws IOException {
return Files.readAllLines(manifest).stream() return Files.readAllLines(manifest).stream()
@@ -7,8 +7,8 @@ import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule; import com.tngtech.archunit.lang.ArchRule;
/** /**
* 설계 08장의 bounded context 경계를 빌드로 강제한다. Gradle leaf를 늘리지 않고 * 설계 08장의 bounded context 경계를 빌드로 강제한다. Gradle leaf를 늘리지 않고 패키지로 나눴으므로(spec D1/D2) 경계는 이 규칙이 유일한
* 패키지로 나눴으므로(spec D1/D2) 경계는 이 규칙이 유일한 방어선이다. * 방어선이다.
*/ */
@AnalyzeClasses(packages = "dev.caskeleton", importOptions = ProductionClassImportOption.class) @AnalyzeClasses(packages = "dev.caskeleton", importOptions = ProductionClassImportOption.class)
class TechLogBoundaryArchTest { class TechLogBoundaryArchTest {
@@ -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 * 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} * {@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 * 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, * covered where it can be: {@code H2ClaimSqlTest} runs the H2 statements against a real H2, {@code
* {@code PersistenceVendorSelectionTest} covers the selector, and * PersistenceVendorSelectionTest} covers the selector, and {@code
* {@code PersistenceVendorProdSafetyValidatorTest} covers the prod refusal. What is left, and what * PersistenceVendorProdSafetyValidatorTest} covers the prod refusal. What is left, and what this
* this test guards, is the wiring between them drifting — a profile quietly changing vendor, or * test guards, is the wiring between them drifting — a profile quietly changing vendor, or local
* local regaining a migration expectation it cannot satisfy. * regaining a migration expectation it cannot satisfy.
*/ */
class ProfileSeparationContractTest { class ProfileSeparationContractTest {
@@ -136,7 +136,8 @@ class ProfileSeparationContractTest {
return path; 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) {} 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<String, Placeholder> placeholders() throws IOException { private static Map<String, Placeholder> placeholders() throws IOException {
Pattern syntax = Pattern.compile("^\\$\\{([A-Z0-9_]+)(?::(.*))?}$"); Pattern syntax = Pattern.compile("^\\$\\{([A-Z0-9_]+)(?::(.*))?}$");
Map<String, Placeholder> found = new LinkedHashMap<>(); Map<String, Placeholder> found = new LinkedHashMap<>();
@@ -23,9 +23,9 @@ import org.yaml.snakeyaml.constructor.SafeConstructor;
* configured header name disagrees with the contract const. That controller is an unconditional * configured header name disagrees with the contract const. That controller is an unconditional
* {@code @RestController} bean, so the constructor failure becomes a {@code BeanCreationException} * {@code @RestController} bean, so the constructor failure becomes a {@code BeanCreationException}
* during context refresh — the process never starts, taking healthcheck/actuator/fileserver down * 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; * with it. Only {@code application-dev.yml} declared the override before this fix; {@code
* {@code application-local.yml} (the profile {@code src/.env:8} actually activates) and * application-local.yml} (the profile {@code src/.env:8} actually activates) and {@code
* {@code application-prod.yml} both resolved to the template default {@code X-XSRF-TOKEN} * 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 * (application.yml:498's inline default, restated verbatim by {@code src/.env:125}), so both
* profiles could not boot. * profiles could not boot.
* *
@@ -54,8 +54,7 @@ class StudioSessionCsrfHeaderProfileContractTest {
} }
private static String csrfHeaderNameOf(Map<?, ?> configuration) { private static String csrfHeaderNameOf(Map<?, ?> configuration) {
Map<?, ?> session = Map<?, ?> session = child(child(child(configuration, "ca-skeleton"), "security"), "session");
child(child(child(configuration, "ca-skeleton"), "security"), "session");
Object value = session == null ? null : session.get("csrf-header-name"); Object value = session == null ? null : session.get("csrf-header-name");
return value == null ? null : value.toString(); return value == null ? null : value.toString();
} }