From 750d1b8d10b22b4cab4a4395056108ba1f5933c0 Mon Sep 17 00:00:00 2001 From: Kamil Tomaszek Date: Tue, 8 Sep 2026 09:32:25 -0700 Subject: [PATCH] feat(dev): add the /version and /health endpoints `/version` reports the ADK version, the implementation language and the running JVM's version. `/health` reports a fixed OK status. PiperOrigin-RevId: 977956041 --- dev/README.md | 34 ++- .../java/com/google/adk/web/AdkWebServer.java | 63 +++--- .../google/adk/web/config/DevUiAssets.java | 85 ++++++++ .../controller/RuntimeConfigController.java | 123 +++++++++++ .../adk/web/controller/VersionController.java | 49 +++++ .../web/AdkWebServerProxyRedirectTest.java | 66 ++++++ .../google/adk/web/AdkWebServerUITest.java | 38 +++- .../adk/web/BackendUrlRedirectTest.java | 87 ++++++++ .../adk/web/config/DevUiAssetsTest.java | 85 ++++++++ .../RuntimeConfigControllerMergeTest.java | 200 ++++++++++++++++++ .../RuntimeConfigControllerTest.java | 106 ++++++++++ .../web/controller/VersionControllerTest.java | 57 +++++ 12 files changed, 951 insertions(+), 42 deletions(-) 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/main/java/com/google/adk/web/controller/VersionController.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/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 create mode 100644 dev/src/test/java/com/google/adk/web/controller/VersionControllerTest.java diff --git a/dev/README.md b/dev/README.md index 12e68cc59..05466e407 100644 --- a/dev/README.md +++ b/dev/README.md @@ -1 +1,33 @@ -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; setting this property takes precedence over it. 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..f75f3afc7 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,7 @@ import com.google.adk.memory.InMemoryMemoryService; import com.google.adk.sessions.BaseSessionService; import com.google.adk.sessions.InMemorySessionService; +import com.google.adk.web.config.DevUiAssets; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -51,6 +52,9 @@ public class AdkWebServer implements WebMvcConfigurer { @Value("${adk.web.ui.dir:#{null}}") private String webUiDir; + @Value("${adk.web.backend-url:}") + private String backendUrl; + @Bean public BaseSessionService sessionService() { // TODO: Add logic to select service based on config (e.g., DB URL) @@ -109,48 +113,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 = DevUiAssets.pathOf(backendUrl); + // 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/DevUiAssets.java b/dev/src/main/java/com/google/adk/web/config/DevUiAssets.java new file mode 100644 index 000000000..2bd341654 --- /dev/null +++ b/dev/src/main/java/com/google/adk/web/config/DevUiAssets.java @@ -0,0 +1,85 @@ +/* + * 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 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; + } + + /** + * The path a gateway strips, taken from {@code backendUrl} on a best-effort basis, or empty when + * there is none to take. This is what the UI's entry redirect has to carry, so it stays + * percent-encoded and never starts with a second slash, which a browser would read as a host. + */ + public static String pathOf(@Nullable String backendUrl) { + if (backendUrl == null) { + return ""; + } + String trimmed = backendUrl.trim(); + if (trimmed.isEmpty()) { + return ""; + } + String path; + try { + // Raw: the result goes into a Location header, so decoding it here would re-encode wrongly. + path = new URI(trimmed).getRawPath(); + } catch (URISyntaxException e) { + return ""; + } + if (path == null) { + return ""; + } + String collapsed = path.replaceAll("^/+", "/"); + return CharMatcher.is('/').trimTrailingFrom(collapsed); + } + + 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..1e3f0c12d --- /dev/null +++ b/dev/src/main/java/com/google/adk/web/controller/RuntimeConfigController.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 com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.adk.web.config.DevUiAssets; +import com.google.common.base.CharMatcher; +import java.io.IOException; +import java.io.InputStream; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Pattern; +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 static final Pattern ABSOLUTE_URL = Pattern.compile("^https?://.+"); + + 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, + @Value("${adk.web.backend-url:}") String backendUrl) { + this.resourceLoader = resourceLoader; + this.objectMapper = objectMapper; + this.webUiDir = webUiDir; + String configured = backendUrl.trim(); + // A trailing slash would double up: the UI appends paths that already start with one. + String normalized = CharMatcher.is('/').trimTrailingFrom(configured); + if (configured.isEmpty()) { + this.backendUrl = ""; + } else if (ABSOLUTE_URL.matcher(normalized).matches()) { + this.backendUrl = normalized; + } else { + // Served exactly as configured, so an explicit value is never silently discarded. + this.backendUrl = configured; + 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.", + configured); + } + } + + /** 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/main/java/com/google/adk/web/controller/VersionController.java b/dev/src/main/java/com/google/adk/web/controller/VersionController.java new file mode 100644 index 000000000..841f26b71 --- /dev/null +++ b/dev/src/main/java/com/google/adk/web/controller/VersionController.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.controller; + +import com.google.adk.Version; +import java.util.Map; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Reports the ADK and language versions, which the dev UI requests on startup, plus a liveness + * endpoint. + */ +@RestController +public class VersionController { + + /** Returns the ADK version, the implementation language, and the running JVM's version. */ + @GetMapping(value = "/version", produces = MediaType.APPLICATION_JSON_VALUE) + public Map version() { + return Map.of( + "version", + Version.JAVA_ADK_VERSION, + "language", + "java", + "language_version", + System.getProperty("java.version", "unknown")); + } + + /** Returns a fixed OK status, so a load balancer can tell the server is up. */ + @GetMapping(value = "/health", produces = MediaType.APPLICATION_JSON_VALUE) + public Map health() { + return Map.of("status", "ok"); + } +} 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..79479db63 --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/AdkWebServerProxyRedirectTest.java @@ -0,0 +1,66 @@ +/* + * 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; + +/** + * Behind a proxy that strips a path prefix, both dev UI entry points redirect to a target that + * still carries the prefix, so the browser follows a path the gateway can still route. Requires + * {@code server.forward-headers-strategy=framework}, which is what puts the prefix in the context + * path for the redirect to pick up. This is the path taken when {@code adk.web.backend-url} is + * unset; setting it supplies the prefix directly and the forwarded one is then ignored. + */ +@SpringBootTest(properties = "server.forward-headers-strategy=framework") +@AutoConfigureMockMvc +public class AdkWebServerProxyRedirectTest { + + @Autowired private MockMvc mockMvc; + + @ParameterizedTest + @ValueSource(strings = {"/", "/dev-ui"}) + public void devUiEntryPoints_behindPrefixStrippingProxy_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/")); + } + + @Test + public void rootEntryPoint_withoutForwardedHeaders_shouldNotGainAPrefix() throws Exception { + // The prefix comes from the header alone, so an unproxied deployment is unaffected. + 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..e3ce2ccba 100644 --- a/dev/src/test/java/com/google/adk/web/AdkWebServerUITest.java +++ b/dev/src/test/java/com/google/adk/web/AdkWebServerUITest.java @@ -40,22 +40,44 @@ 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 devUi_shouldBeServedAtTrailingSlashForm() throws Exception { + mockMvc.perform(get("/dev-ui/")).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 { + public void nonExistentUiPage_shouldReturnNotFound() throws Exception { mockMvc.perform(get("/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..90f48ba27 --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/BackendUrlRedirectTest.java @@ -0,0 +1,87 @@ +/* + * 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.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. + */ +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 Configured { + + @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 turns this into a context path, which a context-relative redirect would prepend + // to the configured prefix. The configured value is the public base, so it wins alone. + mockMvc + .perform(get(path).header("X-Forwarded-Prefix", "/evil")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("http://localhost/my-app/dev-ui/")); + } + } + + @Nested + @SpringBootTest + @AutoConfigureMockMvc + class Unconfigured { + + @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/")); + } + } +} 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..d2bde9f70 --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/config/DevUiAssetsTest.java @@ -0,0 +1,85 @@ +/* + * 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"); + } + + @Test + public void pathOf_noPathToTake_isEmpty() { + assertThat(DevUiAssets.pathOf(null)).isEmpty(); + assertThat(DevUiAssets.pathOf("")).isEmpty(); + assertThat(DevUiAssets.pathOf(" ")).isEmpty(); + assertThat(DevUiAssets.pathOf("https://gw.example.com")).isEmpty(); + assertThat(DevUiAssets.pathOf("https://gw.example.com/")).isEmpty(); + // Opaque, so there is no path component at all. + assertThat(DevUiAssets.pathOf("mailto:someone@example.com")).isEmpty(); + // Unparseable, so nothing can be taken from it. + assertThat(DevUiAssets.pathOf("https://gw.example.com/my app")).isEmpty(); + } + + @Test + public void pathOf_takesThePathWithoutTrailingSlashes() { + assertThat(DevUiAssets.pathOf("https://gw.example.com/my-app")).isEqualTo("/my-app"); + assertThat(DevUiAssets.pathOf("https://gw.example.com/my-app/")).isEqualTo("/my-app"); + assertThat(DevUiAssets.pathOf("https://gw.example.com/my-app///")).isEqualTo("/my-app"); + assertThat(DevUiAssets.pathOf(" https://gw.example.com/my-app ")).isEqualTo("/my-app"); + assertThat(DevUiAssets.pathOf("https://gw.example.com/a/b?q=1#f")).isEqualTo("/a/b"); + } + + @Test + public void pathOf_staysPercentEncoded() { + // The value goes into a Location header, so decoding here would re-encode wrongly, and %2F + // would turn into a path separator. + assertThat(DevUiAssets.pathOf("https://gw.example.com/my%20app")).isEqualTo("/my%20app"); + assertThat(DevUiAssets.pathOf("https://gw.example.com/a%2Fb")).isEqualTo("/a%2Fb"); + } + + @Test + public void pathOf_doubledSlash_doesNotBecomeAHost() { + // "//my-app/dev-ui/" is protocol-relative: a browser would resolve it to the host "my-app". + assertThat(DevUiAssets.pathOf("https://gw.example.com//my-app")).isEqualTo("/my-app"); + } +} 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..8aed09255 --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/controller/RuntimeConfigControllerMergeTest.java @@ -0,0 +1,200 @@ +/* + * 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 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 com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +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); + } + + @Test + public void runtimeConfig_trailingSlash_shouldBeStripped() { + // The UI appends paths starting with a slash, so a trailing one would yield //run_live. + RuntimeConfigController controller = controllerFor("{}", CONFIGURED + "/"); + + assertThat(controller.runtimeConfig().getBody()).containsEntry("backendUrl", CONFIGURED); + } + + @Test + public void runtimeConfig_barePath_shouldStillBeServed() { + // Not absolute, so it is warned about, but an explicit value is never silently discarded. + RuntimeConfigController controller = controllerFor("{}", "/my-app"); + + assertThat(controller.runtimeConfig().getBody()).containsEntry("backendUrl", "/my-app"); + } + + @Test + public void runtimeConfig_upperCaseScheme_shouldStillBeServed() { + // The UI strips the scheme case-sensitively, so this is a value the warning must catch. + RuntimeConfigController controller = controllerFor("{}", "HTTPS://gw.example.com/my-app"); + + assertThat(controller.runtimeConfig().getBody()) + .containsEntry("backendUrl", "HTTPS://gw.example.com/my-app"); + } + + @Test + public void runtimeConfig_schemeOnly_shouldNotBeMangledByTheSlashTrim() { + // Trimming slashes before validating would turn this into "http:". + RuntimeConfigController controller = controllerFor("{}", "http://"); + + assertThat(controller.runtimeConfig().getBody()).containsEntry("backendUrl", "http://"); + } + + @Test + public void runtimeConfig_slashOnly_shouldNotSilentlyFallBackToTheBundledValue() { + // Trimming first would empty this and quietly revert to the bundled value. + RuntimeConfigController controller = controllerFor("{\"backendUrl\":\"http://bundled\"}", "/"); + + assertThat(controller.runtimeConfig().getBody()).containsEntry("backendUrl", "/"); + } + + @Test + public void construction_nonAbsoluteBackendUrl_shouldWarnNamingTheValue() { + assertThat(warningsFor("/my-app")).hasSize(1); + assertThat(warningsFor("/my-app").get(0)).contains("/my-app"); + // The UI strips the scheme case-sensitively, so an upper-case one must warn too. + assertThat(warningsFor("HTTPS://gw.example.com/my-app")).hasSize(1); + } + + @Test + public void construction_absoluteBackendUrl_shouldNotWarn() { + assertThat(warningsFor(CONFIGURED)).isEmpty(); + assertThat(warningsFor(CONFIGURED + "/")).isEmpty(); + assertThat(warningsFor("")).isEmpty(); + } + + /** The WARN messages logged while constructing a controller with {@code backendUrl}. */ + private static List warningsFor(String backendUrl) { + Logger logger = (Logger) LoggerFactory.getLogger(RuntimeConfigController.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + try { + RuntimeConfigController unused = controllerFor("{}", backendUrl); + } finally { + logger.detachAppender(appender); + } + return appender.list.stream() + .filter(event -> event.getLevel() == Level.WARN) + .map(ILoggingEvent::getFormattedMessage) + .toList(); + } + + /** 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); + } +} 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..dfda98d4d --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/controller/RuntimeConfigControllerTest.java @@ -0,0 +1,106 @@ +/* + * 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 Configured { + + @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(properties = "server.forward-headers-strategy=framework") + @AutoConfigureMockMvc + class Unconfigured { + + @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 { + mockMvc + .perform( + get(CONFIG) + .header("X-Forwarded-Prefix", "/evil") + .header("X-Forwarded-Host", "evil.example.com")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.backendUrl").value("")); + } + } +} diff --git a/dev/src/test/java/com/google/adk/web/controller/VersionControllerTest.java b/dev/src/test/java/com/google/adk/web/controller/VersionControllerTest.java new file mode 100644 index 000000000..3f90de126 --- /dev/null +++ b/dev/src/test/java/com/google/adk/web/controller/VersionControllerTest.java @@ -0,0 +1,57 @@ +/* + * 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.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.google.adk.Version; +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.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +/** The dev UI requests /version on startup; it used to 404. */ +@SpringBootTest +@AutoConfigureMockMvc +public class VersionControllerTest { + + @Autowired private MockMvc mockMvc; + + @Test + public void version_shouldReportAdkAndLanguageVersions() throws Exception { + mockMvc + .perform(get("/version")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.version").value(Version.JAVA_ADK_VERSION)) + .andExpect(jsonPath("$.language").value("java")) + .andExpect(jsonPath("$.language_version").value(System.getProperty("java.version"))); + } + + @Test + public void health_shouldReportOk() throws Exception { + mockMvc + .perform(get("/health")) + .andExpect(status().isOk()) + .andExpect(content().json("{\"status\":\"ok\"}")); + } +}