From fba3b36893a46d36dfe91560bce01227cbb5b3ae Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 15:12:33 +0900 Subject: [PATCH 1/4] test(api): require explicit v1 negotiation contract --- .../config/ApiVersionWebFilterTest.java | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 src/test/java/com/clearfolio/viewer/config/ApiVersionWebFilterTest.java diff --git a/src/test/java/com/clearfolio/viewer/config/ApiVersionWebFilterTest.java b/src/test/java/com/clearfolio/viewer/config/ApiVersionWebFilterTest.java new file mode 100644 index 000000000..7f7604241 --- /dev/null +++ b/src/test/java/com/clearfolio/viewer/config/ApiVersionWebFilterTest.java @@ -0,0 +1,113 @@ +package com.clearfolio.viewer.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.server.WebFilterChain; + +import reactor.core.publisher.Mono; + +class ApiVersionWebFilterTest { + + @Test + void nonApiRequestsBypassVersionNegotiation() { + ApiVersionWebFilter filter = new ApiVersionWebFilter(); + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/healthz")); + AtomicBoolean invoked = new AtomicBoolean(); + WebFilterChain chain = current -> { + invoked.set(true); + return Mono.empty(); + }; + + filter.filter(exchange, chain).block(); + + assertThat(invoked).isTrue(); + assertThat(exchange.getResponse().getHeaders()).doesNotContainKey(ApiVersionWebFilter.VERSION_HEADER); + } + + @Test + void missingVersionHeaderUsesCurrentV1Contract() { + ApiVersionWebFilter filter = new ApiVersionWebFilter(); + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/api/v1/convert/jobs")); + AtomicBoolean invoked = new AtomicBoolean(); + + filter.filter(exchange, current -> { + invoked.set(true); + return Mono.empty(); + }).block(); + + assertThat(invoked).isTrue(); + assertThat(exchange.getResponse().getHeaders().getFirst(ApiVersionWebFilter.VERSION_HEADER)) + .isEqualTo(ApiVersionWebFilter.CURRENT_VERSION); + } + + @Test + void explicitCurrentVersionIsAcceptedWithoutChangingRouting() { + ApiVersionWebFilter filter = new ApiVersionWebFilter(); + MockServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.get("/api/v1/viewer/00000000-0000-0000-0000-000000000000") + .header(ApiVersionWebFilter.VERSION_HEADER, ApiVersionWebFilter.CURRENT_VERSION) + ); + AtomicBoolean invoked = new AtomicBoolean(); + + filter.filter(exchange, current -> { + invoked.set(true); + return Mono.empty(); + }).block(); + + assertThat(invoked).isTrue(); + assertThat(exchange.getResponse().getHeaders().getFirst(ApiVersionWebFilter.VERSION_HEADER)) + .isEqualTo(ApiVersionWebFilter.CURRENT_VERSION); + } + + @Test + void blankVersionHeaderPreservesBackwardCompatibleV1Default() { + ApiVersionWebFilter filter = new ApiVersionWebFilter(); + MockServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.get("/api/v1/convert/jobs") + .header(ApiVersionWebFilter.VERSION_HEADER, " ") + ); + AtomicBoolean invoked = new AtomicBoolean(); + + filter.filter(exchange, current -> { + invoked.set(true); + return Mono.empty(); + }).block(); + + assertThat(invoked).isTrue(); + assertThat(exchange.getResponse().getHeaders().getFirst(ApiVersionWebFilter.VERSION_HEADER)) + .isEqualTo(ApiVersionWebFilter.CURRENT_VERSION); + } + + @Test + void unsupportedVersionFailsClosedBeforeControllerDispatch() { + ApiVersionWebFilter filter = new ApiVersionWebFilter(); + MockServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.get("/api/v1/convert/jobs") + .header(ApiVersionWebFilter.VERSION_HEADER, "v2") + ); + AtomicBoolean invoked = new AtomicBoolean(); + + filter.filter(exchange, current -> { + invoked.set(true); + return Mono.empty(); + }).block(); + + assertThat(invoked).isFalse(); + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(exchange.getResponse().getHeaders().getFirst(ApiVersionWebFilter.VERSION_HEADER)) + .isEqualTo(ApiVersionWebFilter.CURRENT_VERSION); + assertThat(exchange.getResponse().getHeaders().getContentType().toString()).isEqualTo("application/json"); + String body = exchange.getResponse().getBodyAsString().block(); + assertThat(body) + .contains("\"errorCode\":\"UNSUPPORTED_API_VERSION\"") + .contains("\"code\":\"UNSUPPORTED_API_VERSION\"") + .contains("\"supportedVersion\":\"v1\"") + .doesNotContain("v2"); + } +} From ba45456f5a1e9ca7eb89b9310b71cf611c9965e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 15:15:29 +0900 Subject: [PATCH 2/4] feat(api): enforce v1 negotiation at request boundary --- .../viewer/config/ApiVersionWebFilter.java | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 src/main/java/com/clearfolio/viewer/config/ApiVersionWebFilter.java diff --git a/src/main/java/com/clearfolio/viewer/config/ApiVersionWebFilter.java b/src/main/java/com/clearfolio/viewer/config/ApiVersionWebFilter.java new file mode 100644 index 000000000..bc43fb5d3 --- /dev/null +++ b/src/main/java/com/clearfolio/viewer/config/ApiVersionWebFilter.java @@ -0,0 +1,83 @@ +package com.clearfolio.viewer.config; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; + +import reactor.core.publisher.Mono; + +/** + * Publishes the current HTTP API version and rejects explicitly unsupported + * client version requests before controller dispatch. + * + *

Clearfolio's shipped public routes are currently rooted at {@code /api/v1}. + * Existing clients that do not send a version header therefore continue to use + * that path-selected contract. Clients that do send the version header must + * request the exact current version; unsupported values fail closed with a + * controlled error envelope instead of being silently interpreted as v1. + */ +@Component +public final class ApiVersionWebFilter implements WebFilter { + + /** + * Response/request header used for explicit API contract negotiation. + */ + public static final String VERSION_HEADER = "X-Clearfolio-Api-Version"; + + /** + * Version identifier for the currently shipped {@code /api/v1} contract. + */ + public static final String CURRENT_VERSION = "v1"; + + /** + * Creates the stateless API-version negotiation filter. + */ + public ApiVersionWebFilter() { + // No mutable state is required for exact version negotiation. + } + + /** + * Applies version negotiation to public API routes while leaving non-API + * surfaces unchanged. + * + * @param exchange current reactive HTTP exchange + * @param chain remaining WebFlux filter chain + * @return completion signal for the filtered request + */ + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + String path = exchange.getRequest().getPath().value(); + if (!path.startsWith("/api/")) { + return chain.filter(exchange); + } + + exchange.getResponse().getHeaders().set(VERSION_HEADER, CURRENT_VERSION); + String requestedVersion = exchange.getRequest().getHeaders().getFirst(VERSION_HEADER); + if (requestedVersion == null + || requestedVersion.isBlank() + || CURRENT_VERSION.equals(requestedVersion)) { + return chain.filter(exchange); + } + + exchange.getResponse().setStatusCode(HttpStatus.BAD_REQUEST); + exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); + byte[] body = unsupportedVersionBody(); + return exchange.getResponse().writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(body))); + } + + private byte[] unsupportedVersionBody() { + String body = ("{\"errorCode\":\"UNSUPPORTED_API_VERSION\"," + + "\"message\":\"Unsupported API version\"," + + "\"traceId\":\"%s\"," + + "\"details\":{\"supportedVersion\":\"v1\"}," + + "\"code\":\"UNSUPPORTED_API_VERSION\"}") + .formatted(UUID.randomUUID()); + return body.getBytes(StandardCharsets.UTF_8); + } +} From e02655f122757b71b4991a04a77a7d3c6c9843e7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 17:07:42 +0900 Subject: [PATCH 3/4] test(api): reject ambiguous duplicate version headers --- .../config/ApiVersionWebFilterTest.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/test/java/com/clearfolio/viewer/config/ApiVersionWebFilterTest.java b/src/test/java/com/clearfolio/viewer/config/ApiVersionWebFilterTest.java index 7f7604241..30695a674 100644 --- a/src/test/java/com/clearfolio/viewer/config/ApiVersionWebFilterTest.java +++ b/src/test/java/com/clearfolio/viewer/config/ApiVersionWebFilterTest.java @@ -84,6 +84,29 @@ void blankVersionHeaderPreservesBackwardCompatibleV1Default() { .isEqualTo(ApiVersionWebFilter.CURRENT_VERSION); } + @Test + void conflictingDuplicateVersionHeadersFailClosedBeforeControllerDispatch() { + ApiVersionWebFilter filter = new ApiVersionWebFilter(); + MockServerWebExchange exchange = MockServerWebExchange.from( + MockServerHttpRequest.get("/api/v1/convert/jobs") + .header(ApiVersionWebFilter.VERSION_HEADER, ApiVersionWebFilter.CURRENT_VERSION, "v2") + ); + AtomicBoolean invoked = new AtomicBoolean(); + + filter.filter(exchange, current -> { + invoked.set(true); + return Mono.empty(); + }).block(); + + assertThat(invoked).isFalse(); + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(exchange.getResponse().getHeaders().getFirst(ApiVersionWebFilter.VERSION_HEADER)) + .isEqualTo(ApiVersionWebFilter.CURRENT_VERSION); + assertThat(exchange.getResponse().getBodyAsString().block()) + .contains("\"errorCode\":\"UNSUPPORTED_API_VERSION\"") + .doesNotContain("v2"); + } + @Test void unsupportedVersionFailsClosedBeforeControllerDispatch() { ApiVersionWebFilter filter = new ApiVersionWebFilter(); From 82d0bb30d5786fedb4fb7f079ba031bb030d1521 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 17:12:16 +0900 Subject: [PATCH 4/4] fix(api): reject ambiguous version authority --- .../viewer/config/ApiVersionWebFilter.java | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/clearfolio/viewer/config/ApiVersionWebFilter.java b/src/main/java/com/clearfolio/viewer/config/ApiVersionWebFilter.java index bc43fb5d3..71e69bc75 100644 --- a/src/main/java/com/clearfolio/viewer/config/ApiVersionWebFilter.java +++ b/src/main/java/com/clearfolio/viewer/config/ApiVersionWebFilter.java @@ -1,6 +1,7 @@ package com.clearfolio.viewer.config; import java.nio.charset.StandardCharsets; +import java.util.List; import java.util.UUID; import org.springframework.http.HttpStatus; @@ -58,12 +59,16 @@ public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { } exchange.getResponse().getHeaders().set(VERSION_HEADER, CURRENT_VERSION); - String requestedVersion = exchange.getRequest().getHeaders().getFirst(VERSION_HEADER); - if (requestedVersion == null - || requestedVersion.isBlank() - || CURRENT_VERSION.equals(requestedVersion)) { + List requestedVersions = exchange.getRequest().getHeaders().get(VERSION_HEADER); + if (requestedVersions == null) { return chain.filter(exchange); } + if (requestedVersions.size() == 1) { + String requestedVersion = requestedVersions.get(0); + if (requestedVersion.isBlank() || CURRENT_VERSION.equals(requestedVersion)) { + return chain.filter(exchange); + } + } exchange.getResponse().setStatusCode(HttpStatus.BAD_REQUEST); exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);