From 29cb304039ace46fbbda26cb570564c4748a0341 Mon Sep 17 00:00:00 2001 From: Kamil Tomaszek Date: Tue, 8 Sep 2026 02:22:18 -0700 Subject: [PATCH] fix(dev): let the dev UI's public address be configured Behind a gateway that strips a path prefix, the Development UI's own API calls went to the origin root, because the bundled `runtime-config.json` ships an empty `backendUrl` with no way to change it. `adk.web.backend-url` now names the address browsers reach the server on. It sets that value and prefixes the entry redirect, so both halves work without anything being forwarded by the proxy. It must be an absolute URL, because the UI reads a value without a scheme as the host of its live connection. Left unset, nothing changes. PiperOrigin-RevId: 977785275 --- dev/README.md | 42 ++++- .../java/com/google/adk/web/AdkWebServer.java | 76 +++++---- .../com/google/adk/web/config/BackendUrl.java | 107 ++++++++++++ .../google/adk/web/config/DevUiAssets.java | 55 ++++++ .../controller/RuntimeConfigController.java | 105 ++++++++++++ .../web/AdkWebServerConfiguredUiDirTest.java | 77 +++++++++ .../web/AdkWebServerProxyRedirectTest.java | 84 ++++++++++ .../google/adk/web/AdkWebServerUITest.java | 49 +++++- .../adk/web/BackendUrlRedirectTest.java | 138 +++++++++++++++ .../google/adk/web/config/BackendUrlTest.java | 158 ++++++++++++++++++ .../adk/web/config/DevUiAssetsTest.java | 49 ++++++ .../RuntimeConfigControllerMergeTest.java | 123 ++++++++++++++ .../RuntimeConfigControllerTest.java | 127 ++++++++++++++ 13 files changed, 1147 insertions(+), 43 deletions(-) create mode 100644 dev/src/main/java/com/google/adk/web/config/BackendUrl.java create mode 100644 dev/src/main/java/com/google/adk/web/config/DevUiAssets.java create mode 100644 dev/src/main/java/com/google/adk/web/controller/RuntimeConfigController.java create mode 100644 dev/src/test/java/com/google/adk/web/AdkWebServerConfiguredUiDirTest.java create mode 100644 dev/src/test/java/com/google/adk/web/AdkWebServerProxyRedirectTest.java create mode 100644 dev/src/test/java/com/google/adk/web/BackendUrlRedirectTest.java create mode 100644 dev/src/test/java/com/google/adk/web/config/BackendUrlTest.java create mode 100644 dev/src/test/java/com/google/adk/web/config/DevUiAssetsTest.java create mode 100644 dev/src/test/java/com/google/adk/web/controller/RuntimeConfigControllerMergeTest.java create mode 100644 dev/src/test/java/com/google/adk/web/controller/RuntimeConfigControllerTest.java 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("")); + } + } +}