diff --git a/dev/README.md b/dev/README.md index 3f4e9a3d3..8932b4d68 100644 --- a/dev/README.md +++ b/dev/README.md @@ -6,3 +6,36 @@ 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 0a6035923..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,7 +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; @@ -52,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) @@ -125,17 +141,25 @@ public void addResourceHandlers(ResourceHandlerRegistry registry) { } /** - * Configures simple automated controllers: "/" and "/dev-ui" both redirect to "/dev-ui/", which - * forwards to the UI's 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=} and the sample READMEs send users to the slashless "/dev-ui", so a - * redirect that dropped it would silently ignore the selection. + * 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/").setKeepQueryParams(true); - registry.addRedirectViewController("/dev-ui", "/dev-ui/").setKeepQueryParams(true); + 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 index 48c742c8b..2ef291c48 100644 --- a/dev/src/main/java/com/google/adk/web/config/DevUiAssets.java +++ b/dev/src/main/java/com/google/adk/web/config/DevUiAssets.java @@ -20,11 +20,15 @@ import org.springframework.core.io.ResourceLoader; /** - * Where the dev UI's static assets live. Normalizes {@code adk.web.ui.dir} into a resource - * location, so callers that need it do not each do it differently. + * 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/"; /** @@ -42,5 +46,10 @@ public static String assetRoot(@Nullable String webUiDir) { 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/AdkWebServerProxyRedirectTest.java b/dev/src/test/java/com/google/adk/web/AdkWebServerProxyRedirectTest.java index 2f4e3bfd5..59e96fd2f 100644 --- a/dev/src/test/java/com/google/adk/web/AdkWebServerProxyRedirectTest.java +++ b/dev/src/test/java/com/google/adk/web/AdkWebServerProxyRedirectTest.java @@ -37,6 +37,10 @@ * *

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 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 index 668f75e57..46ccaf97c 100644 --- a/dev/src/test/java/com/google/adk/web/config/DevUiAssetsTest.java +++ b/dev/src/test/java/com/google/adk/web/config/DevUiAssetsTest.java @@ -42,4 +42,10 @@ public void assetRoot_configuredDir_becomesAFileUrl(String webUiDir) { 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("")); + } + } +}