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;
}
@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,
@@ -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) {
@@ -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 =
@@ -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())
@@ -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());
@@ -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) {
@@ -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);
@@ -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
@@ -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(
@@ -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
@@ -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 <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>:
* 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 <em>outside</em> 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
* <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>: 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 <em>outside</em> 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.
*
* <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
* 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.
*
* <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
* 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.
*
* <p>{@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.
*
* <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
* 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<byte[]>} — 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<byte[]>} — 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.
*
* <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
* 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<String> problems = new ArrayList<>();
List<String> compared = new ArrayList<>();
JsonNode publishedPaths = published.path("paths");
for (Map.Entry<String, JsonNode> 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());
@@ -27,11 +27,11 @@ import org.yaml.snakeyaml.Yaml;
*
* <ol>
* <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
* 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
* <li>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;
* <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
@@ -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 <hex sha256> <filename>}, skipping {@code #}-prefixed comment
* lines such as MANIFEST.sha256's {@code # source: ...} provenance line.
* Parses lines shaped {@code <hex sha256> <filename>}, 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()
@@ -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 {
@@ -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<String, Placeholder> placeholders() throws IOException {
Pattern syntax = Pattern.compile("^\\$\\{([A-Z0-9_]+)(?::(.*))?}$");
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
* {@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();
}