diff --git a/dev/README.md b/dev/README.md index 12e68cc59..8932b4d68 100644 --- a/dev/README.md +++ b/dev/README.md @@ -1 +1,41 @@ -ADK development utilities such as Spring REST server for agent. \ No newline at end of file +ADK development utilities such as Spring REST server for agent. + +## Serving the dev UI + +The UI and its assets are served under `/dev-ui/`, and both `/` and `/dev-ui` +redirect there, keeping the query string. The assets are not served from the +origin root: `/adk_favicon.svg` and the like return 404, and only the `/dev-ui/` +form resolves. + +## Behind a reverse proxy + +When a gateway publishes this server under a path prefix and strips it, tell the +server the address browsers actually reach it on: + +```properties +adk.web.backend-url=https://gateway.example.com/my-app +``` + +That one value does both halves: the entry redirect carries the prefix, and the +UI's own API calls go back through it. Nothing has to be forwarded by the proxy, +and nothing is read from the request. + +It must be an absolute URL. The UI reads a value without a scheme as the host of +its live/websocket connection, so a bare `/my-app` makes that socket dial a host +named `my-app`. + +Include any `server.servlet.context-path` in the value: in the redirect it +replaces the context path rather than stacking on it. + +Leave it unset and nothing changes: the redirect is unprefixed and the bundled +`backendUrl` is served as it always was. A deployment that already restores the +prefix with Spring's own `server.forward-headers-strategy=framework` keeps +working that way; this property takes precedence over it for the redirect's +path. + +Turning that Spring setting on is a decision to trust forwarded headers, and +both the standard `Forwarded` header and the `X-Forwarded-*` family are supplied +by the client unless something overwrites them. The proxy at the edge has to +strip or overwrite both kinds arriving from outside, or a caller can tell the +server it was reached somewhere it was not. Setting `adk.web.backend-url` does +not require that setting at all. diff --git a/dev/src/main/java/com/google/adk/web/AdkWebServer.java b/dev/src/main/java/com/google/adk/web/AdkWebServer.java index b321cef27..c694b9f59 100644 --- a/dev/src/main/java/com/google/adk/web/AdkWebServer.java +++ b/dev/src/main/java/com/google/adk/web/AdkWebServer.java @@ -25,6 +25,9 @@ import com.google.adk.memory.InMemoryMemoryService; import com.google.adk.sessions.BaseSessionService; import com.google.adk.sessions.InMemorySessionService; +import com.google.adk.web.config.BackendUrl; +import com.google.adk.web.config.DevUiAssets; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -51,6 +54,20 @@ public class AdkWebServer implements WebMvcConfigurer { @Value("${adk.web.ui.dir:#{null}}") private String webUiDir; + @Value("${adk.web.backend-url:}") + private String backendUrlProperty; + + private @Nullable BackendUrl parsedBackendUrl; + + /** Parsed here once, and shared, so this and the runtime-config endpoint cannot diverge. */ + @Bean + public synchronized BackendUrl backendUrl() { + if (parsedBackendUrl == null) { + parsedBackendUrl = BackendUrl.from(backendUrlProperty); + } + return parsedBackendUrl; + } + @Bean public BaseSessionService sessionService() { // TODO: Add logic to select service based on config (e.g., DB URL) @@ -109,48 +126,41 @@ public MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter( } /** - * Configures resource handlers for serving static content (like the Dev UI). Maps requests - * starting with "/dev-ui/" to the directory specified by the 'adk.web.ui.dir' system property. + * Maps requests under "/dev-ui/" to the directory named by the 'adk.web.ui.dir' property, or to + * the bundled copy on the classpath when that is unset. */ @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { - if (webUiDir != null && !webUiDir.isEmpty()) { - // Ensure the path uses forward slashes and ends with a slash - String location = webUiDir.replace("\\", "/"); - if (!location.startsWith("file:")) { - location = "file:" + location; // Ensure file: prefix - } - if (!location.endsWith("/")) { - location += "/"; - } - log.debug("Mapping URL path /** to static resources at location: {}", location); - registry - .addResourceHandler("/**") - .addResourceLocations(location) - .setCachePeriod(0) - .resourceChain(true); - - } else { - log.debug( - "System property 'adk.web.ui.dir' or config 'adk.web.ui.dir' is not set. Mapping URL path" - + " /** to classpath:/browser/"); - registry - .addResourceHandler("/**") - .addResourceLocations("classpath:/browser/") - .setCachePeriod(0) - .resourceChain(true); - } + String location = DevUiAssets.assetRoot(webUiDir); + log.debug("Mapping URL path /dev-ui/** to static resources at location: {}", location); + registry + .addResourceHandler("/dev-ui/**") + .addResourceLocations(location) + .setCachePeriod(0) + .resourceChain(true); } /** - * Configures simple automated controllers: - Redirects the root path "/" to "/dev-ui". - Forwards - * requests to "/dev-ui" to "/dev-ui/index.html" so the ResourceHandler serves it. + * Configures simple automated controllers: "/" and "/dev-ui" both redirect to the UI, at {@code + * adk.web.backend-url}'s path when that is set, and it forwards to index.html. The trailing slash + * is required: index.html declares a {@code }, so served from "/dev-ui" the app + * resolves its own router path to "dev-ui" and matches none of its routes. The query string is + * carried across because the UI selects its agent from {@code ?app=}. */ @Override public void addViewControllers(ViewControllerRegistry registry) { - registry.addRedirectViewController("/", "/dev-ui"); - registry.addViewController("/dev-ui").setViewName("forward:/index.html"); - registry.addViewController("/dev-ui/").setViewName("forward:/index.html"); + String prefix = backendUrl().pathPrefix(); + // The configured value is the public base, so do not stack the context path on it. + boolean contextRelative = prefix.isEmpty(); + registry + .addRedirectViewController("/", prefix + "/dev-ui/") + .setKeepQueryParams(true) + .setContextRelative(contextRelative); + registry + .addRedirectViewController("/dev-ui", prefix + "/dev-ui/") + .setKeepQueryParams(true) + .setContextRelative(contextRelative); + registry.addViewController("/dev-ui/").setViewName("forward:/dev-ui/index.html"); } /** diff --git a/dev/src/main/java/com/google/adk/web/config/BackendUrl.java b/dev/src/main/java/com/google/adk/web/config/BackendUrl.java new file mode 100644 index 000000000..c4414a767 --- /dev/null +++ b/dev/src/main/java/com/google/adk/web/config/BackendUrl.java @@ -0,0 +1,107 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.web.config; + +import com.google.common.base.CharMatcher; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The address browsers reach this server on, from {@code adk.web.backend-url}. Parsed in one place, + * so the dev UI's config and its entry redirect read the same setting the same way. + */ +public final class BackendUrl { + + private static final Logger log = LoggerFactory.getLogger(BackendUrl.class); + + /** The UI strips the scheme case-sensitively, so an upper-case one is not usable. */ + private static final Pattern ABSOLUTE_URL = Pattern.compile("^https?://.+"); + + private static final BackendUrl UNSET = new BackendUrl("", ""); + + private final String value; + private final String pathPrefix; + + private BackendUrl(String value, String pathPrefix) { + this.value = value; + this.pathPrefix = pathPrefix; + } + + /** Interprets {@code configured}, warning once if it is not something the UI can use. */ + public static BackendUrl from(@Nullable String configured) { + if (configured == null || configured.trim().isEmpty()) { + return UNSET; + } + String trimmed = configured.trim(); + // A trailing slash would double up: the UI appends paths that already start with one. + String normalized = CharMatcher.is('/').trimTrailingFrom(trimmed); + String path = pathOf(normalized); + if (ABSOLUTE_URL.matcher(normalized).matches() && path != null) { + return new BackendUrl(normalized, path); + } + log.warn( + "adk.web.backend-url should be an absolute URL, but is \"{}\". The dev UI reads a value" + + " without a lower-case http:// or https:// scheme as the host of its live/websocket" + + " connection.", + trimmed); + // Served as configured: an explicit value is never silently discarded. + return new BackendUrl(trimmed, path == null ? "" : path); + } + + /** What the dev UI's runtime config reports, or empty when unset. */ + public String value() { + return value; + } + + /** + * The path a gateway strips, which the entry redirect has to carry, or empty when there is none. + * Percent-encoding is kept, because this goes into a {@code Location} header. + */ + public String pathPrefix() { + return pathPrefix; + } + + /** + * The URL's path, or null when it is absent, relative, or carries something the UI cannot use. + */ + private static @Nullable String pathOf(String url) { + URI uri; + try { + uri = new URI(url); + } catch (URISyntaxException e) { + return null; + } + // The UI appends onto the whole value, so a query, fragment or userinfo would end up spliced + // into the middle of every request it builds. + if (uri.getRawQuery() != null || uri.getRawFragment() != null || uri.getRawUserInfo() != null) { + return null; + } + String raw = uri.getRawPath(); + if (raw == null || raw.isEmpty()) { + return ""; + } + if (!raw.startsWith("/")) { + return null; + } + // "//host" in a Location is protocol-relative, so a browser would read it as a host. + return CharMatcher.is('/').trimTrailingFrom(raw.replaceAll("^/+", "/")); + } +} diff --git a/dev/src/main/java/com/google/adk/web/config/DevUiAssets.java b/dev/src/main/java/com/google/adk/web/config/DevUiAssets.java new file mode 100644 index 000000000..b86f08473 --- /dev/null +++ b/dev/src/main/java/com/google/adk/web/config/DevUiAssets.java @@ -0,0 +1,55 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.web.config; + +import org.jspecify.annotations.Nullable; +import org.springframework.core.io.ResourceLoader; + +/** + * Where the dev UI's static assets live. Shared so the resource handler and the runtime-config + * endpoint resolve the same location; normalizing {@code adk.web.ui.dir} separately in each would + * diverge silently. + */ +public final class DevUiAssets { + + /** The runtime config, relative to the asset root. */ + public static final String RUNTIME_CONFIG_PATH = "assets/config/runtime-config.json"; + + private static final String CLASSPATH_ROOT = ResourceLoader.CLASSPATH_URL_PREFIX + "/browser/"; + + /** + * The asset root: {@code webUiDir} as a {@code file:} URL when set, else the bundled classpath + * copy. Always ends in a slash. + */ + public static String assetRoot(@Nullable String webUiDir) { + if (webUiDir == null || webUiDir.isEmpty()) { + return CLASSPATH_ROOT; + } + String location = webUiDir.replace("\\", "/"); + if (!location.startsWith("file:")) { + location = "file:" + location; + } + return location.endsWith("/") ? location : location + "/"; + } + + /** The location of a single asset, given relative to the asset root. */ + public static String assetLocation(@Nullable String webUiDir, String relativePath) { + return assetRoot(webUiDir) + relativePath; + } + + private DevUiAssets() {} +} diff --git a/dev/src/main/java/com/google/adk/web/controller/RuntimeConfigController.java b/dev/src/main/java/com/google/adk/web/controller/RuntimeConfigController.java new file mode 100644 index 000000000..be07cdfea --- /dev/null +++ b/dev/src/main/java/com/google/adk/web/controller/RuntimeConfigController.java @@ -0,0 +1,105 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.web.controller; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.adk.web.config.BackendUrl; +import com.google.adk.web.config.DevUiAssets; +import java.io.IOException; +import java.io.InputStream; +import java.util.LinkedHashMap; +import java.util.Map; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Serves the dev UI's runtime configuration, shadowing the copy bundled in the static assets so + * {@code adk.web.backend-url} can point the UI at the address browsers reach this server on. The + * bundled document is merged rather than replaced, so keys the UI gains in a later bundle survive. + */ +@RestController +public class RuntimeConfigController { + + private static final Logger log = LoggerFactory.getLogger(RuntimeConfigController.class); + + private final ResourceLoader resourceLoader; + private final ObjectMapper objectMapper; + private final @Nullable String webUiDir; + private final String backendUrl; + + /** Reads the bundled config through {@code resourceLoader}, or from {@code webUiDir} if set. */ + @Autowired + public RuntimeConfigController( + ResourceLoader resourceLoader, + ObjectMapper objectMapper, + @Value("${adk.web.ui.dir:#{null}}") @Nullable String webUiDir, + BackendUrl backendUrl) { + this.resourceLoader = resourceLoader; + this.objectMapper = objectMapper; + this.webUiDir = webUiDir; + this.backendUrl = backendUrl.value(); + } + + /** Serves the bundled config with {@code backendUrl} taken from configuration when set. */ + @GetMapping( + value = "/dev-ui/" + DevUiAssets.RUNTIME_CONFIG_PATH, + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> runtimeConfig() { + Map config = readBundledConfig(); + // Unset leaves a value the bundled document already carries, which is what used to be served. + if (backendUrl.isEmpty()) { + config.putIfAbsent("backendUrl", ""); + } else { + config.put("backendUrl", backendUrl); + } + // The bundled document can change on disk under adk.web.ui.dir, so do not let it be cached. + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(config); + } + + /** + * The bundled config, or an empty document when it is absent or unreadable. A dev UI that cannot + * read its own config is worse than one whose extra keys defaulted, so this never fails the + * request. + */ + private Map readBundledConfig() { + Resource resource = + resourceLoader.getResource( + DevUiAssets.assetLocation(webUiDir, DevUiAssets.RUNTIME_CONFIG_PATH)); + if (!resource.exists()) { + log.debug("No bundled dev UI runtime config at {}; serving backendUrl only.", resource); + return new LinkedHashMap<>(); + } + try (InputStream in = resource.getInputStream()) { + Map parsed = objectMapper.readValue(in, new TypeReference<>() {}); + return parsed == null ? new LinkedHashMap<>() : new LinkedHashMap<>(parsed); + } catch (IOException e) { + log.warn("Could not read the bundled dev UI runtime config at {}.", resource, e); + return new LinkedHashMap<>(); + } + } +} diff --git a/dev/src/test/java/com/google/adk/web/AdkWebServerConfiguredUiDirTest.java b/dev/src/test/java/com/google/adk/web/AdkWebServerConfiguredUiDirTest.java new file mode 100644 index 000000000..0d4c0109d --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/AdkWebServerConfiguredUiDirTest.java @@ -0,0 +1,77 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.web; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +/** + * A directory named by {@code adk.web.ui.dir} is really served under {@code /dev-ui/}. The other + * tests cover the bundled classpath copy, and {@code DevUiAssetsTest} only checks the string this + * normalizes to, which cannot tell whether the resource handler can resolve it. + */ +@SpringBootTest +@AutoConfigureMockMvc +public class AdkWebServerConfiguredUiDirTest { + + @TempDir static Path uiDir; + + @DynamicPropertySource + static void configuredUiDir(DynamicPropertyRegistry registry) { + // @SpringBootTest(properties=...) needs a constant; the temp directory is only known now. + registry.add("adk.web.ui.dir", () -> uiDir.toString()); + } + + @BeforeAll + static void writeUi() throws IOException { + Files.writeString(uiDir.resolve("index.html"), "temp ui"); + Files.writeString(uiDir.resolve("asset.txt"), "from the configured dir"); + } + + @Autowired private MockMvc mockMvc; + + @Test + public void configuredUiDir_shouldBeServedBelowDevUi() throws Exception { + // The body, not just a 200: it proves the bytes came from the configured directory. + mockMvc + .perform(get("/dev-ui/asset.txt")) + .andExpect(status().isOk()) + .andExpect(content().string("from the configured dir")); + } + + @Test + public void configuredUiDir_shouldSupplyTheForwardTarget() throws Exception { + mockMvc + .perform(get("/dev-ui/index.html")) + .andExpect(status().isOk()) + .andExpect(content().string("temp ui")); + } +} diff --git a/dev/src/test/java/com/google/adk/web/AdkWebServerProxyRedirectTest.java b/dev/src/test/java/com/google/adk/web/AdkWebServerProxyRedirectTest.java new file mode 100644 index 000000000..af866ee44 --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/AdkWebServerProxyRedirectTest.java @@ -0,0 +1,84 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.web; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.web.servlet.MockMvc; + +/** + * Spring's own forwarded-header support keeps working: with {@code + * server.forward-headers-strategy=framework} the operator opts in, {@code ForwardedHeaderFilter} + * turns the forwarded prefix into the request's context path, and the redirect picks it up. This + * server adds nothing here and reads no header itself; the test exists so enabling that Spring + * feature keeps behaving as it did. + * + *

The filter honours the standard {@code Forwarded} header as well as {@code X-Forwarded-*}, so + * both are covered. + * + *

This is the path taken when {@code adk.web.backend-url} is unset. Setting it supplies the + * prefix directly and the forwarded one is ignored, though the host and scheme still come from + * these headers while the filter is enabled. + */ +@SpringBootTest(properties = "server.forward-headers-strategy=framework") +@AutoConfigureMockMvc +public class AdkWebServerProxyRedirectTest { + + @Autowired private MockMvc mockMvc; + + @ParameterizedTest + @ValueSource(strings = {"/", "/dev-ui"}) + public void devUiEntryPoints_withSpringForwardedHeaderSupport_shouldKeepThePrefix(String path) + throws Exception { + mockMvc + .perform( + get(path) + .header("X-Forwarded-Prefix", "/my-app") + .header("X-Forwarded-Host", "gw.example.com") + .header("X-Forwarded-Proto", "https")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("https://gw.example.com/my-app/dev-ui/")); + } + + @ParameterizedTest + @ValueSource(strings = {"/", "/dev-ui"}) + public void devUiEntryPoints_withTheStandardForwardedHeader_shouldUseItsHost(String path) + throws Exception { + // RFC 7239 defines no prefix parameter, so this one moves the host and scheme but not the path. + mockMvc + .perform(get(path).header("Forwarded", "host=gw.example.com;proto=https")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("https://gw.example.com/dev-ui/")); + } + + @Test + public void devUiEntryPoint_withNoForwardedHeaders_shouldNotGainAPrefix() throws Exception { + // Control arm: the filter is installed here, so this pins that it leaves this case alone. + mockMvc + .perform(get("/")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/dev-ui/")); + } +} diff --git a/dev/src/test/java/com/google/adk/web/AdkWebServerUITest.java b/dev/src/test/java/com/google/adk/web/AdkWebServerUITest.java index cfb162db2..5d1b46cce 100644 --- a/dev/src/test/java/com/google/adk/web/AdkWebServerUITest.java +++ b/dev/src/test/java/com/google/adk/web/AdkWebServerUITest.java @@ -17,6 +17,7 @@ package com.google.adk.web; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -40,22 +41,52 @@ public class AdkWebServerUITest { @Autowired private MockMvc mockMvc; - @Test - public void rootShouldRedirectToDevUi() throws Exception { + @ParameterizedTest + @ValueSource(strings = {"/", "/dev-ui"}) + public void devUiEntryPoints_shouldRedirectToTrailingSlashForm(String path) throws Exception { + // index.html declares , which only resolves correctly from "/dev-ui/". mockMvc - .perform(get("/")) + .perform(get(path)) .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl("/dev-ui")); + .andExpect(redirectedUrl("/dev-ui/")); + } + + @Test + public void devUiRoot_shouldForwardToIndexHtml() throws Exception { + // MockMvc records a forward without running it, so assert the target, not a status. + mockMvc.perform(get("/dev-ui/")).andExpect(forwardedUrl("/dev-ui/index.html")); + } + + @Test + public void devUiIndexHtml_shouldBeServed() throws Exception { + // The other half: the target the forward names actually resolves through the mount. + mockMvc.perform(get("/dev-ui/index.html")).andExpect(status().isOk()); + } + + @Test + public void devUiAssets_shouldBeServedBelowDevUi() throws Exception { + mockMvc.perform(get("/dev-ui/adk_favicon.svg")).andExpect(status().isOk()); } @ParameterizedTest - @ValueSource(strings = {"/dev-ui", "/dev-ui/"}) - public void devUiEndpointsShouldReturnOk(String path) throws Exception { - mockMvc.perform(get(path)).andExpect(status().isOk()); + @ValueSource(strings = {"/", "/dev-ui"}) + public void devUiEntryPoints_shouldKeepQueryString(String path) throws Exception { + // The UI picks its agent from ?app=, and the sample READMEs send users to "/dev-ui". + mockMvc + .perform(get(path + "?app=my-agent")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/dev-ui/?app=my-agent")); + } + + @Test + public void devUiAssets_shouldNotBeServedAtRoot() throws Exception { + // Narrowing the handler from "/**" to "/dev-ui/**" makes this deliberately unreachable. + mockMvc.perform(get("/adk_favicon.svg")).andExpect(status().isNotFound()); } @Test - public void nonExistentUiPageShouldReturnNotFound() throws Exception { - mockMvc.perform(get("/non-existent-page")).andExpect(status().isNotFound()); + public void nonExistentDevUiPath_shouldReturnNotFound() throws Exception { + // No SPA fallback, deliberately: a deep link 404s rather than serving index.html. + mockMvc.perform(get("/dev-ui/non-existent-page")).andExpect(status().isNotFound()); } } diff --git a/dev/src/test/java/com/google/adk/web/BackendUrlRedirectTest.java b/dev/src/test/java/com/google/adk/web/BackendUrlRedirectTest.java new file mode 100644 index 000000000..79cdc24b7 --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/BackendUrlRedirectTest.java @@ -0,0 +1,138 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.web; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.web.servlet.MockMvc; + +/** + * With {@code adk.web.backend-url} set, the entry redirect carries the gateway's path prefix on its + * own, so a deployment behind a path-stripping proxy needs nothing forwarded from the proxy. The + * property governs the path only: host and scheme still come from forwarded headers wherever the + * operator has enabled {@code server.forward-headers-strategy}. + */ +public class BackendUrlRedirectTest { + + @Nested + @SpringBootTest( + properties = { + "adk.web.backend-url=https://gw.example.com/my-app", + // Without this Spring discards the forwarded header, and the test below could not fail if + // the redirect stopped ignoring it. + "server.forward-headers-strategy=framework" + }) + @AutoConfigureMockMvc + class ConfiguredWithTheFilter { + + @ParameterizedTest + @ValueSource(strings = {"/", "/dev-ui"}) + public void devUiEntryPoints_shouldCarryTheConfiguredPrefix( + String path, @Autowired MockMvc mockMvc) throws Exception { + // No forwarded headers: the configured value is the only thing that knows the prefix. + mockMvc + .perform(get(path)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/my-app/dev-ui/")); + } + + @ParameterizedTest + @ValueSource(strings = {"/", "/dev-ui"}) + public void devUiEntryPoints_shouldNotStackAForwardedPrefix( + String path, @Autowired MockMvc mockMvc) throws Exception { + // The filter makes this a context path; the configured prefix replaces it. + mockMvc + .perform(get(path).header("X-Forwarded-Prefix", "/evil")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/my-app/dev-ui/")); + } + + @Test + public void devUiEntryPoint_forwardedHostAndProto_stillSetTheRedirectsOrigin( + @Autowired MockMvc mockMvc) throws Exception { + // The configured value supplies the path; host and scheme still come from the headers. + mockMvc + .perform( + get("/") + .header("X-Forwarded-Host", "gw.example.com") + .header("X-Forwarded-Proto", "https")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("https://gw.example.com/my-app/dev-ui/")); + } + } + + @Nested + @SpringBootTest(properties = "adk.web.backend-url=https://gw.example.com/my-app") + @AutoConfigureMockMvc + class ConfiguredWithoutTheFilter { + + @Test + public void devUiEntryPoint_forwardedHeaders_reachNothingUnderTheShippedStrategy( + @Autowired MockMvc mockMvc) throws Exception { + // No strategy set, which is what ships: the property supplies the path, these reach nothing. + mockMvc + .perform( + get("/") + .header("X-Forwarded-Prefix", "/evil") + .header("X-Forwarded-Host", "evil.example.com") + .header("Forwarded", "host=evil.example.com;proto=https")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/my-app/dev-ui/")); + } + } + + @Nested + @SpringBootTest + @AutoConfigureMockMvc + class UnconfiguredWithoutTheFilter { + + @ParameterizedTest + @ValueSource(strings = {"/", "/dev-ui"}) + public void devUiEntryPoints_shouldRedirectWithoutAPrefix( + String path, @Autowired MockMvc mockMvc) throws Exception { + mockMvc + .perform(get(path)) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/dev-ui/")); + } + + @ParameterizedTest + @ValueSource(strings = {"/", "/dev-ui"}) + public void devUiEntryPoints_withForwardedHeaders_shouldStillRedirectWithoutAPrefix( + String path, @Autowired MockMvc mockMvc) throws Exception { + // The shipped default: no forwarded-header strategy is set, so nothing in the request can + // reach the redirect. + mockMvc + .perform( + get(path) + .header("X-Forwarded-Prefix", "/evil") + .header("X-Forwarded-Host", "evil.example.com") + .header("Forwarded", "host=evil.example.com;proto=https")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/dev-ui/")); + } + } +} diff --git a/dev/src/test/java/com/google/adk/web/config/BackendUrlTest.java b/dev/src/test/java/com/google/adk/web/config/BackendUrlTest.java new file mode 100644 index 000000000..73cbbfa3d --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/config/BackendUrlTest.java @@ -0,0 +1,158 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.web.config; + +import static com.google.common.truth.Truth.assertThat; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +/** + * One reading of {@code adk.web.backend-url}. The value the dev UI is served and the prefix its + * entry redirect carries come from the same parse, so they cannot disagree. + */ +public class BackendUrlTest { + + private static final String CONFIGURED = "https://gw.example.com/my-app"; + + @Test + public void unset_isEmptyEverywhere() { + for (String in : new String[] {null, "", " "}) { + assertThat(BackendUrl.from(in).value()).isEmpty(); + assertThat(BackendUrl.from(in).pathPrefix()).isEmpty(); + } + } + + @Test + public void absoluteUrl_isServedAndSuppliesThePrefix() { + BackendUrl url = BackendUrl.from(CONFIGURED); + + assertThat(url.value()).isEqualTo(CONFIGURED); + assertThat(url.pathPrefix()).isEqualTo("/my-app"); + } + + @Test + public void trailingSlashes_areStripped() { + // The UI appends paths starting with a slash, so a trailing one would yield //run_live. + assertThat(BackendUrl.from(CONFIGURED + "/").value()).isEqualTo(CONFIGURED); + assertThat(BackendUrl.from(CONFIGURED + "///").pathPrefix()).isEqualTo("/my-app"); + assertThat(BackendUrl.from(" " + CONFIGURED + " ").value()).isEqualTo(CONFIGURED); + } + + @Test + public void hostWithoutPath_hasNoPrefix() { + assertThat(BackendUrl.from("https://gw.example.com").pathPrefix()).isEmpty(); + assertThat(BackendUrl.from("https://gw.example.com/").pathPrefix()).isEmpty(); + } + + @Test + public void percentEncoding_isKept() { + // This goes into a Location header, so decoding would re-encode wrongly and %2F would + // turn into a path separator. + assertThat(BackendUrl.from("https://gw.example.com/my%20app").pathPrefix()) + .isEqualTo("/my%20app"); + assertThat(BackendUrl.from("https://gw.example.com/a%2Fb").pathPrefix()).isEqualTo("/a%2Fb"); + } + + @Test + public void doubledSlash_doesNotBecomeAHost() { + // "//my-app/dev-ui/" is protocol-relative: a browser resolves it to the host "my-app". + assertThat(BackendUrl.from("https://gw.example.com//my-app").pathPrefix()).isEqualTo("/my-app"); + } + + @Test + public void unusableValue_isStillServedButWarns() { + // Never silently discarded, because it is an explicit setting. + assertThat(BackendUrl.from("/my-app").value()).isEqualTo("/my-app"); + assertThat(BackendUrl.from("HTTPS://gw.example.com/x").value()) + .isEqualTo("HTTPS://gw.example.com/x"); + assertThat(BackendUrl.from("http://").value()).isEqualTo("http://"); + + assertThat(warningsFor("/my-app")).hasSize(1); + assertThat(warningsFor("/my-app").get(0)).contains("/my-app"); + assertThat(warningsFor("HTTPS://gw.example.com/x")).hasSize(1); + assertThat(warningsFor("http://")).hasSize(1); + } + + @Test + public void slashOnly_isNotEmptiedByTheSlashTrim() { + // Emptying this would make the served value fall back to whatever the bundled config says, + // silently losing an explicit setting. + assertThat(BackendUrl.from("/").value()).isEqualTo("/"); + assertThat(BackendUrl.from("/").pathPrefix()).isEmpty(); + } + + @Test + public void queryFragmentOrUserinfo_yieldsNoPrefixAndWarns() { + // The UI appends onto the value, so these would land in the middle of every request. + for (String in : + new String[] { + "https://gw.example.com/a/b?q=1", + "https://gw.example.com/a/b#f", + "https://user:pass@gw.example.com/my-app" + }) { + assertThat(BackendUrl.from(in).pathPrefix()).isEmpty(); + assertThat(warningsFor(in)).hasSize(1); + } + } + + @Test + public void valueWithoutALeadingSlashPath_yieldsNoPrefix() { + // "gw.example.com/dev-ui/" in a Location is a relative redirect, i.e. a guess. + assertThat(BackendUrl.from("gw.example.com").pathPrefix()).isEmpty(); + assertThat(warningsFor("gw.example.com")).hasSize(1); + } + + @Test + public void unparseableValue_yieldsNoPrefixAndWarns() { + // The old split lost the prefix here silently; one parse now warns instead. + String malformed = "https://gw.example.com/my app"; + + assertThat(BackendUrl.from(malformed).value()).isEqualTo(malformed); + assertThat(BackendUrl.from(malformed).pathPrefix()).isEmpty(); + assertThat(warningsFor(malformed)).hasSize(1); + } + + @Test + public void usableValue_doesNotWarn() { + assertThat(warningsFor(CONFIGURED)).isEmpty(); + assertThat(warningsFor(CONFIGURED + "/")).isEmpty(); + assertThat(warningsFor("")).isEmpty(); + } + + /** The WARN messages logged while interpreting {@code configured}. */ + private static List warningsFor(String configured) { + Logger logger = (Logger) LoggerFactory.getLogger(BackendUrl.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + try { + BackendUrl unused = BackendUrl.from(configured); + } finally { + logger.detachAppender(appender); + } + return appender.list.stream() + .filter(event -> event.getLevel() == Level.WARN) + .map(ILoggingEvent::getFormattedMessage) + .toList(); + } +} diff --git a/dev/src/test/java/com/google/adk/web/config/DevUiAssetsTest.java b/dev/src/test/java/com/google/adk/web/config/DevUiAssetsTest.java new file mode 100644 index 000000000..c7d53382d --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/config/DevUiAssetsTest.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.web.config; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.Test; + +/** The {@code adk.web.ui.dir} normalization the resource handler resolves assets through. */ +public class DevUiAssetsTest { + + @Test + public void assetRoot_noDirConfigured_fallsBackToTheBundledCopy() { + assertThat(DevUiAssets.assetRoot(null)).isEqualTo("classpath:/browser/"); + assertThat(DevUiAssets.assetRoot("")).isEqualTo("classpath:/browser/"); + } + + @Test + public void assetRoot_configuredDir_becomesAFileUrl() { + assertThat(DevUiAssets.assetRoot("/srv/ui")).isEqualTo("file:/srv/ui/"); + assertThat(DevUiAssets.assetRoot("/srv/ui/")).isEqualTo("file:/srv/ui/"); + assertThat(DevUiAssets.assetRoot("file:/srv/ui")).isEqualTo("file:/srv/ui/"); + } + + @Test + public void assetRoot_windowsSeparators_areNormalized() { + assertThat(DevUiAssets.assetRoot("C:\\srv\\ui")).isEqualTo("file:C:/srv/ui/"); + } + + @Test + public void assetLocation_appendsToTheRoot() { + assertThat(DevUiAssets.assetLocation(null, DevUiAssets.RUNTIME_CONFIG_PATH)) + .isEqualTo("classpath:/browser/assets/config/runtime-config.json"); + } +} diff --git a/dev/src/test/java/com/google/adk/web/controller/RuntimeConfigControllerMergeTest.java b/dev/src/test/java/com/google/adk/web/controller/RuntimeConfigControllerMergeTest.java new file mode 100644 index 000000000..a01dd9462 --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/controller/RuntimeConfigControllerMergeTest.java @@ -0,0 +1,123 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.web.controller; + +import static com.google.common.truth.Truth.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.adk.web.config.BackendUrl; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.DescriptiveResource; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; + +/** + * The served runtime config merges into the bundled document rather than replacing it, so keys the + * dev UI bundle gains later are not silently dropped. {@code adk.web.backend-url} overrides one + * key; an unset value leaves the bundled document exactly as it was served before. + */ +public class RuntimeConfigControllerMergeTest { + + private static final String CONFIGURED = "https://gw.example.com/my-app"; + + @Test + public void runtimeConfig_shouldPreserveOtherBundledKeys() { + String bundled = "{\"backendUrl\":\"\",\"telemetry\":null,\"logo\":{\"text\":\"x\"}}"; + RuntimeConfigController controller = controllerFor(bundled, CONFIGURED); + + Map config = controller.runtimeConfig().getBody(); + + assertThat(config).containsEntry("backendUrl", CONFIGURED); + assertThat(config).containsKey("telemetry"); + assertThat(config).containsEntry("logo", Map.of("text", "x")); + } + + @Test + public void runtimeConfig_shouldOverrideTheBundledBackendUrl() { + RuntimeConfigController controller = + controllerFor("{\"backendUrl\":\"http://stale\"}", CONFIGURED); + + assertThat(controller.runtimeConfig().getBody()).containsEntry("backendUrl", CONFIGURED); + } + + @Test + public void runtimeConfig_propertyUnset_shouldKeepTheBundledBackendUrl() { + // The static handler served this file verbatim, so a hand-set value survived; it still does. + RuntimeConfigController controller = + controllerFor("{\"backendUrl\":\"http://elsewhere:9000\"}"); + + assertThat(controller.runtimeConfig().getBody()) + .containsExactly("backendUrl", "http://elsewhere:9000"); + } + + @Test + public void runtimeConfig_propertyUnsetAndNoBundledKey_shouldStillReportBackendUrl() { + RuntimeConfigController controller = controllerFor("{\"telemetry\":null}"); + + assertThat(controller.runtimeConfig().getBody()).containsEntry("backendUrl", ""); + } + + @Test + public void runtimeConfig_bundledFileMissing_shouldStillServe() { + RuntimeConfigController controller = controllerFor(null, CONFIGURED); + + assertThat(controller.runtimeConfig().getBody()).containsExactly("backendUrl", CONFIGURED); + } + + @Test + public void runtimeConfig_bundledFileMalformed_shouldStillServe() { + RuntimeConfigController controller = controllerFor("not json at all", CONFIGURED); + + assertThat(controller.runtimeConfig().getBody()).containsExactly("backendUrl", CONFIGURED); + } + + @Test + public void runtimeConfig_bundledFileNotAnObject_shouldStillServe() { + // A config that is not a JSON object is ignored rather than failing the request. + RuntimeConfigController controller = controllerFor("[1, 2, 3]", CONFIGURED); + + assertThat(controller.runtimeConfig().getBody()).containsExactly("backendUrl", CONFIGURED); + } + + /** A controller with {@code adk.web.backend-url} unset. */ + private static RuntimeConfigController controllerFor(String body) { + return controllerFor(body, ""); + } + + /** A controller whose bundled config is {@code body}, or absent when {@code body} is null. */ + private static RuntimeConfigController controllerFor(String body, String backendUrl) { + ResourceLoader loader = + new ResourceLoader() { + @Override + public Resource getResource(String location) { + return body == null + ? new DescriptiveResource("absent") + : new ByteArrayResource(body.getBytes(StandardCharsets.UTF_8)); + } + + @Override + public ClassLoader getClassLoader() { + return RuntimeConfigControllerMergeTest.class.getClassLoader(); + } + }; + return new RuntimeConfigController( + loader, new ObjectMapper(), null, BackendUrl.from(backendUrl)); + } +} diff --git a/dev/src/test/java/com/google/adk/web/controller/RuntimeConfigControllerTest.java b/dev/src/test/java/com/google/adk/web/controller/RuntimeConfigControllerTest.java new file mode 100644 index 000000000..111c62e30 --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/controller/RuntimeConfigControllerTest.java @@ -0,0 +1,127 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.adk.web.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.web.servlet.MockMvc; + +/** + * The dev UI learns where its backend lives from {@code adk.web.backend-url}, so its API calls + * reach a server published under a path prefix. Nothing is read from the request, so a client + * cannot influence the value it is served. + */ +public class RuntimeConfigControllerTest { + + private static final String CONFIG = "/dev-ui/assets/config/runtime-config.json"; + + @Nested + @SpringBootTest( + properties = { + "adk.web.backend-url=https://gw.example.com/my-app", + // Without this Spring ignores the forwarded headers anyway, so the tests below could + // not fail if the value went back to being derived from the request. + "server.forward-headers-strategy=framework" + }) + @AutoConfigureMockMvc + class ConfiguredWithTheFilter { + + @Test + public void runtimeConfig_shouldReportTheConfiguredBackendUrl(@Autowired MockMvc mockMvc) + throws Exception { + mockMvc + .perform(get(CONFIG)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.backendUrl").value("https://gw.example.com/my-app")) + .andExpect(header().string("Cache-Control", "no-store")); + } + + @Test + public void runtimeConfig_forwardedHeaders_shouldNotChangeTheValue(@Autowired MockMvc mockMvc) + throws Exception { + // Configuration alone decides this; a client naming its own host cannot move it. + mockMvc + .perform( + get(CONFIG) + .header("X-Forwarded-Prefix", "/evil") + .header("X-Forwarded-Host", "evil.example.com") + .header("X-Forwarded-Proto", "https")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.backendUrl").value("https://gw.example.com/my-app")); + } + } + + @Nested + @SpringBootTest + @AutoConfigureMockMvc + class UnconfiguredWithoutTheFilter { + + @Test + public void runtimeConfig_forwardedHeaders_areNotReadFromTheRequest(@Autowired MockMvc mockMvc) + throws Exception { + // What ships: no filter, so these reach the controller and nothing reads them. + mockMvc + .perform( + get(CONFIG) + .header("X-Forwarded-Prefix", "/evil") + .header("X-Forwarded-Host", "evil.example.com") + .header("Forwarded", "host=evil.example.com;proto=https")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.backendUrl").value("")); + } + } + + @Nested + @SpringBootTest(properties = "server.forward-headers-strategy=framework") + @AutoConfigureMockMvc + class UnconfiguredWithTheFilter { + + @Test + public void runtimeConfig_shouldServeWhatTheBundledFileSaid(@Autowired MockMvc mockMvc) + throws Exception { + // Unset is the default, so an unproxied deployment sees exactly what it saw before. + mockMvc + .perform(get(CONFIG)) + .andExpect(status().isOk()) + // Byte-exact: the bundled file is pretty-printed, so this passes only if the + // controller (which serializes compactly) answered rather than the static handler. + .andExpect(content().string("{\"backendUrl\":\"\"}")); + } + + @Test + public void runtimeConfig_forwardedHeaders_shouldStillReportEmpty(@Autowired MockMvc mockMvc) + throws Exception { + // The filter makes the prefix a real context path here; the served value still ignores it. + mockMvc + .perform( + get(CONFIG) + .header("X-Forwarded-Prefix", "/evil") + .header("X-Forwarded-Host", "evil.example.com")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.backendUrl").value("")); + } + } +}